diff --git a/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json new file mode 100644 index 00000000..35a360d1 --- /dev/null +++ b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/9.json @@ -0,0 +1,579 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "880e0daacbbd5ba04b50b637847db08e", + "entities": [ + { + "tableName": "Libraries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` TEXT NOT NULL, `Name` TEXT NOT NULL, `IsLocal` INTEGER NOT NULL DEFAULT 0, `FolderUri` TEXT, `DisplayOrder` INTEGER NOT NULL DEFAULT 0, `Icon` TEXT NOT NULL DEFAULT 'audiobook', `MediaType` TEXT NOT NULL DEFAULT 'book', `FoldersJson` TEXT, PRIMARY KEY(`Id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "Name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "IsLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "folderUri", + "columnName": "FolderUri", + "affinity": "TEXT" + }, + { + "fieldPath": "displayOrder", + "columnName": "DisplayOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "icon", + "columnName": "Icon", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'audiobook'" + }, + { + "fieldPath": "mediaType", + "columnName": "MediaType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'book'" + }, + { + "fieldPath": "foldersJson", + "columnName": "FoldersJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "Id" + ] + } + }, + { + "tableName": "AudioBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` TEXT NOT NULL, `LibraryId` TEXT, `IsLocal` INTEGER NOT NULL DEFAULT 0, `Title` TEXT NOT NULL, `Author` TEXT, `Narrator` TEXT, `Description` TEXT, `CoverPath` TEXT, `DurationSeconds` REAL NOT NULL DEFAULT 0, `AddedAt` TEXT, `AudioFilesJson` TEXT, `CurrentTimeSeconds` REAL NOT NULL DEFAULT 0, `Progress` REAL NOT NULL DEFAULT 0, `IsFinished` INTEGER NOT NULL DEFAULT 0, `IsDownloaded` INTEGER NOT NULL DEFAULT 0, `LocalPath` TEXT, `LocalCoverPath` TEXT, `ArchivedAt` INTEGER, `SeriesName` TEXT, `SeriesSequence` TEXT, `GenresJson` TEXT, `TagsJson` TEXT, `ChaptersJson` TEXT, PRIMARY KEY(`Id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryId", + "columnName": "LibraryId", + "affinity": "TEXT" + }, + { + "fieldPath": "isLocal", + "columnName": "IsLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "Title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "Author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "Narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "Description", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPath", + "columnName": "CoverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "DurationSeconds", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "addedAt", + "columnName": "AddedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "audioFilesJson", + "columnName": "AudioFilesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "currentTimeSeconds", + "columnName": "CurrentTimeSeconds", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "progress", + "columnName": "Progress", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isFinished", + "columnName": "IsFinished", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDownloaded", + "columnName": "IsDownloaded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localPath", + "columnName": "LocalPath", + "affinity": "TEXT" + }, + { + "fieldPath": "localCoverPath", + "columnName": "LocalCoverPath", + "affinity": "TEXT" + }, + { + "fieldPath": "archivedAt", + "columnName": "ArchivedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesName", + "columnName": "SeriesName", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesSequence", + "columnName": "SeriesSequence", + "affinity": "TEXT" + }, + { + "fieldPath": "genresJson", + "columnName": "GenresJson", + "affinity": "TEXT" + }, + { + "fieldPath": "tagsJson", + "columnName": "TagsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersJson", + "columnName": "ChaptersJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_audiobooks_library", + "unique": false, + "columnNames": [ + "LibraryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_audiobooks_library` ON `${TABLE_NAME}` (`LibraryId`)" + } + ] + }, + { + "tableName": "DownloadItems", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` TEXT NOT NULL, `AudioBookId` TEXT NOT NULL, `Title` TEXT NOT NULL, `Status` INTEGER NOT NULL DEFAULT 0, `TotalBytes` INTEGER NOT NULL DEFAULT 0, `DownloadedBytes` INTEGER NOT NULL DEFAULT 0, `StartedAt` TEXT, `CompletedAt` TEXT, `ErrorMessage` TEXT, `FilesToDownloadJson` TEXT, PRIMARY KEY(`Id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "Title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "Status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalBytes", + "columnName": "TotalBytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "downloadedBytes", + "columnName": "DownloadedBytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startedAt", + "columnName": "StartedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "completedAt", + "columnName": "CompletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "errorMessage", + "columnName": "ErrorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "filesToDownloadJson", + "columnName": "FilesToDownloadJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_downloads_audiobook", + "unique": false, + "columnNames": [ + "AudioBookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_downloads_audiobook` ON `${TABLE_NAME}` (`AudioBookId`)" + } + ] + }, + { + "tableName": "PlaybackProgress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`AudioBookId` TEXT NOT NULL, `PositionSeconds` REAL NOT NULL DEFAULT 0, `IsFinished` INTEGER NOT NULL DEFAULT 0, `UpdatedAt` TEXT, PRIMARY KEY(`AudioBookId`))", + "fields": [ + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "PositionSeconds", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isFinished", + "columnName": "IsFinished", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "updatedAt", + "columnName": "UpdatedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "AudioBookId" + ] + }, + "indices": [ + { + "name": "idx_playback_progress_updated", + "unique": false, + "columnNames": [ + "UpdatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_playback_progress_updated` ON `${TABLE_NAME}` (`UpdatedAt`)" + } + ] + }, + { + "tableName": "PendingProgressUpdates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ItemId` TEXT NOT NULL, `CurrentTime` REAL NOT NULL DEFAULT 0, `IsFinished` INTEGER NOT NULL DEFAULT 0, `Duration` REAL NOT NULL DEFAULT 0, `IsAtomic` INTEGER NOT NULL DEFAULT 0, `Timestamp` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "ItemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentTime", + "columnName": "CurrentTime", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isFinished", + "columnName": "IsFinished", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "duration", + "columnName": "Duration", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isAtomic", + "columnName": "IsAtomic", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timestamp", + "columnName": "Timestamp", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_pending_item", + "unique": false, + "columnNames": [ + "ItemId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_pending_item` ON `${TABLE_NAME}` (`ItemId`)" + } + ] + }, + { + "tableName": "LocalListeningSessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `AudioBookId` TEXT NOT NULL, `LibraryId` TEXT NOT NULL, `StartedAt` INTEGER NOT NULL, `UpdatedAt` INTEGER NOT NULL, `TimeListening` REAL NOT NULL DEFAULT 0, `CurrentTime` REAL NOT NULL DEFAULT 0, `DisplayTitle` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryId", + "columnName": "LibraryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "StartedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "UpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeListening", + "columnName": "TimeListening", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "currentTime", + "columnName": "CurrentTime", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "displayTitle", + "columnName": "DisplayTitle", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_local_session_book", + "unique": false, + "columnNames": [ + "AudioBookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_session_book` ON `${TABLE_NAME}` (`AudioBookId`)" + }, + { + "name": "idx_local_session_library", + "unique": false, + "columnNames": [ + "LibraryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_session_library` ON `${TABLE_NAME}` (`LibraryId`)" + }, + { + "name": "idx_local_session_started", + "unique": false, + "columnNames": [ + "StartedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_session_started` ON `${TABLE_NAME}` (`StartedAt`)" + } + ] + }, + { + "tableName": "LocalBookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`Id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `AudioBookId` TEXT NOT NULL, `Title` TEXT NOT NULL, `Time` REAL NOT NULL, `CreatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "Id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioBookId", + "columnName": "AudioBookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "Title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "Time", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "CreatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "Id" + ] + }, + "indices": [ + { + "name": "idx_local_bookmark_book", + "unique": false, + "columnNames": [ + "AudioBookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_local_bookmark_book` ON `${TABLE_NAME}` (`AudioBookId`)" + }, + { + "name": "idx_local_bookmark_book_time", + "unique": true, + "columnNames": [ + "AudioBookId", + "Time" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_local_bookmark_book_time` ON `${TABLE_NAME}` (`AudioBookId`, `Time`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '880e0daacbbd5ba04b50b637847db08e')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/RecentlyPlayedQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/RecentlyPlayedQueryInstrumentedTest.kt new file mode 100644 index 00000000..13b71513 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/RecentlyPlayedQueryInstrumentedTest.kt @@ -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() + 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 { + 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() + database.openHelper.readableDatabase.query(sql).use { while (it.moveToNext()) ids += it.getString(0) } + return ids + } +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt new file mode 100644 index 00000000..757b1ed0 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt @@ -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 = 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" + } +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt new file mode 100644 index 00000000..e64d18cd --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt @@ -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) = 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 + } +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt index 65076102..05110f50 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/dossier/DossierBookQueryInstrumentedTest.kt @@ -135,6 +135,39 @@ class DossierBookQueryInstrumentedTest { assertEquals("Rich Chapter", queryScope.single().chapters.single().title) } + @Test + fun listenedBookLookupMatchesFormerScopeForThoseIdsOnly() = runBlocking { + val listened = richBook(id = "listened", title = "A Listened") + val listenedArchived = richBook( + id = "listened-archived", + title = "B Archived", + archivedAt = 1_725_769_200_000L, + ) + val notListened = richBook(id = "not-listened", title = "C Shelf Only") + val wrongSource = richBook(id = "wrong-source", title = "D Server", isLocal = 0) + val wrongLibrary = richBook(id = "wrong-library", title = "E Other", libraryId = "other-library") + database.audioBookDao().upsertAll( + listOf(listened, listenedArchived, notListened, wrongSource, wrongLibrary), + ) + val settings = AppSettings( + appMode = AppMode.LOCAL, + selectedLocalLibraryId = ACTIVE_LIBRARY_ID, + ) + val sessionIds = listOf("listened", "listened-archived", "wrong-source", "wrong-library", "gone") + + val former = dossierBooksInActiveScope( + database.audioBookDao().getAll().map { it.toDomain() }, + settings, + ).filter { it.id in sessionIds } + val lookedUp = database.audioBookDao() + .getByIdsInLibraryAndSource(ACTIVE_LIBRARY_ID, isLocal = 1, ids = sessionIds) + .map { it.toDomain() } + + assertEquals(former.map { it.id }.toSet(), lookedUp.map { it.id }.toSet()) + assertEquals(former.sortedBy { it.id }, lookedUp.sortedBy { it.id }) + assertEquals("Rich Chapter", lookedUp.first().chapters.single().title) + } + private fun richBook( id: String, title: String, diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerTest.kt new file mode 100644 index 00000000..96efc797 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerTest.kt @@ -0,0 +1,45 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ninelivesaudio.app.ui.theme.NineLivesAudioTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LibraryErrorBannerTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun bannerShowsTheLineAndOffersRetryAndDismiss() { + var retries by mutableIntStateOf(0) + var dismissals by mutableIntStateOf(0) + + composeTestRule.setContent { + NineLivesAudioTheme { + LibraryErrorBanner( + message = libraryErrorMessage(LibraryLoadFailure.SHELF), + onRetry = { retries++ }, + onDismiss = { dismissals++ }, + ) + } + } + + composeTestRule.onNodeWithText("Saved books could not be read.").assertExists() + composeTestRule.onNodeWithText("Retry").performClick() + composeTestRule.onNodeWithContentDescription("Dismiss").performClick() + + assertEquals(1, retries) + assertEquals(1, dismissals) + } +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsInstrumentedTest.kt new file mode 100644 index 00000000..fe83b405 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsInstrumentedTest.kt @@ -0,0 +1,53 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ninelivesaudio.app.ui.theme.NineLivesAudioTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LibraryGroupHeaderSemanticsInstrumentedTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun headerIsAHeadingWithItsStateAndClickLabel() { + var expanded by mutableStateOf(false) + + composeTestRule.setContent { + NineLivesAudioTheme { + GroupHeaderRow(title = "Saga", count = 3, isExpanded = expanded, onClick = { expanded = !expanded }) + } + } + + val header = composeTestRule.onNodeWithText("Saga", substring = true) + header + .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Heading)) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "Collapsed")) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + assertEquals( + "Expand group", + header.fetchSemanticsNode().config.getOrNull(SemanticsActions.OnClick)?.label, + ) + + header.performClick() + + composeTestRule.onNodeWithText("Saga", substring = true) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "Expanded")) + } +} diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt index aea81e05..f0a801b3 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryLocalCatalogLiveUpdateTest.kt @@ -11,6 +11,7 @@ import com.ninelivesaudio.app.data.local.AppDatabase import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LibraryEntity import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibrarySyncWatermarkStore import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.service.local.LocalFolderAccess @@ -154,6 +155,7 @@ class LibraryLocalCatalogLiveUpdateTest { localBookmarkDao = database.localBookmarkDao(), playbackProgressDao = database.playbackProgressDao(), database = database, + watermarkStore = LibrarySyncWatermarkStore(app.settingsManager), ) val libraryRepository = LibraryRepository( libraryDao = database.libraryDao(), @@ -171,6 +173,7 @@ class LibraryLocalCatalogLiveUpdateTest { settingsManager = app.settingsManager, entitlements = app.entitlementRepository, localFolderAccess = LocalFolderAccess(app), + syncManager = app.syncManager, ) as T } lateinit var viewModel: LibraryViewModel diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt index 9884b77a..499d75f2 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/ui/library/LibraryViewModelFilterRaceTest.kt @@ -10,11 +10,11 @@ import com.ninelivesaudio.app.data.local.dao.LibraryDao import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao -import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.LibraryEntity import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry -import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult +import com.ninelivesaudio.app.data.local.entity.ShelfBookRow import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibrarySyncWatermarkStore import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.Library @@ -125,6 +125,7 @@ private class LibraryViewModelFixture(app: NineLivesApp, database: AppDatabase) localBookmarkDao = emptyDao(), playbackProgressDao = emptyDao(), database = database, + watermarkStore = LibrarySyncWatermarkStore(app.settingsManager), ) private val libraryRepository = LibraryRepository( libraryDao = localLibraryDao(), @@ -140,6 +141,7 @@ private class LibraryViewModelFixture(app: NineLivesApp, database: AppDatabase) settingsManager = app.settingsManager, entitlements = app.entitlementRepository, localFolderAccess = LocalFolderAccess(app), + syncManager = app.syncManager, ) suspend fun awaitInitialShelf() { @@ -158,13 +160,12 @@ private object FixtureLibraries { private class DelayedLibraryFilterDao { private val delayedQueryStarted = CountDownLatch(1) @Volatile private var delayNextQuery = false - @Volatile private var delayedContinuation: Continuation>? = null + @Volatile private var delayedContinuation: Continuation>? = null val audioBookDao: AudioBookDao = proxy { method, args -> when (method.name) { "getFilteredBooks" -> filteredBooks(args) "countByLibrary" -> 2 - "getDistinctSeries", "getDistinctAuthors", "getDistinctGenresJson" -> emptyList() "observeLocalCatalog" -> flowOf(emptyList()) "toString" -> "DelayedLibraryFilterDao" "hashCode" -> System.identityHashCode(this) @@ -190,32 +191,38 @@ private class DelayedLibraryFilterDao { delayNextQuery = false @Suppress("UNCHECKED_CAST") - val continuation = args?.lastOrNull() as? Continuation> + val continuation = args?.lastOrNull() as? Continuation> ?: error("Room suspend continuation missing from getFilteredBooks") delayedContinuation = continuation delayedQueryStarted.countDown() return COROUTINE_SUSPENDED } - private fun betaResult() = RecentlyPlayedResult( - audioBook = AudioBookEntity( - id = "beta-book", - libraryId = FixtureLibraries.beta.id, - isLocal = 0, - title = "Beta Book", - author = "Author", - ), - lastPlayedAt = null, - ) + private fun betaResult() = shelfRow(id = "beta-book", libraryId = FixtureLibraries.beta.id, title = "Beta Book") + + private fun alphaResult() = shelfRow(id = "alpha-book", libraryId = FixtureLibraries.alpha.id, title = "Alpha Book") - private fun alphaResult() = RecentlyPlayedResult( - audioBook = AudioBookEntity( - id = "alpha-book", - libraryId = FixtureLibraries.alpha.id, - isLocal = 0, - title = "Alpha Book", - author = "Author", - ), + private fun shelfRow(id: String, libraryId: String, title: String) = ShelfBookRow( + id = id, + libraryId = libraryId, + isLocal = 0, + title = title, + author = "Author", + narrator = null, + coverPath = null, + durationSeconds = 0.0, + addedAt = null, + currentTimeSeconds = 0.0, + progress = 0.0, + isFinished = 0, + isDownloaded = 0, + localPath = null, + localCoverPath = null, + archivedAt = null, + seriesName = null, + seriesSequence = null, + genresJson = null, + chaptersJson = null, lastPlayedAt = null, ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt index 727388f1..a3070003 100644 --- a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavGraph.Companion.findStartDestination +import com.ninelivesaudio.app.ui.navigation.navigateToTab import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.lifecycle.Lifecycle @@ -250,13 +250,7 @@ class MainActivity : ComponentActivity() { MiniPlayer( playbackManager = playbackManager, onNavigateToPlayer = { - navController.navigate(Routes.PLAYER) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } + navController.navigateToTab(Routes.PLAYER) } ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt index cbd14418..0851b0c5 100644 --- a/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt +++ b/app/src/main/java/com/ninelivesaudio/app/NineLivesApp.kt @@ -2,10 +2,14 @@ package com.ninelivesaudio.app import android.app.Application import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache import com.ninelivesaudio.app.data.remote.ApiService +import com.ninelivesaudio.app.data.remote.imageLoaderHttpClient import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.entitlement.BillingManager import com.ninelivesaudio.app.entitlement.EntitlementRepository @@ -122,6 +126,30 @@ class NineLivesApp : Application(), ImageLoaderFactory { // screen happened to ask for it. billingManager.start() + // The library check timer runs only while the app is visible. The + // process lifecycle covers every activity together, and its stop + // waits a moment past the last activity so a rotation is not a trip + // to the background. Registered here, before settings load, so the + // first foreground entry is never missed: the flag is state, and the + // timer reads it whenever SyncManager.start() gets to it. The server + // ping follows the same flag, so nothing polls in the background. + ProcessLifecycleOwner.get().lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + // Connectivity first: it re-reads the real network state, + // which the sync's entry check then reads. + connectivityMonitor.setAppForeground(true) + syncManager.setAppForeground(true) + } + override fun onStop(owner: LifecycleOwner) { + syncManager.setAppForeground(false) + connectivityMonitor.setAppForeground(false) + // A playing book saves its position now, not 10 seconds on. + playbackManager.onAppBackgrounded() + } + } + ) + // Load settings from disk FIRST, then bring up everything that depends // on them. Order matters: the server URL and auth token must be in // place before any network call fires. Otherwise the first request @@ -166,8 +194,9 @@ class NineLivesApp : Application(), ImageLoaderFactory { // Re-resolve the download slot whenever entitlement is or becomes free. // Deliberately includes the initial emission rather than transitions // only, so a downgrade that happened while the app was dead is still - // handled. Resolution early-returns when there is nothing over cap, so - // the common case costs one query and never touches the worker. + // handled. Resolution reads only download rows and downloaded books, + // a few small queries, and early-returns when there is nothing over + // cap, so the common case never touches the worker. appScope.launch { entitlementRepository.state .map { it.isUnlocked } @@ -191,13 +220,15 @@ class NineLivesApp : Application(), ImageLoaderFactory { } /** - * Coil image loader for the whole app. Reuses the authenticated OkHttpClient - * so server covers carry the auth token, and adds a persistent disk cache so - * covers fetched once survive offline instead of relying on Coil's defaults. + * Coil image loader for the whole app. Builds on the authenticated + * OkHttpClient so server covers carry the auth token, with its own request + * dispatcher so a burst of covers never queues API calls (see + * [imageLoaderHttpClient]). Adds a persistent disk cache so covers fetched + * once survive offline instead of relying on Coil's defaults. */ override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(this) - .okHttpClient { okHttpClient } + .okHttpClient { imageLoaderHttpClient(okHttpClient) } .crossfade(true) .diskCache { DiskCache.Builder() diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt index 1cf9ea38..29758fdd 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/AppDatabase.kt @@ -15,7 +15,7 @@ import com.ninelivesaudio.app.data.local.entity.* LocalListeningSessionEntity::class, LocalBookmarkEntity::class, ], - version = 8, + version = 9, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt new file mode 100644 index 00000000..1edeb291 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt @@ -0,0 +1,16 @@ +package com.ninelivesaudio.app.data.local + +/** + * User text as a LIKE literal, for a LIKE that declares ESCAPE '\'. Without + * it a search for "%" matched every book (and loaded the whole shelf) and + * "_" matched any one character. + */ +internal fun escapeLike(text: String): String = buildString(text.length + 4) { + for (c in text) { + if (c == '\\' || c == '%' || c == '_') append('\\') + append(c) + } +} + +/** A LIKE pattern that finds [text] anywhere, for a LIKE that declares ESCAPE '\'. */ +internal fun containsLikePattern(text: String): String = "%${escapeLike(text)}%" diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt index 2009c2bf..7071cdf3 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/converter/EntityMappers.kt @@ -16,6 +16,15 @@ private inline fun decodeJsonList(jsonString: String?): List = catch (_: Exception) { emptyList() } } ?: emptyList() +// Shelf rows decode these per book. Most server rows hold "[]", which skips +// the JSON parser entirely. +internal fun decodeStringListJson(jsonString: String?): List = + if (jsonString == null || jsonString == "[]") emptyList() else decodeJsonList(jsonString) + +internal fun decodeChapterListJson(jsonString: String?): List = + if (jsonString == null || jsonString == "[]") emptyList() + else decodeJsonList(jsonString).map { it.toDomain() } + // ─── AudioBook ─────────────────────────────────────────────────────────── fun AudioBookEntity.toDomain(): AudioBook = AudioBook( diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt index 2ca43ad0..462eeea6 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/AudioBookDao.kt @@ -7,9 +7,14 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow +import com.ninelivesaudio.app.data.local.entity.BookProgressState +import com.ninelivesaudio.app.data.local.entity.SyncMergeState import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.entity.RecentlyPlayedResult +import com.ninelivesaudio.app.data.local.entity.SlotBookRow +import com.ninelivesaudio.app.data.local.entity.ShelfBookRow import kotlinx.coroutines.flow.Flow @Dao @@ -43,6 +48,29 @@ interface AudioBookDao { @Query("SELECT * FROM AudioBooks WHERE Id IN (:ids)") suspend fun getByIds(ids: List): List + /** Only the local-only fields a server sync keeps, for up to 500 IDs at a time. */ + @Query( + "SELECT Id, IsDownloaded, LocalPath, LocalCoverPath, CurrentTimeSeconds, Progress, IsFinished, ArchivedAt " + + "FROM AudioBooks WHERE Id IN (:ids)" + ) + suspend fun getSyncMergeStates(ids: List): List + + /** Shelf progress for up to 500 IDs, for the progress pull's "anything changed?" check. */ + @Query("SELECT Id, CurrentTimeSeconds, Progress, IsFinished, DurationSeconds FROM AudioBooks WHERE Id IN (:ids)") + suspend fun getProgressStates(ids: List): List + + /** How many SERVER books one library has cached, downloads included. */ + @Query("SELECT COUNT(*) FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 0") + suspend fun countServerBooksByLibrary(libraryId: String): Int + + /** Which of up to 500 IDs are already cached as SERVER books of this library. */ + @Query("SELECT Id FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = 0 AND Id IN (:ids)") + suspend fun getServerIdsInLibrary(libraryId: String, ids: List): List + + /** Every stored localPath except [excludeId]'s, for the shared-folder check on cancel. */ + @Query("SELECT LocalPath FROM AudioBooks WHERE Id != :excludeId AND LocalPath IS NOT NULL AND LocalPath != ''") + suspend fun getLocalPathsExcept(excludeId: String): List + @Query("SELECT * FROM AudioBooks WHERE Id = :id") fun observeById(id: String): Flow @@ -139,17 +167,103 @@ interface AudioBookDao { @Query("UPDATE AudioBooks SET CoverPath = :coverPath WHERE Id = :id") suspend fun updateCoverPath(id: String, coverPath: String) + // ─── Readers outside the Library shelf ─────────────────────────────── + // + // Screens and services that only need a handful of books ask for those + // books, not the whole library. Each query here is bounded by an id list, + // a LIMIT, or a small projection. + + /** Up to 500 books by id, kept to one library and source (the Dossier's listened books). */ + @Query("SELECT * FROM AudioBooks WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND Id IN (:ids)") + suspend fun getByIdsInLibraryAndSource(libraryId: String, isLocal: Int, ids: List): List + + /** + * One page of Android Auto's Library list: live books in one library and + * source, A to Z, light columns only. Id breaks title ties so pages never + * overlap or skip. + */ + @Query( + """ + SELECT Id AS id, Title AS title, Author AS author, Narrator AS narrator, + CoverPath AS coverPath, LocalCoverPath AS localCoverPath, GenresJson AS genresJson + FROM AudioBooks + WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL + ORDER BY Title COLLATE NOCASE, Id + LIMIT :limit OFFSET :offset + """ + ) + suspend fun getAutoBrowsePage(libraryId: String, isLocal: Int, limit: Int, offset: Int): List + + /** Same as [getAutoBrowsePage], downloaded books only. */ + @Query( + """ + SELECT Id AS id, Title AS title, Author AS author, Narrator AS narrator, + CoverPath AS coverPath, LocalCoverPath AS localCoverPath, GenresJson AS genresJson + FROM AudioBooks + WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL AND IsDownloaded = 1 + ORDER BY Title COLLATE NOCASE, Id + LIMIT :limit OFFSET :offset + """ + ) + suspend fun getAutoDownloadedPage(libraryId: String, isLocal: Int, limit: Int, offset: Int): List + + /** + * One page of Android Auto search hits in one library and source: title or + * author contains [pattern], an escaped LIKE pattern (backslash escapes). + */ + @Query( + """ + SELECT Id AS id, Title AS title, Author AS author, Narrator AS narrator, + CoverPath AS coverPath, LocalCoverPath AS localCoverPath, GenresJson AS genresJson + FROM AudioBooks + WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL + AND (Title LIKE :pattern ESCAPE '\' OR Author LIKE :pattern ESCAPE '\') + ORDER BY Title COLLATE NOCASE, Id + LIMIT :limit OFFSET :offset + """ + ) + suspend fun searchAutoPage(libraryId: String, isLocal: Int, pattern: String, limit: Int, offset: Int): List + + /** How many rows [searchAutoPage] can return, counting no further than [cap]. */ + @Query( + """ + SELECT COUNT(*) FROM ( + SELECT 1 FROM AudioBooks + WHERE LibraryId = :libraryId AND IsLocal = :isLocal AND ArchivedAt IS NULL + AND (Title LIKE :pattern ESCAPE '\' OR Author LIKE :pattern ESCAPE '\') + LIMIT :cap + ) + """ + ) + suspend fun countAutoSearch(libraryId: String, isLocal: Int, pattern: String, cap: Int): Int + + /** Slot fields of every downloaded book, in any library. */ + @Query("SELECT Id AS id, IsLocal AS isLocal, IsDownloaded AS isDownloaded, LocalPath AS localPath FROM AudioBooks WHERE IsDownloaded = 1") + suspend fun getDownloadedSlotRows(): List + + /** Slot fields for up to 500 books by id. */ + @Query("SELECT Id AS id, IsLocal AS isLocal, IsDownloaded AS isDownloaded, LocalPath AS localPath FROM AudioBooks WHERE Id IN (:ids)") + suspend fun getSlotRowsByIds(ids: List): List + @Query("DELETE FROM AudioBooks") suspend fun deleteAll() @Query("DELETE FROM AudioBooks WHERE IsLocal = 0") suspend fun deleteAudiobookshelf() + // ─── Recently played ───────────────────────────────────────────────── + // + // These start from PlaybackProgress and walk it newest first through + // idx_playback_progress_updated, looking each book up by id. CROSS JOIN + // pins that order. Left to itself SQLite started from the library index + // instead and read every book in the library, and Home reruns this on + // every playback progress write (every 500 ms while listening). + /** Nine Lives — recently played books with their last-played timestamp. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -159,8 +273,8 @@ interface AudioBookDao { /** Nine Lives — observable version for reactive UI. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -170,8 +284,8 @@ interface AudioBookDao { /** Nine Lives — recently played books filtered by library. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -181,8 +295,8 @@ interface AudioBookDao { /** Android Auto variant applies source scope before LIMIT. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.IsLocal = :isLocal AND ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -196,8 +310,8 @@ interface AudioBookDao { /** Nine Lives — observable recently played books filtered by library. */ @Query(""" SELECT ab.*, pp.UpdatedAt AS lastPlayedAt - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.ArchivedAt IS NULL ORDER BY pp.UpdatedAt DESC LIMIT :limit @@ -214,22 +328,16 @@ interface AudioBookDao { """) suspend fun getByLibraryWithLastPlayed(libraryId: String): List - /** Search audiobooks by title or author. */ - @Query(""" - SELECT * FROM AudioBooks - WHERE Title LIKE '%' || :query || '%' - OR Author LIKE '%' || :query || '%' - ORDER BY Title - """) - suspend fun search(query: String): List - /** Update just the progress fields on an audiobook. */ @Query("UPDATE AudioBooks SET CurrentTimeSeconds = :currentTimeSeconds, Progress = :progress, IsFinished = :isFinished WHERE Id = :id") suspend fun updateProgress(id: String, currentTimeSeconds: Double, progress: Double, isFinished: Int) - /** Dynamic filtered query — built by AudioBookRepository.getFilteredBooks(). */ + /** + * Dynamic filtered shelf query, built by AudioBookRepository.getFilteredBooks(). + * Light rows only (see [ShelfBookRow]), never `ab.*`. + */ @RawQuery(observedEntities = [AudioBookEntity::class, PlaybackProgressEntity::class]) - suspend fun getFilteredBooks(query: SupportSQLiteQuery): List + suspend fun getFilteredBooks(query: SupportSQLiteQuery): List /** Count live audiobooks in a library (drives the empty-state copy, so it * excludes archived books — an archive-only library reads as empty). */ @@ -248,21 +356,9 @@ interface AudioBookDao { @Query(""" SELECT COUNT(DISTINCT ab.Id) - FROM AudioBooks ab - INNER JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId + FROM PlaybackProgress pp + CROSS JOIN AudioBooks ab ON ab.Id = pp.AudioBookId WHERE ab.LibraryId = :libraryId AND ab.IsLocal = :isLocal AND ab.ArchivedAt IS NULL """) suspend fun countRecentlyPlayedByLibrary(libraryId: String, isLocal: Int): Int - - /** Distinct series names for a library. */ - @Query("SELECT DISTINCT SeriesName FROM AudioBooks WHERE LibraryId = :libraryId AND ArchivedAt IS NULL AND SeriesName IS NOT NULL AND SeriesName != '' ORDER BY SeriesName") - suspend fun getDistinctSeries(libraryId: String): List - - /** Distinct authors for a library. */ - @Query("SELECT DISTINCT Author FROM AudioBooks WHERE LibraryId = :libraryId AND ArchivedAt IS NULL AND Author IS NOT NULL AND Author != '' ORDER BY Author") - suspend fun getDistinctAuthors(libraryId: String): List - - /** Distinct genres for a library (genres stored as JSON array). */ - @Query("SELECT DISTINCT GenresJson FROM AudioBooks WHERE LibraryId = :libraryId AND ArchivedAt IS NULL AND GenresJson IS NOT NULL AND GenresJson != '[]' AND GenresJson != ''") - suspend fun getDistinctGenresJson(libraryId: String): List } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt index f8ef5f21..4ad63813 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/DownloadItemDao.kt @@ -5,6 +5,8 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.ninelivesaudio.app.data.local.entity.DownloadItemEntity +import com.ninelivesaudio.app.data.local.entity.DownloadRowWithBook +import com.ninelivesaudio.app.data.local.entity.DownloadedBookRow import kotlinx.coroutines.flow.Flow @Dao @@ -32,15 +34,36 @@ interface DownloadItemDao { suspend fun deleteAll() /** - * Active downloads. Status 0=Queued, 1=Downloading, 2=Paused, 6=Preparing. - * - * Preparing is included so a provisional slot claim stays VISIBLE while its - * metadata fetch is in flight. Leaving it out would make a book the user - * just asked for vanish from the Downloads screen for the length of a - * network round trip, which reads as the tap having done nothing. + * Every download row whose book is still cached, with that book's cover and + * downloaded flag, for the Downloads screen. Rows for a book that left the + * database drop out, as they always have. Which rows land in the active and + * completed lists is decided in Kotlin by `splitDownloadRows`. */ - @Query("SELECT * FROM DownloadItems WHERE Status IN (0, 1, 2, 6) ORDER BY StartedAt DESC") - fun observeActive(): Flow> + @Query( + """ + SELECT d.*, + COALESCE(ab.LocalCoverPath, ab.CoverPath) AS BookCoverPath, + ab.IsDownloaded AS BookIsDownloaded + FROM DownloadItems d + INNER JOIN AudioBooks ab ON ab.Id = d.AudioBookId + """ + ) + fun observeAllWithBooks(): Flow> + + /** + * Every server book on the device, from the book rows. Download rows can be + * gone (an older Clear All deleted them and kept the files), and the + * Downloads screen still has to list the book. Reads only the three columns + * the screen needs, never the chapter or file blobs. + */ + @Query( + """ + SELECT Id, Title, COALESCE(LocalCoverPath, CoverPath) AS BookCoverPath + FROM AudioBooks + WHERE IsDownloaded = 1 AND IsLocal = 0 + """ + ) + fun observeDownloadedServerBooks(): Flow> /** * Downloadable items for the drain worker: Queued (0) or interrupted @@ -53,8 +76,4 @@ interface DownloadItemDao { */ @Query("SELECT * FROM DownloadItems WHERE Status IN (0, 1) ORDER BY StartedAt ASC") suspend fun getDownloadable(): List - - /** Get completed downloads. Status 3=Completed */ - @Query("SELECT * FROM DownloadItems WHERE Status = 3 ORDER BY CompletedAt DESC") - fun observeCompleted(): Flow> } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt index 450841d0..b44e30ba 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/dao/PlaybackProgressDao.kt @@ -15,6 +15,10 @@ interface PlaybackProgressDao { @Query("SELECT * FROM PlaybackProgress WHERE AudioBookId = :audioBookId") suspend fun getByAudioBookId(audioBookId: String): PlaybackProgressEntity? + /** Saved positions for up to 500 books at once. */ + @Query("SELECT * FROM PlaybackProgress WHERE AudioBookId IN (:audioBookIds)") + suspend fun getByAudioBookIds(audioBookIds: List): List + @Query("SELECT PositionSeconds, IsFinished FROM PlaybackProgress WHERE AudioBookId = :audioBookId") suspend fun getPositionAndFinished(audioBookId: String): PositionResult? diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AutoBrowseRow.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AutoBrowseRow.kt new file mode 100644 index 00000000..6ae701ab --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AutoBrowseRow.kt @@ -0,0 +1,16 @@ +package com.ninelivesaudio.app.data.local.entity + +/** + * One Android Auto browse or search row: what a car list shows and where its + * cover lives. Description, track list, chapters and tags stay out, so a page + * of 50 reads 50 small rows instead of decoding whole books. + */ +data class AutoBrowseRow( + val id: String, + val title: String, + val author: String? = null, + val narrator: String? = null, + val coverPath: String? = null, + val localCoverPath: String? = null, + val genresJson: String? = null, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt new file mode 100644 index 00000000..e3ef286d --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/DownloadRowWithBook.kt @@ -0,0 +1,38 @@ +package com.ninelivesaudio.app.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Embedded + +/** + * A download row joined with the two things the Downloads screen needs from its + * book: the cover to show and whether the book is already on the device. One + * query instead of a book lookup per row on every progress tick. + */ +data class DownloadRowWithBook( + @Embedded + val download: DownloadItemEntity, + + /** The book's saved cover when it has one, otherwise its server cover. */ + @ColumnInfo(name = "BookCoverPath") + val coverPath: String?, + + @ColumnInfo(name = "BookIsDownloaded") + val bookIsDownloaded: Int, +) + +/** + * A server book whose files are on the device, read from the book row itself. + * The Downloads screen lists these whether or not a download row survived, so + * offline books never drop out of the one screen built to manage them. + */ +data class DownloadedBookRow( + @ColumnInfo(name = "Id") + val id: String, + + @ColumnInfo(name = "Title") + val title: String, + + /** The book's saved cover when it has one, otherwise its server cover. */ + @ColumnInfo(name = "BookCoverPath") + val coverPath: String?, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt index bcb8158d..303577f0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/PlaybackProgressEntity.kt @@ -2,9 +2,14 @@ package com.ninelivesaudio.app.data.local.entity import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey -@Entity(tableName = "PlaybackProgress") +@Entity( + tableName = "PlaybackProgress", + // Lets the recently played queries read newest first and stop at LIMIT. + indices = [Index(value = ["UpdatedAt"], name = "idx_playback_progress_updated")], +) data class PlaybackProgressEntity( @PrimaryKey @ColumnInfo(name = "AudioBookId") diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt new file mode 100644 index 00000000..2d28584f --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/ShelfBookRow.kt @@ -0,0 +1,92 @@ +package com.ninelivesaudio.app.data.local.entity + +import androidx.room.ColumnInfo +import com.ninelivesaudio.app.data.local.converter.decodeChapterListJson +import com.ninelivesaudio.app.data.local.converter.decodeStringListJson +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.util.toEpochMillis +import kotlin.time.Duration.Companion.seconds + +/** + * One Library shelf row: every column a shelf or a group row shows, without + * the heavy ones. Description is most of a server row's weight (about 1.9 KB + * a book on a big library), and the audio file and tag lists are never shown + * on a shelf, so reading `ab.*` for 50,000 books decoded about 115 MB per + * filter change for nothing. + * + * ChaptersJson stays because the row's "Ch x/y" label needs it, and it is + * "[]" for every list-synced server book anyway. + */ +data class ShelfBookRow( + @ColumnInfo(name = "Id") val id: String, + @ColumnInfo(name = "LibraryId") val libraryId: String?, + @ColumnInfo(name = "IsLocal") val isLocal: Int, + @ColumnInfo(name = "Title") val title: String, + @ColumnInfo(name = "Author") val author: String?, + @ColumnInfo(name = "Narrator") val narrator: String?, + @ColumnInfo(name = "CoverPath") val coverPath: String?, + @ColumnInfo(name = "DurationSeconds") val durationSeconds: Double, + @ColumnInfo(name = "AddedAt") val addedAt: String?, + @ColumnInfo(name = "CurrentTimeSeconds") val currentTimeSeconds: Double, + @ColumnInfo(name = "Progress") val progress: Double, + @ColumnInfo(name = "IsFinished") val isFinished: Int, + @ColumnInfo(name = "IsDownloaded") val isDownloaded: Int, + @ColumnInfo(name = "LocalPath") val localPath: String?, + @ColumnInfo(name = "LocalCoverPath") val localCoverPath: String?, + @ColumnInfo(name = "ArchivedAt") val archivedAt: Long?, + @ColumnInfo(name = "SeriesName") val seriesName: String?, + @ColumnInfo(name = "SeriesSequence") val seriesSequence: String?, + @ColumnInfo(name = "GenresJson") val genresJson: String?, + @ColumnInfo(name = "ChaptersJson") val chaptersJson: String?, + @ColumnInfo(name = "lastPlayedAt") val lastPlayedAt: String?, +) + +/** + * The select list for [ShelfBookRow], aliased so Room maps it by name. Keep + * it in step with the fields above. Description, AudioFilesJson, and + * TagsJson are left out on purpose. + */ +internal const val SHELF_BOOK_COLUMNS = + "ab.Id AS Id, ab.LibraryId AS LibraryId, ab.IsLocal AS IsLocal, ab.Title AS Title, " + + "ab.Author AS Author, ab.Narrator AS Narrator, ab.CoverPath AS CoverPath, " + + "ab.DurationSeconds AS DurationSeconds, ab.AddedAt AS AddedAt, " + + "ab.CurrentTimeSeconds AS CurrentTimeSeconds, ab.Progress AS Progress, " + + "ab.IsFinished AS IsFinished, ab.IsDownloaded AS IsDownloaded, ab.LocalPath AS LocalPath, " + + "ab.LocalCoverPath AS LocalCoverPath, ab.ArchivedAt AS ArchivedAt, " + + "ab.SeriesName AS SeriesName, ab.SeriesSequence AS SeriesSequence, " + + "ab.GenresJson AS GenresJson, ab.ChaptersJson AS ChaptersJson, " + + "pp.UpdatedAt AS lastPlayedAt" + +/** + * A shelf book. Description, audio files, and tags are EMPTY here, not + * unknown, so this book is for showing and navigating only. Never hand it to + * AudioBookRepository.save or anything else that writes a row back: that + * would wipe the stored description and track list. Code that needs the + * whole book reads it by id (getById). + */ +fun ShelfBookRow.toShelfBook(): AudioBook = AudioBook( + id = id, + libraryId = libraryId, + isLocal = isLocal == 1, + title = title, + author = author ?: "", + narrator = narrator, + description = null, + coverPath = coverPath, + duration = durationSeconds.seconds, + addedAt = addedAt?.toEpochMillis(), + audioFiles = emptyList(), + seriesName = seriesName, + seriesSequence = seriesSequence, + genres = decodeStringListJson(genresJson), + tags = emptyList(), + chapters = decodeChapterListJson(chaptersJson), + currentTime = currentTimeSeconds.seconds, + progress = progress, + isFinished = isFinished == 1, + isDownloaded = isDownloaded == 1, + localPath = localPath, + localCoverPath = localCoverPath, + archivedAt = archivedAt, + lastPlayedAt = lastPlayedAt?.toEpochMillis(), +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SlotBookRow.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SlotBookRow.kt new file mode 100644 index 00000000..83771fc8 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SlotBookRow.kt @@ -0,0 +1,13 @@ +package com.ninelivesaudio.app.data.local.entity + +/** + * The four AudioBooks fields the free tier's download slot reads. Kept small + * so building the slot candidates never decodes descriptions, track lists or + * chapters. + */ +data class SlotBookRow( + val id: String, + val isLocal: Int, + val isDownloaded: Int, + val localPath: String?, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt new file mode 100644 index 00000000..531cfd74 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/SyncRowStates.kt @@ -0,0 +1,29 @@ +package com.ninelivesaudio.app.data.local.entity + +import androidx.room.ColumnInfo + +/** + * The local-only fields a server sync must keep on a cached book: download + * state, on-disk paths, local progress, and the archive flag. Read instead of + * the whole row so a big library's sync does not load every book's audio + * file and chapter lists into memory just to keep a handful of columns. + */ +data class SyncMergeState( + @ColumnInfo(name = "Id") val id: String, + @ColumnInfo(name = "IsDownloaded") val isDownloaded: Int = 0, + @ColumnInfo(name = "LocalPath") val localPath: String? = null, + @ColumnInfo(name = "LocalCoverPath") val localCoverPath: String? = null, + @ColumnInfo(name = "CurrentTimeSeconds") val currentTimeSeconds: Double = 0.0, + @ColumnInfo(name = "Progress") val progress: Double = 0.0, + @ColumnInfo(name = "IsFinished") val isFinished: Int = 0, + @ColumnInfo(name = "ArchivedAt") val archivedAt: Long? = null, +) + +/** A cached book's shelf progress, for deciding whether a server progress pull changes anything. */ +data class BookProgressState( + @ColumnInfo(name = "Id") val id: String, + @ColumnInfo(name = "CurrentTimeSeconds") val currentTimeSeconds: Double = 0.0, + @ColumnInfo(name = "Progress") val progress: Double = 0.0, + @ColumnInfo(name = "IsFinished") val isFinished: Int = 0, + @ColumnInfo(name = "DurationSeconds") val durationSeconds: Double = 0.0, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt index e96e1695..c4317cbc 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/migration/Migrations.kt @@ -98,6 +98,14 @@ val MIGRATION_7_8 = object : Migration(7, 8) { } } +val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + // Index only, no data changes. The recently played queries walk + // PlaybackProgress newest first instead of reading the whole library. + db.execSQL("CREATE INDEX IF NOT EXISTS `idx_playback_progress_updated` ON `PlaybackProgress` (`UpdatedAt`)") + } +} + /** * All migrations to register with Room, in order. * Add new migrations here as they are created. @@ -110,4 +118,5 @@ val ALL_MIGRATIONS: Array = arrayOf( MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_8_9, ) diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt index 90ffdb89..12f95f54 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ApiService.kt @@ -168,6 +168,7 @@ class ApiService @Inject constructor( // any real library, so a normal shelf never reaches it. private const val LIBRARY_ITEMS_PAGE_CAP = 1000 private const val LISTENING_SESSIONS_PAGE_SIZE = 50 + private const val SORT_ADDED_AT = "addedAt" private const val LISTENING_SESSIONS_ITEM_PAGE_CAP = 3 private const val LISTENING_SESSIONS_ALL_PAGE_CAP = 20 } @@ -675,36 +676,132 @@ class ApiService @Inject constructor( // ─── Library Items (Paginated batch load) ──────────────────────────── /** - * Every item in a library, paginated. A page that fails part-way through - * yields [RemoteResult.Partial]: the books already fetched are still worth - * showing, but the caller has to know the shelf stopped short rather than - * ended. The same is true when a page merely comes back empty or shorter - * than [limit] while the server's own reported total says more exist — - * [runPaginatedFetch] is what decides that (issue #14, PR #30 review, - * finding B); only actually reaching the reported total is a genuine Ok. + * Every item in a library, paginated, handed to [onPage] a page at a time + * so the caller can save each page as it lands instead of holding the + * whole shelf in memory. Returns the count of distinct books delivered. + * + * A page that fails part-way through yields [RemoteResult.Partial] (or + * Failed with nothing delivered), and so does a page that merely comes + * back empty or short while the server's own reported total says more + * exist; [runPaginatedFetchStreaming] decides that (issue #14, PR #30 + * review, finding B). Only reaching the reported total is a genuine Ok. + * + * Sorted oldest-added first: a book added during a multi-minute download + * lands at the end instead of pushing every later page down by one row, + * which used to repeat one book and skip another. Rows repeated anyway + * (a removal mid-download shifts the other way) are dropped by id, and + * the count check then reports the run short rather than complete. ABS + * indexes (libraryId, mediaType, createdAt), so the order is cheap. + * A failed page gets [PAGE_RETRY_DELAYS_MS] more tries before the run + * stops short. */ - suspend fun getLibraryItems(libraryId: String, limit: Int = 100): RemoteResult> = + internal suspend fun streamLibraryItems( + libraryId: String, + limit: Int = 100, + onPage: suspend (List) -> Unit, + ): RemoteResult = withContext(Dispatchers.IO) { - runPaginatedFetch( + runPaginatedFetchStreaming( limit = limit, maxPages = LIBRARY_ITEMS_PAGE_CAP, - onPageFailure = { page, e -> Log.w(TAG, "getLibraryItems($libraryId) failed at page $page", e) }, + onPageFailure = { page, e -> Log.w(TAG, "streamLibraryItems($libraryId) failed at page $page", e) }, + itemKey = { it.id }, + onPage = onPage, ) { page -> - val response = api.getLibraryItems(libraryId, limit, page) - if (!response.isSuccessful) { - Log.w(TAG, "getLibraryItems($libraryId): HTTP ${response.code()} at page $page") - return@runPaginatedFetch PageOutcome.Stopped("page $page: HTTP ${response.code()}") + fetchPageWithRetry { + val response = api.getLibraryItems( + libraryId = libraryId, + limit = limit, + page = page, + sort = SORT_ADDED_AT, + desc = 0, + ) + if (!response.isSuccessful) { + Log.w(TAG, "streamLibraryItems($libraryId): HTTP ${response.code()} at page $page") + return@fetchPageWithRetry PageOutcome.Stopped( + "page $page: HTTP ${response.code()}", + retryable = isRetryableHttpStatus(response.code()), + ) + } + + val body = response.body() + if (body == null) { + Log.w(TAG, "streamLibraryItems($libraryId): no body at page $page") + return@fetchPageWithRetry PageOutcome.Stopped("page $page: empty body", retryable = true) + } + + PageOutcome.Page(body.results.map { mapToAudioBook(it, libraryId) }, body.total) } + } + } - val body = response.body() - if (body == null) { - Log.w(TAG, "getLibraryItems($libraryId): no body at page $page") - return@runPaginatedFetch PageOutcome.Stopped("page $page: empty body") + /** + * The library's book count and its most recently added book, in one tiny + * request (one minified item). Used to ask "anything new since the last + * sync?" without downloading the shelf. A missing total next to a + * non-empty page is reported as a failure, because a zero total would + * otherwise read as "the library emptied". + */ + internal suspend fun getLibraryHead(libraryId: String): RemoteResult = + withContext(Dispatchers.IO) { + remoteResultCatching(onFailure = { e -> Log.w(TAG, "getLibraryHead($libraryId) failed", e) }) { + val response = api.getLibraryItems( + libraryId = libraryId, + limit = 1, + page = 0, + minified = 1, + sort = SORT_ADDED_AT, + desc = 1, + ) + if (!response.isSuccessful) return@remoteResultCatching RemoteResult.Failed("head: HTTP ${response.code()}") + val body = response.body() ?: return@remoteResultCatching RemoteResult.Failed("head: empty body") + val newest = body.results.firstOrNull() + if (newest != null && body.total <= 0) { + return@remoteResultCatching RemoteResult.Failed("head: no book count reported") } + RemoteResult.Ok(LibraryHead(total = body.total, newestItemId = newest?.id, newestAddedAt = newest?.addedAt)) + } + } + /** + * Books added at or after [cutoff], newest first, mapped exactly as the + * full download maps them. Pages stop at the first older book; see + * [runIncrementalFetch] for when this gives up instead. + */ + internal suspend fun getLibraryItemsAddedSince( + libraryId: String, + cutoff: Long, + pageSize: Int, + maxPages: Int, + ): RemoteResult> = withContext(Dispatchers.IO) { + runIncrementalFetch( + pageSize = pageSize, + maxPages = maxPages, + cutoff = cutoff, + addedAt = { it.addedAt }, + itemKey = { it.id }, + onPageFailure = { page, e -> Log.w(TAG, "getLibraryItemsAddedSince($libraryId) failed at page $page", e) }, + ) { page -> + fetchPageWithRetry { + val response = api.getLibraryItems( + libraryId = libraryId, + limit = pageSize, + page = page, + minified = 0, + sort = SORT_ADDED_AT, + desc = 1, + ) + if (!response.isSuccessful) { + return@fetchPageWithRetry PageOutcome.Stopped( + "HTTP ${response.code()}", + retryable = isRetryableHttpStatus(response.code()), + ) + } + val body = response.body() ?: return@fetchPageWithRetry PageOutcome.Stopped("empty body", retryable = true) PageOutcome.Page(body.results.map { mapToAudioBook(it, libraryId) }, body.total) } } + } // ─── Single Item ───────────────────────────────────────────────────── @@ -928,32 +1025,66 @@ class ApiService @Inject constructor( // ─── Bookmarks ─────────────────────────────────────────────────────── - suspend fun getBookmarks(itemId: String): List = withContext(Dispatchers.IO) { - try { - val response = api.getMe() - if (!response.isSuccessful) return@withContext emptyList() + private val meBookmarksCache = MeBookmarksCache() - response.body()?.bookmarks - ?.filter { it.libraryItemId == itemId } - ?.sortedBy { it.time } - ?.map { b -> - Bookmark( - id = b.id, - libraryItemId = b.libraryItemId, - title = b.title, - time = b.time, - createdAt = b.createdAt, - ) - } ?: emptyList() - } catch (e: Exception) { - emptyList() + /** + * One book's bookmarks. Uses the per-book route when the server has it and + * falls back to a briefly cached /api/me on older servers, because /api/me + * carries every progress row the account owns (megabytes at a big library). + */ + suspend fun getBookmarks(itemId: String): RemoteResult> = withContext(Dispatchers.IO) { + loadItemBookmarks( + itemId = itemId, + sessionKey = "$authGeneration|${settingsManager.currentSettings.serverUrl}", + cache = meBookmarksCache, + fetchItem = ::fetchItemBookmarks, + fetchAll = ::fetchAllBookmarksFromMe, + ) + } + + private suspend fun fetchItemBookmarks(itemId: String): ItemBookmarksOutcome { + val response = try { + api.getItemBookmarks(itemId) + } catch (e: kotlinx.serialization.SerializationException) { + // A server between the route landing and its 2.36.0 shape, or a + // proxy page. Either way the /api/me fallback still works. + return ItemBookmarksOutcome.Unsupported } + if (response.code() == 404 || response.code() == 405) { + response.errorBody()?.close() + return ItemBookmarksOutcome.Unsupported + } + if (!response.isSuccessful) { + response.errorBody()?.close() + return ItemBookmarksOutcome.Failed("HTTP ${response.code()} loading bookmarks") + } + val body = response.body() ?: return ItemBookmarksOutcome.Failed("Empty bookmarks body") + return ItemBookmarksOutcome.Found(body.bookmarks.map { it.toBookmark() }) } + private suspend fun fetchAllBookmarksFromMe(): RemoteResult> { + val response = api.getMe() + if (!response.isSuccessful) { + response.errorBody()?.close() + return RemoteResult.Failed("HTTP ${response.code()} loading bookmarks") + } + val body = response.body() ?: return RemoteResult.Failed("Empty profile body") + return RemoteResult.Ok(body.bookmarks.orEmpty().map { it.toBookmark() }) + } + + private fun ApiBookmark.toBookmark(): Bookmark = Bookmark( + id = id, + libraryItemId = libraryItemId, + title = title, + time = time, + createdAt = createdAt, + ) + suspend fun createBookmark(itemId: String, title: String, time: Double): Boolean = withContext(Dispatchers.IO) { try { val response = api.createBookmark(itemId, CreateBookmarkRequest(title, time)) + if (response.isSuccessful) meBookmarksCache.invalidate() response.isSuccessful } catch (e: Exception) { false @@ -964,6 +1095,7 @@ class ApiService @Inject constructor( withContext(Dispatchers.IO) { try { val response = api.deleteBookmark(itemId, time) + if (response.isSuccessful) meBookmarksCache.invalidate() response.isSuccessful } catch (e: Exception) { false @@ -1041,25 +1173,6 @@ class ApiService @Inject constructor( ) } - /** - * Parses the ABS combined seriesName field (e.g. "Dungeon Crawler Carl #7") into a - * (name, sequence) pair. The non-expanded library items endpoint returns this single - * concatenated string instead of the structured series array that the expanded endpoint - * provides. Supported formats: - * "Series Name #7" → ("Series Name", "7") - * "Series Name #1.5" → ("Series Name", "1.5") - * "Series Name" → ("Series Name", null) - */ - private fun parseSeriesNameField(seriesName: String): Pair { - val trimmed = seriesName.trim() - val hashMatch = Regex("""^(.+?)\s*#([\d.]+)\s*$""").find(trimmed) - return if (hashMatch != null) { - hashMatch.groupValues[1].trim() to hashMatch.groupValues[2] - } else { - trimmed to null - } - } - private fun normalizeProgress(value: Double): Double { val nonNegative = value.coerceAtLeast(0.0) return if (nonNegative > 1.0) { diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt index 0a59cbec..915bdabf 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/AudiobookshelfApi.kt @@ -49,13 +49,21 @@ interface AudiobookshelfApi { @GET("api/libraries") suspend fun getLibraries(): Response - /** Get paginated library items. */ + /** + * Get paginated library items. [sort] and [desc] are left off the URL + * when null, which keeps the full download's request exactly as it was. + * `sort=addedAt` is honoured by every Audiobookshelf 2.x server (the + * SQL-backed list since 2.4 maps it to the item's createdAt column, the + * older in-memory list sorts by the item's addedAt field). + */ @GET("api/libraries/{libraryId}/items") suspend fun getLibraryItems( @Path("libraryId") libraryId: String, @Query("limit") limit: Int = 100, @Query("page") page: Int = 0, @Query("minified") minified: Int = 0, + @Query("sort") sort: String? = null, + @Query("desc") desc: Int? = null, ): Response // ─── Items ─────────────────────────────────────────────────────────── @@ -108,6 +116,10 @@ interface AudiobookshelfApi { // ─── Bookmarks ─────────────────────────────────────────────────────── + /** One book's bookmarks. Audiobookshelf 2.36.0 and later, older servers answer 404. */ + @GET("api/me/bookmarks/{itemId}") + suspend fun getItemBookmarks(@Path("itemId") itemId: String): Response + @POST("api/me/item/{itemId}/bookmark") suspend fun createBookmark( @Path("itemId") itemId: String, diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt new file mode 100644 index 00000000..7e3a2e59 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/BookmarkFetch.kt @@ -0,0 +1,107 @@ +package com.ninelivesaudio.app.data.remote + +import com.ninelivesaudio.app.domain.model.Bookmark +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * What the per-book bookmark route (GET /api/me/bookmarks/{itemId}, added in + * Audiobookshelf 2.36.0) said. [Unsupported] means an older server answered + * 404 or 405, or sent a body that is not the expected shape, so the caller + * falls back to the bookmarks inside /api/me. + */ +internal sealed interface ItemBookmarksOutcome { + data class Found(val bookmarks: List) : ItemBookmarksOutcome + data object Unsupported : ItemBookmarksOutcome + data class Failed(val reason: String) : ItemBookmarksOutcome +} + +/** How long the bookmarks pulled out of /api/me are reused across player loads. */ +internal const val ME_BOOKMARKS_TTL_MS = 5 * 60_000L + +/** + * Bookmarks read from /api/me, kept for [ttlMs] so opening books on an older + * server does not download the whole profile (every progress row the account + * has) each time. One fetch at a time, so two loads back to back share it. + * Keyed by [sessionKey] so a different server URL or account never sees the + * last one's bookmarks. A failure is never cached. + */ +internal class MeBookmarksCache( + private val ttlMs: Long = ME_BOOKMARKS_TTL_MS, + private val now: () -> Long = System::currentTimeMillis, +) { + private val mutex = Mutex() + + // Guards the fields below. [invalidate] takes it without waiting on + // [mutex], and the staleness check and the store happen inside one hold of + // it, so a list fetched before a bookmark change can never be kept after it. + private val lock = Any() + private var invalidations = 0L + private var cachedKey: String? = null + private var cachedAtMs = 0L + private var cached: List? = null + + suspend fun get( + sessionKey: String, + fetch: suspend () -> RemoteResult>, + ): RemoteResult> = mutex.withLock { + val startedAt = synchronized(lock) { + val hit = cached + if (hit != null && cachedKey == sessionKey && now() - cachedAtMs < ttlMs) { + return@withLock RemoteResult.Ok(hit) + } + invalidations + } + val result = fetch() + // A bookmark added or removed while this fetch was in flight makes the + // answer stale, so it is handed back once but not kept. + if (result is RemoteResult.Ok) { + synchronized(lock) { + if (startedAt == invalidations) { + cachedKey = sessionKey + cachedAtMs = now() + cached = result.value + } + } + } + result + } + + /** Drops the cached list, after a bookmark is added or removed. */ + fun invalidate() = synchronized(lock) { + invalidations++ + cached = null + } +} + +/** + * One book's bookmarks, from the per-book route when the server has it and + * from the cached /api/me otherwise. Every failure comes back as + * [RemoteResult.Failed], never as an empty list, so a network blip cannot + * pass for "this book has no bookmarks". + */ +internal suspend fun loadItemBookmarks( + itemId: String, + sessionKey: String, + cache: MeBookmarksCache, + fetchItem: suspend (String) -> ItemBookmarksOutcome, + fetchAll: suspend () -> RemoteResult>, +): RemoteResult> = remoteResultCatching { + when (val outcome = fetchItem(itemId)) { + is ItemBookmarksOutcome.Found -> RemoteResult.Ok(sortedForBook(itemId, outcome.bookmarks)) + is ItemBookmarksOutcome.Failed -> RemoteResult.Failed(outcome.reason) + ItemBookmarksOutcome.Unsupported -> cache.get(sessionKey) { + try { + fetchAll() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + RemoteResult.Failed(describeFailure(e)) + } + }.map { sortedForBook(itemId, it) } + } +} + +private fun sortedForBook(itemId: String, bookmarks: List): List = + bookmarks.filter { it.libraryItemId == itemId }.sortedBy { it.time } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ImageHttpClient.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ImageHttpClient.kt new file mode 100644 index 00000000..fd2b910f --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ImageHttpClient.kt @@ -0,0 +1,19 @@ +package com.ninelivesaudio.app.data.remote + +import okhttp3.Dispatcher +import okhttp3.OkHttpClient + +/** + * The HTTP client cover images load through: the app client with the same auth, + * base URL and certificate setup, but its own request [Dispatcher]. + * + * OkHttp's dispatcher lets 5 requests per host wait on a response at once. With + * one shared client, a screen full of covers on a slow server (a cover the + * server has not resized yet takes a while to answer) filled those slots, and + * the play, session and progress calls queued behind them. Covers now wait in + * their own line. The connection pool is still shared. + */ +fun imageLoaderHttpClient(appClient: OkHttpClient): OkHttpClient = + appClient.newBuilder() + .dispatcher(Dispatcher()) + .build() diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/IncrementalFetch.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/IncrementalFetch.kt new file mode 100644 index 00000000..e41a2172 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/IncrementalFetch.kt @@ -0,0 +1,136 @@ +package com.ninelivesaudio.app.data.remote + +import kotlinx.coroutines.CancellationException + +/** + * The cheapest question a library can be asked: how many books it has and + * which one was added last. One `limit=1&minified=1&sort=addedAt&desc=1` + * request on Audiobookshelf. [newestItemId] and [newestAddedAt] are null when + * the library is empty. + */ +internal data class LibraryHead( + val total: Int, + val newestItemId: String?, + val newestAddedAt: Long?, +) + +/** What an "added since" fetch brought back: the books and the server's book count. */ +internal data class IncrementalFetch( + val items: List, + val total: Int, +) + +/** What to do after one page of an "added since" fetch. */ +internal sealed interface IncrementalPageStep { + /** Every book on the page is newer than the cutoff and more may follow. */ + data object Continue : IncrementalPageStep + + /** The page reached the cutoff or the end of the list. */ + data object Done : IncrementalPageStep + + /** + * The answer cannot be trusted or is not worth finishing. The caller + * falls back to a full download instead of guessing. + */ + data class GiveUp(val reason: String) : IncrementalPageStep +} + +/** + * Decides one page of a newest-first "added since" fetch. + * + * Audiobookshelf has no "updated since" sort, so the fetch pages through books + * newest-added first and stops at the first book older than [cutoff]. The + * order is checked rather than assumed: a server that ignored the sort would + * hand back books in any order, and stopping at the first old one would then + * miss new books further down. Equal neighbours are fine, because a book + * added while paging pushes the next page down by one and repeats a row. + * + * [previousPageLast] is the last added time seen on the page before, so the + * order is checked across the page boundary too. A book with no added time + * gives up, as does running out of [maxPages] (that many new books is a job + * for the full download). + */ +internal fun incrementalPageStep( + addedAts: List, + previousPageLast: Long?, + cutoff: Long, + pageSize: Int, + pageIndex: Int, + maxPages: Int, +): IncrementalPageStep { + if (addedAts.isEmpty()) return IncrementalPageStep.Done + if (addedAts.any { it == null }) return IncrementalPageStep.GiveUp("a book came back without an added date") + val times = addedAts.map { it!! } + var last = previousPageLast + for (time in times) { + if (last != null && time > last) { + return IncrementalPageStep.GiveUp("the server did not sort by added date") + } + last = time + } + if (times.last() < cutoff) return IncrementalPageStep.Done + if (times.size < pageSize) return IncrementalPageStep.Done + if (pageIndex + 1 >= maxPages) return IncrementalPageStep.GiveUp("more than $maxPages pages of new books") + return IncrementalPageStep.Continue +} + +/** + * The paging loop for an "added since" fetch, kept free of Retrofit and + * logging so its stopping rules are pinned by tests against a fake + * [fetchPage]. Rows repeated across pages (a book added mid-fetch shifts the + * offsets) are dropped by [itemKey]. A server whose reported total changes + * between pages is mid-change, and the fetch gives up rather than reason + * about a moving count. + * + * Every failure comes back as [RemoteResult.Failed]. There is no partial + * answer here: the caller either trusts the whole thing or does a full + * download. Cancellation is rethrown. + */ +internal suspend fun runIncrementalFetch( + pageSize: Int, + maxPages: Int, + cutoff: Long, + addedAt: (T) -> Long?, + itemKey: (T) -> String, + onPageFailure: (page: Int, e: Exception) -> Unit = { _, _ -> }, + fetchPage: suspend (page: Int) -> PageOutcome, +): RemoteResult> { + require(maxPages > 0) { "maxPages must be positive" } + val items = mutableListOf() + val seen = mutableSetOf() + var page = 0 + var total: Int? = null + var previousLast: Long? = null + return try { + while (true) { + val outcome = fetchPage(page) + if (outcome is PageOutcome.Stopped) return RemoteResult.Failed(outcome.reason) + outcome as PageOutcome.Page + if (total != null && outcome.total != total) { + return RemoteResult.Failed("page $page: book count moved from $total to ${outcome.total}") + } + total = outcome.total + val addedAts = outcome.results.map(addedAt) + val step = incrementalPageStep( + addedAts = addedAts, + previousPageLast = previousLast, + cutoff = cutoff, + pageSize = pageSize, + pageIndex = page, + maxPages = maxPages, + ) + if (step is IncrementalPageStep.GiveUp) return RemoteResult.Failed("page $page: ${step.reason}") + outcome.results.forEach { if (seen.add(itemKey(it))) items.add(it) } + previousLast = addedAts.lastOrNull() ?: previousLast + if (step == IncrementalPageStep.Done) return RemoteResult.Ok(IncrementalFetch(items.toList(), outcome.total)) + page++ + } + @Suppress("UNREACHABLE_CODE") + error("unreachable: the loop above only exits via return") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + onPageFailure(page, e) + RemoteResult.Failed("page $page: ${describeFailure(e)}") + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt index eb85969c..f5714972 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/RemoteResult.kt @@ -65,11 +65,12 @@ internal sealed class PageOutcome { val reportedPage: Int? = null, val reportedPageCount: Int? = null, ) : PageOutcome() - data class Stopped(val reason: String) : PageOutcome() + /** [retryable] marks a stop worth another try (a 5xx, a rate limit), not a 4xx. */ + data class Stopped(val reason: String, val retryable: Boolean = false) : PageOutcome() } /** - * The pagination loop ApiService.getLibraryItems() (and any future paginated + * The pagination loop ApiService.streamLibraryItems() (and any future paginated * fetch) runs, extracted so its termination logic is pinned directly against * a fake [fetchPage] instead of only being exercised through a live Retrofit * call. A page whose [PageOutcome.Page.results] come back empty, or shorter @@ -96,8 +97,53 @@ internal suspend fun runPaginatedFetch( itemKey: ((T) -> String)? = null, fetchPage: suspend (page: Int) -> PageOutcome, ): RemoteResult> { - require(maxPages > 0) { "maxPages must be positive" } val allItems = mutableListOf() + return when ( + val counted = runPaginatedFetchStreaming( + limit = limit, + maxPages = maxPages, + onPageFailure = onPageFailure, + itemKey = itemKey, + onPage = { allItems.addAll(it) }, + fetchPage = fetchPage, + ) + ) { + is RemoteResult.Ok -> RemoteResult.Ok(allItems.toList()) + is RemoteResult.Partial -> RemoteResult.Partial(allItems.toList(), counted.reason) + is RemoteResult.Failed -> counted + } +} + +/** [stoppedShort] by count, for a fetch that hands its pages on instead of keeping them. */ +private fun stoppedShortCount(fetched: Int, reason: String): RemoteResult = + if (fetched == 0) RemoteResult.Failed(reason) else RemoteResult.Partial(fetched, reason) + +/** [paginationResult] by count. */ +private fun paginationCountResult(fetched: Int, total: Int, currentPage: Int): RemoteResult = + if (total == 0 || fetched >= total) { + RemoteResult.Ok(fetched) + } else { + stoppedShortCount(fetched, "page $currentPage: got $fetched of $total reported") + } + +/** + * [runPaginatedFetch] without holding the pages: each page's new rows go to + * [onPage] as they arrive and only the count comes back. The termination + * rules are the same code, so Ok still means the reported total was reached. + * A big library's sync saves each page as it lands instead of keeping every + * book in memory until the last page, and a page that throws in [onPage] + * stops the fetch short exactly like a failed request. + */ +internal suspend fun runPaginatedFetchStreaming( + limit: Int, + maxPages: Int, + onPageFailure: (page: Int, e: Exception) -> Unit = { _, _ -> }, + itemKey: ((T) -> String)? = null, + onPage: suspend (List) -> Unit, + fetchPage: suspend (page: Int) -> PageOutcome, +): RemoteResult { + require(maxPages > 0) { "maxPages must be positive" } + var fetched = 0 val seenKeys = mutableSetOf() var currentPage = 0 var highestReportedTotal = 0 @@ -105,14 +151,14 @@ internal suspend fun runPaginatedFetch( return try { while (true) { if (currentPage >= maxPages) { - return stoppedShort(allItems, "page $currentPage: reached the $maxPages page cap") + return stoppedShortCount(fetched, "page $currentPage: reached the $maxPages page cap") } when (val outcome = fetchPage(currentPage)) { - is PageOutcome.Stopped -> return stoppedShort(allItems, outcome.reason) + is PageOutcome.Stopped -> return stoppedShortCount(fetched, outcome.reason) is PageOutcome.Page -> { if (outcome.reportedPage != null && outcome.reportedPage != currentPage) { - return stoppedShort( - allItems, + return stoppedShortCount( + fetched, "page $currentPage: server reported page ${outcome.reportedPage}", ) } @@ -123,37 +169,38 @@ internal suspend fun runPaginatedFetch( if (outcome.results.isNotEmpty() && outcome.reportedPageCount != null && outcome.reportedPageCount <= currentPage ) { - return stoppedShort( - allItems, + return stoppedShortCount( + fetched, "page $currentPage: invalid page count ${outcome.reportedPageCount}", ) } if (expectedPageCount != null && outcome.reportedPageCount != null && expectedPageCount != outcome.reportedPageCount ) { - return stoppedShort( - allItems, + return stoppedShortCount( + fetched, "page $currentPage: server changed page count from $expectedPageCount to ${outcome.reportedPageCount}", ) } if (outcome.reportedPageCount != null) expectedPageCount = outcome.reportedPageCount highestReportedTotal = maxOf(highestReportedTotal, outcome.total) if (outcome.results.isEmpty()) { - return paginationResult(allItems, highestReportedTotal, currentPage) + return paginationCountResult(fetched, highestReportedTotal, currentPage) } val newResults = itemKey?.let { key -> outcome.results.filter { seenKeys.add(key(it)) } } ?: outcome.results if (newResults.isEmpty()) { - return stoppedShort(allItems, "page $currentPage: repeated rows made no progress") + return stoppedShortCount(fetched, "page $currentPage: repeated rows made no progress") } - allItems.addAll(newResults) + onPage(newResults) + fetched += newResults.size if ( - (highestReportedTotal > 0 && allItems.size >= highestReportedTotal) || + (highestReportedTotal > 0 && fetched >= highestReportedTotal) || outcome.results.size < limit || (outcome.reportedPageCount != null && currentPage == outcome.reportedPageCount - 1) ) { - return paginationResult(allItems, highestReportedTotal, currentPage) + return paginationCountResult(fetched, highestReportedTotal, currentPage) } currentPage++ } @@ -165,10 +212,49 @@ internal suspend fun runPaginatedFetch( throw e } catch (e: Exception) { onPageFailure(currentPage, e) - stoppedShort(allItems, "page $currentPage: ${describeFailure(e)}") + stoppedShortCount(fetched, "page $currentPage: ${describeFailure(e)}") + } +} + +/** How long a failed library page waits before each retry. */ +internal val PAGE_RETRY_DELAYS_MS = listOf(2_000L, 6_000L) + +/** + * Fetches one page, trying again after each of [delaysMs] when it fails in a + * way worth retrying: a thrown network error, or a [PageOutcome.Stopped] + * marked retryable (a 5xx or a rate limit). A 401 or 404 is returned at + * once. One slow page on a slow home server used to end the whole download + * short with no second try. The last failure is returned (or rethrown) as + * is, so the paging loop reports it exactly as before. Cancellation is + * never retried. + */ +internal suspend fun fetchPageWithRetry( + delaysMs: List = PAGE_RETRY_DELAYS_MS, + sleep: suspend (Long) -> Unit = { kotlinx.coroutines.delay(it) }, + fetch: suspend () -> PageOutcome, +): PageOutcome { + var attempt = 0 + while (true) { + val outcome = try { + fetch() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (attempt >= delaysMs.size) throw e + null + } + if (outcome != null) { + val retry = outcome is PageOutcome.Stopped && outcome.retryable && attempt < delaysMs.size + if (!retry) return outcome + } + sleep(delaysMs[attempt]) + attempt++ } } +/** HTTP statuses worth another try: server errors, timeouts, and rate limits. */ +internal fun isRetryableHttpStatus(code: Int): Boolean = code >= 500 || code == 408 || code == 429 + private const val MAX_FAILURE_REASON = 120 /** diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/SeriesNameParser.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/SeriesNameParser.kt new file mode 100644 index 00000000..1b591b85 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/SeriesNameParser.kt @@ -0,0 +1,37 @@ +package com.ninelivesaudio.app.data.remote + +/** + * Splits the list endpoint's combined `seriesName` into the first series' + * name and sequence. + * + * Audiobookshelf's list endpoint never sends the structured series array. It + * sends one string, `" #"` per series, joined with `", "`: + * "Series Name #7" -> ("Series Name", "7") + * "Series Name #1.5" -> ("Series Name", "1.5") + * "S1 #1, S2 #3" -> ("S1", "1") first series only + * "Love, Death #2" -> ("Love, Death", "2") a comma inside a name + * "Saga #1-3" -> ("Saga", "1-3") sequences are free text + * "Series Name" -> ("Series Name", null) + * + * The old parser only accepted a numeric sequence at the very end, so a + * multi-series book became one bogus series ("S1 #1, S2") and a non-numeric + * sequence kept its "#..." in the name, splitting the series per book. + * + * A first series with no sequence followed by one with a sequence + * ("S1, S2 #3") reads the same as a series named "S1, S2". The string + * carries nothing that tells them apart, so it parses as the latter. + */ +internal fun parseSeriesNameField(seriesName: String): Pair { + val trimmed = seriesName.trim() + if (trimmed.isEmpty()) return null to null + val match = FIRST_SERIES_SEGMENT.find(trimmed) + return if (match != null) { + match.groupValues[1].trim() to match.groupValues[2].trim() + } else { + trimmed to null + } +} + +// Lazy name, then "#", then a sequence with no comma or "#" in it, ending at +// the series separator or the end of the string. +private val FIRST_SERIES_SEGMENT = Regex("""^(.+?)\s*#([^,#]+?)\s*(?:,\s*|$)""") diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlInterceptor.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlInterceptor.kt new file mode 100644 index 00000000..3b67a7c2 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlInterceptor.kt @@ -0,0 +1,58 @@ +package com.ninelivesaudio.app.data.remote + +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Sends every Audiobookshelf item cover request to the server the app is using + * right now, whatever host the stored URL names. + * + * Book rows keep an absolute cover URL from whichever server address synced + * them. Switching between a LAN and a VPN address left every cover pointing at + * the old host, which either does not answer on the new network or gets no + * sign-in token (the token only goes to the current server), so the whole + * shelf fell back to placeholders until a full resync rewrote the rows. The + * URL is now rebuilt from the current server and the item id at request time, + * so existing rows work as they are and no resync is needed. + * + * Covers stay cached under the stored URL, so a switch back and forth does not + * download the library's covers again. The query (the `width` that list rows + * and Android Auto ask for) is kept. + */ +class ServerCoverUrlInterceptor( + private val serverUrl: () -> String, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val server = validatedServerBaseUrl(serverUrl()) ?: return chain.proceed(request) + val target = currentServerCoverUrl(request.url, server) + if (target == null || target == request.url) return chain.proceed(request) + return chain.proceed(request.newBuilder().url(target).build()) + } +} + +/** + * [url] moved onto [server] when it is an item cover (`.../api/items/{id}/cover`), + * with the server's own base path and the original query. Null for anything + * else: audio files, other API calls, other paths. + */ +internal fun currentServerCoverUrl(url: HttpUrl, server: HttpUrl): HttpUrl? { + val segments = url.encodedPathSegments + val n = segments.size + if (n < 4) return null + if (segments[n - 4] != "api" || segments[n - 3] != "items" || segments[n - 1] != "cover") return null + val itemId = segments[n - 2].takeIf { it.isNotEmpty() } ?: return null + + val builder = HttpUrl.Builder() + .scheme(server.scheme) + .host(server.host) + .port(server.port) + server.encodedPathSegments.filter { it.isNotEmpty() }.forEach { builder.addEncodedPathSegment(it) } + builder.addEncodedPathSegment("api") + .addEncodedPathSegment("items") + .addEncodedPathSegment(itemId) + .addEncodedPathSegment("cover") + .encodedQuery(url.encodedQuery) + return builder.build() +} diff --git a/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt b/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt index 7283b0ba..f3747711 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/remote/dto/ApiDtos.kt @@ -225,6 +225,12 @@ data class ApiBookmark( val createdAt: Long = 0, ) +/** GET /api/me/bookmarks/{itemId} */ +@Serializable +data class ApiItemBookmarksResponse( + val bookmarks: List = emptyList(), +) + // ─── Bookmark Request ──────────────────────────────────────────────────── @Serializable diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt index f75ccc5b..3e327538 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/AudioBookRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.withTransaction import androidx.sqlite.db.SimpleSQLiteQuery import com.ninelivesaudio.app.data.local.AppDatabase +import com.ninelivesaudio.app.data.local.containsLikePattern import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.data.local.dao.AudioBookDao @@ -11,22 +12,39 @@ import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.dao.LocalListeningSessionDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow import com.ninelivesaudio.app.data.local.entity.LocalCatalogEntry +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity +import com.ninelivesaudio.app.data.local.entity.SHELF_BOOK_COLUMNS +import com.ninelivesaudio.app.data.local.entity.SyncMergeState +import com.ninelivesaudio.app.data.local.entity.toShelfBook import com.ninelivesaudio.app.data.remote.ApiService import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.util.mapCooperatively import com.ninelivesaudio.app.domain.util.toEpochMillis +import com.ninelivesaudio.app.service.shelfProgress import com.ninelivesaudio.app.service.local.LocalBookFingerprint import com.ninelivesaudio.app.service.local.folderNameOfTrackUri import com.ninelivesaudio.app.service.local.matchMovedLocalBooks import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -40,8 +58,38 @@ class AudioBookRepository @Inject constructor( private val localBookmarkDao: LocalBookmarkDao, private val playbackProgressDao: PlaybackProgressDao, private val database: AppDatabase, + private val watermarkStore: LibrarySyncWatermarkStore, ) { - private val syncLibraryItemsMutex = Mutex() + // Library syncs run here, not in whoever asked, so a caller that leaves + // (the Library tab closing, a newer load replacing an older one) stops + // waiting without throwing away a download someone else is sharing. + private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val syncFlights = LibrarySyncSingleFlight(syncScope) + + // One lock per library and signed-in account (the same key as the + // flights). Fetch, save, and prune for a library happen under its lock, + // so an older response can never land after a newer complete one pruned. + // Other libraries no longer wait behind a big one's download, and a new + // account never waits behind (or shares) the previous account's sync, + // whose writes stop once it notices the switch. + private val libraryLocks = ConcurrentHashMap() + private fun lockFor(flightKey: String): Mutex = libraryLocks.computeIfAbsent(flightKey) { Mutex() } + + private suspend fun isCurrent(identity: LibrarySyncIdentity): Boolean = + watermarkStore.currentSyncIdentity() == identity + + // Background full downloads that keep failing wait before trying again. + private val fullSyncFailures = ConcurrentHashMap() + + private val _booksSaved = MutableSharedFlow(extraBufferCapacity = 16) + + /** A library id, each time a sync saves books for it (every page of a full download). */ + val booksSaved: SharedFlow = _booksSaved.asSharedFlow() + + private val _deferredFullSyncs = MutableStateFlow>(emptySet()) + + /** Libraries whose full download is waiting for an unmetered network. */ + val deferredFullSyncs: StateFlow> = _deferredFullSyncs.asStateFlow() /** Observe all audiobooks (reactive). */ fun observeAll(): Flow> = @@ -63,10 +111,6 @@ class AudioBookRepository @Inject constructor( fun observeById(id: String): Flow = audioBookDao.observeById(id).map { it?.toDomain() } - /** Get all audiobooks from local DB (one-shot). */ - suspend fun getAll(): List = - audioBookDao.getAll().map { it.toDomain() } - /** Get audiobooks by library (one-shot). */ suspend fun getByLibrary(libraryId: String): List = audioBookDao.getByLibrary(libraryId).map { it.toDomain() } @@ -91,13 +135,6 @@ class AudioBookRepository @Inject constructor( suspend fun getById(id: String): AudioBook? = audioBookDao.getById(id)?.toDomain() - /** Search audiobooks by title or author. */ - suspend fun search(query: String): List { - val normalized = query.trim() - if (normalized.isEmpty()) return getAll() - return audioBookDao.search(normalized).map { it.toDomain() } - } - /** Get recently played audiobooks for Nine Lives home screen. */ suspend fun getRecentlyPlayed(limit: Int = 9): List> = audioBookDao.getRecentlyPlayed(limit).map { result -> @@ -145,6 +182,9 @@ class AudioBookRepository @Inject constructor( * Get filtered books for a library, pushing WHERE clauses to SQL. * Eliminates the need to hold all books in memory for filtering. * + * Returns light shelf books: description, audio files, and tags are + * empty. Never pass one to [save] or [saveAll], read the book by id. + * * @param tab 0=All, 1=InProgress, 2=Completed, 3=Downloaded * @param hideFinished whether to exclude finished books * @param downloadedOnly whether to show only downloaded books @@ -158,19 +198,12 @@ class AudioBookRepository @Inject constructor( searchQuery: String = "", ): List { val sql = buildLibrarySql(tab, hideFinished, downloadedOnly, searchQuery.isNotBlank()) + val args = buildLibrarySqlArgs(libraryId, searchQuery) - val args = mutableListOf(libraryId) - if (searchQuery.isNotBlank()) { - val pattern = "%${searchQuery}%" - args.addAll(listOf(pattern, pattern, pattern, pattern)) - } - - val results = audioBookDao.getFilteredBooks(SimpleSQLiteQuery(sql, args.toTypedArray())) - return results.map { result -> - result.audioBook.toDomain().copy( - lastPlayedAt = result.lastPlayedAt?.toEpochMillis() - ) - } + val results = audioBookDao.getFilteredBooks(SimpleSQLiteQuery(sql, args)) + // Shelf books carry no description, audio files, or tags. They are + // for showing and navigating, never for saving back. See toShelfBook. + return results.mapCooperatively { it.toShelfBook() } } /** Count all audiobooks in a library. */ @@ -186,57 +219,234 @@ class AudioBookRepository @Inject constructor( suspend fun countRecentlyPlayedForAuto(libraryId: String, isLocal: Boolean): Int = audioBookDao.countRecentlyPlayedByLibrary(libraryId, if (isLocal) 1 else 0) - /** Get distinct series names for a library. */ - suspend fun getDistinctSeries(libraryId: String): List = - audioBookDao.getDistinctSeries(libraryId) - - /** Get distinct authors for a library. */ - suspend fun getDistinctAuthors(libraryId: String): List = - audioBookDao.getDistinctAuthors(libraryId) - - /** Get distinct genres for a library (parsed from JSON arrays). */ - suspend fun getDistinctGenres(libraryId: String): List { - val jsonStrings = audioBookDao.getDistinctGenresJson(libraryId) - return jsonStrings - .flatMap { json -> - try { - Json.decodeFromString>(json) - } catch (_: Exception) { - emptyList() - } - } - .filter { it.isNotBlank() } - .distinct() - .sorted() + /** + * Download every item in a library and save it, a page at a time. This is + * the full sync: sign-in, a server or account switch, a switch back from + * Local mode, an explicit refresh (pull to refresh, Retry, Sync Now), and + * the fallback whenever a change check cannot be trusted. It never waits + * for an unmetered network or a failure backoff. + * + * A partial fetch still saves what it got: some of the shelf beats none + * of it, and the caller keeps the failure reason for the bug report. A + * complete (Ok) fetch is authoritative for this library, including a + * confirmed-empty one, so the cache is pruned to match it exactly. See + * [pruneServerLibraryTo]. A sync already running for this library is + * shared, not repeated (see [LibrarySyncSingleFlight]). + * + * Returns how many books the fetch saved. + */ + suspend fun syncLibraryItems(libraryId: String): RemoteResult { + val identity = watermarkStore.currentSyncIdentity() + val flightKey = identity.flightKey(libraryId) + return syncFlights.run(flightKey, LibrarySyncKind.FULL) { + lockFor(flightKey).withLock { runFullSyncLocked(libraryId, identity) } + }.itemCountResult() ?: RemoteResult.Failed("sync did not run") } + /** Whether this library has any server books cached, so a shelf exists to show. */ + internal suspend fun hasServerBooks(libraryId: String): Boolean = + audioBookDao.countServerBooksByLibrary(libraryId) > 0 + /** - * Fetch all items for a library from server and save to local DB. A - * partial fetch still saves what it got: some of the shelf beats none of - * it, and the caller keeps the failure reason for the bug report. - * - * A complete (Ok) fetch, by contrast, is authoritative for this library - * — including a confirmed-empty one, so the cache is reconciled to match - * it exactly rather than just upserted-into. See [reconcileServerLibrary]. + * The background check for one library: ask the server whether anything + * was added or removed since the last good sync (one tiny request), and + * do the least work that brings the shelf back in line. Nothing new means + * nothing fetched and nothing written. Added books are fetched on their + * own. Removals, doubt, a first sync, or a day-old last full download + * fall back to the full download, which waits for an unmetered network + * when [isMetered] and a cached shelf exists. See [planLibrarySync]. */ - suspend fun syncLibraryItems(libraryId: String): RemoteResult> = runSerializedLibraryItemSync( - mutex = syncLibraryItemsMutex, - libraryId = libraryId, - fetchItems = { apiService.getLibraryItems(libraryId) }, - mergeItems = { remote -> mergeSyncedBooks(remote, audioBookDao::getByIds) }, - upsertAll = { books -> audioBookDao.upsertAll(books.map { it.toEntity() }) }, - cachedNonDownloadedIds = audioBookDao::getNonDownloadedServerIdsByLibrary, - deleteByIds = audioBookDao::deleteServerBooksByIds, - deleteAllServerBooks = { id -> audioBookDao.deleteServerBooksByLibrary(id) }, - ) + internal suspend fun refreshLibraryItemsIfChanged(libraryId: String, isMetered: Boolean): LibraryRefreshOutcome { + val identity = watermarkStore.currentSyncIdentity() + val flightKey = identity.flightKey(libraryId) + return syncFlights.run(flightKey, LibrarySyncKind.CHECK) { + lockFor(flightKey).withLock { runCheckLocked(libraryId, identity, isMetered) } + } + } + + private suspend fun runCheckLocked( + libraryId: String, + identity: LibrarySyncIdentity, + isMetered: Boolean, + ): LibraryRefreshOutcome { + if (!isCurrent(identity)) return LibraryRefreshOutcome.CheckFailed(SYNC_ACCOUNT_CHANGED) + val key = identity.account + val watermark = watermarkStore.get(key, libraryId) + val localCount = audioBookDao.countServerBooksByLibrary(libraryId) + val hasCachedRows = localCount > 0 + val change = if (watermark == null) { + LibraryChange.Full("no earlier sync of this library") + } else { + when (val head = apiService.getLibraryHead(libraryId)) { + is RemoteResult.Ok -> decideLibraryChange(watermark, head.value, localCount) + is RemoteResult.Partial -> return LibraryRefreshOutcome.CheckFailed(head.reason) + is RemoteResult.Failed -> return LibraryRefreshOutcome.CheckFailed(head.reason) + } + } + val plan = planLibrarySync( + change = change, + isStale = watermark?.let { isWatermarkStale(it, System.currentTimeMillis()) } ?: false, + isMetered = isMetered, + hasCachedRows = hasCachedRows, + ) + val reason = (change as? LibraryChange.Full)?.reason ?: "last full download is over a day old" + return when (plan) { + LibrarySyncPlan.SKIP -> LibraryRefreshOutcome.Unchanged(watermark?.itemCount ?: localCount) + LibrarySyncPlan.DEFER_FULL -> deferUntilUnmetered(libraryId, reason) + LibrarySyncPlan.FULL -> runBackgroundFullLocked(libraryId, identity, reason) + LibrarySyncPlan.INCREMENTAL -> + runIncrementalLocked(libraryId, identity, watermark!!, isMetered, hasCachedRows) + } + } + + private suspend fun runIncrementalLocked( + libraryId: String, + identity: LibrarySyncIdentity, + watermark: com.ninelivesaudio.app.domain.model.LibrarySyncWatermark, + isMetered: Boolean, + hasCachedRows: Boolean, + ): LibraryRefreshOutcome { + val fetched = apiService.getLibraryItemsAddedSince( + libraryId = libraryId, + cutoff = incrementalCutoff(watermark), + pageSize = INCREMENTAL_PAGE_SIZE, + maxPages = INCREMENTAL_MAX_PAGES, + ) + val key = identity.account + val value = (fetched as? RemoteResult.Ok)?.value + ?: return fallBackToFullLocked(libraryId, identity, (fetched as? RemoteResult.Failed)?.reason ?: "added-since fetch failed", isMetered, hasCachedRows) + val books = value.items + val alreadyCached = fetchByIdChunks(books.map { it.id }) { ids -> + audioBookDao.getServerIdsInLibrary(libraryId, ids) + }.toHashSet() + val newBookCount = newBooksSinceWatermark(watermark, books, alreadyCached) + if (books.isNotEmpty()) { + val merged = mergeForSave(books) + // Added books are saved even when the counts disagree below: they + // are real, and the full download that follows prunes, not this. + // Never into a cache another account has taken over meanwhile: + // the account check and the save are one step no sign-in splits. + val entities = withContext(Dispatchers.Default) { merged.map { it.toEntity() } } + val saved = withContext(NonCancellable) { + watermarkStore.runIfCurrent(identity) { audioBookDao.upsertAll(entities) } + } + if (!saved) return LibraryRefreshOutcome.CheckFailed(SYNC_ACCOUNT_CHANGED) + _booksSaved.tryEmit(libraryId) + } + if (newBookCount == null || !incrementalCountsAgree(watermark.itemCount, newBookCount, value.total)) { + // The books just saved are not in the watermark's count, so it no + // longer describes the cache. Forgetting it keeps every later + // check on the full download until one completes. + watermarkStore.remove(key, libraryId) + return fallBackToFullLocked( + libraryId, + identity, + if (newBookCount == null) { + "cannot tell which books near the newest one are new" + } else { + "server has ${value.total} books, expected ${watermark.itemCount} + $newBookCount new" + }, + isMetered, + hasCachedRows, + ) + } + watermarkStore.put(watermarkAfterIncremental(watermark, books, value.total, System.currentTimeMillis())) + return LibraryRefreshOutcome.Incremental(newBookCount = newBookCount, itemCount = value.total) + } + + private suspend fun fallBackToFullLocked( + libraryId: String, + identity: LibrarySyncIdentity, + reason: String, + isMetered: Boolean, + hasCachedRows: Boolean, + ): LibraryRefreshOutcome = + if (shouldDeferFullSync(isMetered, explicit = false, hasCachedRows)) { + deferUntilUnmetered(libraryId, reason) + } else { + runBackgroundFullLocked(libraryId, identity, reason) + } + + private suspend fun runBackgroundFullLocked( + libraryId: String, + identity: LibrarySyncIdentity, + reason: String, + ): LibraryRefreshOutcome { + if (isFullSyncBackedOff(fullSyncFailures[libraryId], monotonicNowMs())) { + return LibraryRefreshOutcome.Deferred("$reason (waiting after failed downloads)", untilUnmetered = false) + } + return runFullSyncLocked(libraryId, identity) + } + + private fun deferUntilUnmetered(libraryId: String, reason: String): LibraryRefreshOutcome { + _deferredFullSyncs.update { it + libraryId } + return LibraryRefreshOutcome.Deferred(reason, untilUnmetered = true) + } + + private suspend fun runFullSyncLocked(libraryId: String, identity: LibrarySyncIdentity): LibraryRefreshOutcome { + // A check that got here is now a full download: let a full refresh + // asked for meanwhile share it rather than queue a second one. + syncFlights.upgradeToFull(identity.flightKey(libraryId)) + val key = identity.account + val tally = FullSyncTally() + val result = runLibraryItemSyncPass( + libraryId = libraryId, + fetchPages = { onPage -> apiService.streamLibraryItems(libraryId, onPage = onPage) }, + mergeItems = ::mergeForSave, + upsertAll = ::saveMerged, + cachedNonDownloadedIds = audioBookDao::getNonDownloadedServerIdsByLibrary, + deleteByIds = audioBookDao::deleteServerBooksByIds, + deleteAllServerBooks = { id -> audioBookDao.deleteServerBooksByLibrary(id) }, + onPageSaved = { books -> + tally.add(books) + _booksSaved.tryEmit(libraryId) + }, + writeIfCurrent = { write -> watermarkStore.runIfCurrent(identity, write) }, + ) + // Another account signed in mid-download. Nothing more was written, + // and this account's watermark and failure count stay as they were. + if (result is RemoteResult.Failed && result.reason == SYNC_ACCOUNT_CHANGED) { + return LibraryRefreshOutcome.Full(result) + } + val watermark = watermarkAfterFullSync(key, libraryId, tally, result, System.currentTimeMillis()) + if (watermark != null) watermarkStore.put(watermark) else watermarkStore.remove(key, libraryId) + if (result is RemoteResult.Ok) { + fullSyncFailures.remove(libraryId) + _deferredFullSyncs.update { it - libraryId } + } else { + fullSyncFailures.compute(libraryId) { _, previous -> + FullSyncFailures(count = (previous?.count ?: 0) + 1, lastFailureAtMs = monotonicNowMs()) + } + } + return LibraryRefreshOutcome.Full(result) + } + + // Thousands of books are merged and JSON-encoded here, so keep it off the + // main thread the Library calls from. + private suspend fun mergeForSave(remote: List): List = + withContext(Dispatchers.Default) { + mergeSyncedBooksLean( + remote, + audioBookDao::getSyncMergeStates, + audioBookDao::getByIds, + playbackProgressDao::getByAudioBookIds, + ) + } + + private suspend fun saveMerged(books: List) { + audioBookDao.upsertAll(withContext(Dispatchers.Default) { books.map { it.toEntity() } }) + } + + private fun monotonicNowMs(): Long = System.nanoTime() / 1_000_000L /** - * Applies an authoritative library-list removal through the same mutex as - * item sync. A list response cannot prune this library while an older item - * response is still in flight and about to upsert its stale books. + * Applies an authoritative library-list removal under the same lock as + * that library's item sync. A list response cannot prune this library + * while an older item response is still in flight and about to upsert its + * stale books. */ internal suspend fun pruneServerBooksForRemovedLibrary(libraryId: String): Boolean = - runSerializedLibraryItemPrune(syncLibraryItemsMutex) { + runSerializedLibraryItemPrune(lockFor(watermarkStore.currentSyncIdentity().flightKey(libraryId))) { audioBookDao.deleteServerBooksByLibrary(libraryId) audioBookDao.hasDownloadedServerBooks(libraryId) } @@ -442,6 +652,43 @@ class AudioBookRepository @Inject constructor( suspend fun deleteAll() { audioBookDao.deleteAll() } + + // ─── Readers outside the Library shelf ─────────────────────────────── + + /** + * The books among [ids] that belong to one library and source, looked up + * 500 ids at a time. Ids that are missing or belong elsewhere are left out. + */ + suspend fun getByIdsInLibraryAndSource( + libraryId: String, + isLocal: Boolean, + ids: Collection, + ): List = + fetchByIdChunks(ids.distinct()) { chunk -> + audioBookDao.getByIdsInLibraryAndSource(libraryId, if (isLocal) 1 else 0, chunk) + }.map { it.toDomain() } + + /** One page of Android Auto's Library list, A to Z, light rows. */ + suspend fun getAutoBrowsePage(libraryId: String, isLocal: Boolean, limit: Int, offset: Int): List = + audioBookDao.getAutoBrowsePage(libraryId, if (isLocal) 1 else 0, limit, offset) + + /** One page of Android Auto's Downloaded list, A to Z, light rows. */ + suspend fun getAutoDownloadedPage(libraryId: String, isLocal: Boolean, limit: Int, offset: Int): List = + audioBookDao.getAutoDownloadedPage(libraryId, if (isLocal) 1 else 0, limit, offset) + + /** One page of Android Auto search hits for an escaped LIKE [pattern], light rows. */ + suspend fun searchAutoPage( + libraryId: String, + isLocal: Boolean, + pattern: String, + limit: Int, + offset: Int, + ): List = + audioBookDao.searchAutoPage(libraryId, if (isLocal) 1 else 0, pattern, limit, offset) + + /** How many Android Auto search hits [pattern] has, counting no further than [cap]. */ + suspend fun countAutoSearch(libraryId: String, isLocal: Boolean, pattern: String, cap: Int): Int = + audioBookDao.countAutoSearch(libraryId, if (isLocal) 1 else 0, pattern, cap) } private const val MAXIMUM_AUDIOBOOK_LOOKUP_BIND_COUNT = 500 @@ -474,16 +721,15 @@ internal suspend fun mergeSyncedBooks( /** * Reconciles the DAO's cached SERVER rows for one library against a - * completed [syncLibraryItems] fetch, extracted so the branching is - * unit-testable without Room (issue #14, PR #30 review). + * completed fetch, extracted so the branching is unit-testable without Room + * (issue #14, PR #30 review). * - * A complete ([isComplete]) fetch is authoritative for [libraryId] — the + * A complete ([isComplete]) fetch is authoritative for [libraryId]: the * cache should end up matching it exactly, including down to nothing when * [merged] is empty (a previously populated library that genuinely emptied * out). An incomplete (Partial) fetch is never authoritative: it upserts * what it got (some of the shelf beats none of it) but never prunes, - * because a page it couldn't reach is not proof those books are gone — - * that retention is unchanged from before this fix. + * because a page it couldn't reach is not proof those books are gone. * * [cachedNonDownloadedIds], [deleteByIds], and [deleteAllServerBooks] are * expected to scope to server (non-local) rows and exempt downloaded books. @@ -503,10 +749,31 @@ internal suspend fun reconcileServerLibrary( upsertAll(merged) } if (!isComplete) return - if (merged.isEmpty()) { + pruneServerLibraryTo( + keptIds = merged.mapTo(mutableSetOf()) { it.id }, + libraryId = libraryId, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) +} + +/** + * The prune half of [reconcileServerLibrary], for a complete fetch whose + * books were already saved page by page: every cached non-downloaded server + * book of [libraryId] not in [keptIds] goes, and an empty [keptIds] empties + * the library. Only ever called after a complete fetch. + */ +internal suspend fun pruneServerLibraryTo( + keptIds: Set, + libraryId: String, + cachedNonDownloadedIds: suspend (libraryId: String) -> List, + deleteByIds: suspend (libraryId: String, ids: List) -> Unit, + deleteAllServerBooks: suspend (libraryId: String) -> Unit, +) { + if (keptIds.isEmpty()) { deleteAllServerBooks(libraryId) } else { - val keptIds = merged.mapTo(mutableSetOf()) { it.id } val missingIds = cachedNonDownloadedIds(libraryId).filterNot { it in keptIds } for (ids in missingIds.chunked(MAXIMUM_SERVER_BOOK_DELETE_BIND_COUNT)) { deleteByIds(libraryId, ids) @@ -515,46 +782,104 @@ internal suspend fun reconcileServerLibrary( } /** - * Serializes one item's full fetch, merge, and reconciliation sequence. Both - * SyncManager and LibraryViewModel can refresh the same library independently. - * Holding [mutex] across the network fetch makes a later caller fetch only - * after every earlier response has been applied, so an old response cannot - * upsert books that a newer complete response already pruned. + * One full fetch-save-prune pass for a library, the caller holding its lock. + * + * Each page is merged and saved as it arrives ([fetchPages] hands pages to + * the callback), so a big library fills the shelf progressively and never + * sits in memory whole. Saves run NonCancellable so a page is saved entirely + * or not at all. Only a complete (Ok) fetch prunes, against every id it + * delivered, and the prune also runs NonCancellable: once the fetch has + * proved what the server holds, the cache is brought in line even if the + * caller is cancelled in between. A Partial or Failed fetch keeps the pages + * it saved and prunes nothing. + * + * [writeIfCurrent] runs a write only while the account the pass runs for is + * still signed in, checking and writing as one step that no sign-in can + * split (see [LibrarySyncWatermarkStore.runIfCurrent]). Each save and the + * prune go through it, and once it refuses the pass stops, writes nothing + * more, and fails with [SYNC_ACCOUNT_CHANGED]: the cache now belongs to + * someone else, whose own sync fills and prunes it. */ -internal suspend fun runSerializedLibraryItemSync( - mutex: Mutex, +internal suspend fun runLibraryItemSyncPass( libraryId: String, - fetchItems: suspend () -> RemoteResult>, + fetchPages: suspend (onPage: suspend (List) -> Unit) -> RemoteResult, mergeItems: suspend (List) -> List, upsertAll: suspend (List) -> Unit, cachedNonDownloadedIds: suspend (libraryId: String) -> List, deleteByIds: suspend (libraryId: String, ids: List) -> Unit, deleteAllServerBooks: suspend (libraryId: String) -> Unit, -): RemoteResult> = mutex.withLock { - val result = fetchItems() - val remote = when (result) { - is RemoteResult.Ok -> result.value - is RemoteResult.Partial -> result.value - is RemoteResult.Failed -> return@withLock result + onPageSaved: suspend (List) -> Unit = {}, + writeIfCurrent: suspend (write: suspend () -> Unit) -> Boolean = { write -> write(); true }, +): RemoteResult { + val keptIds = HashSet() + val result = try { + fetchPages { page -> + val merged = mergeItems(page) + val saved = withContext(NonCancellable) { + writeIfCurrent { if (merged.isNotEmpty()) upsertAll(merged) } + } + // Thrown to stop the download: the rest is for an account that + // is no longer signed in. + if (!saved) throw SyncAccountChangedException() + merged.mapTo(keptIds) { it.id } + onPageSaved(merged) + } + } catch (_: SyncAccountChangedException) { + return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) } - val merged = mergeItems(remote) - - withContext(NonCancellable) { - reconcileServerLibrary( - isComplete = result is RemoteResult.Ok, - merged = merged, - libraryId = libraryId, - upsertAll = upsertAll, - cachedNonDownloadedIds = cachedNonDownloadedIds, - deleteByIds = deleteByIds, - deleteAllServerBooks = deleteAllServerBooks, - ) + // A streaming fetch turns a throw in onPage into a short result, so + // look again rather than trusting the catch above alone. + if (!writeIfCurrent {}) return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) + if (result is RemoteResult.Ok) { + // The read of cached ids and every delete run inside one gated + // step, so a sign-in waits for the whole prune rather than slipping + // in between the read and a delete. + val pruned = withContext(NonCancellable) { + writeIfCurrent { + pruneServerLibraryTo( + keptIds = keptIds, + libraryId = libraryId, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) + } + } + if (!pruned) return RemoteResult.Failed(SYNC_ACCOUNT_CHANGED) } + return result +} - when (result) { - is RemoteResult.Partial -> RemoteResult.Partial(merged, result.reason) - else -> RemoteResult.Ok(merged) - } +/** Why a sync stopped: the account it ran for is no longer the signed-in one. */ +internal const val SYNC_ACCOUNT_CHANGED = "the signed-in account changed during the sync" + +private class SyncAccountChangedException : Exception(SYNC_ACCOUNT_CHANGED) + +/** + * [runLibraryItemSyncPass] under [mutex]. Holding the lock across the whole + * fetch makes a later caller fetch only after every earlier response has + * been applied, so an old response cannot upsert books that a newer + * complete response already pruned. + */ +internal suspend fun runSerializedLibraryItemSync( + mutex: Mutex, + libraryId: String, + fetchPages: suspend (onPage: suspend (List) -> Unit) -> RemoteResult, + mergeItems: suspend (List) -> List, + upsertAll: suspend (List) -> Unit, + cachedNonDownloadedIds: suspend (libraryId: String) -> List, + deleteByIds: suspend (libraryId: String, ids: List) -> Unit, + deleteAllServerBooks: suspend (libraryId: String) -> Unit, +): RemoteResult = mutex.withLock { + runLibraryItemSyncPass( + libraryId = libraryId, + fetchPages = fetchPages, + mergeItems = mergeItems, + upsertAll = upsertAll, + cachedNonDownloadedIds = cachedNonDownloadedIds, + deleteByIds = deleteByIds, + deleteAllServerBooks = deleteAllServerBooks, + ) } /** Runs an authoritative item prune in the same ordering domain as item sync. */ @@ -563,13 +888,86 @@ internal suspend fun runSerializedLibraryItemPrune( prune: suspend () -> T, ): T = mutex.withLock { prune() } +/** + * [mergeSyncedBooks] reading only the columns the merge keeps. The whole row + * (audio file and chapter lists) is read only for a downloaded book whose + * server copy came back without tracks or chapters, the one case the merge + * borrows them from the local row. A book new to the cache takes the + * position the progress pull saved for it ([getProgressRows]), see + * [withPulledProgress]. + */ +internal suspend fun mergeSyncedBooksLean( + remote: List, + getMergeStates: suspend (List) -> List, + getFullRows: suspend (List) -> List, + getProgressRows: suspend (List) -> List, +): List { + if (remote.isEmpty()) return emptyList() + val states = fetchByIdChunks(remote.map { it.id }, getMergeStates).associateBy { it.id } + val needDetail = remote.filter { book -> + states[book.id]?.isDownloaded == 1 && (book.audioFiles.isEmpty() || book.chapters.isEmpty()) + }.map { it.id } + val details = if (needDetail.isEmpty()) emptyMap() else fetchByIdChunks(needDetail, getFullRows).associateBy { it.id } + val newIds = remote.filter { states[it.id] == null }.map { it.id } + val pulled = if (newIds.isEmpty()) emptyMap() else fetchByIdChunks(newIds, getProgressRows).associateBy { it.audioBookId } + return remote.map { book -> + val state = states[book.id] + if (state == null) withPulledProgress(book, pulled[book.id]) else mergeSyncedBook(book, state, details[book.id]) + } +} + +/** + * A book new to the cache, with the position the progress pull already saved + * for it. The library list carries no listening progress, and a pull fetches + * only the few most recently listened unknown books one by one, so every + * other book with progress used to land on the shelf at zero and unfinished + * (wrong in In Progress, Completed, and hide finished) until a later pull. + * The saved row wins only when it is ahead, as local progress does in + * [mergeSyncedBook]. + */ +internal fun withPulledProgress(remote: AudioBook, row: PlaybackProgressEntity?): AudioBook { + if (row == null) return remote + val finished = row.isFinished == 1 + val remoteTime = remote.currentTime.inWholeMilliseconds / 1000.0 + if (row.positionSeconds <= remoteTime && (!finished || remote.isFinished)) return remote + return remote.copy( + currentTime = row.positionSeconds.seconds, + progress = shelfProgress( + currentTime = row.positionSeconds, + duration = remote.duration.inWholeMilliseconds / 1000.0, + isFinished = finished, + existingProgress = remote.progress, + ), + isFinished = finished || remote.isFinished, + ) +} + +internal fun AudioBookEntity.toSyncMergeState(): SyncMergeState = SyncMergeState( + id = id, + isDownloaded = isDownloaded, + localPath = localPath, + localCoverPath = localCoverPath, + currentTimeSeconds = currentTimeSeconds, + progress = progress, + isFinished = isFinished, + archivedAt = archivedAt, +) + /** * Merge a server book with its local row during sync, preserving local-only * state the server does not know about: the download flag, the on-disk audio * path, the cover persisted at download time, and any local playback progress * that is ahead of the server. Pure, so it is unit-testable without the DB. */ -internal fun mergeSyncedBook(remote: AudioBook, local: AudioBookEntity?): AudioBook { +internal fun mergeSyncedBook(remote: AudioBook, local: AudioBookEntity?): AudioBook = + mergeSyncedBook(remote, local?.toSyncMergeState(), local) + +/** + * [mergeSyncedBook] from the kept columns alone. [detail] is the whole local + * row, needed only to borrow tracks or chapters for a downloaded book whose + * server copy has none. Without it those stay as the server sent them. + */ +internal fun mergeSyncedBook(remote: AudioBook, local: SyncMergeState?, detail: AudioBookEntity?): AudioBook { if (local == null) return remote val withDownload = if (local.isDownloaded == 1) { @@ -577,15 +975,24 @@ internal fun mergeSyncedBook(remote: AudioBook, local: AudioBookEntity?): AudioB isDownloaded = true, localPath = local.localPath, localCoverPath = local.localCoverPath, - audioFiles = remote.audioFiles.ifEmpty { local.toDomain().audioFiles }, - chapters = remote.chapters.ifEmpty { local.toDomain().chapters }, + audioFiles = remote.audioFiles.ifEmpty { detail?.toDomain()?.audioFiles.orEmpty() }, + chapters = remote.chapters.ifEmpty { detail?.toDomain()?.chapters.orEmpty() }, ) } else remote - // Preserve local progress if it's ahead of the server (offline playback). + // Preserve local progress if it's ahead of the server (offline playback), + // or if it is a completion the server copy does not carry. The library + // list holds no listening progress, so its copy always reads unfinished + // at zero, and a book the progress pull marked finished at position zero + // (finished on the server without a saved place) has no time to be + // ahead with. Comparing times alone wiped that completion on the next + // refresh. Only the progress pull, which reads the server's own record, + // may mark a book unfinished again. val localTime = local.currentTimeSeconds val remoteTime = withDownload.currentTime.inWholeMilliseconds / 1000.0 - val merged = if (localTime > remoteTime) { + val localDone = local.isFinished == 1 || local.progress >= 1.0 + val remoteDone = withDownload.isFinished || withDownload.progress >= 1.0 + val merged = if (localTime > remoteTime || (localDone && !remoteDone)) { withDownload.copy( currentTime = localTime.seconds, progress = local.progress, @@ -619,7 +1026,7 @@ internal fun buildLibrarySql( downloadedOnly: Boolean, hasSearch: Boolean, ): String = buildString { - append("SELECT ab.*, pp.UpdatedAt AS lastPlayedAt FROM AudioBooks ab") + append("SELECT ").append(SHELF_BOOK_COLUMNS).append(" FROM AudioBooks ab") append(" LEFT JOIN PlaybackProgress pp ON ab.Id = pp.AudioBookId") append(" WHERE ab.LibraryId = ?") @@ -655,10 +1062,23 @@ internal fun buildLibrarySql( append(" AND ab.IsDownloaded = 1") } - // Search + // Search. The bound patterns are escaped (see buildLibrarySqlArgs), so + // "%" and "_" typed in the search box match themselves. if (hasSearch) { - append(" AND (ab.Title LIKE ? OR ab.Author LIKE ? OR ab.SeriesName LIKE ? OR ab.Narrator LIKE ?)") + append(" AND (ab.Title LIKE ? ESCAPE '\\' OR ab.Author LIKE ? ESCAPE '\\'") + append(" OR ab.SeriesName LIKE ? ESCAPE '\\' OR ab.Narrator LIKE ? ESCAPE '\\')") } - append(" ORDER BY ab.Title") + // No ORDER BY: the Library sorts in Kotlin (sortBooks), so sorting here + // too was paid twice. Android Auto reads its own paged queries. +} + +/** + * The arguments for [buildLibrarySql]: the library, then one escaped pattern + * per searched column when there is a search. + */ +internal fun buildLibrarySqlArgs(libraryId: String, searchQuery: String): Array { + if (searchQuery.isBlank()) return arrayOf(libraryId) + val pattern = containsLikePattern(searchQuery) + return arrayOf(libraryId, pattern, pattern, pattern, pattern) } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt index 238dddda..ab212ccb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/BookmarkRepository.kt @@ -3,6 +3,7 @@ package com.ninelivesaudio.app.data.repository import com.ninelivesaudio.app.data.local.dao.LocalBookmarkDao import com.ninelivesaudio.app.data.local.entity.LocalBookmarkEntity import com.ninelivesaudio.app.data.remote.ApiService +import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.Bookmark import com.ninelivesaudio.app.service.SettingsManager @@ -16,7 +17,8 @@ import javax.inject.Singleton * player when the user marks a moment in a scanned-local file. * * In AUDIOBOOKSHELF mode, delegates to ApiService which round-trips - * /api/me/item/{itemId}/bookmark. + * the per-book bookmark route (or a cached /api/me on older servers) and + * /api/me/item/{itemId}/bookmark for changes. * * The repository owns the dispatch so callers (PlayerViewModel etc.) don't * need to know which backend is live — they just call get/create/delete with @@ -28,9 +30,10 @@ class BookmarkRepository @Inject constructor( private val bookmarkDao: LocalBookmarkDao, private val settingsManager: SettingsManager, ) { - suspend fun getBookmarks(itemId: String): List { + /** A failure stays a failure, so the player can say so instead of showing no bookmarks. */ + suspend fun getBookmarks(itemId: String): RemoteResult> { return if (isLocal()) { - bookmarkDao.getByAudioBookId(itemId).map { it.toDomain() } + RemoteResult.Ok(bookmarkDao.getByAudioBookId(itemId).map { it.toDomain() }) } else { apiService.getBookmarks(itemId) } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt new file mode 100644 index 00000000..d6b1212d --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlanner.kt @@ -0,0 +1,544 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.data.remote.LibraryHead +import com.ninelivesaudio.app.data.remote.RemoteResult +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.LibrarySyncWatermark +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.selects.select + +// The pure decisions behind the library change check and the "added since" +// sync. Nothing here touches Room, Retrofit, or the clock, so every rule is +// pinned by unit tests (LibrarySyncPlannerTest, LibrarySyncSingleFlightTest). + +/** How old the last full download may get before a check refreshes everything. */ +internal const val FULL_REFRESH_MAX_AGE_MS = 24 * 60 * 60_000L + +/** + * How far before the stored newest book an "added since" fetch reaches back. + * A server scan can stamp a book's added time a moment before another book + * that commits first, so a sync can see the later one and not the earlier. + * Re-fetching that sliver is idempotent. It is only an optimisation: a book + * missed anyway shows up in the count check and triggers a full download. + * Kept short because a bulk import adds hundreds of books a few seconds + * apart, and every one inside the window is downloaded again on each check + * that finds something new. + */ +internal const val INCREMENTAL_OVERLAP_MS = 60_000L + +/** + * Page size for the "added since" fetch. Smaller than the full download's 100 + * because a check that finds changes usually finds a handful of new books, + * and each full-detail book is a few kilobytes on what may be a cell network. + */ +internal const val INCREMENTAL_PAGE_SIZE = 25 + +/** Past this many pages (500 books) the full download is the simpler answer. */ +internal const val INCREMENTAL_MAX_PAGES = 20 + +/** How many libraries' watermarks are kept across every server and account. */ +internal const val MAX_STORED_WATERMARKS = 64 + +/** A server and the account signed in to it. Watermarks never cross accounts. */ +internal data class SyncAccountKey(val serverUrl: String, val username: String) + +/** + * Who a library sync runs for: the server, the account, and which sign-in + * ([sessionHash], a hash of its token, so two token sign-ins that both keep + * an empty username still differ). Flights and per-library locks are keyed + * by this, so a sync started for one account is never shared with, queued + * in front of, or written into the cache for the next one. + */ +internal data class LibrarySyncIdentity(val account: SyncAccountKey, val sessionHash: Int) { + fun flightKey(libraryId: String): String = + listOf(account.serverUrl, account.username, sessionHash.toString(), libraryId).joinToString("\u0000") +} + +internal fun List.watermarkFor( + key: SyncAccountKey, + libraryId: String, +): LibrarySyncWatermark? = firstOrNull { + it.serverUrl == key.serverUrl && it.username == key.username && it.libraryId == libraryId +} + +/** Replace this library's watermark, keeping only the most recently written [cap]. */ +internal fun List.withWatermark( + watermark: LibrarySyncWatermark, + cap: Int = MAX_STORED_WATERMARKS, +): List { + val others = filterNot { + it.serverUrl == watermark.serverUrl && it.username == watermark.username && it.libraryId == watermark.libraryId + } + return (others + watermark).sortedByDescending { it.updatedAtMs }.take(cap) +} + +internal fun List.withoutWatermark( + key: SyncAccountKey, + libraryId: String, +): List = filterNot { + it.serverUrl == key.serverUrl && it.username == key.username && it.libraryId == libraryId +} + +/** What the tiny head request says about a library since its last good sync. */ +internal sealed interface LibraryChange { + data object NoChange : LibraryChange + + /** Books were added and nothing suggests anything was removed. */ + data object Incremental : LibraryChange + + /** Only a full download can bring the shelf back in line. */ + data class Full(val reason: String) : LibraryChange +} + +/** + * Compares the server's head (book count and newest added book) with what + * the last good sync recorded. + * + * - Same newest book and same count: nothing was added or removed. + * - A newer newest book: books were added. The "added since" fetch then + * checks the count again to catch removals that happened alongside. + * - Anything else (fewer books, the newest one gone, a count change with + * the same newest book, missing fields) can only be explained by a + * removal or a server oddity, and only a full download answers that. + * + * [localServerRowCount] is how many server books for this library are in the + * cache. Fewer than the watermark recorded means the cache lost rows (a + * server switch wipes them, for one) and the watermark no longer describes it. + */ +internal fun decideLibraryChange( + watermark: LibrarySyncWatermark?, + head: LibraryHead, + localServerRowCount: Int, +): LibraryChange { + if (watermark == null) return LibraryChange.Full("no earlier sync of this library") + if (localServerRowCount < watermark.itemCount) { + return LibraryChange.Full("the saved shelf has $localServerRowCount of ${watermark.itemCount} books") + } + val newestTime = head.newestAddedAt + if (head.newestItemId == null || newestTime == null) { + return if (head.total == 0 && head.newestItemId == null) { + if (watermark.itemCount == 0) LibraryChange.NoChange else LibraryChange.Full("the library is now empty") + } else { + LibraryChange.Full("the newest book came back without an id or added date") + } + } + val storedNewest = watermark.newestAddedAt ?: return LibraryChange.Incremental + return when { + newestTime < storedNewest -> LibraryChange.Full("the newest book was removed") + newestTime == storedNewest && head.newestItemId == watermark.newestItemId -> + if (head.total == watermark.itemCount) { + LibraryChange.NoChange + } else { + LibraryChange.Full("book count moved from ${watermark.itemCount} to ${head.total} with no new book") + } + else -> LibraryChange.Incremental + } +} + +/** + * Whether the last full download is old enough that a check should refresh + * everything. This is the only way a metadata edit on the server (a title + * fix, a new series) reaches a shelf nobody pulls to refresh, because the + * server cannot be asked for edited books. A clock that moved backward also + * counts as stale, so a bad clock can never freeze the shelf. + */ +internal fun isWatermarkStale( + watermark: LibrarySyncWatermark, + nowMs: Long, + maxAgeMs: Long = FULL_REFRESH_MAX_AGE_MS, +): Boolean { + val age = nowMs - watermark.lastFullSyncAtMs + return age < 0 || age >= maxAgeMs +} + +/** + * A full download found necessary by a background check waits for an + * unmetered network. One the user asked for (pull to refresh, Retry, Sync + * Now, sign-in) never waits, and neither does one for a library with nothing + * cached, because then the shelf would sit empty. + */ +internal fun shouldDeferFullSync( + isMetered: Boolean, + explicit: Boolean, + hasCachedRows: Boolean, +): Boolean = isMetered && !explicit && hasCachedRows + +internal enum class LibrarySyncPlan { SKIP, INCREMENTAL, FULL, DEFER_FULL } + +/** + * What a background check does about one library. A full download that is + * due only because the last one is old ([isStale]) is not worth cell data, + * so on a metered network it steps down to whatever the change check says. + */ +internal fun planLibrarySync( + change: LibraryChange, + isStale: Boolean, + isMetered: Boolean, + hasCachedRows: Boolean, +): LibrarySyncPlan = when (change) { + is LibraryChange.Full -> + if (shouldDeferFullSync(isMetered, explicit = false, hasCachedRows)) LibrarySyncPlan.DEFER_FULL else LibrarySyncPlan.FULL + LibraryChange.Incremental -> + if (isStale && !isMetered) LibrarySyncPlan.FULL else LibrarySyncPlan.INCREMENTAL + LibraryChange.NoChange -> + if (isStale && !isMetered) LibrarySyncPlan.FULL else LibrarySyncPlan.SKIP +} + +/** The lower edge of an "added since" fetch. A library that was empty fetches everything. */ +internal fun incrementalCutoff(watermark: LibrarySyncWatermark): Long = + watermark.newestAddedAt?.let { it - INCREMENTAL_OVERLAP_MS } ?: Long.MIN_VALUE + +/** + * After applying the added books, the server's count must equal the old + * count plus the books that are new to the cache. Any gap means books were + * also removed (or moved away), which only a full download can prune. + */ +internal fun incrementalCountsAgree( + previousCount: Int, + newBookCount: Int, + serverTotal: Int, +): Boolean = previousCount + newBookCount == serverTotal + +/** + * How many of the fetched books the last good sync's count left out, or null + * when that cannot be told and only a full download is safe. + * + * A book added after the stored newest one cannot be in that count, even when + * the cache already holds it: an earlier check whose counts disagreed saved + * it, or a progress pull, or a full download that stopped short. Counting by + * cache presence alone let the retry after "one removed, one added" read + * 100 + 0 = 100 and call the shelf current with the removed book still on it. + * + * Inside the overlap window cache presence says nothing either. A delayed + * scan can stamp a new book just before the stored newest one, and a + * progress pull can cache it before the check runs. Counting it as old then + * hides a removal made alongside it. So the watermark records which books + * in the window its count includes ([LibrarySyncWatermark.overlapItemIds]), + * and a book there is new exactly when it is not on that list. A watermark + * saved before that list existed cannot tell, so a cached book in its window + * (other than the stored newest one) makes the answer null. + * + * Books below the window are on the fetch only because they shared its last + * page, and count only when the cache lacks them. + */ +internal fun newBooksSinceWatermark( + watermark: LibrarySyncWatermark, + fetched: List, + alreadyCached: Set, +): Int? { + val storedNewest = watermark.newestAddedAt ?: return fetched.distinctBy { it.id }.size + val cutoff = incrementalCutoff(watermark) + val counted = watermark.overlapItemIds?.toHashSet() + var newCount = 0 + for (book in fetched.distinctBy { it.id }) { + val added = book.addedAt ?: return null + val isNew = when { + added < cutoff -> book.id !in alreadyCached + counted != null -> book.id !in counted && book.id != watermark.newestItemId + book.id == watermark.newestItemId -> false + added > storedNewest -> true + book.id in alreadyCached -> return null + else -> true + } + if (isNew) newCount++ + } + return newCount +} + +/** + * Running totals of a full download, kept page by page so the books + * themselves never have to be held: how many distinct books, the newest by + * added date, and whether any book came without an added date. + */ +internal class FullSyncTally { + private val seen = HashSet() + // Books close behind the newest so far, trimmed as the newest moves. + private val recent = ArrayList>() + var count: Int = 0 + private set + var newestAddedAt: Long? = null + private set + var newestItemId: String? = null + private set + var missingAddedAt: Boolean = false + private set + + fun add(books: List) { + for (book in books) { + if (!seen.add(book.id)) continue + count++ + val time = book.addedAt + if (time == null) { + missingAddedAt = true + } else { + if (newestAddedAt == null || time > newestAddedAt!!) { + newestAddedAt = time + newestItemId = book.id + } + if (time >= newestAddedAt!! - INCREMENTAL_OVERLAP_MS) recent += book.id to time + if (recent.size > RECENT_TRIM_AT) trimRecent() + } + } + } + + /** Every book this download counted whose added date falls inside the next check's overlap window. */ + val overlapItemIds: List + get() { + trimRecent() + return recent.map { it.first } + } + + private fun trimRecent() { + val newest = newestAddedAt ?: return + recent.removeAll { it.second < newest - INCREMENTAL_OVERLAP_MS } + } + + private companion object { + const val RECENT_TRIM_AT = 512 + } +} + +/** + * The watermark a full download leaves, null meaning the stored one must be + * forgotten. Only a complete ([RemoteResult.Ok]) download earns one. One that + * stopped short may have saved new books the old watermark does not count + * and left server edits unapplied, so keeping the old watermark would let the + * next check find "nothing new" and report the failed refresh as recovered. + * Also null when any book lacks an added date: without it the next check + * could not compare, so the library stays on full downloads instead of + * trusting a guess. + */ +internal fun watermarkAfterFullSync( + key: SyncAccountKey, + libraryId: String, + tally: FullSyncTally, + result: RemoteResult<*>, + nowMs: Long, +): LibrarySyncWatermark? { + if (result !is RemoteResult.Ok) return null + if (tally.missingAddedAt) return null + return LibrarySyncWatermark( + serverUrl = key.serverUrl, + username = key.username, + libraryId = libraryId, + newestAddedAt = tally.newestAddedAt, + newestItemId = tally.newestItemId, + itemCount = tally.count, + overlapItemIds = tally.overlapItemIds, + lastFullSyncAtMs = nowMs, + updatedAtMs = nowMs, + ) +} + +/** The first wait after a background full download fails or stops short. */ +internal const val FULL_SYNC_BACKOFF_BASE_MS = 15 * 60_000L + +/** The longest wait between background full download attempts. */ +internal const val FULL_SYNC_BACKOFF_MAX_MS = 4 * 60 * 60_000L + +/** How a library's background full downloads have been failing lately. */ +internal data class FullSyncFailures(val count: Int, val lastFailureAtMs: Long) + +/** 15 minutes after the first failure, doubling each time, capped at 4 hours. */ +internal fun fullSyncBackoffMs(failureCount: Int): Long { + if (failureCount <= 0) return 0L + val shift = (failureCount - 1).coerceAtMost(10) + return (FULL_SYNC_BACKOFF_BASE_MS shl shift).coerceAtMost(FULL_SYNC_BACKOFF_MAX_MS) +} + +/** + * Whether a background full download should wait because recent ones kept + * failing. A Partial run on a flaky server used to leave a non-success record, + * and every flap of the server back to reachable restarted the whole + * multi-minute download. Only background checks honour this. A refresh the + * user asked for always runs. [nowMs] is a monotonic clock, and a reading + * before the last failure counts as waited out. + */ +internal fun isFullSyncBackedOff(failures: FullSyncFailures?, nowMs: Long): Boolean { + if (failures == null || failures.count <= 0) return false + val waited = nowMs - failures.lastFailureAtMs + return waited in 0 until fullSyncBackoffMs(failures.count) +} + +/** + * The watermark after an "added since" fetch whose counts agreed. The newest + * book is the first one the server listed (it sorted newest first), and the + * last full download time carries over unchanged. The fetch reached back to + * the old window's edge, so it holds every book in the new window too, and + * the counts agreeing means all of them are in [serverTotal]: those become + * the new [LibrarySyncWatermark.overlapItemIds]. + */ +internal fun watermarkAfterIncremental( + previous: LibrarySyncWatermark, + fetched: List, + serverTotal: Int, + nowMs: Long, +): LibrarySyncWatermark { + val first = fetched.firstOrNull() + val firstTime = first?.addedAt + val advance = first != null && firstTime != null && + (previous.newestAddedAt == null || firstTime >= previous.newestAddedAt) + val newestAddedAt = if (advance) firstTime else previous.newestAddedAt + val overlapItemIds = if (newestAddedAt == null) { + previous.overlapItemIds + } else { + fetched.filter { book -> book.addedAt?.let { it >= newestAddedAt - INCREMENTAL_OVERLAP_MS } == true } + .map { it.id } + .distinct() + } + return previous.copy( + newestAddedAt = newestAddedAt, + newestItemId = if (advance) first!!.id else previous.newestItemId, + itemCount = serverTotal, + overlapItemIds = overlapItemIds, + updatedAtMs = nowMs, + ) +} + +/** What one library's sync or check actually did. */ +internal sealed interface LibraryRefreshOutcome { + /** The check found nothing new. Nothing was fetched or written. */ + data class Unchanged(val itemCount: Int) : LibraryRefreshOutcome + + /** + * A full download is needed but waits: for an unmetered network when + * [untilUnmetered], otherwise for the failure backoff to run out. + */ + data class Deferred(val reason: String, val untilUnmetered: Boolean = true) : LibraryRefreshOutcome + + /** The head request itself failed, so nothing is known either way. */ + data class CheckFailed(val reason: String) : LibraryRefreshOutcome + + /** Added books were fetched and saved, and the counts agreed. */ + data class Incremental(val newBookCount: Int, val itemCount: Int) : LibraryRefreshOutcome + + /** The full download ran, as asked or as the fallback. [result] counts the books it saved. */ + data class Full(val result: RemoteResult) : LibraryRefreshOutcome +} + +/** + * The outcome as a book-count result for sync reports. Null for a deferral, + * which is no verdict at all: the shelf is known to be behind, so it must + * not be reported as current, and nothing failed either. + */ +internal fun LibraryRefreshOutcome.itemCountResult(): RemoteResult? = when (this) { + is LibraryRefreshOutcome.Unchanged -> RemoteResult.Ok(itemCount) + is LibraryRefreshOutcome.Deferred -> null + is LibraryRefreshOutcome.CheckFailed -> RemoteResult.Failed(reason) + is LibraryRefreshOutcome.Incremental -> RemoteResult.Ok(itemCount) + is LibraryRefreshOutcome.Full -> result +} + +/** Whether this outcome wrote books to the cache, so a shelf showing them should re-read. */ +internal val LibraryRefreshOutcome.wroteBooks: Boolean + get() = when (this) { + is LibraryRefreshOutcome.Incremental -> true + is LibraryRefreshOutcome.Full -> result !is RemoteResult.Failed + else -> false + } + +/** A background check or a full download. */ +internal enum class LibrarySyncKind { CHECK, FULL } + +/** + * Whether a new request can share the sync already running for the same + * library. A full download answers anything. A check answers only another + * check: someone who asked for a full refresh waits for the check to finish + * and then gets their own full download. + */ +internal fun canJoinInFlight(inFlight: LibrarySyncKind, requested: LibrarySyncKind): Boolean = + inFlight == LibrarySyncKind.FULL || requested == LibrarySyncKind.CHECK + +/** + * One sync per library at a time, shared by everyone who asks while it runs. + * + * Sign-in's sync and the Library's own load used to queue behind one mutex + * and each download the whole library, back to back. Now a second request + * that [canJoinInFlight] awaits the running one and gets the same result. A + * request that cannot join waits for the running one to finish and then + * starts its own, so the old ordering still holds: one fetch-and-apply after + * another, never an older response landing after a newer one. + * + * The sync runs in [scope], not in the caller. A caller that is cancelled + * (the Library leaving, a newer load replacing an older one) stops waiting, + * but the download carries on for whoever else is waiting and still lands in + * the cache, instead of throwing away minutes of work. + */ +internal class LibrarySyncSingleFlight( + private val scope: CoroutineScope, +) { + private class Flight( + @Volatile var kind: LibrarySyncKind, + val deferred: Deferred, + val upgraded: CompletableDeferred = CompletableDeferred(), + ) + + private val lock = Any() + private val flights = HashMap>() + + suspend fun run(libraryId: String, kind: LibrarySyncKind, block: suspend () -> R): R { + while (true) { + var blocking: Flight? = null + val joined: Flight? = synchronized(lock) { + val running = flights[libraryId] + when { + running != null && canJoinInFlight(running.kind, kind) -> running + running != null -> { + blocking = running + null + } + else -> startLocked(libraryId, kind, block) + } + } + if (joined != null) return joined.deferred.await() + // Not ours to share yet. Wait until it finishes or turns into a + // full download (see [upgradeToFull]), then look again. + val waitOn = blocking!! + select { + waitOn.deferred.onJoin {} + waitOn.upgraded.onJoin {} + } + // Upgraded means this flight is (or was) the full download we + // wanted, even if it already finished and left the map. + if (canJoinInFlight(waitOn.kind, kind)) return waitOn.deferred.await() + } + } + + /** + * Marks the running sync of [libraryId] as a full download. A check that + * finds it needs one calls this, so a full refresh asked for meanwhile + * (sign-in's sync while the Library's first check downloads a never + * synced library) joins it instead of downloading everything again + * right after. + */ + fun upgradeToFull(libraryId: String) { + synchronized(lock) { + val running = flights[libraryId] ?: return + if (running.kind != LibrarySyncKind.FULL) { + running.kind = LibrarySyncKind.FULL + running.upgraded.complete(Unit) + } + } + } + + private fun startLocked(libraryId: String, kind: LibrarySyncKind, block: suspend () -> R): Flight { + lateinit var flight: Flight + val deferred = scope.async(start = CoroutineStart.LAZY) { + try { + block() + } finally { + synchronized(lock) { + if (flights[libraryId] === flight) flights.remove(libraryId) + } + } + } + flight = Flight(kind, deferred) + flights[libraryId] = flight + deferred.start() + return flight + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt new file mode 100644 index 00000000..55565b43 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/LibrarySyncWatermarkStore.kt @@ -0,0 +1,89 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.domain.model.LibrarySyncWatermark +import com.ninelivesaudio.app.service.SettingsManager +import kotlinx.coroutines.CancellationException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Where each library's last good sync stands, per server and account, + * persisted in the settings document next to the last sync record. + * + * Written only after the books it describes are saved, and only while the + * same server and account are still signed in, so a write can never land on + * someone else's session. A failed write is logged by the caller's absence of + * a watermark next time, which only costs a full download. + */ +@Singleton +class LibrarySyncWatermarkStore @Inject constructor( + private val settingsManager: SettingsManager, +) { + internal fun currentAccount(): SyncAccountKey { + val settings = settingsManager.currentSettings + return SyncAccountKey(serverUrl = settings.serverUrl, username = settings.username) + } + + /** + * The server, account, and sign-in a sync runs for. Read again before + * each write, so a sync that outlives its sign-in stops writing. + */ + internal suspend fun currentSyncIdentity(): LibrarySyncIdentity { + val token = try { + settingsManager.getAuthToken() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + return LibrarySyncIdentity(currentAccount(), token?.hashCode() ?: 0) + } + + /** + * Runs [write] only if [identity] is still the signed-in one, and holds + * the auth token lock while it runs. Checking first and writing after + * left a gap: the sync could pass the check, suspend inside a DAO call, + * and resume after the next account signed in and synced, then prune + * that account's books. Under the lock a sign-in, sign-out, or token + * swap waits for the write, and a write that comes after one sees it. + * Returns whether [write] ran. [write] must not touch the settings + * document (put and remove take the same lock). + */ + internal suspend fun runIfCurrent(identity: LibrarySyncIdentity, write: suspend () -> Unit): Boolean = + settingsManager.withAuthTokenLocked { token -> + if (LibrarySyncIdentity(currentAccount(), token?.hashCode() ?: 0) != identity) { + false + } else { + write() + true + } + } + + internal fun get(key: SyncAccountKey, libraryId: String): LibrarySyncWatermark? = + settingsManager.currentSettings.librarySyncWatermarks.watermarkFor(key, libraryId) + + /** Saves [watermark] if its server and account are still the signed-in ones. */ + internal suspend fun put(watermark: LibrarySyncWatermark): Boolean = write { settings -> + if (settings.serverUrl != watermark.serverUrl || settings.username != watermark.username) { + settings + } else { + settings.copy(librarySyncWatermarks = settings.librarySyncWatermarks.withWatermark(watermark)) + } + } + + /** Forgets a library's watermark so its next check runs a full download. */ + internal suspend fun remove(key: SyncAccountKey, libraryId: String): Boolean = write { settings -> + val kept = settings.librarySyncWatermarks.withoutWatermark(key, libraryId) + if (kept.size == settings.librarySyncWatermarks.size) settings else settings.copy(librarySyncWatermarks = kept) + } + + private suspend fun write( + transform: (com.ninelivesaudio.app.domain.model.AppSettings) -> com.ninelivesaudio.app.domain.model.AppSettings, + ): Boolean = try { + settingsManager.updateSettingsIfAuthenticated(transform) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + false + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt b/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt index bafa7fa0..66bea297 100644 --- a/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt +++ b/app/src/main/java/com/ninelivesaudio/app/di/NetworkModule.kt @@ -5,6 +5,7 @@ import com.ninelivesaudio.app.BuildConfig import com.ninelivesaudio.app.data.remote.AudiobookshelfApi import com.ninelivesaudio.app.data.remote.AuthInterceptor import com.ninelivesaudio.app.data.remote.DynamicBaseUrlInterceptor +import com.ninelivesaudio.app.data.remote.ServerCoverUrlInterceptor import com.ninelivesaudio.app.data.remote.SelfSignedCertTrustManager.configureSelfSignedCerts import com.ninelivesaudio.app.service.SettingsManager import dagger.Module @@ -42,6 +43,9 @@ object NetworkModule { ): OkHttpClient { val builder = OkHttpClient.Builder() .addInterceptor(dynamicBaseUrlInterceptor) + // After the base URL step, before auth: a cover stored with an old + // server address goes to the current one and gets its token. + .addInterceptor(ServerCoverUrlInterceptor { settingsManager.currentSettings.serverUrl }) .addNetworkInterceptor(authInterceptor) // Only log HTTP requests in debug builds to prevent token/URL leakage in production diff --git a/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt b/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt index 2fef35cf..76976dcf 100644 --- a/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt +++ b/app/src/main/java/com/ninelivesaudio/app/domain/model/AppSettings.kt @@ -51,6 +51,13 @@ data class AppSettings( // Per-install high water mark for persisted sync outcomes. Ordering a // verdict by wall time lets a clock rollback reject every later sync. val lastSyncOutcomeSequence: Long = 0L, + // Where each library's book list stood after its last good sync, so a + // cold start can ask the server "anything new?" instead of downloading + // every book again. Kept here rather than in Room: no schema migration, + // it rides the same durable document as lastSync, and it is written only + // after the books it describes are already saved, so a crash can only + // leave it older than the shelf, never ahead of it. + val librarySyncWatermarks: List = emptyList(), ) { /** The selected library for the current source mode. The two modes never share a fallback. */ val activeLibraryId: String? @@ -93,6 +100,31 @@ data class LastSyncRecord( val serverUrl: String = "", ) +/** + * One library's book list as of its last complete sync, for one server and + * account. [newestAddedAt] and [newestItemId] are the most recently added + * book the server had then (null for an empty library), [itemCount] is how + * many books it reported, and [lastFullSyncAtMs] is the wall clock time of + * the last full download, which bounds how long a metadata edit can go + * unseen (the server cannot be asked for edited books, only added ones). + * [overlapItemIds] lists the books [itemCount] includes whose added date is + * within a minute of [newestAddedAt], so the next check can tell a book + * stamped just before the newest one apart from one it already counted. + * Null on a watermark saved before the list existed. + */ +@Serializable +data class LibrarySyncWatermark( + val serverUrl: String, + val username: String, + val libraryId: String, + val newestAddedAt: Long? = null, + val newestItemId: String? = null, + val itemCount: Int = 0, + val overlapItemIds: List? = null, + val lastFullSyncAtMs: Long = 0L, + val updatedAtMs: Long = 0L, +) + @Serializable data class ServerProfile( val name: String = "", diff --git a/app/src/main/java/com/ninelivesaudio/app/domain/util/Cooperative.kt b/app/src/main/java/com/ninelivesaudio/app/domain/util/Cooperative.kt new file mode 100644 index 00000000..f77be2cc --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/domain/util/Cooperative.kt @@ -0,0 +1,26 @@ +package com.ninelivesaudio.app.domain.util + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +/** How many items a long loop handles between cancellation checks. */ +internal const val COOPERATIVE_CHECK_EVERY = 256 + +/** + * [map] that stops when its coroutine is cancelled, checking every + * [checkEvery] items. A plain map over tens of thousands of rows never + * suspends, so a cancelled shelf build kept decoding to the end while the + * newer one started beside it. + */ +internal suspend inline fun List.mapCooperatively( + checkEvery: Int = COOPERATIVE_CHECK_EVERY, + transform: (T) -> R, +): List { + val context = currentCoroutineContext() + val out = ArrayList(size) + for (index in indices) { + if (index % checkEvery == 0) context.ensureActive() + out.add(transform(this[index])) + } + return out +} diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt index dd5dbdb1..2981f360 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/DownloadSlotResolver.kt @@ -8,7 +8,7 @@ import com.ninelivesaudio.app.domain.model.DownloadStatus * * Assembled by the caller from three tables, because no single one of them is * sufficient. `DownloadEngine` writes the offline fields on `AudioBooks` only - * AFTER writing `Completed`, and `clearCompleted()` deletes `DownloadItems` rows + * AFTER writing `Completed`, and an older Clear All deleted `DownloadItems` rows * while leaving the files on disk. Either source alone therefore misses real * offline books. */ diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt index 19f509d6..942c7185 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/FreeTier.kt @@ -17,6 +17,10 @@ import com.ninelivesaudio.app.ui.library.ViewMode */ object FreeTier { + /** Said wherever a download is refused because the one free slot is taken. */ + const val DOWNLOAD_SLOT_NOTICE = "Free keeps one downloaded book at a time. " + + "Delete the one you have, or unlock for unlimited offline books." + /** * Sorts a free install can choose: all of them, as of 2026-08-15. * diff --git a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt new file mode 100644 index 00000000..4c52f8d4 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt @@ -0,0 +1,126 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.data.local.containsLikePattern +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlinx.serialization.json.Json + +/** + * What an Android Auto row needs from a book: its text and where its cover + * lives. Kept apart from [AudioBook] so a light browse row can never be + * mistaken for a whole book and saved over one. + */ +internal data class AutoBookItem( + val id: String, + val title: String, + val author: String, + val narrator: String?, + val genre: String?, + val coverPath: String?, + val localCoverPath: String?, +) { + val effectiveCoverPath: String? get() = localCoverPath ?: coverPath +} + +internal fun AudioBook.toAutoBookItem(): AutoBookItem = AutoBookItem( + id = id, + title = title, + author = author, + narrator = narrator, + genre = genres.firstOrNull(), + coverPath = coverPath, + localCoverPath = localCoverPath, +) + +private val autoRowJson = Json { ignoreUnknownKeys = true } + +internal fun AutoBrowseRow.toAutoBookItem(): AutoBookItem = AutoBookItem( + id = id, + title = title, + author = author.orEmpty(), + narrator = narrator, + genre = genresJson + ?.let { runCatching { autoRowJson.decodeFromString>(it) }.getOrNull() } + ?.firstOrNull(), + coverPath = coverPath, + localCoverPath = localCoverPath, +) + +/** + * Largest page an Auto list reads in one go. Browsers that do not page (a + * legacy subscribe arrives as page 0 with Int.MAX_VALUE) would otherwise read + * and build a row for every book in the library. + */ +internal const val AUTO_BROWSE_MAX_PAGE_SIZE = 500 + +/** + * One page of an Auto list for the active library and source, read straight + * from SQL with [load] as LIMIT and OFFSET. Nothing outside the page is read. + */ +internal suspend fun autoBrowsePage( + settings: AppSettings, + page: Int, + pageSize: Int, + load: suspend (libraryId: String, isLocal: Boolean, limit: Int, offset: Int) -> List, +): List { + val libraryId = settings.activeLibraryId ?: return emptyList() + if (page < 0 || pageSize <= 0) return emptyList() + // A page bigger than the cap is read as one capped list. Capping the + // limit but not the offset would skip the books between the cap and the + // next page start, so later pages of an oversized size are empty instead. + if (pageSize > AUTO_BROWSE_MAX_PAGE_SIZE) { + if (page > 0) return emptyList() + return load(libraryId, settings.appMode == AppMode.LOCAL, AUTO_BROWSE_MAX_PAGE_SIZE, 0) + } + val offset = page.toLong() * pageSize.toLong() + if (offset > Int.MAX_VALUE) return emptyList() + return load(libraryId, settings.appMode == AppMode.LOCAL, pageSize, offset.toInt()) +} + +/** + * Most search hits Auto ever lists. A one letter query in a big library would + * otherwise build a row and fetch a cover for thousands of books. + */ +internal const val AUTO_SEARCH_RESULT_CAP = 100 + +/** + * [query] as a LIKE "contains" pattern escaped by [containsLikePattern], so + * `%`, `_` and `\` match themselves. Null for a blank query, which searches + * nothing. + */ +internal fun autoSearchPattern(query: String): String? = + query.trim().takeIf { it.isNotEmpty() }?.let(::containsLikePattern) + +/** + * One page of Auto search hits in the active library and source, never past + * [AUTO_SEARCH_RESULT_CAP]. A blank query or no active library reads nothing. + */ +internal suspend fun autoSearchPage( + settings: AppSettings, + query: String, + page: Int, + pageSize: Int, + load: suspend (libraryId: String, isLocal: Boolean, pattern: String, limit: Int, offset: Int) -> List, +): List { + val libraryId = settings.activeLibraryId ?: return emptyList() + val pattern = autoSearchPattern(query) ?: return emptyList() + if (page < 0 || pageSize <= 0) return emptyList() + val offset = page.toLong() * pageSize.toLong() + if (offset >= AUTO_SEARCH_RESULT_CAP) return emptyList() + val limit = minOf(pageSize.toLong(), AUTO_SEARCH_RESULT_CAP - offset).toInt() + return load(libraryId, settings.appMode == AppMode.LOCAL, pattern, limit, offset.toInt()) +} + +/** How many hits [autoSearchPage] can list for [query], at most [AUTO_SEARCH_RESULT_CAP]. */ +internal suspend fun autoSearchCount( + settings: AppSettings, + query: String, + count: suspend (libraryId: String, isLocal: Boolean, pattern: String, cap: Int) -> Int, +): Int { + val libraryId = settings.activeLibraryId ?: return 0 + val pattern = autoSearchPattern(query) ?: return 0 + return count(libraryId, settings.appMode == AppMode.LOCAL, pattern, AUTO_SEARCH_RESULT_CAP) + .coerceIn(0, AUTO_SEARCH_RESULT_CAP) +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt index e51b5da5..9895c16f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/ConnectivityMonitor.kt @@ -5,20 +5,27 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import android.os.SystemClock import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import okhttp3.Request +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -36,12 +43,11 @@ class ConnectivityMonitor @Inject constructor( private const val TAG = "ConnectivityMonitor" /** Minimum background duration (ms) before triggering recovery on foreground. */ private const val MIN_BACKGROUND_DURATION_MS = 5_000L - /** Reachability probe budget. Short so the app drops to offline quickly. */ - private const val PROBE_TIMEOUT_MS = 5_000L } // A clone of the app client with a short total-call timeout, used only for the // reachability probe so it fails fast instead of waiting out the 30s sync timeout. + // Each probe sets its own call budget on top (see probeServerWithRetry). private val probeClient: OkHttpClient by lazy { okHttpClient.newBuilder() .callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) @@ -58,11 +64,18 @@ class ConnectivityMonitor @Inject constructor( // background checks also share reachabilityCheckGate, so probes cannot race. private var reachabilityJob: Job? = null private val reachabilityJobLock = Any() - private val reachabilityCheckGate = ReachabilityCheckGate { performServerReachabilityCheck() } + private val reachabilityCheckGate = ReachabilityCheckGate( + nowMs = { SystemClock.elapsedRealtime() }, + reuseWindowMs = PROBE_REUSE_WINDOW_MS, + ) { performServerReachabilityCheck() } // Track when the app went to background for debouncing foreground recovery @Volatile private var backgroundedAt: Long = 0L + // Set from the process lifecycle (NineLivesApp). The periodic ping only + // runs while this is true. + private val appInForeground = MutableStateFlow(false) + // Emitted when the app returns from a meaningful background period private val _appResumedFromBackground = MutableSharedFlow(extraBufferCapacity = 1) val appResumedFromBackground: SharedFlow = _appResumedFromBackground.asSharedFlow() @@ -72,6 +85,16 @@ class ConnectivityMonitor @Inject constructor( private val _isOnline = MutableStateFlow(false) val isOnline: StateFlow = _isOnline.asStateFlow() + // Unknown counts as metered, so nothing big waits on a guess of free data. + private val _isMetered = MutableStateFlow(true) + + /** + * Whether the active network may cost the user money (cell data, a + * hotspot, Wi-Fi marked metered). Background full library downloads wait + * while this is true. Re-read from the OS on every network callback. + */ + val isMetered: StateFlow = _isMetered.asStateFlow() + private val _isServerReachable = MutableStateFlow(false) val isServerReachable: StateFlow = _isServerReachable.asStateFlow() @@ -89,51 +112,157 @@ class ConnectivityMonitor @Inject constructor( // ─── Network Callback ───────────────────────────────────────────────── + // Every network the callbacks have reported, and whether it carries + // internet. The online flag is re-derived from all of them plus the OS + // default network, so one onLost (often for the old network after the + // new one is already up) cannot mark the app offline on its own. + private val knownNetworks = ConcurrentHashMap() + + // Bumped whenever the path to the server may have changed: online + // flipped, a new network appeared, a known one was lost, or a caller + // asked for a fresh probe. A finished probe's answer is shared only + // within one generation (see ReachabilityCheckGate). + private val networkGeneration = AtomicLong(0L) + + // True once startMonitoring ran, so a foreground entry before settings + // load does not probe a blank server URL. + @Volatile private var monitoring = false + + // Process-level background start (SystemClock.elapsedRealtime), 0 before + // the first trip to the background. + @Volatile private var processBackgroundedAt: Long = 0L + + // Foreground-only OS re-reads while the network still reads as down + // right after the app comes back (see FOREGROUND_SETTLE_REREADS_MS). + private var settleJob: Job? = null + private val settleJobLock = Any() + private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - _isOnline.value = true - updateConnectionStatus() + val isNew = knownNetworks.put(network, true) == null + if (isNew) networkGeneration.incrementAndGet() + reevaluateNetwork() // Check server reachability on reconnect (deduplicated) launchReachabilityCheck() } override fun onLost(network: Network) { - // Check if we still have any active network - val activeNetwork = connectivityManager.activeNetwork - val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } - val stillConnected = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - - if (!stillConnected) { - _isOnline.value = false - _isServerReachable.value = false - updateConnectionStatus() - } + if (knownNetworks.remove(network) != null) networkGeneration.incrementAndGet() + // Re-derive from every network still known plus the OS default. + // A backgrounded app can be network-blocked, so the OS default + // reads null here even with Wi-Fi up. Coming back to the + // foreground re-reads it (reevaluateOnForeground). + reevaluateNetwork() } override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - if (_isOnline.value != hasInternet) { - _isOnline.value = hasInternet - updateConnectionStatus() - if (hasInternet) { - launchReachabilityCheck() - } + // Fires when Wi-Fi is marked metered or not, too. + knownNetworks[network] = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val (wasOnline, online) = reevaluateNetwork() + if (online && !wasOnline) launchReachabilityCheck() + } + + override fun onBlockedStatusChanged(network: Network, blocked: Boolean) { + // Android blocks a backgrounded app's network and unblocks it when + // the app returns. The unblock is the moment the OS default + // network becomes readable again. + if (blocked) return + val (wasOnline, online) = reevaluateNetwork() + if (online && (!wasOnline || !_isServerReachable.value)) { + launchReachabilityCheck() } } } /** - * Cancel any in-flight reachability check and start a fresh one. - * Prevents unbounded concurrent server pings during network flaps - * (e.g., WiFi → cellular handoff firing onAvailable + onCapabilitiesChanged). + * Re-derives [isOnline] from the OS default network and every network the + * callbacks know about, and returns (online before, online now). Going + * offline also clears server reachability. No network request. */ - private fun launchReachabilityCheck() { - synchronized(reachabilityJobLock) { - reachabilityJob?.cancel() - reachabilityJob = scope.launch { reachabilityCheckGate.run() } + private fun reevaluateNetwork(): Pair = synchronized(knownNetworks) { + val active = try { + connectivityManager.activeNetwork + } catch (_: Exception) { + null + } + val activeCaps = active?.let { capabilitiesOf(it) } + val activeHasInternet = + activeCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + // The default network is known too, so its replayed onAvailable at + // registration does not count as a new network. Only when the + // callbacks can also report it lost, though: they never see a VPN. + if (active != null && defaultNetworkIsRemembered( + hasInternet = activeHasInternet, + isVpn = activeCaps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == false, + ) + ) { + knownNetworks[active] = true + } + val online = networkStateSaysOnline( + defaultHasInternet = activeHasInternet, + knownNetworksHaveInternet = knownNetworks.values, + ) + val wasOnline = _isOnline.value + _isOnline.value = online + if (!online) _isServerReachable.value = false + if (online != wasOnline) networkGeneration.incrementAndGet() + refreshIsMetered() + updateConnectionStatus() + wasOnline to online + } + + private fun capabilitiesOf(network: Network): NetworkCapabilities? = try { + connectivityManager.getNetworkCapabilities(network) + } catch (_: Exception) { + null + } + + /** + * Drops networks the OS no longer knows (an onLost that never arrived + * while the app was frozen), so a stale entry cannot keep the app + * "online" with no network at all. + */ + private fun pruneGoneNetworks() { + val gone = knownNetworks.keys.filter { network -> + try { + connectivityManager.getNetworkCapabilities(network) == null + } catch (_: Exception) { + true + } } + gone.forEach { knownNetworks.remove(it) } } + /** + * Start a reachability check, or join the one already running for the + * same network generation. A check from an older generation is cancelled + * (its answer describes a path that may be gone). [fresh] starts a new + * generation first, for callers that must not reuse an earlier answer. + * Cold start used to send 3 to 4 /ping in about 1.5 seconds because each + * caller cancelled the one before and pinged again. + */ + private fun launchReachabilityCheck(fresh: Boolean = false): Job = + synchronized(reachabilityJobLock) { + if (fresh) networkGeneration.incrementAndGet() + val generation = networkGeneration.get() + val running = reachabilityJob + if (running != null && running.isActive && reachabilityJobGeneration == generation) { + return@synchronized running + } + running?.cancel() + reachabilityJobGeneration = generation + val key = reuseKey() + scope.launch { reachabilityCheckGate.run(key) }.also { reachabilityJob = it } + } + + // The generation the running reachabilityJob belongs to. Guarded by reachabilityJobLock. + private var reachabilityJobGeneration = -1L + + private fun reuseKey(): ProbeReuseKey = ProbeReuseKey( + generation = networkGeneration.get(), + serverUrl = settingsManager.currentSettings.serverUrl.trim(), + ) + fun requestReachabilityCheck() { launchReachabilityCheck() } @@ -152,24 +281,39 @@ class ConnectivityMonitor @Inject constructor( } // Initial state check + monitoring = true checkCurrentConnectivity() - // Start periodic server ping (every 60 seconds) + // Start the periodic server ping, foreground only: every 60 seconds, + // or 15 while the network is up and the server is not answering, so a + // server that comes back is noticed soon. collectLatest cancels the + // loop the moment the app leaves the foreground. In the background + // the network callbacks still re-check on every network change, and + // the playback progress push marks the server reachable when it lands + // (see reportServerAnswered), so nothing needs a timer there. pingJob?.cancel() pingJob = scope.launch { - while (isActive) { - delay(60_000) + runForegroundPing( + appInForeground = appInForeground, + // Restarts the wait whenever the status changes the interval, + // so a server that just went unreachable is re-probed 15 + // seconds later, not at the end of the 60 already running. + intervalMs = combine(_isOnline, _isServerReachable) { online, reachable -> + nextPingDelayMs(isOnline = online, isServerReachable = reachable) + }, // Route through launchReachabilityCheck so the periodic ping // shares the single-flight cancellation with the callback- and // foreground-driven checks. Calling checkServerReachable() // directly let two checks race and the slower (stale) one win // the last write to _isServerReachable. - launchReachabilityCheck() - } + check = { launchReachabilityCheck().join() }, + ) } } fun stopMonitoring() { + monitoring = false + cancelSettleRereads() try { connectivityManager.unregisterNetworkCallback(networkCallback) } catch (_: Exception) {} @@ -183,7 +327,10 @@ class ConnectivityMonitor @Inject constructor( // ─── Checks ─────────────────────────────────────────────────────────── private fun checkCurrentConnectivity() { - refreshIsOnlineFromSystem() + // No forced new generation here: the default network's onAvailable, + // replayed at registration, may already have started this probe. + pruneGoneNetworks() + reevaluateNetwork() // Initial server check (deduplicated) launchReachabilityCheck() @@ -199,16 +346,34 @@ class ConnectivityMonitor @Inject constructor( * Also called directly by the Home reconnect tap (no network request here, * just re-reading the OS state) so [SyncManager.syncNow]'s own shouldRunSync * pre-check sees a fresh flag instead of the stale one, and its own - * checkServerReachable() call is left as the tap's single /ping. + * checkServerReachable() call is left as the tap's single /ping. It + * starts a new network generation, so that /ping is never answered from + * an earlier probe's shared result. */ fun refreshIsOnlineFromSystem() { - val activeNetwork = connectivityManager.activeNetwork - val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } - _isOnline.value = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - updateConnectionStatus() + pruneGoneNetworks() + reevaluateNetwork() + // An explicit re-read (the reconnect tap, a mode switch) wants a + // probe of its own, not one shared from just before. + networkGeneration.incrementAndGet() + } + + /** Re-reads the default network's metered state from the OS. */ + fun refreshIsMetered(): Boolean { + val metered = try { + connectivityManager.isActiveNetworkMetered + } catch (_: Exception) { + true + } + _isMetered.value = metered + return metered } - suspend fun checkServerReachable(): Boolean = reachabilityCheckGate.run() + /** + * The server's reachability, from a fresh /ping or from one that finished + * within [PROBE_REUSE_WINDOW_MS] on the same network and server. + */ + suspend fun checkServerReachable(): Boolean = reachabilityCheckGate.run(reuseKey()) suspend fun probeServerReachable(): Boolean = checkServerReachable() @@ -224,17 +389,45 @@ class ConnectivityMonitor @Inject constructor( updateConnectionStatus() return false } - val reachable = withContext(Dispatchers.IO) { + val pingUrl = "${serverUrl.trimEnd('/')}/ping" + val reachable = probeServerWithRetry( + wasReachable = _isServerReachable.value, + stillOnline = { _isOnline.value }, + probe = { budgetMs -> pingOnce(pingUrl, budgetMs) }, + ) + _isServerReachable.value = reachable + updateConnectionStatus() + return reachable + } + + private suspend fun pingOnce(pingUrl: String, budgetMs: Long): Boolean = + withContext(Dispatchers.IO) { try { - val request = Request.Builder().url("${serverUrl.trimEnd('/')}/ping").build() - probeClient.newCall(request).execute().use { true } + val request = Request.Builder().url(pingUrl).build() + val call = probeClient.newCall(request) + call.timeout().timeout(budgetMs, TimeUnit.MILLISECONDS) + call.execute().use { response -> pingStatusMeansReachable(response.code) } } catch (_: Exception) { false } } - _isServerReachable.value = reachable + + /** + * A real request to the server just succeeded (a playback progress push). + * That proves reachability better than a /ping, and with no ping in the + * background it is how a server marked unreachable comes back while the + * app is not visible, which in turn flushes the offline progress queue + * and lets Android Auto stream again. A failed request proves nothing, + * so there is no counterpart: only a probe marks the server unreachable. + */ + fun reportServerAnswered() { + // Even when already marked reachable: a probe that failed just + // before must not answer the next caller (the reconnect listener's + // check) with "unreachable" after the server just proved otherwise. + reachabilityCheckGate.serverAnswered() + if (!serverAnswerMarksReachable(_isOnline.value, _isServerReachable.value)) return + _isServerReachable.value = true updateConnectionStatus() - return reachable } // ─── Sync State (updated by SyncManager) ───────────────────────────── @@ -246,6 +439,73 @@ class ConnectivityMonitor @Inject constructor( // ─── App Lifecycle (foreground / background) ───────────────────────── + /** + * The app came to the foreground (true) or left it (false), from the + * process lifecycle so it covers every activity together. The periodic + * ping runs only in the foreground. + * + * Coming back re-reads the real network state first. Android blocks a + * backgrounded app's network, so a network change while it was away can + * leave [isOnline] false with Wi-Fi fine, and nothing else would ever + * re-read it (no ping runs in the background, and the ping loop cannot + * get past an offline flag). Call this before anything that reads + * [isOnline] on entry, such as SyncManager's entry check. + */ + fun setAppForeground(inForeground: Boolean) { + if (inForeground) { + val backgroundedAt = processBackgroundedAt + val backgroundForMs = + if (backgroundedAt == 0L) null else SystemClock.elapsedRealtime() - backgroundedAt + appInForeground.value = true + reevaluateOnForeground(backgroundForMs) + } else { + processBackgroundedAt = SystemClock.elapsedRealtime() + appInForeground.value = false + cancelSettleRereads() + } + } + + /** + * Foreground entry: re-derive connectivity from the OS, probe the server + * when [foregroundEntryProbes] says so, and keep re-reading for a few + * seconds while the OS still reports no network (the unblock can land + * just after the app is visible). + */ + private fun reevaluateOnForeground(backgroundForMs: Long?) { + if (!monitoring) return + pruneGoneNetworks() + val (wasOnline, online) = reevaluateNetwork() + Log.d(TAG, "foreground: background=${backgroundForMs}ms online $wasOnline -> $online") + if (foregroundEntryProbes(wasOnline, online, _isServerReachable.value, backgroundForMs)) { + launchReachabilityCheck(fresh = true) + } + if (!online) startSettleRereads() + } + + private fun startSettleRereads() { + synchronized(settleJobLock) { + settleJob?.cancel() + settleJob = scope.launch { + rereadUntilOnline(FOREGROUND_SETTLE_REREADS_MS) { + if (!appInForeground.value) return@rereadUntilOnline true + val (wasOnline, online) = reevaluateNetwork() + if (online && !wasOnline) { + Log.d(TAG, "foreground settle: network readable again") + launchReachabilityCheck(fresh = true) + } + online + } + } + } + } + + private fun cancelSettleRereads() { + synchronized(settleJobLock) { + settleJob?.cancel() + settleJob = null + } + } + /** * Called when the app moves to the background (Activity.onStop). * Records the timestamp for debouncing foreground recovery. @@ -258,10 +518,11 @@ class ConnectivityMonitor @Inject constructor( * Called when the app returns to the foreground (Activity.onStart). * * After device sleep or extended background, TCP connections in OkHttp's - * pool are often dead but not yet detected — making all API calls fail - * until the pool cycles. Fix: evict idle connections immediately, then - * force a server reachability check so the rest of the app knows the - * connection state within seconds instead of waiting up to 60s. + * pool are often dead but not yet detected, making all API calls fail + * until the pool cycles. Fix: evict idle connections immediately. The + * reachability check that follows runs from [setAppForeground], which + * the process lifecycle calls just after this, once the network state + * has been re-read. */ fun onAppForegrounded() { // Never backgrounded yet (first foreground after cold start): there are @@ -273,7 +534,7 @@ class ConnectivityMonitor @Inject constructor( val elapsed = System.currentTimeMillis() - backgroundedAt if (elapsed < MIN_BACKGROUND_DURATION_MS) return - Log.d(TAG, "onAppForegrounded: background=${elapsed}ms — evicting stale connections") + Log.d(TAG, "onAppForegrounded: background=${elapsed}ms, evicting stale connections") // Kill stale TCP connections so the next request opens a fresh socket try { @@ -282,9 +543,6 @@ class ConnectivityMonitor @Inject constructor( Log.w(TAG, "onAppForegrounded: evictAll failed: ${e.message}") } - // Force immediate server check (don't wait for the 60s timer) - launchReachabilityCheck() - // Notify observers (PlaybackManager) that we're back from background _appResumedFromBackground.tryEmit(Unit) } @@ -317,10 +575,235 @@ internal fun computeConnectionStatus( else -> ConnectivityMonitor.ConnectionStatus.SERVER_UNREACHABLE } +/** First probe budget. Short so a gone server shows as unreachable quickly. */ +internal const val PROBE_TIMEOUT_MS = 5_000L + +/** Wait before the confirming probe, so a busy moment can pass. */ +internal const val PROBE_RETRY_DELAY_MS = 3_000L + +/** Budget for the confirming probe. Longer, for a busy Pi or a slow VPN. */ +internal const val PROBE_RETRY_TIMEOUT_MS = 10_000L + +/** The periodic ping while the server answers (or the network is down). */ +internal const val PING_INTERVAL_MS = 60_000L + +/** The periodic ping while the network is up and the server is not answering. */ +internal const val PING_RETRY_INTERVAL_MS = 15_000L + +/** + * Any answer from /ping below 500 means Audiobookshelf itself is up. A 5xx is + * a reverse proxy (or tunnel) answering for a server that is down, which used + * to read as Connected because any response counted. + */ +internal fun pingStatusMeansReachable(code: Int): Boolean = code in 100..499 + +/** + * One /ping, and when it fails while the server was reachable and the OS + * still reports a network, one more after [retryDelayMs] with a longer + * budget before the server is called unreachable. + * + * Calling it unreachable switches a streaming user's Library to Downloaded + * only, so one slow answer from a busy Pi or a VPN must not do that. A server + * already marked unreachable gets no retry: a failure there changes nothing, + * and a success flips it back on the first try. + */ +internal suspend fun probeServerWithRetry( + wasReachable: Boolean, + stillOnline: () -> Boolean, + probe: suspend (budgetMs: Long) -> Boolean, + sleep: suspend (Long) -> Unit = { delay(it) }, + firstBudgetMs: Long = PROBE_TIMEOUT_MS, + retryDelayMs: Long = PROBE_RETRY_DELAY_MS, + retryBudgetMs: Long = PROBE_RETRY_TIMEOUT_MS, +): Boolean { + if (probe(firstBudgetMs)) return true + if (!wasReachable || !stillOnline()) return false + sleep(retryDelayMs) + if (!stillOnline()) return false + return probe(retryBudgetMs) +} + +/** + * The periodic ping loop. It runs only while [appInForeground] is true and + * stops the moment it turns false, so nothing pings in the background. + * + * Each wait is [intervalMs]'s latest value, and a new value restarts the + * wait in progress. The loop used to pick its delay before sleeping, so a + * server that went unreachable during a 60 second wait was first re-probed + * up to 60 seconds later instead of 15. + */ +internal suspend fun runForegroundPing( + appInForeground: StateFlow, + intervalMs: Flow, + sleep: suspend (Long) -> Unit = { delay(it) }, + check: suspend () -> Unit, +) { + appInForeground.collectLatest { inForeground -> + if (!inForeground) return@collectLatest + intervalMs.distinctUntilChanged().collectLatest { interval -> + while (true) { + sleep(interval) + check() + } + } + } +} + +/** + * A successful server request marks the server reachable only while the OS + * reports a network, and only when it is not already marked reachable. + */ +internal fun serverAnswerMarksReachable(isOnline: Boolean, isServerReachable: Boolean): Boolean = + isOnline && !isServerReachable + +/** How long the periodic ping waits before its next probe. */ +internal fun nextPingDelayMs(isOnline: Boolean, isServerReachable: Boolean): Long = + if (isOnline && !isServerReachable) PING_RETRY_INTERVAL_MS else PING_INTERVAL_MS + +/** Which probe answers can be shared: same network generation, same server. */ +internal data class ProbeReuseKey(val generation: Long, val serverUrl: String) + +/** + * How long a finished probe's answer is shared with callers asking about the + * same network and server. Cold start has the monitor, the sync's entry + * check, the Library and playback all asking within a second or two. + */ +internal const val PROBE_REUSE_WINDOW_MS = 3_000L + +/** + * Whether a probe that finished at [finishedAtMs] under [previousKey] answers + * a caller asking under [key] at [nowMs]. A null key never shares, and a + * clock reading before the finish (or the window passing) means probe again. + */ +internal fun canReuseProbe( + previousKey: Any?, + key: Any?, + finishedAtMs: Long, + nowMs: Long, + windowMs: Long, +): Boolean { + if (key == null || previousKey != key) return false + val age = nowMs - finishedAtMs + return age in 0 until windowMs +} + +/** + * Runs reachability checks one at a time. A caller whose [run] key matches + * a check that finished under [reuseWindowMs] ago (including one it waited + * on) gets that answer instead of a new /ping. A failed answer stops being + * shared once [serverAnswered] reports a real request that succeeded, even + * one that landed while that probe was still running. + */ internal class ReachabilityCheckGate( + private val nowMs: () -> Long = { 0L }, + private val reuseWindowMs: Long = 0L, private val check: suspend () -> Boolean, ) { + private class Finished(val key: Any?, val atMs: Long, val reachable: Boolean, val answers: Long) + private val mutex = Mutex() + private var last: Finished? = null // guarded by mutex - suspend fun run(): Boolean = mutex.withLock { check() } + // Counts successful server requests reported through serverAnswered. + private val answers = AtomicLong(0L) + + /** A real request to the server just succeeded. No suspension, no lock. */ + fun serverAnswered() { + answers.incrementAndGet() + } + + suspend fun run(key: Any? = null): Boolean = mutex.withLock { + val previous = last + if (previous != null && + (previous.reachable || previous.answers == answers.get()) && + canReuseProbe(previous.key, key, previous.atMs, nowMs(), reuseWindowMs) + ) { + return@withLock previous.reachable + } + // Read before the probe, so an answer that lands mid-probe still + // keeps a failure from being shared. + val answersAtStart = answers.get() + val reachable = check() + last = Finished(key, nowMs(), reachable, answersAtStart) + reachable + } +} + +/** + * Whether the app has a network, from the OS default network and every + * network the callbacks still know about. A single onLost used to decide this + * from the default network alone, which reads null for a backgrounded app + * Android has network-blocked, so the old Wi-Fi's onLost marked the app + * offline with the new Wi-Fi already up. INTERNET, not VALIDATED: a home + * server on a LAN with no route to the internet never validates. + */ +internal fun networkStateSaysOnline( + defaultHasInternet: Boolean, + knownNetworksHaveInternet: Collection, +): Boolean = defaultHasInternet || knownNetworksHaveInternet.any { it } + +/** + * Whether the OS default network goes into the known networks list. Only a + * network the callbacks can also report lost belongs there, and the callback + * request leaves out VPNs (NetworkRequest.Builder adds NOT_VPN by default). + * A Tailscale default used to be remembered, so after the VPN went away and + * then Wi-Fi and cell dropped, its stale entry kept the app "online" with no + * network at all. A connected VPN still counts while it is the default: the + * default network is read live on every re-evaluation. + */ +internal fun defaultNetworkIsRemembered(hasInternet: Boolean, isVpn: Boolean): Boolean = + hasInternet && !isVpn + +/** Minimum background time before a foreground entry re-probes a server that was answering. */ +internal const val FOREGROUND_REPROBE_AFTER_MS = 5_000L + +/** + * Whether a foreground entry probes the server, after the network state has + * just been re-read from the OS. + * + * - No network: nothing to probe. + * - The re-read found a network the stale state had missed: probe, this is + * the stuck "Offline" coming unstuck. + * - The server is not marked reachable: probe, so the screen stops lying. + * - First entry after a cold start ([backgroundForMs] null): startMonitoring + * already probes. + * - Otherwise only after a real trip to the background, not a quick flip. + */ +internal fun foregroundEntryProbes( + wasOnline: Boolean, + isOnline: Boolean, + isServerReachable: Boolean, + backgroundForMs: Long?, +): Boolean = when { + !isOnline -> false + !wasOnline -> true + !isServerReachable -> true + backgroundForMs == null -> false + else -> backgroundForMs >= FOREGROUND_REPROBE_AFTER_MS +} + +/** + * When a foreground entry still reads no network, how long after it to read + * the OS state again. Android lifts its background network block a moment + * after the app is visible. Reads only (no request), foreground only, and it + * stops at the first one that finds a network. + */ +internal val FOREGROUND_SETTLE_REREADS_MS = listOf(1_000L, 2_000L, 4_000L) + +/** + * Waits out each delay in [delaysMs] and calls [reread], stopping as soon as + * it returns true. Returns how many rereads ran. + */ +internal suspend fun rereadUntilOnline( + delaysMs: List, + sleep: suspend (Long) -> Unit = { delay(it) }, + reread: () -> Boolean, +): Int { + var reads = 0 + for (wait in delaysMs) { + sleep(wait) + reads += 1 + if (reread()) break + } + return reads } diff --git a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt index 74a3d1b6..34cadc28 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -13,14 +13,22 @@ import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus +import com.ninelivesaudio.app.service.download.CancelCleanupDecision import com.ninelivesaudio.app.service.download.DOWNLOAD_WORK_NAME +import com.ninelivesaudio.app.service.download.applyCancelCleanup +import com.ninelivesaudio.app.service.download.cancelCleanupStillClear +import com.ninelivesaudio.app.service.download.cancelMayTouchFiles +import com.ninelivesaudio.app.service.download.decideCancelCleanup import com.ninelivesaudio.app.service.download.DownloadEngine import com.ninelivesaudio.app.service.download.DownloadNotifications import com.ninelivesaudio.app.service.download.DownloadQueueWorker import com.ninelivesaudio.app.service.download.estimateTotalBytes import com.ninelivesaudio.app.service.download.finishInOwnerScope +import com.ninelivesaudio.app.service.download.lookUpInChunks import com.ninelivesaudio.app.service.download.pauseKeepsCompletedRow import com.ninelivesaudio.app.service.download.DownloadSlotStore +import com.ninelivesaudio.app.service.download.ResumeDecision +import com.ninelivesaudio.app.service.download.decideResume import com.ninelivesaudio.app.service.download.selectNextDownload import com.ninelivesaudio.app.service.download.writeAfterEngineStops import dagger.hilt.android.qualifiers.ApplicationContext @@ -73,6 +81,21 @@ class DownloadManager @Inject constructor( * first, the fetch runs outside the lock, and promotion re-takes it. */ private val slotMutex = Mutex() + + /** + * Keeps cancel cleanup from racing anything that brings a download back. + * + * Cancel holds it from the row read through the engine stop, the row delete, + * the ownership snapshot and the file deletion. Resume holds it across its + * read, its write and the drain start. Promotion in [queueDownload] holds it + * for the write that makes a row pickable, and every drain start goes through + * [enqueueDrain], which takes it too. Without it a resume could read a + * Paused row, lose the race to cancel, write the row back and start the + * engine, and cleanup would then delete files the resumed engine reuses. + * + * Lock order: this one first, [slotMutex] inside it, never the reverse. + */ + private val rowMutex = Mutex() private val workManager: WorkManager by lazy { WorkManager.getInstance(context) } /** @@ -131,6 +154,23 @@ class DownloadManager @Inject constructor( drainRunning = false } + /** + * The book the engine is streaming right now in this process, or null. + * Cancel cleanup checks it one last time before deleting anything. + */ + @Volatile + private var engineBookId: String? = null + + /** Called by [DownloadQueueWorker] right before it runs the engine on a book. */ + fun onEngineStarted(audioBookId: String) { + engineBookId = audioBookId + } + + /** Called by [DownloadQueueWorker] from a finally once the engine returns or throws. */ + fun onEngineStopped() { + engineBookId = null + } + /** * Re-show the paused notification when a persisted pause survived the * process that owned its only Resume control. @@ -293,23 +333,25 @@ class DownloadManager @Inject constructor( // entitlement drop resolved a different winner, or a stranded-claim // sweep cleaned it up. A blind upsert would resurrect it and put the // free tier over its cap. - val promoted = slotMutex.withLock { - val existing = downloadItemDao.getById(downloadId) - val rowStillOurs = existing?.status == DownloadStatus.Preparing.ordinal - // Status alone is not sufficient. An entitlement drop during the - // fetch can resolve a DIFFERENT winner while this row sits untouched - // in Preparing, and promoting it then would put a free install over - // its cap with the row looking perfectly legitimate. - val stillTheWinner = slotStore.canClaim(audioBook.id) - - if (!rowStillOurs || !stillTheWinner) { - // Failed promotion deletes its own row rather than leaving an - // orphan occupying the slot forever. - if (existing != null) downloadItemDao.deleteById(downloadId) - return@withLock false + val promoted = rowMutex.withLock { + slotMutex.withLock { + val existing = downloadItemDao.getById(downloadId) + val rowStillOurs = existing?.status == DownloadStatus.Preparing.ordinal + // Status alone is not sufficient. An entitlement drop during the + // fetch can resolve a DIFFERENT winner while this row sits untouched + // in Preparing, and promoting it then would put a free install over + // its cap with the row looking perfectly legitimate. + val stillTheWinner = slotStore.canClaim(audioBook.id) + + if (!rowStillOurs || !stillTheWinner) { + // Failed promotion deletes its own row rather than leaving an + // orphan occupying the slot forever. + if (existing != null) downloadItemDao.deleteById(downloadId) + return@withLock false + } + downloadItemDao.upsert(downloadItem.toEntity()) + true } - downloadItemDao.upsert(downloadItem.toEntity()) - true } claimSettled = true if (!promoted) return QueueResult.BlockedByFreeSlot(slotStore.persistedWinner) @@ -423,12 +465,13 @@ class DownloadManager @Inject constructor( * gives up and proceeds, which is the same risk profile as before this * function existed. */ - private suspend fun stopDrainAndAwait() { + private suspend fun stopDrainAndAwait(): Boolean { workManager.cancelUniqueWork(DOWNLOAD_WORK_NAME) - awaitDrainStopped() + return awaitDrainStopped() } - private suspend fun awaitDrainStopped() { + /** True once the drain is confirmed stopped, false when the ceiling ran out first. */ + private suspend fun awaitDrainStopped(): Boolean { val deadline = System.currentTimeMillis() + WORKER_STOP_TIMEOUT_MS while (System.currentTimeMillis() < deadline) { // The worker's own flag is the authority. WorkInfo is consulted only @@ -439,10 +482,11 @@ class DownloadManager @Inject constructor( workManager.getWorkInfosForUniqueWork(DOWNLOAD_WORK_NAME).await() }.getOrNull()?.any { !it.state.isFinished } ?: false - if (!stillScheduled) return + if (!stillScheduled) return true } delay(WORKER_STOP_POLL_MS) } + return false } /** @@ -488,14 +532,34 @@ class DownloadManager @Inject constructor( } /** Resume a paused/failed download by re-queuing it and ensuring the drain runs. */ - suspend fun resumeDownload(downloadId: String) { - val entity = downloadItemDao.getById(downloadId) ?: return - val item = entity.toDomain() - if (item.status != DownloadStatus.Paused && item.status != DownloadStatus.Failed) return - - // Reset to Queued. The engine skips already-finished files on re-run. - downloadItemDao.upsert(entity.copy(status = DownloadStatus.Queued.ordinal)) - if (!downloadsPaused) enqueueDrain(replace = false) + /** How a Resume or Retry tap went. */ + enum class ResumeResult { RESUMED, BLOCKED_BY_FREE_SLOT, NOT_RESUMABLE } + + suspend fun resumeDownload(downloadId: String): ResumeResult { + // One critical section from the read to the drain start, so a cancel + // either sees the Queued row or deletes it before this reads it. + rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) ?: return ResumeResult.NOT_RESUMABLE + val decision = slotMutex.withLock { + val decided = decideResume(entity.toDomain().status) { + slotStore.canClaim(entity.audioBookId) + } + if (decided == ResumeDecision.REQUEUE) { + // Reset to Queued. The engine skips already-finished files on re-run. + downloadItemDao.upsert(entity.copy(status = DownloadStatus.Queued.ordinal)) + if (slotStore.slotApplies) slotStore.persistedWinner = entity.audioBookId + } + decided + } + return when (decision) { + ResumeDecision.REQUEUE -> { + if (!downloadsPaused) enqueueDrainHoldingRowLock(replace = false) + ResumeResult.RESUMED + } + ResumeDecision.BLOCKED_BY_FREE_SLOT -> ResumeResult.BLOCKED_BY_FREE_SLOT + ResumeDecision.IGNORE -> ResumeResult.NOT_RESUMABLE + } + } } /** Pause the whole download queue: stop the drain and show a Resume notification. */ @@ -509,7 +573,7 @@ class DownloadManager @Inject constructor( } /** Resume the whole download queue. */ - fun resumeQueue() { + suspend fun resumeQueue() { downloadsPaused = false DownloadNotifications.clearPaused(context) enqueueDrain(replace = false) @@ -517,10 +581,21 @@ class DownloadManager @Inject constructor( /** Cancel a download and clean up; restart the drain if it was the active one. */ suspend fun cancelDownload(downloadId: String): Unit = finishInOwnerScope(scope) { - val entity = downloadItemDao.getById(downloadId) - val wasDownloading = entity?.status == DownloadStatus.Downloading.ordinal - writeAfterEngineStops(wasDownloading, { stopDrainAndAwait() }) { - downloadItemDao.deleteById(downloadId) + // Held from the read to the last deleted file. Resume, promotion and + // every drain start wait here, so nothing can bring this book's row + // back or start the engine on it while its files are being judged. + val wasDownloading = rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) + val downloading = entity?.status == DownloadStatus.Downloading.ordinal + var stopConfirmed = false + writeAfterEngineStops(downloading, { stopConfirmed = stopDrainAndAwait() }) { + downloadItemDao.deleteById(downloadId) + // Still before any drain restart below, so nothing is writing here. + if (entity != null && cancelMayTouchFiles(entity.status, downloading, stopConfirmed)) { + removeCancelledPartialFiles(entity.audioBookId) + } + } + downloading } if (downloadItemDao.getDownloadable().isEmpty()) { @@ -534,6 +609,57 @@ class DownloadManager @Inject constructor( } } + /** + * Delete the partial folder a cancelled download left behind (#51). + * + * Runs only once the engine is known to be off this book, and after this + * book's row is gone, with [rowMutex] held by the caller. The rules live in + * [decideCancelCleanup] and keep the files on any doubt, including when + * another book or download points at the same folder (#49). Right before + * deleting, [cancelCleanupStillClear] checks again that no row for the book + * or folder came back and the engine is not on the book. Best effort: a + * failure here never fails the cancel. + */ + private suspend fun removeCancelledPartialFiles(audioBookId: String) { + try { + val bookEntity = audioBookDao.getById(audioBookId) ?: return + val location = engine.downloadLocationFor(bookEntity.toDomain()) + val otherBookPaths = audioBookDao.getLocalPathsExcept(audioBookId) + // Every download row still around, this book's included if it was + // queued again, mapped to the folder it would write to. + suspend fun liveRows() = downloadItemDao.getAll().map { it.audioBookId }.distinct() + suspend fun foldersOf(bookIds: List) = + lookUpInChunks(bookIds) { audioBookDao.getByIds(it) } + .map { engine.downloadLocationFor(it.toDomain()).folder } + val otherFolders = foldersOf(liveRows()) + val bookIsDownloaded = bookEntity.isDownloaded == 1 || !bookEntity.localPath.isNullOrEmpty() + + val decision = withContext(Dispatchers.IO) { + decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders) + } + when (decision) { + is CancelCleanupDecision.Keep -> + android.util.Log.d("DownloadManager", "cancel cleanup kept files: ${decision.reason}") + is CancelCleanupDecision.Delete -> { + val rowBookIds = liveRows() + val clear = withContext(Dispatchers.IO) { + cancelCleanupStillClear(audioBookId, decision.folder, rowBookIds, foldersOf(rowBookIds), engineBookId) + } + if (!clear) { + android.util.Log.d("DownloadManager", "cancel cleanup kept files: a row or the engine came back") + return + } + val count = withContext(Dispatchers.IO) { applyCancelCleanup(decision) } + android.util.Log.d("DownloadManager", "cancel cleanup removed $count files") + } + } + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (e: Exception) { + android.util.Log.w("DownloadManager", "cancel cleanup skipped: ${e.message}") + } + } + /** Delete a download's files and DB record. */ suspend fun deleteDownload(audioBookId: String): Unit = finishInOwnerScope(scope) { val downloadEntity = downloadItemDao.getByAudioBookId(audioBookId) @@ -613,8 +739,15 @@ class DownloadManager @Inject constructor( * [replace] = true (REPLACE): used when pausing/cancelling the active book. * It cancels the running drain (stopping the engine on the current book) and * starts a fresh drain that skips the now paused/removed item and continues. + * + * Takes [rowMutex], so no drain starts while a cancel is judging files. */ - private fun enqueueDrain(replace: Boolean) { + private suspend fun enqueueDrain(replace: Boolean) { + rowMutex.withLock { enqueueDrainHoldingRowLock(replace) } + } + + /** [enqueueDrain] for a caller that already holds [rowMutex]. */ + private fun enqueueDrainHoldingRowLock(replace: Boolean) { // One gate for every automatic restart. pauseDownload, cancelDownload // and deleteDownload all restart the drain on their own, and none of // them knew about a queue-level pause, so any of them would silently diff --git a/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt b/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt new file mode 100644 index 00000000..1926dc21 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/ForegroundSyncPolicy.kt @@ -0,0 +1,139 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.data.local.entity.BookProgressState +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.domain.model.SyncResult +import com.ninelivesaudio.app.domain.model.UserProgress +import kotlin.math.abs + +// The pure rules behind SyncManager's foreground timer, its check records, +// and its progress pull, pinned by ForegroundSyncPolicyTest. + +/** A foreground entry inside this long after the last check does not check again. */ +internal const val FOREGROUND_CHECK_DEBOUNCE_MS = 2 * 60_000L + +/** How often a check repeats while the app stays in the foreground. */ +internal const val FOREGROUND_CHECK_INTERVAL_MS = 15 * 60_000L + +/** + * How long to wait after the app comes to the foreground before its check. + * No check yet, one at least [FOREGROUND_CHECK_DEBOUNCE_MS] old, or a clock + * reading before the last one all mean "now". A check under two minutes old + * (flipping to another app and straight back) waits out the rest of its + * 15-minute interval instead. + */ +internal fun delayBeforeEntryCheck(lastCheckAtMs: Long?, nowMs: Long): Long { + if (lastCheckAtMs == null) return 0L + val elapsed = nowMs - lastCheckAtMs + if (elapsed < 0 || elapsed >= FOREGROUND_CHECK_DEBOUNCE_MS) return 0L + return (FOREGROUND_CHECK_INTERVAL_MS - elapsed).coerceAtLeast(0L) +} + +/** + * Whether a timer tick or a server return should run a check. Anything that + * ran a sync in the last two minutes (Sync Now, sign-in, the entry check) + * already answered the question. + */ +internal fun isCheckDue(lastCheckAtMs: Long?, nowMs: Long): Boolean { + if (lastCheckAtMs == null) return true + val elapsed = nowMs - lastCheckAtMs + return elapsed < 0 || elapsed >= FOREGROUND_CHECK_DEBOUNCE_MS +} + +/** What a background check did across every library, for deciding its record. */ +internal data class CheckTally( + val anyDeferred: Boolean = false, + val wroteBooks: Boolean = false, + val libraryListChanged: Boolean = false, +) + +/** + * Whether a background check writes a [com.ninelivesaudio.app.domain.model.LastSyncRecord]. + * + * - A failure or a short fetch is written: that is the truth and the banner + * should say so. + * - A deferred full download writes nothing more: the shelf is known to be + * behind, so a success would be a lie, and nothing failed. + * - Books saved or the library list changing is written as the success it + * is, which also tells the Library to re-read its shelf. + * - Nothing changed writes nothing, unless the last record was not a + * success (or there is none), in which case the clean check heals it. + */ +internal fun shouldPersistCheckReport( + reportResult: SyncResult, + tally: CheckTally, + previousResult: SyncResult?, +): Boolean = when { + reportResult != SyncResult.SUCCESS -> true + tally.anyDeferred -> false + tally.wroteBooks || tally.libraryListChanged -> true + else -> previousResult != SyncResult.SUCCESS +} + +/** + * Whether the server's library list differs from the cached one in a way the + * Library shows: a library added, removed, or renamed. + */ +internal fun libraryListChanged(cached: List, fetched: List): Boolean = + cached.map { it.id to it.name }.toSet() != fetched.map { it.id to it.name }.toSet() + +/** + * The app plays audiobooks only. Podcast libraries need per-episode playback + * the app does not have, so background syncs skip them instead of + * downloading shelves nobody can play. Picking one in the Library still + * loads it on demand. + */ +internal fun isSyncedLibrary(library: Library): Boolean = library.mediaType == "book" + +/** How many books unknown to the cache a progress pull fetches one by one. */ +internal const val MAX_UNKNOWN_BOOK_FETCHES = 12 + +/** + * Which books a progress pull looks up one at a time because the cache has + * no row for them yet: the most recently listened first, at most [cap]. + * A fresh install's first pull used to send one request per book with + * progress, thousands in a row for a long-time listener, before the library + * sync could start. The handful kept here is enough for Home's recently + * played row, and the library sync brings the rest. + */ +internal fun unknownBooksToFetch( + progress: List, + knownIds: Set, + cap: Int = MAX_UNKNOWN_BOOK_FETCHES, +): Set = progress + .asSequence() + .filter { it.libraryItemId.isNotBlank() && it.libraryItemId !in knownIds } + .sortedByDescending { it.lastUpdate ?: Long.MIN_VALUE } + .map { it.libraryItemId } + .distinct() + .take(cap) + .toSet() + +private const val POSITION_EPSILON_SECONDS = 0.5 +private const val PROGRESS_EPSILON = 1e-6 + +/** + * Whether importing a server progress record would change nothing: the saved + * position row and the book's shelf fields already say the same thing. Every + * pull used to rewrite every book's whole row (audio file and chapter lists + * included) inside a transaction, even when nothing moved. + */ +internal fun serverProgressIsAlreadyLocal( + positionSeconds: Double, + progress: Double, + isFinished: Boolean, + updatedAt: String?, + localRow: PlaybackProgressEntity?, + localBook: BookProgressState?, +): Boolean { + if (localRow == null) return false + val rowMatches = abs(localRow.positionSeconds - positionSeconds) < POSITION_EPSILON_SECONDS && + (localRow.isFinished == 1) == isFinished && + localRow.updatedAt == updatedAt + if (!rowMatches) return false + if (localBook == null) return true + return abs(localBook.currentTimeSeconds - positionSeconds) < POSITION_EPSILON_SECONDS && + abs(localBook.progress - progress) < PROGRESS_EPSILON && + (localBook.isFinished == 1) == isFinished +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt index 6a1bc465..30d35df9 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/MediaBrowseTree.kt @@ -14,6 +14,7 @@ import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.isInActiveLibrary +import com.ninelivesaudio.app.ui.components.thumbnailCoverUrl import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -147,32 +148,20 @@ class MediaBrowseTree @Inject constructor( .map { (book, _) -> book } }, ) - .map(::bookToMediaItem) + .map { bookToMediaItem(it.toAutoBookItem()) } } - parentId == DOWNLOADED_ID -> { - val libraryId = settings.activeLibraryId ?: return emptyList() - downloadedBooksForAuto( - audioBookRepository.getFilteredBooks(libraryId, downloadedOnly = true), - settings, - ) - .sortedBy { it.title.lowercase() } - .drop(page * pageSize) - .take(pageSize) - .map(::bookToMediaItem) - } + // Library and Downloaded read one page from SQL, A to Z, light + // rows only. Sorting and paging the whole library in memory cost + // seconds and about 100 MB per page at 50k books, and repeated on + // every artwork notify. + parentId == DOWNLOADED_ID -> + autoBrowsePage(settings, page, pageSize, audioBookRepository::getAutoDownloadedPage) + .map { bookToMediaItem(it.toAutoBookItem()) } - parentId == LIBRARY_ID -> { - val libraryId = settings.activeLibraryId ?: return emptyList() - browseBooksForAuto( - audioBookRepository.getFilteredBooks(libraryId), - settings, - ) - .sortedBy { it.title.lowercase() } - .drop(page * pageSize) - .take(pageSize) - .map(::bookToMediaItem) - } + parentId == LIBRARY_ID -> + autoBrowsePage(settings, page, pageSize, audioBookRepository::getAutoBrowsePage) + .map { bookToMediaItem(it.toAutoBookItem()) } else -> emptyList() } @@ -217,20 +206,36 @@ class MediaBrowseTree @Inject constructor( // playable Auto item (e.g. from a stale queued media id). audioBookRepository.getById(bookId) ?.takeIf { browseBooksForAuto(listOf(it), settings).isNotEmpty() } - ?.let(::bookToMediaItem) + ?.let { bookToMediaItem(it.toAutoBookItem()) } } else -> null } } - /** Search books by title/author. */ - suspend fun search(query: String): List { + /** + * How many books match [query] by title or author in the active library + * and source, at most [AUTO_SEARCH_RESULT_CAP]. Counts in SQL, so no rows + * are built and no covers are fetched. + */ + suspend fun searchCount(query: String): Int { + apiService.awaitAuthReady() + val settings = resolveActiveScope() + if (!canBrowseAuto(settings, apiService.isAuthenticated)) return 0 + return autoSearchCount(settings, query, audioBookRepository::countAutoSearch) + } + + /** + * One page of search hits, read from SQL. Rows and cover fetches happen + * for this page only. A blank query finds nothing rather than the whole + * library. + */ + suspend fun search(query: String, page: Int, pageSize: Int): List { apiService.awaitAuthReady() val settings = resolveActiveScope() if (!canBrowseAuto(settings, apiService.isAuthenticated)) return emptyList() - return browseBooksForAuto(audioBookRepository.search(query), settings) - .map(::bookToMediaItem) + return autoSearchPage(settings, query, page, pageSize, audioBookRepository::searchAutoPage) + .map { bookToMediaItem(it.toAutoBookItem()) } } /** Extract the original AudioBook ID from a media ID like "book_{id}". */ @@ -263,7 +268,7 @@ class MediaBrowseTree @Inject constructor( // ─── Builders ────────────────────────────────────────────────────── @OptIn(UnstableApi::class) - private fun bookToMediaItem(book: AudioBook): MediaItem { + private fun bookToMediaItem(book: AutoBookItem): MediaItem { val metadataBuilder = MediaMetadata.Builder() .setTitle(book.title) .setArtist(book.author) @@ -276,8 +281,8 @@ class MediaBrowseTree @Inject constructor( metadataBuilder.setComposer(book.narrator) } - if (book.genres.isNotEmpty()) { - metadataBuilder.setGenre(book.genres.first()) + if (!book.genre.isNullOrEmpty()) { + metadataBuilder.setGenre(book.genre) } // Never set artworkUri to the ABS server URL (or a local file:// @@ -323,7 +328,7 @@ class MediaBrowseTree @Inject constructor( * flicker. Never throws — a fetch failure just leaves the row without * art. */ - private fun scheduleArtworkFetch(book: AudioBook) { + private fun scheduleArtworkFetch(book: AutoBookItem) { val bookId = book.id // The epoch gate is checked and set synchronously, BEFORE ever // launching a coroutine, so a book that already had an attempt this @@ -385,7 +390,7 @@ class MediaBrowseTree @Inject constructor( * cover) source directly; falls back to the authenticated remote fetch * for a server cover. Returns null on any failure. */ - private fun fetchArtworkBytes(book: AudioBook): ByteArray? { + private fun fetchArtworkBytes(book: AutoBookItem): ByteArray? { val source = book.localCoverPath ?: book.coverPath ?: return null val uri = runCatching { Uri.parse(source) }.getOrNull() ?: return null // A factory, not an already-open stream: ArtworkCodec may need to @@ -411,7 +416,7 @@ class MediaBrowseTree @Inject constructor( } } - val remoteUrl = book.coverPath?.takeIf { it.startsWith("http") } ?: return null + val remoteUrl = autoArtworkRemoteUrl(book.coverPath, ARTWORK_MAX_DIMENSION) ?: return null // A factory rather than a single execute(): ArtworkCodec may need to // re-issue the request on a rare bounds-mark overrun (see its kdoc). val remoteOpener = { @@ -594,12 +599,12 @@ internal class ArtworkFetchBurstTracker { * syncNow() bails on the reachability probe, so the epoch stops clearing for * the whole drive. * - * [MAX_EPOCH_AGE_MS] deliberately mirrors SyncManager.DEFAULT_SYNC_INTERVAL_MS. - * In ABS mode syncCompleted is already effectively a 5-minute heartbeat (it - * emits on every sync pass that clears the pre-checks, whether or not - * anything actually changed), and that is the configuration verified clean on - * a real head unit. So the floor does not invent a new traffic profile, it - * gives LOCAL and offline modes the one that was already proven. + * [MAX_EPOCH_AGE_MS] keeps the 5-minute cadence the old background sync + * timer gave syncCompleted, the configuration verified clean on a real head + * unit. That timer now runs only while the phone app is in the foreground + * (every 15 minutes), and a car session usually has the phone screen off, so + * this floor is now what keeps the epoch moving in every mode, not just LOCAL + * and offline. */ internal class ArtworkFetchEpoch( private val nowMs: () -> Long = System::currentTimeMillis, @@ -655,6 +660,14 @@ internal class ArtworkFetchEpoch( } } +/** + * The server cover address an Auto row fetches, asking for [widthPx] wide. + * Without a width Audiobookshelf sends the original, often several MB, only + * for it to be shrunk to browse size here. Null for anything not http(s). + */ +internal fun autoArtworkRemoteUrl(coverPath: String?, widthPx: Int): String? = + coverPath?.takeIf { it.startsWith("http") }?.let { thumbnailCoverUrl(it, widthPx) } + internal fun browseBooksForAuto( books: List, settings: AppSettings, @@ -662,11 +675,6 @@ internal fun browseBooksForAuto( !book.isArchived && book.isInActiveLibrary(settings) } -internal fun downloadedBooksForAuto( - books: List, - settings: AppSettings, -): List = browseBooksForAuto(books, settings).filter { it.isDownloaded } - internal suspend fun recentBooksForAuto( settings: AppSettings, maxItems: Int, diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt index a461c06e..e137042e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackManager.kt @@ -64,6 +64,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import java.io.File import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration @@ -238,8 +239,22 @@ internal data class PolledProgressReport( val bookId: String, val currentTime: Double, val duration: Double, + /** + * The immediate-save request count when this sample was taken. Every later + * sample carries a count at least as high, so the conflated channel can + * drop a sample without losing the request it carried. + */ + val saveRequest: Long = 0L, ) +/** + * Whether a polled sample asks for an immediate local save: it is the first + * sample the reporting worker has delivered this play (null), or an + * immediate save was requested since the last sample it delivered. + */ +internal fun polledReportForcesSave(saveRequest: Long, lastDeliveredSaveRequest: Long?): Boolean = + lastDeliveredSaveRequest == null || saveRequest != lastDeliveredSaveRequest + private class StaleProgressWriteException : Exception() internal fun newPolledProgressReportChannel(): Channel = @@ -249,8 +264,15 @@ internal class PlaybackProgressOwner { private val ownerLock = Any() private val mutexes = mutableMapOf() private val snapshotGenerations = mutableMapOf() - - data class SnapshotToken(val bookId: String, val generation: Long) + // Bumped by a paused seek. A snapshot taken before it holds an older + // position, but its listening time is still good. + private val positionGenerations = mutableMapOf() + + data class SnapshotToken( + val bookId: String, + val generation: Long, + val positionGeneration: Long = 0L, + ) private fun mutexFor(bookId: String): Mutex = synchronized(ownerLock) { mutexes.getOrPut(bookId, ::Mutex) @@ -259,9 +281,21 @@ internal class PlaybackProgressOwner { fun snapshotToken(bookId: String): SnapshotToken = synchronized(ownerLock) { val generation = (snapshotGenerations[bookId] ?: 0L) + 1L snapshotGenerations[bookId] = generation - SnapshotToken(bookId, generation) + SnapshotToken(bookId, generation, positionGenerations[bookId] ?: 0L) } + fun invalidateSnapshotPositions(bookId: String) { + synchronized(ownerLock) { + positionGenerations[bookId] = (positionGenerations[bookId] ?: 0L) + 1L + } + } + + private fun isCurrent(token: SnapshotToken): Boolean = + token.generation == snapshotGenerations[token.bookId] + + private fun positionIsCurrent(token: SnapshotToken): Boolean = + token.positionGeneration == (positionGenerations[token.bookId] ?: 0L) + fun invalidateSnapshots(bookId: String) { synchronized(ownerLock) { snapshotGenerations[bookId] = (snapshotGenerations[bookId] ?: 0L) + 1L @@ -275,11 +309,23 @@ internal class PlaybackProgressOwner { suspend fun sync(bookId: String, block: suspend () -> T): T = mutexFor(bookId).withLock { block() } - suspend fun syncSnapshot(token: SnapshotToken, block: suspend () -> Unit) { + /** + * Runs [block] if nothing has retired [token]. If only a paused seek has + * (its position is older than the seek's), [onPositionSuperseded] runs + * instead, so the snapshot's listening time is still saved without its + * old position. + */ + suspend fun syncSnapshot( + token: SnapshotToken, + onPositionSuperseded: (suspend () -> Unit)? = null, + block: suspend () -> Unit, + ) { mutexFor(token.bookId).withLock { - if (synchronized(ownerLock) { token.generation == snapshotGenerations[token.bookId] }) { - block() + val (current, positionCurrent) = synchronized(ownerLock) { + isCurrent(token) to positionIsCurrent(token) } + if (!current) return@withLock + if (positionCurrent) block() else onPositionSuperseded?.invoke() } } @@ -289,7 +335,7 @@ internal class PlaybackProgressOwner { flushProgress: suspend () -> Unit, ) { mutexFor(token.bookId).withLock { - if (synchronized(ownerLock) { token.generation == snapshotGenerations[token.bookId] }) { + if (synchronized(ownerLock) { isCurrent(token) && positionIsCurrent(token) }) { flushProgress() syncTerminal() } @@ -297,6 +343,28 @@ internal class PlaybackProgressOwner { } } +/** + * Save a seek made while paused. A pause save still waiting for the book's + * lock captured the position from before this seek, so its position write is + * retired first, on the caller's thread, before this save is queued. + * Otherwise a delayed pause save could land after this one and put the old + * position back. Its listening time still gets saved. + */ +internal fun PlaybackProgressOwner.launchPausedSeekSave( + scope: CoroutineScope, + bookId: String, + save: suspend () -> Unit, +): Job { + invalidateSnapshotPositions(bookId) + return scope.launch { + try { + report(bookId) { save() } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) {} + } +} + internal suspend fun PlaybackProgressOwner.resolveAndSavePlaybackPosition( bookId: String, candidate: Duration, @@ -866,6 +934,18 @@ class PlaybackManager @Inject constructor( ) ) return@withNewLoadRequest false + // Nothing pings while the app is in the background, so a car + // session can start on a stale "unreachable". Probe once before + // the load refuses a streamed book on it, the way restore does. + if ( + shouldProbeServerBeforeRestore( + book = book, + connectionStatus = connectivityMonitor.connectionStatus.value, + ) + ) { + connectivityMonitor.checkServerReachable() + } + withContext(Dispatchers.Main) { loadAudioBookOwned(loadRequest, book, skipServiceStart = true) } @@ -923,6 +1003,10 @@ class PlaybackManager @Inject constructor( private var positionPollingJob: Job? = null private var progressReportingJob: Job? = null private var progressReportingChannel: Channel? = null + // Bumped by anything that should save the position now instead of on the + // 10 second cadence: a seek, a chapter change, the app leaving the + // foreground. Read by the poll with each sample. + private val immediateSaveRequests = AtomicLong(0L) private var sessionSyncJob: Job? = null private val playbackProgressOwner = PlaybackProgressOwner() private val pendingTerminalOwner = PendingTerminalOwner() @@ -993,7 +1077,17 @@ class PlaybackManager @Inject constructor( if (snapshot != null) { val snapshotToken = playbackProgressOwner.snapshotToken(snapshot.bookId) scope.launch(Dispatchers.IO) { - playbackProgressOwner.syncSnapshot(snapshotToken) { + playbackProgressOwner.syncSnapshot( + token = snapshotToken, + // A paused seek landed first. Save the listening + // time against the seek's position, never this + // snapshot's older one. + onPositionSuperseded = { + latestPausedSeek?.takeIf { it.first == snapshot.bookId }?.let { (_, position) -> + syncPlaybackProgress(snapshot.copy(position = position)) + } + }, + ) { syncPlaybackProgress(snapshot) } } @@ -1079,6 +1173,9 @@ class PlaybackManager @Inject constructor( private var currentLocalSessionId: Long? = null private var localSessionAccumSec: Double = 0.0 private var pendingPauseSnapshot: PlaybackProgressSnapshot? = null + // The book and position of the last seek made while paused, read by a + // pause save that the seek overtook. + @Volatile private var latestPausedSeek: Pair? = null // Cap to ignore long gaps (background, doze) between heartbeats. 60s ≫ the 12s normal interval. private val localSessionMaxTickSec: Double = 60.0 @@ -2503,6 +2600,22 @@ class PlaybackManager @Inject constructor( } } + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + // Covers every seek, including the notification scrubber and + // Android Auto, which reach the player without passing seekTo(). + // A load's own seek lands while LOADING, so it only bumps the + // request. SEEK_ADJUSTMENT is left out on purpose: the player can + // raise it on its own after a paused restore, and saving then + // would stamp an old position as new and queue it for the server. + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + onSeekApplied() + } + } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { val idx = exoPlayer?.currentMediaItemIndex ?: 0 val reasonStr = when (reason) { @@ -2531,6 +2644,9 @@ class PlaybackManager @Inject constructor( val reportChannel = newPolledProgressReportChannel() progressReportingChannel = reportChannel progressReportingJob = scope.launch { + // Null until the first sample of this play lands, so a play (and the + // resume rewind it may apply) is saved at once. + var lastDeliveredSaveRequest: Long? = null for (report in reportChannel) { try { playbackProgressOwner.report(report.bookId) { @@ -2539,8 +2655,12 @@ class PlaybackManager @Inject constructor( currentTime = report.currentTime, duration = report.duration, isFinished = false, + force = polledReportForcesSave(report.saveRequest, lastDeliveredSaveRequest), ) } + // Only after the save returned, so a failed forced save is + // forced again on the next sample. + lastDeliveredSaveRequest = report.saveRequest } catch (cancellation: CancellationException) { throw cancellation } catch (_: Exception) {} @@ -2554,6 +2674,10 @@ class PlaybackManager @Inject constructor( _position.value = pos chapterPlayer?.absolutePositionMs = pos.inWholeMilliseconds updateCurrentChapter(pos) + // Read after the chapter check so a chapter change in this tick + // rides on this sample. Seeks run on this same main thread, so + // no seek lands between the position read and this one. + val saveRequest = immediateSaveRequests.get() // Keep the 500 ms playback clock independent from slow server I/O. // The single reporting worker stays sequential while this conflated @@ -2567,6 +2691,7 @@ class PlaybackManager @Inject constructor( bookId = reportBook.id, currentTime = pos.toDouble(kotlin.time.DurationUnit.SECONDS), duration = dur.toDouble(kotlin.time.DurationUnit.SECONDS), + saveRequest = saveRequest, ), ) } @@ -2574,6 +2699,75 @@ class PlaybackManager @Inject constructor( } } + /** Save the position on the poll's next sample instead of the 10 second cadence. */ + private fun requestImmediateProgressSave() { + immediateSaveRequests.incrementAndGet() + } + + /** + * The app left the foreground. A playing book saves its position on the + * next poll sample, so a process killed in the background loses nothing + * past that moment. A paused book already saved on pause. + */ + fun onAppBackgrounded() { + requestImmediateProgressSave() + } + + /** + * A seek landed, from the app, the notification, Android Auto, or a + * chapter jump. While playing, the next poll sample saves it. While paused + * nothing polls, so save it now through the same per-book owner the poll + * uses. The server push keeps its usual throttle. + */ + private fun onSeekApplied() { + requestImmediateProgressSave() + if (positionPollingJob?.isActive == true) return + if (_playbackState.value != PlaybackState.PAUSED) return + val book = _currentBook.value ?: return + val player = exoPlayer ?: return + if (player.mediaItemCount == 0) return + val position = getCurrentPosition() + val duration = _duration.value + latestPausedSeek = book.id to position + playbackProgressOwner.launchPausedSeekSave(scope, book.id) { + syncManager.reportPlaybackPosition( + itemId = book.id, + currentTime = position.toDouble(kotlin.time.DurationUnit.SECONDS), + duration = duration.toDouble(kotlin.time.DurationUnit.SECONDS), + isFinished = false, + force = true, + ) + } + } + + /** + * The service is going away with a book still playing. Save where it is + * before the player is released. Detached from the scope's cancellation + * so the write finishes during teardown. + */ + private fun saveProgressBeforeTeardown() { + if (positionPollingJob?.isActive != true) return + val book = _currentBook.value ?: return + if (exoPlayer == null) return + val position = getCurrentPosition() + val duration = _duration.value + scope.launch(NonCancellable) { + try { + playbackProgressOwner.report(book.id) { + syncManager.reportPlaybackPosition( + itemId = book.id, + currentTime = position.toDouble(kotlin.time.DurationUnit.SECONDS), + duration = duration.toDouble(kotlin.time.DurationUnit.SECONDS), + isFinished = false, + force = true, + ) + } + } catch (e: Exception) { + Log.e(TAG, "Teardown progress save failed", e) + } + } + } + private fun stopPositionPolling() { positionPollingJob?.cancel() positionPollingJob = null @@ -2602,6 +2796,7 @@ class PlaybackManager @Inject constructor( } if (newIndex != _currentChapterIndex.value) { + requestImmediateProgressSave() _currentChapterIndex.value = newIndex val chapter = if (newIndex >= 0) cachedChapters[newIndex] else null _currentChapter.value = chapter @@ -2884,11 +3079,17 @@ class PlaybackManager @Inject constructor( ) private suspend fun syncPlaybackProgress(snapshot: PlaybackProgressSnapshot) { + // Same rule as a seek's save, so a pause save carrying a position at + // the very end keeps the book finished instead of undoing it. + val finished = positionCountsAsFinished( + currentTimeSec = snapshot.position.toDouble(kotlin.time.DurationUnit.SECONDS), + durationSec = snapshot.duration.toDouble(kotlin.time.DurationUnit.SECONDS), + ) val terminal = terminalPlaybackSnapshot( bookId = snapshot.bookId, position = snapshot.position, duration = snapshot.duration, - isFinished = false, + isFinished = finished, serverSessionId = snapshot.serverSessionId, timeListened = snapshot.serverTimeListened, serverListening = snapshot.serverListening, @@ -2903,7 +3104,7 @@ class PlaybackManager @Inject constructor( progressRepository.savePushOrEnqueueProgress( itemId = snapshot.bookId, currentTime = posSec, - isFinished = false, + isFinished = finished, duration = snapshot.duration.toDouble(kotlin.time.DurationUnit.SECONDS), pushToServer = false, onPersisted = { @@ -3126,6 +3327,7 @@ class PlaybackManager @Inject constructor( /** Full teardown: release player, session, and all resources. Called on service destroy. */ fun releaseAll() { Log.d(TAG, "releaseAll: full teardown starting (sessionInit=$sessionInitialized)") + saveProgressBeforeTeardown() stopPositionPolling() stopSessionSync() val session = mediaSession diff --git a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt index 29fc5e44..4b5daa57 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/PlaybackService.kt @@ -136,8 +136,8 @@ class PlaybackService : MediaLibraryService() { // Start a fresh browse-artwork epoch so every book is // eligible for another fetch attempt. Note this emits on // every sync pass that clears SyncManager's pre-checks, - // changed data or not, so in ABS mode it behaves as a - // ~5-minute heartbeat rather than a strict change signal. + // changed data or not (a foreground check every 15 + // minutes), so it is a heartbeat, not a change signal. // That is deliberate and harmless: a working set that fits // the cache re-queries into hits and fetches nothing. mediaBrowseTree.invalidateArtworkEpoch() @@ -427,9 +427,9 @@ class PlaybackService : MediaLibraryService() { // Trigger async search; results delivered via onGetSearchResult serviceScope.launch(Dispatchers.IO) { try { - val results = mediaBrowseTree.search(query) - Log.d(TAG, "onSearch: query='$query' → ${results.size} results") - session.notifySearchResultChanged(browser, query, results.size, params) + val count = mediaBrowseTree.searchCount(query) + Log.d(TAG, "onSearch: query='$query' → $count results") + session.notifySearchResultChanged(browser, query, count, params) } catch (e: Exception) { Log.e(TAG, "onSearch($query) failed: ${e.message}", e) } @@ -453,9 +453,7 @@ class PlaybackService : MediaLibraryService() { } return serviceScope.future(Dispatchers.IO) { try { - val results = mediaBrowseTree.search(query) - .drop(page * pageSize) - .take(pageSize) + val results = mediaBrowseTree.search(query, page, pageSize) Log.d(TAG, "onGetSearchResult: query='$query' → ${results.size} results") LibraryResult.ofItemList(ImmutableList.copyOf(results), params) } catch (e: Exception) { diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt index abc5280e..96aa643d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SettingsManager.kt @@ -393,6 +393,26 @@ class SettingsManager @Inject constructor( true } + /** + * Runs [block] with the stored auth token while holding [authTokenMutex], + * so no sign-in, sign-out, or token swap can land until it returns. A + * library sync checks its account and writes the cache inside one of + * these, which keeps a sync that outlives its sign-in from writing or + * pruning once the next account's token exists. [block] must not call + * anything here that takes the same lock (it is not reentrant). + */ + internal suspend fun withAuthTokenLocked(block: suspend (token: String?) -> T): T = + authTokenMutex.withLock { + val token = try { + withContext(Dispatchers.IO) { encryptedPrefs.getString(KEY_AUTH_TOKEN, null) } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + null + } + block(token) + } + suspend fun markChangelogVersionSeen(version: String) { if (currentSettings.lastSeenChangelogVersion == version) return updateSettings { settings -> diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt index 01a884ea..3df4fb18 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncManager.kt @@ -2,14 +2,21 @@ package com.ninelivesaudio.app.service import com.ninelivesaudio.app.data.remote.ApiService +import android.os.SystemClock import android.util.Log import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.data.local.dao.AudioBookDao +import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao +import com.ninelivesaudio.app.data.local.entity.BookProgressState import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.data.repository.ProgressRepository +import com.ninelivesaudio.app.data.repository.itemCountResult +import com.ninelivesaudio.app.data.repository.wroteBooks +import com.ninelivesaudio.app.data.repository.LibraryRefreshOutcome +import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.LastSyncRecord @@ -27,14 +34,24 @@ private const val TAG = "SyncManager" private const val MIN_POSITION_SYNC_INTERVAL_MS = 30_000L private const val MIN_POSITION_DELTA = 2.0 private const val MIN_PROGRESS_DELTA = 0.01 +private const val PROGRESS_LOOKUP_CHUNK = 500 + +/** A full sync of every library, or the change check that does the least work needed. */ +internal enum class SyncMode { FULL, CHECK } /** - * Manages periodic synchronization with the Audiobookshelf server. + * Manages synchronization with the Audiobookshelf server. * * Responsibilities: - * - Periodic sync of libraries and audiobooks from server - * - Progress sync (pull from server, push offline queue) - * - Offline queue flushing on reconnect + * - A check while the app is in the foreground: on every foreground entry + * (debounced, see [delayBeforeEntryCheck]) and every 15 minutes after. + * A check pulls progress, flushes the offline queue, and asks each + * library whether anything changed ([SyncMode.CHECK]). Nothing runs on a + * timer in the background. Background playback pushes its own progress + * ([reportPlaybackPosition], [flushPlaybackProgress]) and the offline + * queue still flushes on reconnect. + * - A full sync of every library on sign-in, a mode switch back to the + * server, and Settings' Sync Now ([SyncMode.FULL]) * - Throttled position reporting during playback * * Port of C# SyncService.cs. @@ -48,11 +65,8 @@ class SyncManager @Inject constructor( private val audioBookDao: AudioBookDao, private val connectivityMonitor: ConnectivityMonitor, private val settingsManager: SettingsManager, + private val playbackProgressDao: PlaybackProgressDao, ) { - companion object { - private const val INITIAL_DELAY_MS = 500L // 0.5s — populate home screen fast - private const val DEFAULT_SYNC_INTERVAL_MS = 300_000L // 5 minutes - } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lifecycleOwner = SyncLifecycleOwner(scope) @@ -62,7 +76,22 @@ class SyncManager @Inject constructor( // Sync state @Volatile private var activeItemId: String? = null + // When a sync last ran (any mode, monotonic clock), for the foreground + // debounce. In memory on purpose: a cold start should always check. + @Volatile private var lastCheckAtMs: Long? = null + + // A foreground entry or timer check found the app not ready (offline) + // and ran nothing. Cleared by the next check that runs. A flow so the + // server-return listener sees it even when it is set after the status + // already turned Connected. + private val missedCheckWhileNotReady = MutableStateFlow(false) + + // Set from the process lifecycle (NineLivesApp). The timer only runs + // while this is true. + private val appInForeground = MutableStateFlow(false) + private val playbackThrottleOwner = PlaybackThrottleOwner() + private val localPositionCadence = LocalPositionWriteCadence() // ─── Events ────────────────────────────────────────────────────────────── @@ -85,31 +114,57 @@ class SyncManager @Inject constructor( private val _syncCompleted = MutableSharedFlow(replay = 0, extraBufferCapacity = 4) val syncCompleted: SharedFlow = _syncCompleted.asSharedFlow() + /** + * A check pulled progress. A check that found the book list unchanged + * writes no [LastSyncRecord], so a screen that re-reads its shelf on new + * records listens here instead to pick up the progress it just pulled. + */ + private val _progressPulled = MutableSharedFlow(replay = 0, extraBufferCapacity = 4) + val progressPulled: SharedFlow = _progressPulled.asSharedFlow() + // ─── Lifecycle ─────────────────────────────────────────────────────────── /** - * Start the periodic sync timer. + * The app came to the foreground (true) or left it (false). Called from + * the process lifecycle, so it reflects every activity together, not one + * screen. Leaving stops the timer at once. + */ + fun setAppForeground(inForeground: Boolean) { + appInForeground.value = inForeground + } + + /** + * Start the foreground check timer and the reconnect listeners. * Call this once when the app is initialized and authenticated. */ fun start() { lifecycleOwner.restart { + // The foreground timer. collectLatest cancels the loop the moment + // the app leaves the foreground, so nothing polls in the background. launch { - // Fast first sync - delay(INITIAL_DELAY_MS) - syncNow() - - // Periodic sync - while (isActive) { - delay(DEFAULT_SYNC_INTERVAL_MS) - syncNow() - // Also retry the offline queue here. The rising-edge flush only - // fires once per reconnect, so a push that failed right after - // reconnect would otherwise sit queued until the next full - // disconnect/reconnect cycle. - flushOfflineQueue() + appInForeground.collectLatest { inForeground -> + if (!inForeground) return@collectLatest + delay(delayBeforeEntryCheck(lastCheckAtMs, monotonicNowMs())) + runForegroundCheck() + while (isActive) { + delay(FOREGROUND_CHECK_INTERVAL_MS) + runForegroundCheck() + } } } + // A full download a check put off on a metered network runs as + // soon as the foreground app is on an unmetered one. + launch { + combine( + appInForeground, + connectivityMonitor.isMetered, + audioBookRepository.deferredFullSyncs, + ) { inForeground, metered, deferred -> inForeground && !metered && deferred.isNotEmpty() } + .distinctUntilChanged() + .collect { ready -> if (ready) checkNow() } + } + // Flush offline queue on the rising edge of (connected AND not LOCAL). // Triggers on both connectivity returns and mode switches back to AUDIOBOOKSHELF // while already connected, so progress queued during LOCAL mode doesn't sit indefinitely. @@ -127,25 +182,32 @@ class SyncManager @Inject constructor( } } - // Resync when the server comes back after a failed or partial sync. + // Check when the server comes back after a failed or partial sync. // The flush above only pushes queued progress, so without this the // library and its "Last sync failed" banner stayed stale until a - // manual Retry or the next periodic tick (up to 5 minutes). + // manual Retry or the next check. Foreground only, under the same + // two-minute debounce as every other check, and a check (not a + // full download): a flapping server used to restart the whole + // download on every return. A full download a check still needs + // after a failure waits out the repository's backoff. // SYNCING counts as live so a sync's own status flip is not an edge. launch { - combine( - connectivityMonitor.connectionStatus, - settingsManager.settings, - ) { status, settings -> isServerSessionLive(status, settings.appMode) } - .distinctUntilChanged() - .collect { live -> - if (live && shouldResyncOnServerReturn( - settingsManager.currentSettings.lastSyncForCurrentServer()?.result, - ) - ) { - syncNow() - } - } + runServerReturnChecks( + serverLive = combine( + connectivityMonitor.connectionStatus, + settingsManager.settings, + ) { status, settings -> isServerSessionLive(status, settings.appMode) }, + missedCheckWhileNotReady = missedCheckWhileNotReady, + shouldCheck = { missed -> + shouldCheckOnServerReturn( + inForeground = appInForeground.value, + checkDue = isCheckDue(lastCheckAtMs, monotonicNowMs()), + missedCheckWhileNotReady = missed, + lastResult = settingsManager.currentSettings.lastSyncForCurrentServer()?.result, + ) + }, + check = { checkNow() }, + ) } launch { @@ -163,7 +225,7 @@ class SyncManager @Inject constructor( if (oldMode != null && shouldReconnectForModeTransition(oldMode, newMode)) { performModeSwitchReconnect( refreshIsOnline = connectivityMonitor::refreshIsOnlineFromSystem, - syncNow = { syncNow() }, + syncNow = { syncNow(SyncMode.FULL) }, ) } } @@ -179,38 +241,57 @@ class SyncManager @Inject constructor( // ─── Sync Operations ───────────────────────────────────────────────────── /** - * Execute a full sync: libraries + audiobooks + progress. - * Thread-safe via Mutex. + * Whether a change check is due under the shared two-minute debounce. The + * Library reads this so opening it right after the entry check (a cold + * start) does not send its own check again. + */ + internal fun isCheckDueNow(): Boolean = isCheckDue(lastCheckAtMs, monotonicNowMs()) + + /** A timer tick or foreground entry: a check, unless one just ran, then the offline queue. */ + private suspend fun runForegroundCheck() { + if (isCheckDue(lastCheckAtMs, monotonicNowMs())) { + // Offline on entry (the network still coming back) runs nothing. + // The server-return listener above runs the check once connected. + if (checkNow().attempt == SyncAttempt.SKIPPED_NOT_READY) missedCheckWhileNotReady.value = true + } + // Retry the offline queue too. The rising-edge flush only fires once + // per reconnect, so a push that failed right after reconnect would + // otherwise sit queued until the next full disconnect/reconnect cycle. + flushOfflineQueue() + } + + /** Run a [SyncMode.CHECK] now: progress, then each library's change check. */ + internal suspend fun checkNow(): SyncNowResult = syncNow(SyncMode.CHECK) + + /** + * Execute a sync: progress, the library list, then each book library's + * items, fully ([SyncMode.FULL], the default) or through the change + * check ([SyncMode.CHECK]). Thread-safe via Mutex. * * Returns the [SyncNowResult] this attempt actually produced. Callers * that report the outcome to the user (Settings' manual "Sync Now") must * render [SyncNowResult.record] directly rather than rereading - * [SettingsManager.currentSettings] afterward — a concurrent periodic - * sync can write its own outcome in the gap between this attempt - * finishing and a reread, and the reread would then report THAT - * attempt's result instead of this one's. A [SyncAttempt.SKIPPED] result - * means nothing ran at all: [SyncNowResult.record] is null and must not - * be read as this call's outcome either. + * [SettingsManager.currentSettings] afterward — a concurrent sync can + * write its own outcome in the gap between this attempt finishing and a + * reread, and the reread would then report THAT attempt's result instead + * of this one's. A [SyncAttempt.SKIPPED_NOT_READY] or + * [SyncAttempt.SKIPPED_BUSY] result means nothing ran at all, and + * [SyncAttempt.CHECKED_NOTHING_TO_RECORD] means a check ran and found + * nothing worth a new record: [SyncNowResult.record] is null for all three + * and must not be read as this call's outcome. * * The actual control flow (gate, reachability probe, mutex, persist) is * [runSyncAttempt] — extracted so its invariants are pinned directly by * tests instead of through a stand-in helper this method could quietly * stop calling. */ - internal suspend fun syncNow(): SyncNowResult { + internal suspend fun syncNow(mode: SyncMode = SyncMode.FULL): SyncNowResult { val serverUrlAtStart = settingsManager.currentSettings.serverUrl + var tally = CheckTally() + var recordSkipped = false - return runSyncAttempt( - // Cheap pre-check: authenticated, non-LOCAL, and the OS reports a - // network. Evaluated fresh every call — the post-probe recheck - // below must see a mode switch or sign-out that landed mid-probe. - isSyncReady = { - shouldRunSync( - isOnline = connectivityMonitor.isOnline.value, - isLocalMode = settingsManager.currentSettings.appMode == AppMode.LOCAL, - hasAuth = hasAuthToken(), - ) - }, + val result = runSyncAttempt( + isSyncReady = { isSyncReady() }, // Actually reach the server before committing to a sync. A live // VPN interface (e.g. Tailscale) keeps isOnline=true with no real // connectivity, so without this probe the sync fires and hangs @@ -226,32 +307,75 @@ class SyncManager @Inject constructor( onLockReleasing = { _isSyncing.value = false connectivityMonitor.setSyncing(false) + lastCheckAtMs = monotonicNowMs() + missedCheckWhileNotReady.value = false }, // Progress sync FIRST — this populates the home screen grid // immediately. Library sync runs after (heavier, fetches all // book metadata). runSyncWork = { syncProgress() - fetchLibrarySyncReport( - fetchLibraries = libraryRepository::syncFromServer, - // syncLibraryItems already preserves local download state. - fetchItems = { library -> audioBookRepository.syncLibraryItems(library.id) }, - ) + when (mode) { + SyncMode.FULL -> fetchLibrarySyncReportFromCounts( + fetchLibraries = libraryRepository::syncFromServer, + fetchItemCounts = { library -> + if (!isSyncedLibrary(library)) RemoteResult.Ok(0) + else audioBookRepository.syncLibraryItems(library.id) + }, + ) + SyncMode.CHECK -> { + val cachedLibraries = libraryRepository.getAudiobookshelf() + fetchLibrarySyncReportFromCounts( + fetchLibraries = { + libraryRepository.syncFromServer().also { fetched -> + if (fetched is RemoteResult.Ok && libraryListChanged(cachedLibraries, fetched.value)) { + tally = tally.copy(libraryListChanged = true) + } + } + }, + fetchItemCounts = { library -> + if (!isSyncedLibrary(library)) { + RemoteResult.Ok(0) + } else { + val outcome = audioBookRepository.refreshLibraryItemsIfChanged( + libraryId = library.id, + isMetered = connectivityMonitor.refreshIsMetered(), + ) + tally = tally.copy( + anyDeferred = tally.anyDeferred || outcome is LibraryRefreshOutcome.Deferred, + wroteBooks = tally.wroteBooks || outcome.wroteBooks, + ) + // A deferral is no verdict. Report the cached + // shelf so it neither fails nor counts as zero. + outcome.itemCountResult() + ?: RemoteResult.Ok(audioBookRepository.countByLibrary(library.id)) + } + }, + ) + } + } }, persistOutcome = { report -> - persistSyncOutcome( - report = report, - completedAtMs = System.currentTimeMillis(), - serverUrlAtStart = serverUrlAtStart, - isEligibleSession = { settings -> - settings.appMode == AppMode.AUDIOBOOKSHELF - }, - updateSettingsIfAuthenticated = settingsManager::updateSettingsIfAuthenticated, - ).also { outcome -> - if (!outcome.persisted) { - Log.e(TAG, "syncNow: failed to persist sync outcome") - } + val previousResult = settingsManager.currentSettings.lastSyncForCurrentServer()?.result + if (mode == SyncMode.CHECK && !shouldPersistCheckReport(report.result, tally, previousResult)) { + recordSkipped = true _syncCompleted.tryEmit(Unit) + PersistedSyncOutcome(recorded = false, persisted = true, record = null) + } else { + persistSyncOutcome( + report = report, + completedAtMs = System.currentTimeMillis(), + serverUrlAtStart = serverUrlAtStart, + isEligibleSession = { settings -> + settings.appMode == AppMode.AUDIOBOOKSHELF + }, + updateSettingsIfAuthenticated = settingsManager::updateSettingsIfAuthenticated, + ).also { outcome -> + if (!outcome.persisted) { + Log.e(TAG, "syncNow: failed to persist sync outcome") + } + _syncCompleted.tryEmit(Unit) + } } }, // A failed probe still has to be recorded. Returning silently @@ -274,11 +398,41 @@ class SyncManager @Inject constructor( } }, ) + if (result.attempt == SyncAttempt.RAN || result.attempt == SyncAttempt.DISCARDED_SERVER_CHANGED) { + // Progress was pulled even when the book list said nothing new. + _progressPulled.tryEmit(Unit) + } + return if (recordSkipped && result.attempt == SyncAttempt.DISCARDED_SERVER_CHANGED) { + SyncNowResult(SyncAttempt.CHECKED_NOTHING_TO_RECORD, null) + } else { + result + } } + private fun monotonicNowMs(): Long = SystemClock.elapsedRealtime() + + /** + * Cheap pre-check: authenticated, non-LOCAL, and the OS reports a + * network. Evaluated fresh every call, so a recheck after the probe sees + * a mode switch or sign-out that landed mid-probe. + */ + private suspend fun isSyncReady(): Boolean = shouldRunSync( + isOnline = connectivityMonitor.isOnline.value, + isLocalMode = settingsManager.currentSettings.appMode == AppMode.LOCAL, + hasAuth = hasAuthToken(), + ) + /** * Sync progress from the server. * Server is source of truth (except for actively playing items). + * + * Local state is read in batches up front, a record that already matches + * it is skipped, and an import updates only the book's progress columns. + * Books the cache has never seen are looked up one by one only for the + * most recently listened few (see [unknownBooksToFetch]). The library + * sync brings the rest, and their progress rows import either way, so + * the sync shelves those books with that progress (see + * [com.ninelivesaudio.app.data.repository.withPulledProgress]). */ private suspend fun syncProgress() { try { @@ -286,17 +440,33 @@ class SyncManager @Inject constructor( val serverProgressList = progressRepository.fetchAllProgressFromServer() if (serverProgressList.isEmpty()) return + val ids = serverProgressList.map { it.libraryItemId }.distinct() + val books = HashMap() + val rows = HashMap() + for (chunk in ids.chunked(PROGRESS_LOOKUP_CHUNK)) { + audioBookDao.getProgressStates(chunk).associateByTo(books) { it.id } + playbackProgressDao.getByAudioBookIds(chunk).associateByTo(rows) { it.audioBookId } + } + val fetchIndividually = unknownBooksToFetch(serverProgressList, books.keys) + for (progress in serverProgressList) { // Active playback and queued local writes own progress. In // particular, never compare server and phone wall clocks to // decide whether an unsent offline write survives. try { - var book = audioBookDao.getById(progress.libraryItemId) - if (book == null) { - val remoteBook = audioBookRepository.fetchFromServer(progress.libraryItemId) + val itemId = progress.libraryItemId + var book = books[itemId] + if (book == null && itemId in fetchIndividually) { + val remoteBook = audioBookRepository.fetchFromServer(itemId) if (remoteBook != null) { audioBookDao.upsert(remoteBook.toEntity()) - book = audioBookDao.getById(progress.libraryItemId) + book = BookProgressState( + id = itemId, + currentTimeSeconds = remoteBook.currentTime.inWholeMilliseconds / 1000.0, + progress = remoteBook.progress, + isFinished = if (remoteBook.isFinished) 1 else 0, + durationSeconds = remoteBook.duration.inWholeMilliseconds / 1000.0, + ).also { books[itemId] = it } } } @@ -311,35 +481,47 @@ class SyncManager @Inject constructor( } val updatedAtStr = progress.lastUpdate?.toIso8601() + if (serverProgressIsAlreadyLocal( + positionSeconds = positionSeconds, + progress = progress.progress, + isFinished = progress.isFinished, + updatedAt = updatedAtStr, + localRow = rows[itemId], + localBook = book, + ) + ) continue // The repository checks the queue while holding the same // per-book ownership used by enqueue and flush. A pending // local write wins regardless of server clock skew. - val imported = progressRepository.importServerProgressIfNoPending( + val bookKnown = book != null + progressRepository.importServerProgressIfNoPending( PlaybackProgressEntity( - audioBookId = progress.libraryItemId, + audioBookId = itemId, positionSeconds = positionSeconds, isFinished = if (progress.isFinished) 1 else 0, updatedAt = updatedAtStr, ), importToken = importToken, onImported = { - if (book != null) { - audioBookDao.upsert( - book.copy( - currentTimeSeconds = positionSeconds, - progress = progress.progress, - isFinished = if (progress.isFinished) 1 else 0, - ) + if (bookKnown) { + audioBookDao.updateProgress( + id = itemId, + currentTimeSeconds = positionSeconds, + progress = progress.progress, + isFinished = if (progress.isFinished) 1 else 0, ) } }, ) - if (!imported) continue + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // Continue with next item } } + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // Non-fatal } @@ -368,20 +550,26 @@ class SyncManager @Inject constructor( } /** - * Report playback position with throttling. - * Always saves locally. Only pushes to server if throttle conditions are met. + * Report a playback position sampled by the player's 500 ms poll. + * + * Saves locally at most every [LOCAL_POSITION_WRITE_INTERVAL_MS], or at + * once when [force] is set (a seek, a chapter change, the first sample of + * a play, the app leaving the foreground, teardown), when the book is + * finished, or when the server push is due. Pause and stop save through + * their own terminal paths. A crash loses at most about 10 seconds, which + * the resume rewind covers. The server push keeps its own throttle + * ([shouldPushPlaybackPosition]) and only goes out with a local save. */ suspend fun reportPlaybackPosition( itemId: String, currentTime: Double, duration: Double, isFinished: Boolean, + force: Boolean = false, ) { val safeCurrentTime = currentTime.coerceAtLeast(0.0) val safeDuration = duration.coerceAtLeast(0.0) - // Only auto-mark as finished if position is within 1 second of the end. - // Exact >= comparison can fire prematurely during seeks near the end. - val computedFinished = isFinished || (safeDuration > 0.0 && safeDuration - safeCurrentTime < 1.0) + val computedFinished = isFinished || positionCountsAsFinished(safeCurrentTime, safeDuration) val isLocalMode = settingsManager.currentSettings.appMode == AppMode.LOCAL val now = System.currentTimeMillis() val shouldSync = !isLocalMode && shouldPushPlaybackPosition( @@ -392,25 +580,34 @@ class SyncManager @Inject constructor( now = now, ) + val nowMonotonic = monotonicNowMs() + val cadence = localPositionCadence.snapshot(itemId) + val pushToServer = shouldSync && connectivityMonitor.isOnline.value + if ( + !shouldWriteLocalPosition( + cadence = cadence, + nowMs = nowMonotonic, + force = force, + isFinished = computedFinished, + pushToServer = pushToServer, + ) + ) return + + // Only the progress columns. The old full-row REPLACE rewrote the + // book's description and blobs twice a second. val updateShelf: suspend () -> Unit = { - readAndWriteShelfProgressIfCurrent( - isCurrent = { true }, - read = { audioBookDao.getById(itemId) }, - write = { book -> - if (book != null) { - audioBookDao.upsert( - book.copy( - currentTimeSeconds = safeCurrentTime, - progress = shelfProgress( - currentTime = safeCurrentTime, - duration = safeDuration, - isFinished = computedFinished, - existingProgress = book.progress, - ), - isFinished = if (computedFinished) 1 else 0, - ), - ) - } + writeShelfProgressColumns( + currentTime = safeCurrentTime, + duration = safeDuration, + isFinished = computedFinished, + readExistingProgress = { audioBookDao.getById(itemId)?.progress ?: 0.0 }, + update = { time, progress, finished -> + audioBookDao.updateProgress( + id = itemId, + currentTimeSeconds = time, + progress = progress, + isFinished = finished, + ) }, ) } @@ -429,13 +626,21 @@ class SyncManager @Inject constructor( currentTime = safeCurrentTime, isFinished = computedFinished, duration = safeDuration, - pushToServer = shouldSync && connectivityMonitor.isOnline.value, + pushToServer = pushToServer, onPersisted = updateShelf, ) } + localPositionCadence.recordWrite( + itemId = itemId, + atMs = nowMonotonic, + pushFailed = pushToServer && !pushed, + ) if (pushed) { playbackThrottleOwner.recordSuccess(itemId, safeCurrentTime, now) + // Nothing pings in the background, so a push that lands is what + // tells a background session the server is back. + connectivityMonitor.reportServerAnswered() } } @@ -569,6 +774,41 @@ internal fun isServerSessionLive( internal fun shouldResyncOnServerReturn(lastResult: SyncResult?): Boolean = lastResult != null && lastResult != SyncResult.SUCCESS +/** + * Whether the server coming back (status turning Connected) runs a check: + * foreground only, under the two-minute debounce, and only when the last + * attempt did not fully succeed or a foreground check was skipped because + * the app was offline at the time. The second case is the app coming back + * from the background reading "Offline" for a moment: its entry check ran + * nothing, and without this the shelf waited 15 minutes for the next timer. + */ +internal fun shouldCheckOnServerReturn( + inForeground: Boolean, + checkDue: Boolean, + missedCheckWhileNotReady: Boolean, + lastResult: SyncResult?, +): Boolean = inForeground && checkDue && + (missedCheckWhileNotReady || shouldResyncOnServerReturn(lastResult)) + +/** + * The server-return listener. Runs [check] when the server turns live and + * [shouldCheck] agrees, and again when a skipped entry check sets the missed + * flag while the server is already live. The flag used to be read only on the + * live edge, so an entry check that read offline, then lost the race to the + * Connected status, set it with no edge left to consume it, and the shelf + * waited for the next timer. + */ +internal suspend fun runServerReturnChecks( + serverLive: Flow, + missedCheckWhileNotReady: Flow, + shouldCheck: (missed: Boolean) -> Boolean, + check: suspend () -> Unit, +) { + combine(serverLive, missedCheckWhileNotReady) { live, missed -> live to missed } + .distinctUntilChanged() + .collect { (live, missed) -> if (live && shouldCheck(missed)) check() } +} + internal fun shouldReconnectForModeTransition( previousMode: AppMode, newMode: AppMode, @@ -629,6 +869,14 @@ internal enum class SyncAttempt { /** Another sync already held the mutex. */ SKIPPED_BUSY, + /** + * A check ran (progress pulled, every library asked) and found nothing + * that called for a new record: the book lists matched, or a full + * download is waiting for an unmetered network. The record already + * stored is not this attempt's result. + */ + CHECKED_NOTHING_TO_RECORD, + /** A sync actually ran (including the reachability-probe-failure path, which records FAILED). */ RAN, @@ -767,6 +1015,79 @@ internal suspend fun runSyncAttempt( } } +/** How often the 500 ms playback poll saves the position locally. */ +internal const val LOCAL_POSITION_WRITE_INTERVAL_MS = 10_000L + +internal data class LocalPositionCadenceSnapshot( + /** Monotonic time of the last local save, null before the first one. */ + val lastWriteAtMs: Long? = null, + /** The server push that went out with the last save failed. */ + val lastPushFailed: Boolean = false, +) + +internal class LocalPositionWriteCadence { + private val lock = Any() + private val states = mutableMapOf() + + fun snapshot(itemId: String): LocalPositionCadenceSnapshot = synchronized(lock) { + states[itemId] ?: LocalPositionCadenceSnapshot() + } + + fun recordWrite(itemId: String, atMs: Long, pushFailed: Boolean) { + synchronized(lock) { + states[itemId] = LocalPositionCadenceSnapshot(atMs, pushFailed) + } + } +} + +/** + * Whether a polled position is saved locally now. + * + * Saves on [force], a finished book, a due server push, the first sample, or + * once [LOCAL_POSITION_WRITE_INTERVAL_MS] has passed. A due push skips the + * wait only when the previous push landed: the push throttle stays due + * until a push succeeds, so a server refusing connections would otherwise + * bring back a save (and a failed request) every 500 ms. After a failure + * the retry rides the 10 second save. + */ +internal fun shouldWriteLocalPosition( + cadence: LocalPositionCadenceSnapshot, + nowMs: Long, + force: Boolean, + isFinished: Boolean, + pushToServer: Boolean, +): Boolean { + if (force || isFinished) return true + val lastWriteAt = cadence.lastWriteAtMs ?: return true + if (pushToServer && !cadence.lastPushFailed) return true + return nowMs - lastWriteAt >= LOCAL_POSITION_WRITE_INTERVAL_MS || nowMs < lastWriteAt +} + +/** + * Write a playback position into the shelf row's progress columns only. + * Reads the stored progress only when the duration is unknown and the old + * value has to stand. + */ +internal suspend fun writeShelfProgressColumns( + currentTime: Double, + duration: Double, + isFinished: Boolean, + readExistingProgress: suspend () -> Double, + update: suspend (currentTime: Double, progress: Double, isFinished: Int) -> Unit, +) { + val existingProgress = if (!isFinished && duration <= 0.0) readExistingProgress() else 0.0 + update( + currentTime, + shelfProgress( + currentTime = currentTime, + duration = duration, + isFinished = isFinished, + existingProgress = existingProgress, + ), + if (isFinished) 1 else 0, + ) +} + internal data class PlaybackThrottleSnapshot( val lastSyncedTime: Double = 0.0, val lastSyncTimestamp: Long = 0L, @@ -827,3 +1148,14 @@ internal suspend fun readAndWriteShelfProgressIfCurrent( write(current) return true } + +/** + * A saved position within 1 second of the end counts as finished. Exact >= + * comparison can fire prematurely during seeks near the end. Every local + * position save uses this one rule, so a later save of the same position + * never marks the book unfinished again. + */ +internal fun positionCountsAsFinished(currentTimeSec: Double, durationSec: Double): Boolean { + val safeDuration = durationSec.coerceAtLeast(0.0) + return safeDuration > 0.0 && safeDuration - currentTimeSec.coerceAtLeast(0.0) < 1.0 +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt b/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt index f0a6b63b..b2dfd46d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/SyncReport.kt @@ -106,6 +106,13 @@ internal fun buildShelfSyncReport( libraries: RemoteResult>?, selectedLibrary: Library?, items: RemoteResult>?, +): SyncReport? = buildShelfSyncReportFromCount(libraries, selectedLibrary, items?.map { it.size }) + +/** [buildShelfSyncReport] for an item fetch that reports how many books it saved. */ +internal fun buildShelfSyncReportFromCount( + libraries: RemoteResult>?, + selectedLibrary: Library?, + items: RemoteResult?, ): SyncReport? { if (libraries == null && items == null) return null @@ -116,8 +123,8 @@ internal fun buildShelfSyncReport( null -> if (selectedLibrary == null) 0 else 1 } val bookCount = when (items) { - is RemoteResult.Ok -> items.value.size - is RemoteResult.Partial -> items.value.size + is RemoteResult.Ok -> items.value + is RemoteResult.Partial -> items.value is RemoteResult.Failed, null -> 0 } val itemName = selectedLibrary?.name ?: "library" @@ -165,6 +172,18 @@ internal fun unreachableServerSyncReport(): SyncReport = SyncReport( internal suspend fun fetchLibrarySyncReport( fetchLibraries: suspend () -> RemoteResult>, fetchItems: suspend (Library) -> RemoteResult>, +): SyncReport = fetchLibrarySyncReportFromCounts( + fetchLibraries = fetchLibraries, + fetchItemCounts = { library -> fetchItems(library).map { books -> books.size } }, +) + +/** + * [fetchLibrarySyncReport] for item fetches that report how many books they + * saved rather than handing the books back. + */ +internal suspend fun fetchLibrarySyncReportFromCounts( + fetchLibraries: suspend () -> RemoteResult>, + fetchItemCounts: suspend (Library) -> RemoteResult, ): SyncReport { val librariesResult = try { fetchLibraries() @@ -176,14 +195,13 @@ internal suspend fun fetchLibrarySyncReport( val libraries = librariesResult.valueOrEmpty() val itemResults = libraries.map { library -> - val result = try { - fetchItems(library) + try { + fetchItemCounts(library) } catch (e: CancellationException) { throw e } catch (e: Exception) { RemoteResult.Failed(describeFailure(e)) } - result.map { books -> books.size } } return buildSyncReport( diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt new file mode 100644 index 00000000..bc124848 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -0,0 +1,274 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.DownloadStatus +import java.io.File +import java.io.IOException +import java.net.URI +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path + +// ─── Cancelled download cleanup (decision is unit-testable) ─────────────── +// +// A cancelled download used to leave its partial folder on disk for good +// (#51). Cleaning it up deletes user files, so every rule here leans toward +// keeping them. Any doubt at all is a Keep. + +/** Where a book's download folder sits and whether the app alone owns the root. */ +internal data class DownloadLocation( + val root: File, + val folder: File, + /** + * True when the root is the app's own storage, which nothing else can + * write to on API 30+. False for a user-configured download path, which + * may hold the user's own audiobooks. + */ + val rootIsAppOwned: Boolean, +) + +/** + * Pick the download root the same way the engine does, without creating it. + * + * A configured path wins when it resolves and stays out of system dirs. + * Otherwise [defaultRoot] (the app's own storage). The root only counts as app + * owned when it really is [defaultRoot], so a user who typed that same path in + * by hand still gets the full cleanup. On fallback the [defaultRoot] instance + * itself comes back, which the engine uses to log a rejected path. + */ +internal fun resolveDownloadRoot(configuredPath: String, defaultRoot: File): Pair { + val trimmed = configuredPath.trim() + if (trimmed.isNotEmpty()) { + val candidate = runCatching { File(trimmed).canonicalFile }.getOrNull() + if (candidate != null && FORBIDDEN_DOWNLOAD_ROOTS.none { candidate.absolutePath.startsWith(it) }) { + val defaultCanonical = runCatching { defaultRoot.canonicalFile }.getOrNull() + return candidate to (candidate == defaultCanonical) + } + } + return defaultRoot to true +} + +/** System dirs a configured download path may never point into. */ +internal val FORBIDDEN_DOWNLOAD_ROOTS = listOf("/system", "/data/data", "/data/user", "/proc", "/dev") + +/** + * Whether the engine is provably off this download, so its folder can be + * touched at all. + * + * A Downloading row only qualifies once the stop was confirmed (the stop gives + * up after a timeout and the engine may still be streaming). Paused and Failed + * rows are never picked by the drain. A Queued row can be picked at any + * moment, and a Completed row is a finished download that cancel must leave + * exactly as it was, so both keep their files. + */ +internal fun cancelMayTouchFiles(status: Int, wasDownloading: Boolean, stopConfirmed: Boolean): Boolean = + when { + wasDownloading -> stopConfirmed + status == DownloadStatus.Paused.ordinal -> true + status == DownloadStatus.Failed.ordinal -> true + else -> false + } + +internal enum class CancelKeepReason { + /** + * The root is a user-picked download folder. Other tools can write there + * (a `.part` file from a browser or a sync app looks just like ours), so + * cancel never deletes anything from it. + */ + USER_FOLDER, + + /** The book row is marked downloaded, so this is a finished download. */ + BOOK_DOWNLOADED, + + /** The folder is missing, so there is nothing to clean. */ + NO_FOLDER, + + /** The folder is not a plain direct child of the download root. */ + OUTSIDE_ROOT, + + /** Another book row or download points at this folder (#49). */ + SHARED, + + /** The folder holds something the engine never writes. */ + UNEXPECTED_CONTENTS, + + /** A path could not be resolved, so overlap cannot be ruled out. */ + UNRESOLVABLE, +} + +internal sealed interface CancelCleanupDecision { + data class Keep(val reason: CancelKeepReason) : CancelCleanupDecision + + /** + * Delete exactly [files] (all directly inside the canonical [folder]), then + * [folder] itself once it is empty. + */ + data class Delete( + val folder: File, + val files: List, + ) : CancelCleanupDecision +} + +/** + * Decide what a cancelled download may delete from [location]. + * + * Kept, in this order, when: + * - the root is a user-picked download folder rather than the app's own storage, + * - the book row says the book is downloaded, + * - the folder is not a real direct child of the root (a symlink, the root + * itself, `..`, or anything that resolves elsewhere), + * - the folder does not exist, + * - any [otherBookPaths] entry (another book's stored localPath) is the + * folder, sits inside it, or contains it, or cannot be resolved, + * - any [otherDownloadFolders] entry (where another download row would + * write) is the folder, + * - the folder holds a subdirectory, a symlink, or anything not a plain file. + * + * Otherwise every file goes and then the empty folder. That only ever happens + * in the app's own storage, which nothing else can write to. + * + * Content URIs in [otherBookPaths] are skipped. They come from the Storage + * Access Framework, which cannot reach app storage on API 30+, and a + * user-configured root is never touched at all. + */ +internal fun decideCancelCleanup( + location: DownloadLocation, + bookIsDownloaded: Boolean, + otherBookPaths: List, + otherDownloadFolders: List, +): CancelCleanupDecision { + if (!location.rootIsAppOwned) return CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER) + if (bookIsDownloaded) return CancelCleanupDecision.Keep(CancelKeepReason.BOOK_DOWNLOADED) + + val root = canonicalOrNull(location.root) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + val folderPath = location.folder.toPath() + if (Files.isSymbolicLink(folderPath)) { + return CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT) + } + val folder = canonicalOrNull(location.folder) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + if (folder == root || folder.parentFile != root) { + return CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT) + } + if (!Files.isDirectory(folder.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER) + } + + for (raw in otherBookPaths) { + val other = bookPathAsFile(raw) ?: continue + val otherCanonical = canonicalOrNull(other) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + if (pathsOverlap(folder, otherCanonical)) { + return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + } + } + for (other in otherDownloadFolders) { + val otherCanonical = canonicalOrNull(other) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + if (otherCanonical == folder) return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + } + + val children = try { + // DirectoryStream, not Files.list(...).toList(): Stream.toList needs API 34. + Files.newDirectoryStream(folder.toPath()).use { stream -> stream.toList() } + } catch (_: IOException) { + return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + } catch (_: SecurityException) { + return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + } + if (children.any { !Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) }) { + return CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS) + } + + return CancelCleanupDecision.Delete(folder, children.map(Path::toFile)) +} + +/** + * The last look before a [CancelCleanupDecision.Delete] runs: true only when + * no download row exists for [audioBookId], none would write to [folder], and + * the engine is not on the book ([engineBookId]). [liveRowBookIds] and + * [liveRowFolders] are read fresh right before the delete. A folder that will + * not resolve counts as a match, so any doubt keeps the files. + */ +internal fun cancelCleanupStillClear( + audioBookId: String, + folder: File, + liveRowBookIds: List, + liveRowFolders: List, + engineBookId: String?, +): Boolean { + if (engineBookId == audioBookId) return false + if (audioBookId in liveRowBookIds) return false + val target = canonicalOrNull(folder) ?: return false + return liveRowFolders.none { other -> canonicalOrNull(other)?.let { it == target } ?: true } +} + +/** + * Carry out a [CancelCleanupDecision.Delete]. Deletes each listed path without + * following links, then the folder only if it is empty by then. Never recurses. + * Returns how many files went. + */ +internal fun applyCancelCleanup(decision: CancelCleanupDecision.Delete): Int { + val folder = decision.folder + var deleted = 0 + for (file in decision.files) { + val path = file.toPath() + // Re-check right before the delete. Only a plain file directly inside + // this exact folder is ever removed. + if (file.parentFile != folder) continue + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) continue + if (runCatching { Files.deleteIfExists(path) }.getOrDefault(false)) deleted++ + } + // Fails on a non-empty folder, which is the point: anything that showed + // up since the decision stays. + runCatching { Files.deleteIfExists(folder.toPath()) } + return deleted +} + +/** + * Most ids one book lookup binds. Android 11 ships SQLite 3.28, which refuses a + * statement with more than 999 bound values, so a single IN over every download + * row failed (and the cleanup quietly kept the files) past that many rows. + */ +internal const val CLEANUP_LOOKUP_CHUNK = 500 + +/** Run [lookup] over [ids] in chunks of at most [chunkSize] and join the results. */ +internal suspend fun lookUpInChunks( + ids: List, + chunkSize: Int = CLEANUP_LOOKUP_CHUNK, + lookup: suspend (List) -> List, +): List { + val results = mutableListOf() + for (chunk in ids.chunked(chunkSize)) { + results += lookup(chunk) + } + return results +} + +private fun canonicalOrNull(file: File): File? = try { + file.canonicalFile +} catch (_: IOException) { + null +} catch (_: SecurityException) { + null +} + +/** A stored localPath as a File: plain paths and file:// URIs. Null for anything else. */ +private fun bookPathAsFile(raw: String): File? { + val value = raw.trim() + if (value.isEmpty()) return null + if (value.startsWith("file:", ignoreCase = true)) { + val path = runCatching { URI(value).path }.getOrNull() + // A file URI that will not parse is still a file path of some kind. + return File(path ?: value.removePrefix("file://")) + } + if (value.contains("://")) return null + return File(value) +} + +/** Equal, or one sits inside the other. Both must be canonical. */ +private fun pathsOverlap(a: File, b: File): Boolean { + val pa = a.toPath() + val pb = b.toPath() + return pa == pb || pa.startsWith(pb) || pb.startsWith(pa) +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt index bc3b57c4..df9d413a 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadEngine.kt @@ -10,8 +10,10 @@ import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.data.remote.AudiobookshelfApi import com.ninelivesaudio.app.data.remote.dto.ApiAudioFile +import com.ninelivesaudio.app.data.remote.dto.ApiChapter import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.Chapter import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.service.SettingsManager @@ -228,15 +230,18 @@ class DownloadEngine @Inject constructor( // a cover failure must never fail an otherwise-complete download. val localCoverUri = persistCover(book, downloadDir) - // Update audiobook as downloaded with local path + // Mark the book downloaded and keep the track list and chapters that were + // just downloaded. List-synced rows hold an empty manifest, and offline + // playback falls back to sorting files by name without one. val bookEntity = audioBookDao.getById(audioBook.id) if (bookEntity != null) { audioBookDao.upsert( - bookEntity.copy( - isDownloaded = 1, + completedDownloadBook( + row = bookEntity.toDomain(), + downloaded = book, localPath = downloadDir.absolutePath, - localCoverPath = localCoverUri ?: bookEntity.localCoverPath, - ) + localCoverUri = localCoverUri, + ).toEntity() ) } @@ -269,15 +274,18 @@ class DownloadEngine @Inject constructor( } } - /** Fetch full book details (audio file metadata) from the server. */ + /** Fetch full book details (audio files and chapters) from the server. */ suspend fun fetchFullBookDetails(audioBookId: String): AudioBook? { return try { val response = api.getItem(audioBookId, expanded = 1) if (response.isSuccessful) { response.body()?.let { apiItem -> val audioFiles = toDomainAudioFiles(apiItem.media?.audioFiles.orEmpty()) + val chapters = toDomainChapters(apiItem.media?.chapters.orEmpty()) - audioBookDao.getById(audioBookId)?.toDomain()?.copy(audioFiles = audioFiles) + audioBookDao.getById(audioBookId)?.toDomain()?.let { row -> + row.copy(audioFiles = audioFiles, chapters = chapters.ifEmpty { row.chapters }) + } } } else null } catch (cancellation: CancellationException) { @@ -296,28 +304,37 @@ class DownloadEngine @Inject constructor( return File(basePath, folderName) } + /** + * Where [audioBook]'s download folder is under the current settings, for + * cancel cleanup. Resolves exactly like [getDownloadPath] but creates + * nothing. + */ + internal fun downloadLocationFor(audioBook: AudioBook): DownloadLocation { + val (root, appOwned) = resolveDownloadRoot( + configuredPath = settingsManager.currentSettings.downloadPath, + defaultRoot = defaultBasePath(), + ) + val folderName = downloadFolderName(audioBook.author, audioBook.title, audioBook.id) + return DownloadLocation(root = root, folder = File(root, folderName), rootIsAppOwned = appOwned) + } + /** Base storage directory for all downloads. */ private fun getBasePath(): File { // Respect user-configured path when possible, with path traversal validation. val configuredPath = settingsManager.currentSettings.downloadPath.trim() - if (configuredPath.isNotEmpty()) { - try { - val candidate = File(configuredPath).canonicalFile - // Reject paths targeting sensitive system directories - val forbidden = listOf("/system", "/data/data", "/data/user", "/proc", "/dev") - val isSafe = forbidden.none { candidate.absolutePath.startsWith(it) } - if (isSafe) { - return candidate.also { it.mkdirs() } - } - Log.w(TAG, "getBasePath: Configured path rejected (targets system dir): $configuredPath") - } catch (e: Exception) { - Log.w(TAG, "getBasePath: Failed to resolve configured path: $configuredPath", e) - } + val fallback = defaultBasePath() + val (root, _) = resolveDownloadRoot(configuredPath, fallback) + // The fallback instance itself comes back only when the configured path lost. + if (configuredPath.isNotEmpty() && root === fallback) { + Log.w(TAG, "getBasePath: Configured path rejected or unresolvable: $configuredPath") } + return root.also { it.mkdirs() } + } - // Fallback to app-specific external storage. + /** App-specific external storage, the fallback root. */ + private fun defaultBasePath(): File { val musicDir = context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) ?: context.filesDir - return File(musicDir, "Audiobookshelf").also { it.mkdirs() } + return File(musicDir, "Audiobookshelf") } } @@ -348,3 +365,31 @@ internal fun toDomainAudioFiles(files: List): List = size = af.metadata?.size ?: 0, ) } + +/** + * Map the expanded item response's chapters onto the domain model, with the same + * filtering and blank-title fallback as the item mapping in ApiService. + */ +internal fun toDomainChapters(chapters: List): List = + chapters + .filter { c -> c.start >= 0.0 && c.end > c.start } + .map { c -> Chapter(id = c.id, start = c.start, end = c.end, title = c.title.ifBlank { "Chapter ${c.id}" }) } + +/** + * The book row to save when a download finishes. The track list that was just + * downloaded names the files on disk, so it replaces whatever the row held + * (usually the empty list a library sync leaves). Chapters come along when the + * download has them. An empty list never wipes what the row already had. + */ +internal fun completedDownloadBook( + row: AudioBook, + downloaded: AudioBook, + localPath: String, + localCoverUri: String?, +): AudioBook = row.copy( + isDownloaded = true, + localPath = localPath, + localCoverPath = localCoverUri ?: row.localCoverPath, + audioFiles = downloaded.audioFiles.ifEmpty { row.audioFiles }, + chapters = downloaded.chapters.ifEmpty { row.chapters }, +) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt index 30bec295..452fc115 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadPolicies.kt @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.service.download import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.DownloadStatus import kotlin.math.pow // ─── Download decision logic (pure, unit-testable) ──────────────────────── @@ -90,3 +91,22 @@ internal fun retryBackoffMs(retryCount: Int): Long = /** A file is already downloaded if it exists on disk with non-zero length. */ internal fun shouldSkipDownloadedFile(exists: Boolean, length: Long): Boolean = exists && length > 0 + +/** What a Resume or Retry tap on one download row should do. */ +internal enum class ResumeDecision { REQUEUE, BLOCKED_BY_FREE_SLOT, IGNORE } + +/** + * Only a Paused or Failed row can be resumed, and only when its book may hold + * the free slot ([canClaim] is true whenever the slot does not apply). A + * Failed row gives the slot up, so another book can download meanwhile. + * Requeueing it anyway left it Queued forever: the drain only runs the slot + * winner, which was the other book. + */ +internal suspend fun decideResume( + status: DownloadStatus, + canClaim: suspend () -> Boolean, +): ResumeDecision = when { + status != DownloadStatus.Paused && status != DownloadStatus.Failed -> ResumeDecision.IGNORE + !canClaim() -> ResumeDecision.BLOCKED_BY_FREE_SLOT + else -> ResumeDecision.REQUEUE +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt index 2e93cf9f..64897a71 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadQueueWorker.kt @@ -115,19 +115,25 @@ class DownloadQueueWorker( // and Android rate-limits notification posts, so unthrottled updates // get dropped and the bar appears frozen / far behind. var lastNotifiedPercent = -1 - val result = withContext(Dispatchers.IO) { - engine.download(item, book) { id, downloaded, total -> - manager.publishProgress(id, downloaded, total) - val percent = if (total > 0) { - ((downloaded.toDouble() / total) * 100).toInt() - } else { - -1 - } - if (percent != lastNotifiedPercent) { - lastNotifiedPercent = percent - setForeground(foregroundInfo(item.title, downloaded, total)) + // Cancel cleanup checks this last thing before deleting files. + manager.onEngineStarted(item.audioBookId) + val result = try { + withContext(Dispatchers.IO) { + engine.download(item, book) { id, downloaded, total -> + manager.publishProgress(id, downloaded, total) + val percent = if (total > 0) { + ((downloaded.toDouble() / total) * 100).toInt() + } else { + -1 + } + if (percent != lastNotifiedPercent) { + lastNotifiedPercent = percent + setForeground(foregroundInfo(item.title, downloaded, total)) + } } } + } finally { + manager.onEngineStopped() } manager.notifyTerminal(result) android.util.Log.d(TAG, "done id=${item.id} result=${result.status}") diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt index 8532643b..c984d7f1 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadSlotStore.kt @@ -4,6 +4,8 @@ import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.data.local.dao.PlaybackProgressDao +import com.ninelivesaudio.app.data.local.entity.SlotBookRow +import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.entitlement.DownloadSlotResolver import com.ninelivesaudio.app.entitlement.EntitlementCachePrefs @@ -57,43 +59,27 @@ class DownloadSlotStore @Inject constructor( * * Built from the union of live download rows and retained offline state, * because neither source alone is complete: the engine writes the offline - * fields only after writing Completed, and `clearCompleted()` deletes rows + * fields only after writing Completed, and an older Clear All deleted rows * while leaving files on disk. */ suspend fun buildCandidates(): List = withContext(Dispatchers.IO) { val rows = downloadItemDao.getAll().map { it.toDomain() } val rowsByBook = rows.groupBy { it.audioBookId } - val offlineBooks = audioBookDao.getAll() - .map { it.toDomain() } - .filter { it.isDownloaded || rowsByBook.containsKey(it.id) } - - // Keyed off AudioBooks rather than download rows, so a claim whose book - // record has gone is naturally reported as unusable rather than being - // silently dropped from the candidate set. - val bookIds = (offlineBooks.map { it.id } + rowsByBook.keys).distinct() - - bookIds.map { bookId -> - val book = offlineBooks.firstOrNull { it.id == bookId } - // The newest row wins when duplicates exist. The pre-existing queue - // race can already have left more than one row per book on upgraded - // installs, and no dedupe migration runs. - val row = rowsByBook[bookId] - ?.maxByOrNull { it.startedAt ?: Long.MIN_VALUE } - - SlotCandidate( - audioBookId = bookId, - isLocal = book?.isLocal ?: false, - hasAudioBookRecord = book != null, - downloadStatus = row?.status, - isDownloaded = book?.isDownloaded ?: false, - hasLocalPath = !book?.localPath.isNullOrBlank(), - filesExist = filesExist(book?.localPath), - progressUpdatedAt = progressMillis(bookId), - completedAt = row?.completedAt, - startedAt = row?.startedAt, - ) - } + // Only downloaded books and books with a download row can hold the + // slot, so only those rows are read, and only their slot fields. + val books = loadSlotBooks( + rowBookIds = rowsByBook.keys, + getDownloaded = audioBookDao::getDownloadedSlotRows, + getByIds = audioBookDao::getSlotRowsByIds, + ) + + assembleSlotCandidates( + rowsByBook = rowsByBook, + books = books, + filesExist = ::filesExist, + progressMillis = { progressMillis(it) }, + ) } /** Resolve, persist and return the winner. */ @@ -145,6 +131,66 @@ class DownloadSlotStore @Inject constructor( } } +/** Largest id list bound in one slot lookup, safely under SQLite's 999. */ +private const val SLOT_LOOKUP_CHUNK = 500 + +/** + * The AudioBooks rows that can matter to the slot: every downloaded book plus + * any book named by a download row. Keyed by id. Never reads the rest of the + * library. + */ +internal suspend fun loadSlotBooks( + rowBookIds: Collection, + getDownloaded: suspend () -> List, + getByIds: suspend (List) -> List, +): Map { + val books = LinkedHashMap() + getDownloaded().forEach { books[it.id] = it } + val missing = rowBookIds.filterNot { it in books }.distinct() + for (chunk in missing.chunked(SLOT_LOOKUP_CHUNK)) { + getByIds(chunk).forEach { books[it.id] = it } + } + return books +} + +/** + * One candidate per book that is downloaded or has a download row. + * + * Keyed off AudioBooks as well as download rows, so a claim whose book record + * has gone is reported as unusable rather than silently dropped. + */ +internal suspend fun assembleSlotCandidates( + rowsByBook: Map>, + books: Map, + filesExist: (String?) -> Boolean, + progressMillis: suspend (String) -> Long?, +): List { + val offlineBooks = books.values.filter { it.isDownloaded == 1 || rowsByBook.containsKey(it.id) } + val offlineById = offlineBooks.associateBy { it.id } + val bookIds = (offlineBooks.map { it.id } + rowsByBook.keys).distinct() + + return bookIds.map { bookId -> + val book = offlineById[bookId] + // The newest row wins when duplicates exist. The pre-existing queue + // race can already have left more than one row per book on upgraded + // installs, and no dedupe migration runs. + val row = rowsByBook[bookId]?.maxByOrNull { it.startedAt ?: Long.MIN_VALUE } + + SlotCandidate( + audioBookId = bookId, + isLocal = book?.isLocal == 1, + hasAudioBookRecord = book != null, + downloadStatus = row?.status, + isDownloaded = book?.isDownloaded == 1, + hasLocalPath = !book?.localPath.isNullOrBlank(), + filesExist = filesExist(book?.localPath), + progressUpdatedAt = progressMillis(bookId), + completedAt = row?.completedAt, + startedAt = row?.startedAt, + ) + } +} + /** Statuses that mean a row is a live or finished occupant of the slot. */ internal val SLOT_OCCUPYING_STATUSES = setOf( DownloadStatus.Preparing, diff --git a/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanCoordinator.kt b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanCoordinator.kt new file mode 100644 index 00000000..6e182951 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanCoordinator.kt @@ -0,0 +1,107 @@ +package com.ninelivesaudio.app.service.local + +import com.ninelivesaudio.app.domain.model.Library +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton + +/** A folder scan the user started from Settings. */ +sealed interface LocalScanRequest { + /** Add Folder: scan a newly picked folder and create its library. */ + data class AddFolder(val folderUri: String) : LocalScanRequest + + /** Rescan: scan an existing library's folder again. */ + data class Rescan(val library: Library) : LocalScanRequest +} + +/** What a finished scan imported into [library]. */ +data class LocalScanImport( + val library: Library, + val scanResult: LocalLibraryScanner.ScanResult, +) + +sealed interface LocalScanState { + data object Idle : LocalScanState + + data class Running(val request: LocalScanRequest) : LocalScanState + + /** + * A scan ended. Exactly one of [imported] and [failure] is set. It stays + * here until Settings shows it and calls [LocalScanCoordinator.acknowledge], + * so a scan that finished while Settings was closed still reports when + * Settings opens again. + */ + data class Finished( + val id: Long, + val request: LocalScanRequest, + val imported: LocalScanImport?, + val failure: String?, + ) : LocalScanState +} + +/** + * Owns Add Folder and Rescan for the whole app (issue #57). They used to run + * in the Settings screen's own scope, so leaving Settings mid-scan cancelled + * the scan and nothing was imported. This scope lives as long as the app, so + * the scan finishes wherever the user goes, and Settings just watches [state]. + * + * One scan at a time. Settings disables its buttons while one runs, and a + * second request that slips through is refused rather than racing the first + * one's reconciliation. + */ +@Singleton +class LocalScanCoordinator internal constructor( + private val scope: CoroutineScope, + private val work: suspend (LocalScanRequest) -> LocalScanImport, +) { + @Inject + constructor(importer: LocalScanImporter) : this( + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + work = importer::run, + ) + + private val _state = MutableStateFlow(LocalScanState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val finishedIds = AtomicLong() + + /** Starts [request]. Returns false if a scan is already running. */ + fun start(request: LocalScanRequest): Boolean { + val running = LocalScanState.Running(request) + while (true) { + val current = _state.value + if (current is LocalScanState.Running) return false + if (_state.compareAndSet(current, running)) break + } + scope.launch { + val finished = try { + val imported = work(request) + LocalScanState.Finished(finishedIds.incrementAndGet(), request, imported, failure = null) + } catch (e: Exception) { + LocalScanState.Finished( + finishedIds.incrementAndGet(), + request, + imported = null, + failure = e.message ?: e.javaClass.simpleName, + ) + } + _state.value = finished + } + return true + } + + /** Settings showed the result of scan [id], so it can go back to Idle. */ + fun acknowledge(id: Long) { + val current = _state.value + if (current is LocalScanState.Finished && current.id == id) { + _state.compareAndSet(current, LocalScanState.Idle) + } + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanImporter.kt b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanImporter.kt new file mode 100644 index 00000000..8bf012aa --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/local/LocalScanImporter.kt @@ -0,0 +1,132 @@ +package com.ninelivesaudio.app.service.local + +import android.net.Uri +import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibraryRepository +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.ui.settings.failEmptyScanWithErrors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The work behind Add Folder and Rescan: scan the folder, import what it + * found, and reconcile the library against it. [LocalScanCoordinator] runs + * this in an app-wide scope so leaving Settings no longer throws a scan away + * (issue #57). Settings only shows the result. + */ +@Singleton +class LocalScanImporter @Inject constructor( + private val localScanner: LocalLibraryScanner, + private val localMetadataExtractor: LocalMetadataExtractor, + private val libraryRepository: LibraryRepository, + private val audioBookRepository: AudioBookRepository, + private val settingsManager: SettingsManager, +) { + + suspend fun run(request: LocalScanRequest): LocalScanImport = when (request) { + is LocalScanRequest.AddFolder -> addFolder(request.folderUri) + is LocalScanRequest.Rescan -> rescan(request.library) + } + + private suspend fun addFolder(uriString: String): LocalScanImport { + // Derive a display name from the URI path + val uri = Uri.parse(uriString) + val displayName = uri.lastPathSegment + ?.substringAfterLast(':') + ?.substringAfterLast('/') + ?.ifBlank { null } + ?: "Local Library" + + val scanResult = withContext(Dispatchers.IO) { localScanner.scan(uri) } + failEmptyScanWithErrors(scanResult) + + // Create or reuse the local library row after confirming the folder is readable. + val library = libraryRepository.createLocalLibrary(displayName, uriString) + + importAndReconcile(library.id, scanResult) + + // Select this library + settingsManager.updateSettings { + it.copy(selectedLocalLibraryId = library.id) + } + return LocalScanImport(library, scanResult) + } + + private suspend fun rescan(library: Library): LocalScanImport { + val folderUri = requireNotNull(library.folderUri) { "This library has no folder" } + val scanResult = withContext(Dispatchers.IO) { localScanner.scan(Uri.parse(folderUri)) } + failEmptyScanWithErrors(scanResult) + + importAndReconcile(library.id, scanResult) + return LocalScanImport(library, scanResult) + } + + /** + * Import what the scan found, then reconcile the library against it: any + * local book whose folder was not seen in this scan leaves the library + * (issue #20). Without this a rescan only ever adds, so moving a folder one + * level deeper leaves a ghost entry pointing at files that are gone, and + * playing it fails. + * + * Removal is gated on [LocalLibraryScanner.ScanResult.coverageComplete], NOT + * on "the scan reported no warnings". The distinction is the whole safety + * argument: a folder that could not be listed, a revoked permission, or a + * traversal cut short by the depth or folder cap all mean books exist that + * this scan never saw, and absence from a partial inventory is not evidence + * a book is gone. Those scans import and reconcile nothing. A warning that + * is not a coverage gap (a single unreadable file, whose id the scan hands + * back as retained) no longer freezes the whole library forever. + * + * "Leaves the library" means archived, not deleted. A folder can come back: + * the card is out of the library and its Archive tab entry keeps the cover, + * progress, and history, and re-adding the folder restores it in place. The + * Orphaned Books sweep in Settings stays the one path that actually + * deletes rows, and it already cascades progress, sessions, bookmarks, and + * the local_covers file. + */ + private suspend fun importAndReconcile( + libraryId: String, + scanResult: LocalLibraryScanner.ScanResult, + ) { + val books = scanResult.books.map { it.toAudioBook(libraryId) } + val seenIds = scanResult.seenBookIds() + // Captured before the import, so a book that arrived in THIS scan is + // distinguishable from one that was already in the library. + val existingIds = if (scanResult.coverageComplete) { + audioBookRepository.getLiveLocalIds(libraryId) + } else { + emptyList() + } + + audioBookRepository.importLocalBooksCarryingMoves( + libraryId = libraryId, + books = books, + existingIds = existingIds, + seenIds = seenIds, + ) + + if (!scanResult.coverageComplete) return + + archiveMissingBooks(libraryId, seenIds) + } + + /** + * The single archive choke point: before soft-deleting the books missing + * from [scannedIds], copy any still-readable content:// folder cover to + * durable storage. Both archive triggers (whole-folder Remove and the + * missing-books pass after a rescan) route through here, so a book scanned + * under an older build keeps its cover once archived — the folder is still + * accessible at this point (permission not yet released; a rescan only + * archives what a clean scan reported missing). Already-durable and + * unreadable covers are left as-is (best-effort). + */ + suspend fun archiveMissingBooks(libraryId: String, scannedIds: List) { + audioBookRepository.persistFolderCovers(libraryId) { uri, id -> + localMetadataExtractor.persistFolderCover(uri, id) + } + audioBookRepository.removeMissingLocalBooks(libraryId, scannedIds) + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt index b3f1179b..066e25e2 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/bookdetail/BookDetailViewModel.kt @@ -15,6 +15,7 @@ import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.domain.model.ListeningSession import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.isInActiveLibrary +import com.ninelivesaudio.app.entitlement.FreeTier import com.ninelivesaudio.app.service.DownloadManager import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.PlaybackManager @@ -52,6 +53,27 @@ internal fun historyPresentation(result: RemoteResult>): ) } +/** + * The library list sync carries no chapters, so most server books open with an + * empty chapter list. Fetch the single item for them while the server is + * reachable. Scanned-local and archived books have nothing to ask the server. + */ +internal fun shouldFetchDetailChapters( + book: AudioBook, + appMode: AppMode, + connection: ConnectivityMonitor.ConnectionStatus, +): Boolean = + appMode == AppMode.AUDIOBOOKSHELF && + !book.isLocal && + !book.isArchived && + book.chapters.isEmpty() && + (connection == ConnectivityMonitor.ConnectionStatus.CONNECTED || + connection == ConnectivityMonitor.ConnectionStatus.SYNCING) + +/** The stored chapters when the row has them, else the ones fetched for this screen. */ +internal fun detailChapters(stored: List, fetched: List?): List = + stored.ifEmpty { fetched.orEmpty() }.sortedBy { it.start } + /** Tracks resumes for one ViewModel lifetime, never across process recreation. */ internal class BookDetailResumeTracker { private var hasResumed = false @@ -137,6 +159,12 @@ class BookDetailViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val resumeTracker = BookDetailResumeTracker() + // Chapters from the single-item fetch, for a row the library list synced + // without them. Held for this screen only and never written back, so the + // shelf rows stay light. + private var serverChapters: List? = null + private var chapterFetchStarted = false + init { if (bookId.isNotEmpty()) { loadBook() @@ -181,6 +209,15 @@ class BookDetailViewModel @Inject constructor( sourceAccessible = access.isPlayable || access.book.isArchived, needsFolderRecovery = access.needsFolderRecovery, ) + if (!chapterFetchStarted && shouldFetchDetailChapters( + book = access.book, + appMode = settings.appMode, + connection = connectivityMonitor.connectionStatus.value, + ) + ) { + chapterFetchStarted = true + loadServerChapters() + } } catch (e: Exception) { _uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load: ${e.message}") @@ -189,6 +226,30 @@ class BookDetailViewModel @Inject constructor( } } + private fun loadServerChapters() { + viewModelScope.launch { + val fetched = try { + audioBookRepository.fetchFromServer(bookId) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + null + } + if (fetched == null) { + // The call failed, so a resume or a later open can try again. + // A book the server says has no chapters is not asked twice. + chapterFetchStarted = false + return@launch + } + if (fetched.chapters.isEmpty()) return@launch + serverChapters = fetched.chapters + _uiState.update { state -> + val held = state.book ?: return@update state + state.copy(chapters = detailChapters(held.chapters, serverChapters)) + } + } + } + private fun populateFromBook( book: AudioBook, sourceAccessible: Boolean, @@ -222,7 +283,7 @@ class BookDetailViewModel @Inject constructor( isArchived = book.isArchived, sourceAccessible = sourceAccessible, needsFolderRecovery = needsFolderRecovery, - chapters = book.chapters.sortedBy { c -> c.start }, + chapters = detailChapters(book.chapters, serverChapters), addedAt = book.addedAt, downloadState = if (book.isDownloaded) DownloadButtonState.COMPLETED else it.downloadState, ) @@ -363,8 +424,7 @@ class BookDetailViewModel @Inject constructor( it.copy( downloadState = previous, downloadProgress = 0, - downloadNotice = "Free keeps one downloaded book at a time. " + - "Delete the one you have, or unlock for unlimited offline books.", + downloadNotice = FreeTier.DOWNLOAD_SLOT_NOTICE, downloadNoticeOffersUnlock = true, ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt b/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt index 90e0ba86..89cbf65e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/components/BookCoverImage.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent +import java.net.URI /** * Displays an audiobook cover image, falling back to a vintage worn-book @@ -13,6 +14,10 @@ import coil.compose.SubcomposeAsyncImageContent * * Uses SubcomposeAsyncImage so that broken/missing server URLs also * show the vintage placeholder instead of a blank rectangle. + * + * [thumbnailWidthPx] asks the server for a smaller cover. Long lists of small + * rows pass it, while book detail and the player leave it null and get the + * server's default size. Saved covers on the device are never touched. */ @Composable fun BookCoverImage( @@ -24,12 +29,15 @@ fun BookCoverImage( contentScale: ContentScale = ContentScale.Crop, alignment: Alignment = Alignment.TopCenter, imageModel: Any? = null, + thumbnailWidthPx: Int? = null, ) { val seed = bookId?.hashCode() ?: title?.hashCode() ?: 0 if (!coverUrl.isNullOrEmpty()) { SubcomposeAsyncImage( - model = imageModel ?: coverUrl, + model = imageModel + ?: thumbnailWidthPx?.let { thumbnailCoverUrl(coverUrl, it) } + ?: coverUrl, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale, @@ -58,3 +66,43 @@ fun BookCoverImage( ) } } + +/** + * Cover width for small list rows, in pixels. A 64dp row at the densest screens + * draws about 230px, and Audiobookshelf hands back 400px when no width is asked + * for, so lists of thousands of books fetch roughly a third of the pixels. + */ +const val THUMBNAIL_COVER_WIDTH_PX = 240 + +private val SERVER_COVER_PATH = Regex(".*/api/items/[^/]+/cover") + +/** + * [url] with `width=[widthPx]` added when it is an Audiobookshelf item cover + * (`.../api/items/{id}/cover` over http or https). Audiobookshelf resizes to + * that width and keeps the aspect ratio. Anything else comes back unchanged: + * file and content URIs for saved covers, other hosts' images, URLs that will + * not parse, and URLs that already name a width. + */ +internal fun thumbnailCoverUrl(url: String, widthPx: Int): String { + if (widthPx <= 0) return url + val uri = try { + URI(url) + } catch (_: Exception) { + return url + } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return url + if (!SERVER_COVER_PATH.matches(uri.rawPath.orEmpty())) return url + val query = uri.rawQuery.orEmpty() + if (query.split('&').any { it.substringBefore('=') == "width" }) return url + + val fragmentAt = url.indexOf('#') + val base = if (fragmentAt >= 0) url.substring(0, fragmentAt) else url + val fragment = if (fragmentAt >= 0) url.substring(fragmentAt) else "" + val separator = when { + !base.contains('?') -> "?" + base.endsWith("?") || base.endsWith("&") -> "" + else -> "&" + } + return "$base${separator}width=$widthPx$fragment" +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt index fe55b860..46e8fc17 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierViewModel.kt @@ -21,9 +21,12 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -292,6 +295,10 @@ class NightwatchDossierViewModel @Inject constructor( private var loadJob: Job? = null + // Bumped by every load, read only on the main thread. A load publishes + // only while it is still the newest one. + private var loadGeneration = 0L + fun onPeriodChanged(period: DossierPeriod) { // Clamped on the way in as well as in the load, so a locked period // never lands in state even briefly. The load-side clamp is the one @@ -306,6 +313,7 @@ class NightwatchDossierViewModel @Inject constructor( // Cancel any in-flight load so rapid period switches cannot let a slower // earlier load finish last and overwrite the newest selection with stale data. loadJob?.cancel() + val generation = ++loadGeneration loadJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } @@ -332,11 +340,6 @@ class NightwatchDossierViewModel @Inject constructor( return@launch } val allSessions = history.sessions - val scopedBooks = settings.activeLibraryId?.let { libraryId -> - audioBookRepository.getByLibraryAndSource(libraryId, isLocalMode) - }.orEmpty() - val allBooks = dossierBooksInActiveScope(scopedBooks, settings) - val bookMap = allBooks.associateBy { it.id } // Time period window, clamped by entitlement. // @@ -356,115 +359,137 @@ class NightwatchDossierViewModel @Inject constructor( } val cutoffMillis = period.cutoffMillis() val endMillis = period.endMillis() - val recentSessions = allSessions - .filter { it.startedAt in cutoffMillis..endMillis } - .let { dossierSessionsInActiveScope(it, bookMap.keys) } - - // Sanitize session durations: cap timeListening at wall-clock span - val sanitizedSessions = recentSessions.map { session -> - session.copy(timeListening = sanitizeListeningTime(session)) - } - - // Honor the "include archived in stats" toggle: when off, drop - // sessions for archived (LOCAL soft-deleted) books from every - // total below. When on, excludedBookIds is empty (a no-op). - val archivedBookIds = allBooks.filter { it.isArchived }.map { it.id }.toSet() - val includeArchived = settingsManager.currentSettings.includeArchivedInStats - val allowedBookIds = statsBookIds(bookMap.keys, archivedBookIds, includeArchived) - val excludedBookIds = bookMap.keys - allowedBookIds - - // Filter noise: sessions under 60 seconds - val minThreshold = 60.seconds - val aboveThreshold = sanitizedSessions.filter { it.timeListening >= minThreshold } - val noiseSessions = sanitizedSessions.size - aboveThreshold.size - val validSessions = aboveThreshold.filterNot { it.libraryItemId in excludedBookIds } - - // Aggregate - val totalTime = validSessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } - val sessionsByBook = validSessions.groupBy { it.libraryItemId } - - // Per-book stats - val bookStats = sessionsByBook.mapNotNull { (bookId, sessions) -> - val book = bookMap[bookId] - val bookTime = sessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } - // Skip books with less than 2 minutes total - if (bookTime < 120.seconds) return@mapNotNull null - - val title = book?.title - ?: sessions.firstOrNull()?.displayTitle - ?: "Unknown Title" - - BookStat( - bookId = bookId, - title = title, - author = book?.author ?: "Unknown", - narrator = book?.narrator, - listeningTime = bookTime, - sessionCount = sessions.size, - progress = book?.progressPercent ?: 0.0, - chaptersTotal = book?.chapters?.size ?: 0, - currentChapter = (book?.getCurrentChapterIndex() ?: -1) + 1, - isFinished = book?.isFinished ?: false, - coverUrl = book?.effectiveCoverPath, - whisper = book?.let { generateBookWhisper(it, bookTime) }, - ) - }.sortedByDescending { it.listeningTime } - - // Narrator stats - val narratorStats = buildNarratorStats(bookStats, totalTime) - - // Genre stats - val genreStats = buildGenreStats(bookStats, bookMap, totalTime) - - // Author stats - val authorStats = buildAuthorStats(bookStats) - - // Derived stats - val booksFinished = bookStats.count { it.isFinished } - val dailyAverage = if (totalTime > Duration.ZERO) - (totalTime.inWholeSeconds / period.dayCount()).seconds else Duration.ZERO - val bestDayResult = findBestSpecificDay(validSessions) - - // Temporal patterns - val (hourly, peakHour) = buildTemporalStats(validSessions) - val peakDay = findPeakDay(validSessions) - - // Generate contextual whispers - val headerWhisper = generateHeaderWhisper(totalTime, validSessions.size) - val overviewWhisper = generateOverviewWhisper(totalTime, bookStats.size) - val narratorWhisper = generateNarratorWhisper(narratorStats) - val authorWhisper = generateAuthorWhisper(authorStats) - val genreWhisper = generateGenreWhisper(genreStats) - val temporalWhisper = generateTemporalWhisper(peakHour, peakDay) + val windowSessions = allSessions.filter { it.startedAt in cutoffMillis..endMillis } + + // Only the books this window listened to, looked up by id and + // decoded off the main thread. Everything below is a lookup by + // a session's book id, so the rest of the library never loads. + val bookMap = loadDossierBooks( + sessions = windowSessions, + settings = settings, + fetchByIds = audioBookRepository::getByIdsInLibraryAndSource, + ) + val allBooks = bookMap.values + + // The aggregation walks every session and book, so it runs on + // Default too. It publishes back on the main thread, and only if + // no newer load has started. Cancelling does not stop CPU work + // already running, so a superseded load used to finish its + // totals and write them over the newer report. + publishIfStillCurrent( + dispatcher = Dispatchers.Default, + isCurrent = { generation == loadGeneration }, + publish = { apply -> _uiState.update(apply) }, + ) { + val recentSessions = dossierSessionsInActiveScope(windowSessions, bookMap.keys) + + // Sanitize session durations: cap timeListening at wall-clock span + val sanitizedSessions = recentSessions.map { session -> + session.copy(timeListening = sanitizeListeningTime(session)) + } - _uiState.update { - it.copy( - isLoading = false, - isConnected = true, - error = null, - historyWarning = history.warning, - totalListeningTime = totalTime, - totalSessions = validSessions.size, - filteredNoiseSessions = noiseSessions, - uniqueBooks = bookStats.size, - bookStats = bookStats, - narratorStats = narratorStats, - authorStats = authorStats, - genreStats = genreStats, - booksFinished = booksFinished, - dailyAverage = dailyAverage, - bestDay = bestDayResult?.first, - bestDayTime = bestDayResult?.second ?: Duration.ZERO, - hourlyDistribution = hourly, - peakHour = peakHour, - peakDayOfWeek = peakDay, - headerWhisper = headerWhisper, - overviewWhisper = overviewWhisper, - narratorWhisper = narratorWhisper, - authorWhisper = authorWhisper, - genreWhisper = genreWhisper, - temporalWhisper = temporalWhisper, - ) + // Honor the "include archived in stats" toggle: when off, drop + // sessions for archived (LOCAL soft-deleted) books from every + // total below. When on, excludedBookIds is empty (a no-op). + val archivedBookIds = allBooks.filter { it.isArchived }.map { it.id }.toSet() + val includeArchived = settingsManager.currentSettings.includeArchivedInStats + val allowedBookIds = statsBookIds(bookMap.keys, archivedBookIds, includeArchived) + val excludedBookIds = bookMap.keys - allowedBookIds + + // Filter noise: sessions under 60 seconds + val minThreshold = 60.seconds + val aboveThreshold = sanitizedSessions.filter { it.timeListening >= minThreshold } + val noiseSessions = sanitizedSessions.size - aboveThreshold.size + val validSessions = aboveThreshold.filterNot { it.libraryItemId in excludedBookIds } + + // Aggregate + val totalTime = validSessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } + val sessionsByBook = validSessions.groupBy { it.libraryItemId } + + // Per-book stats + val bookStats = sessionsByBook.mapNotNull { (bookId, sessions) -> + val book = bookMap[bookId] + val bookTime = sessions.fold(Duration.ZERO) { acc, s -> acc + s.timeListening } + // Skip books with less than 2 minutes total + if (bookTime < 120.seconds) return@mapNotNull null + + val title = book?.title + ?: sessions.firstOrNull()?.displayTitle + ?: "Unknown Title" + + BookStat( + bookId = bookId, + title = title, + author = book?.author ?: "Unknown", + narrator = book?.narrator, + listeningTime = bookTime, + sessionCount = sessions.size, + progress = book?.progressPercent ?: 0.0, + chaptersTotal = book?.chapters?.size ?: 0, + currentChapter = (book?.getCurrentChapterIndex() ?: -1) + 1, + isFinished = book?.isFinished ?: false, + coverUrl = book?.effectiveCoverPath, + whisper = book?.let { generateBookWhisper(it, bookTime) }, + ) + }.sortedByDescending { it.listeningTime } + + // Narrator stats + val narratorStats = buildNarratorStats(bookStats, totalTime) + + // Genre stats + val genreStats = buildGenreStats(bookStats, bookMap, totalTime) + + // Author stats + val authorStats = buildAuthorStats(bookStats) + + // Derived stats + val booksFinished = bookStats.count { it.isFinished } + val dailyAverage = if (totalTime > Duration.ZERO) + (totalTime.inWholeSeconds / period.dayCount()).seconds else Duration.ZERO + val bestDayResult = findBestSpecificDay(validSessions) + + // Temporal patterns + val (hourly, peakHour) = buildTemporalStats(validSessions) + val peakDay = findPeakDay(validSessions) + + // Generate contextual whispers + val headerWhisper = generateHeaderWhisper(totalTime, validSessions.size) + val overviewWhisper = generateOverviewWhisper(totalTime, bookStats.size) + val narratorWhisper = generateNarratorWhisper(narratorStats) + val authorWhisper = generateAuthorWhisper(authorStats) + val genreWhisper = generateGenreWhisper(genreStats) + val temporalWhisper = generateTemporalWhisper(peakHour, peakDay) + + val report: (DossierState) -> DossierState = { state -> + state.copy( + isLoading = false, + isConnected = true, + error = null, + historyWarning = history.warning, + totalListeningTime = totalTime, + totalSessions = validSessions.size, + filteredNoiseSessions = noiseSessions, + uniqueBooks = bookStats.size, + bookStats = bookStats, + narratorStats = narratorStats, + authorStats = authorStats, + genreStats = genreStats, + booksFinished = booksFinished, + dailyAverage = dailyAverage, + bestDay = bestDayResult?.first, + bestDayTime = bestDayResult?.second ?: Duration.ZERO, + hourlyDistribution = hourly, + peakHour = peakHour, + peakDayOfWeek = peakDay, + headerWhisper = headerWhisper, + overviewWhisper = overviewWhisper, + narratorWhisper = narratorWhisper, + authorWhisper = authorWhisper, + genreWhisper = genreWhisper, + temporalWhisper = temporalWhisper, + ) + } + report } } catch (e: CancellationException) { // A newer period switch cancelled this stale load. Rethrow so it @@ -472,11 +497,13 @@ class NightwatchDossierViewModel @Inject constructor( // cancelled load would clobber the state the newer load is building. throw e } catch (e: Exception) { - _uiState.update { - it.copy( - isLoading = false, - error = "Failed to compile the dossier: ${e.message}", - ) + if (generation == loadGeneration) { + _uiState.update { + it.copy( + isLoading = false, + error = "Failed to compile the dossier: ${e.message}", + ) + } } } } @@ -826,6 +853,40 @@ internal fun dossierBooksInActiveScope( settings: AppSettings, ): List = books.filter { it.isInActiveLibrary(settings) } +/** + * The books a Dossier report needs: the ones [sessions] listened to, in the + * active library and source, keyed by id. Never reads the whole library, and + * runs on [dispatcher] so decoding the rows stays off the main thread. + */ +internal suspend fun loadDossierBooks( + sessions: List, + settings: AppSettings, + fetchByIds: suspend (libraryId: String, isLocal: Boolean, ids: List) -> List, + dispatcher: CoroutineDispatcher = Dispatchers.Default, +): Map = withContext(dispatcher) { + val libraryId = settings.activeLibraryId ?: return@withContext emptyMap() + val ids = sessions.map { it.libraryItemId }.distinct() + if (ids.isEmpty()) return@withContext emptyMap() + val isLocal = settings.appMode == AppMode.LOCAL + dossierBooksInActiveScope(fetchByIds(libraryId, isLocal, ids), settings).associateBy { it.id } +} + +/** + * Run [compute] on [dispatcher], then hand its result to [publish] back on + * the caller's context, but only while [isCurrent] still says this load is + * the newest one. Coming back to the caller also rethrows a cancellation that + * landed while [compute] ran, so a cancelled load never reaches [publish]. + */ +internal suspend fun publishIfStillCurrent( + dispatcher: CoroutineDispatcher, + isCurrent: () -> Boolean, + publish: (T) -> Unit, + compute: () -> T, +) { + val result = withContext(dispatcher) { compute() } + if (isCurrent()) publish(result) +} + internal fun dossierSessionsInActiveScope( sessions: List, scopedBookIds: Set, diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt new file mode 100644 index 00000000..54a9db98 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadLists.kt @@ -0,0 +1,94 @@ +package com.ninelivesaudio.app.ui.downloads + +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus + +/** One download row with what the screen needs from its book. */ +internal data class DownloadRow( + val download: DownloadItem, + val coverPath: String?, + val bookIsDownloaded: Boolean, +) + +/** A downloaded book as the screen needs it, from the book row. */ +internal data class DownloadedBook( + val audioBookId: String, + val title: String, + val coverPath: String?, +) + +/** The Downloads screen's two lists, newest first. */ +internal data class DownloadLists( + val active: List, + val completed: List, +) + +private val IN_PROGRESS = setOf( + DownloadStatus.Queued, + DownloadStatus.Downloading, + DownloadStatus.Paused, + DownloadStatus.Preparing, +) + +/** + * Sort every download row into the active and completed lists. + * + * Active holds Queued, Downloading, Paused and Preparing (a slot claim waiting + * on its details fetch, kept visible so the tap never looks ignored), plus + * Failed. A failed download used to sit in neither list, so a server timeout + * made the book vanish with no way to retry. A Failed row stays out only when + * it is stale: the book got downloaded anyway, or a later attempt for the same + * book exists (that row speaks for the book instead). + * + * Completed lists every book on the device. A Completed row speaks for its + * book, and a downloaded book with no Completed row (its row was cleared, or + * the engine has not written it yet) is listed from [downloadedBooks] after + * them, by title. Without that, an offline book vanished from Downloads the + * moment its tracking row went. + */ +internal fun splitDownloadRows( + rows: List, + downloadedBooks: List = emptyList(), +): DownloadLists { + val byBook = rows.groupBy { it.download.audioBookId } + val active = rows.filter { row -> + when (row.download.status) { + in IN_PROGRESS -> true + DownloadStatus.Failed -> !row.isStaleFailure(byBook[row.download.audioBookId].orEmpty()) + else -> false + } + }.sortedByDescending { it.download.startedAt ?: 0L } + val completedRows = rows + .filter { it.download.status == DownloadStatus.Completed } + .sortedByDescending { it.download.completedAt ?: 0L } + val listedBooks = completedRows.mapTo(HashSet()) { it.download.audioBookId } + val bookOnly = downloadedBooks + .filter { it.audioBookId !in listedBooks } + .sortedBy { it.title.lowercase() } + .map { book -> + DownloadRow( + download = DownloadItem( + id = "$BOOK_ONLY_ID_PREFIX${book.audioBookId}", + audioBookId = book.audioBookId, + title = book.title, + status = DownloadStatus.Completed, + ), + coverPath = book.coverPath, + bookIsDownloaded = true, + ) + } + val completed = completedRows + bookOnly + return DownloadLists(active = active, completed = completed) +} + +/** Id prefix for a Completed entry built from a book row, never a real download id. */ +internal const val BOOK_ONLY_ID_PREFIX = "book:" + +private fun DownloadRow.isStaleFailure(sameBook: List): Boolean { + if (bookIsDownloaded) return true + val startedAt = download.startedAt ?: 0L + return sameBook.any { other -> + other.download.id != download.id && + (other.download.status != DownloadStatus.Failed || (other.download.startedAt ?: 0L) > startedAt) + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt index 310a11be..72d1113c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsScreen.kt @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.ui.downloads import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -21,6 +22,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ninelivesaudio.app.ui.components.BookCoverImage +import com.ninelivesaudio.app.ui.components.THUMBNAIL_COVER_WIDTH_PX import com.ninelivesaudio.app.domain.model.DownloadStatus import com.ninelivesaudio.app.domain.util.toDisplaySize @@ -60,6 +62,20 @@ fun DownloadsScreen( contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + uiState.notice?.let { message -> + item { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextMuted, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = viewModel::dismissNotice) + .padding(vertical = 8.dp), + ) + } + } + // ── Active Downloads Section ────────────────────────── if (uiState.activeDownloads.isNotEmpty()) { item { @@ -82,22 +98,12 @@ fun DownloadsScreen( // ── Completed Downloads Section ─────────────────────── if (uiState.completedDownloads.isNotEmpty()) { item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, bottom = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + // No Clear All here. It deleted the download rows and + // kept the files, so offline books vanished from the + // one screen that manages them. Each book's Delete + // removes its files. + Box(modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)) { SectionHeader(title = "Completed") - - TextButton(onClick = { viewModel.clearCompleted() }) { - Text( - text = "Clear All", - color = NineLivesTheme.colors.goldFilament, - style = MaterialTheme.typography.labelMedium, - ) - } } } @@ -174,6 +180,7 @@ private fun ActiveDownloadCard( modifier = Modifier.fillMaxSize(), title = download.title, bookId = download.audioBookId, + thumbnailWidthPx = THUMBNAIL_COVER_WIDTH_PX, ) } @@ -252,7 +259,7 @@ private fun ActiveDownloadCard( ) { Icon( Icons.Outlined.PlayArrow, - contentDescription = "Resume", + contentDescription = if (download.status == DownloadStatus.Failed) "Retry" else "Resume", tint = NineLivesTheme.colors.goldFilament, modifier = Modifier.size(20.dp), ) @@ -346,6 +353,7 @@ private fun CompletedDownloadCard( modifier = Modifier.fillMaxSize(), title = download.title, bookId = download.audioBookId, + thumbnailWidthPx = THUMBNAIL_COVER_WIDTH_PX, ) } @@ -376,12 +384,16 @@ private fun CompletedDownloadCard( color = NineLivesTheme.colors.archiveSuccess, fontSize = 11.sp, ) - Text( - text = download.sizeDisplay, - style = MaterialTheme.typography.labelSmall, - color = NineLivesTheme.colors.archiveTextMuted, - fontSize = 11.sp, - ) + // A book listed from its row alone has no download + // record, so no byte count to show. + if (download.totalBytes > 0) { + Text( + text = download.sizeDisplay, + style = MaterialTheme.typography.labelSmall, + color = NineLivesTheme.colors.archiveTextMuted, + fontSize = 11.sp, + ) + } } } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt index 1e224c11..134fb610 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/downloads/DownloadsViewModel.kt @@ -2,14 +2,12 @@ package com.ninelivesaudio.app.ui.downloads import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ninelivesaudio.app.data.local.converter.effectiveCoverPath import com.ninelivesaudio.app.data.local.converter.toDomain -import com.ninelivesaudio.app.data.local.dao.AudioBookDao import com.ninelivesaudio.app.data.local.dao.DownloadItemDao import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.DownloadItem -import com.ninelivesaudio.app.domain.model.DownloadStatus +import com.ninelivesaudio.app.entitlement.FreeTier import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.DownloadManager @@ -23,7 +21,6 @@ import javax.inject.Inject class DownloadsViewModel @Inject constructor( private val downloadManager: DownloadManager, private val downloadItemDao: DownloadItemDao, - private val audioBookDao: AudioBookDao, private val connectivityMonitor: ConnectivityMonitor, private val settingsManager: SettingsManager, ) : ViewModel() { @@ -41,6 +38,8 @@ class DownloadsViewModel @Inject constructor( val showEmptyState: Boolean = true, val connectionStatus: ConnectionStatus = ConnectionStatus.OFFLINE, val isLocalMode: Boolean = false, + /** Why the last Resume or Retry did not start, or null. */ + val notice: String? = null, ) private val _uiState = MutableStateFlow(UiState()) @@ -63,52 +62,48 @@ class DownloadsViewModel @Inject constructor( } init { - // Observe active downloads + // The download rows and the downloaded book rows feed both lists. The + // queries re-run on every download progress write and every book row + // write (playback saves its place), so unchanged lists are dropped + // before they reach the screen. viewModelScope.launch { - downloadItemDao.observeActive().collect { entities -> - val items = entities.mapNotNull { entity -> - val item = entity.toDomain() - val book = audioBookDao.getById(item.audioBookId) - // Skip items where book was deleted from DB - if (book == null) return@mapNotNull null - DownloadUiItem( - download = item, - coverPath = book.effectiveCoverPath, - ).withLiveProgress() - } - // Drop live entries for downloads that are no longer active so the - // overlay map cannot grow without bound. - val activeIds = items.mapTo(HashSet()) { it.download.id } - liveProgress = liveProgress.filterKeys { it in activeIds } - _uiState.update { - it.copy( - activeDownloads = items, - showEmptyState = items.isEmpty() && it.completedDownloads.isEmpty(), - ) - } + combine( + downloadItemDao.observeAllWithBooks(), + downloadItemDao.observeDownloadedServerBooks(), + ) { rows, books -> + splitDownloadRows( + rows = rows.map { row -> + DownloadRow( + download = row.download.toDomain(), + coverPath = row.coverPath, + bookIsDownloaded = row.bookIsDownloaded == 1, + ) + }, + downloadedBooks = books.map { book -> + DownloadedBook(audioBookId = book.id, title = book.title, coverPath = book.coverPath) + }, + ) } - } - - // Observe completed downloads - viewModelScope.launch { - downloadItemDao.observeCompleted().collect { entities -> - val items = entities.mapNotNull { entity -> - val item = entity.toDomain() - val book = audioBookDao.getById(item.audioBookId) - // Skip items where book was deleted from DB - if (book == null) return@mapNotNull null - DownloadUiItem( - download = item, - coverPath = book.effectiveCoverPath, - ) + .distinctUntilChanged() + .collect { lists -> + val active = lists.active.map { + DownloadUiItem(download = it.download, coverPath = it.coverPath).withLiveProgress() + } + val completed = lists.completed.map { + DownloadUiItem(download = it.download, coverPath = it.coverPath) + } + // Drop live entries for downloads that are no longer active so the + // overlay map cannot grow without bound. + val activeIds = active.mapTo(HashSet()) { it.download.id } + liveProgress = liveProgress.filterKeys { it in activeIds } + _uiState.update { + it.copy( + activeDownloads = active, + completedDownloads = completed, + showEmptyState = active.isEmpty() && completed.isEmpty(), + ) + } } - _uiState.update { - it.copy( - completedDownloads = items, - showEmptyState = items.isEmpty() && it.activeDownloads.isEmpty(), - ) - } - } } // Observe progress updates @@ -160,10 +155,15 @@ class DownloadsViewModel @Inject constructor( fun resumeDownload(downloadId: String) { viewModelScope.launch { - downloadManager.resumeDownload(downloadId) + val result = downloadManager.resumeDownload(downloadId) + _uiState.update { it.copy(notice = resumeNotice(result)) } } } + fun dismissNotice() { + _uiState.update { it.copy(notice = null) } + } + fun cancelDownload(downloadId: String) { viewModelScope.launch { downloadManager.cancelDownload(downloadId) @@ -176,21 +176,6 @@ class DownloadsViewModel @Inject constructor( } } - fun clearCompleted() { - viewModelScope.launch { - val completed = _uiState.value.completedDownloads - completed.forEach { item -> - try { - // Only remove the download tracking record — keep the actual files - // on disk so the user's downloaded audiobooks remain playable. - downloadItemDao.deleteById(item.download.id) - } catch (_: Exception) { - // Continue clearing others even if one fails - } - } - } - } - /** Queue a new download for an audiobook. */ fun queueDownload(audioBook: AudioBook) { viewModelScope.launch { @@ -198,3 +183,13 @@ class DownloadsViewModel @Inject constructor( } } } + +/** + * A Retry the free slot refuses has to say so. It used to requeue anyway and + * sit in Queued forever with no word. + */ +internal fun resumeNotice(result: DownloadManager.ResumeResult): String? = when (result) { + DownloadManager.ResumeResult.BLOCKED_BY_FREE_SLOT -> FreeTier.DOWNLOAD_SLOT_NOTICE + DownloadManager.ResumeResult.RESUMED, + DownloadManager.ResumeResult.NOT_RESUMABLE -> null +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt index e8404a10..a825162e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeScreen.kt @@ -61,7 +61,7 @@ fun HomeScreen( if (uiState.showEmptyState && !uiState.isLoading) { EmptyHomeState( - isLocalMode = uiState.isLocalMode, + kind = homeEmptyKind(uiState.isLocalMode, uiState.libraryHasBooks), connectionPillState = connectionPillState, onReconnect = viewModel::reconnect, onNavigateToLibrary = onNavigateToLibrary, @@ -420,12 +420,13 @@ private fun NineLivesAltar( @Composable private fun EmptyHomeState( - isLocalMode: Boolean, + kind: HomeEmptyKind, connectionPillState: HomeConnectionPillState, onReconnect: () -> Unit, onNavigateToLibrary: () -> Unit, onNavigateToSettings: () -> Unit, ) { + val copy = homeEmptyCopy(kind) Box( modifier = Modifier .fillMaxSize() @@ -465,7 +466,7 @@ private fun EmptyHomeState( Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (isLocalMode) "No local audio yet" else "The Archive stands empty", + text = copy.subtitle, style = MaterialTheme.typography.titleMedium, color = NineLivesTheme.colors.archiveTextSecondary, textAlign = TextAlign.Center, @@ -474,14 +475,12 @@ private fun EmptyHomeState( Spacer(modifier = Modifier.height(4.dp)) Text( - text = if (isLocalMode) { - "Add a folder of audiobooks in Settings to begin" - } else { + text = copy.body ?: ( CopyEngine.getEmptyStateFlavor( CopyStyleGuide.EmptyStates.EMPTY_LIBRARY_RITUAL, CopyStyleGuide.EmptyStates.EMPTY_LIBRARY_UNHINGED, ) ?: "Begin listening to fill these halls with your progress" - }, + ), style = MaterialTheme.typography.bodySmall, color = NineLivesTheme.colors.archiveTextMuted, textAlign = TextAlign.Center, @@ -491,7 +490,10 @@ private fun EmptyHomeState( Spacer(modifier = Modifier.height(32.dp)) Button( - onClick = if (isLocalMode) onNavigateToSettings else onNavigateToLibrary, + onClick = when (copy.action) { + HomeEmptyAction.OPEN_SETTINGS -> onNavigateToSettings + HomeEmptyAction.OPEN_LIBRARY -> onNavigateToLibrary + }, colors = ButtonDefaults.buttonColors( containerColor = NineLivesTheme.colors.goldFilament, contentColor = NineLivesTheme.colors.onAccent, @@ -499,7 +501,7 @@ private fun EmptyHomeState( shape = RoundedCornerShape(12.dp), ) { Text( - text = if (isLocalMode) "Open Settings" else "Enter The Archive", + text = copy.button, fontWeight = FontWeight.SemiBold, ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt index 626c0f14..13b16605 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt @@ -64,6 +64,12 @@ class HomeViewModel @Inject constructor( val connectionStatus: ConnectionStatus = ConnectionStatus.OFFLINE, val isLocalMode: Boolean = false, val hasAuthToken: Boolean? = null, + /** + * Whether the active library holds any books at all, so an empty Home + * can tell "nothing played yet" apart from "nothing imported" (#59). + * Only looked up while Home has nothing to show. + */ + val libraryHasBooks: Boolean = false, ) private val _uiState = MutableStateFlow(UiState()) @@ -124,10 +130,12 @@ class HomeViewModel @Inject constructor( .collectLatest { (settings, hasAuthToken) -> val libraryId = settings.activeLibraryId if (libraryId != null && canShowHomeBooks(settings, hasAuthToken)) { + // The recent-books flow re-emits on any AudioBooks change, so + // a folder import refreshes the book count along with it. audioBookDao.observeRecentlyPlayedByLibrary(libraryId, 9) - .collect { results -> processRecentlyPlayed(results) } + .collect { results -> processRecentlyPlayed(results, libraryId) } } else { - processRecentlyPlayed(emptyList()) + processRecentlyPlayed(emptyList(), libraryId = null) } } } @@ -139,17 +147,18 @@ class HomeViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true) } try { val libraryId = settingsManager.currentSettings.activeLibraryId - val results = if ( - libraryId != null && canShowHomeBooks( + val visibleLibraryId = libraryId?.takeIf { + canShowHomeBooks( settingsManager.currentSettings, settingsManager.hasAuthToken.value, ) - ) { - audioBookDao.getRecentlyPlayedByLibrary(libraryId, 9) + } + val results = if (visibleLibraryId != null) { + audioBookDao.getRecentlyPlayedByLibrary(visibleLibraryId, 9) } else { emptyList() } - processRecentlyPlayed(results) + processRecentlyPlayed(results, visibleLibraryId) } catch (e: Exception) { _uiState.update { it.copy(showEmptyState = true) } } finally { @@ -174,7 +183,9 @@ class HomeViewModel @Inject constructor( settingsManager.currentSettings.appMode == AppMode.AUDIOBOOKSHELF }, refreshIsOnline = connectivityMonitor::refreshIsOnlineFromSystem, - syncNow = syncManager::syncNow, + // A reconnect asks each library what changed rather than + // downloading them all again. Sync Now still does the full pass. + syncNow = { syncManager.checkNow() }, ) } } @@ -188,7 +199,7 @@ class HomeViewModel @Inject constructor( // Future: unlock hidden lore, achievement, or atmospheric event. } - private suspend fun processRecentlyPlayed(results: List) { + private suspend fun processRecentlyPlayed(results: List, libraryId: String?) { val settings = settingsManager.currentSettings val accessibleLocalIds = if (settings.appMode == AppMode.LOCAL) { settings.activeLibraryId @@ -235,10 +246,19 @@ class HomeViewModel @Inject constructor( val totalSeconds = results.sumOf { it.audioBook.currentTimeSeconds } val totalText = formatListeningTime(totalSeconds) + // Counted only when Home is empty. A failed count reads as no books, + // which is the copy Home showed before this check existed. + val libraryHasBooks = if (lives.isEmpty() && libraryId != null) { + runCatching { audioBookDao.countByLibrary(libraryId) > 0 }.getOrDefault(false) + } else { + lives.isNotEmpty() + } + _uiState.update { it.copy( lives = lives, showEmptyState = lives.isEmpty(), + libraryHasBooks = libraryHasBooks, totalListeningTimeText = totalText, totalListeningSeconds = totalSeconds, ) @@ -331,6 +351,69 @@ internal fun canShowHomeBooks( AppMode.LOCAL -> settings.selectedLocalLibraryId != null } +/** Which empty Home to show (#59). */ +internal enum class HomeEmptyKind { + /** Local mode, nothing imported: send them to Settings to add a folder. */ + LOCAL_NO_BOOKS, + + /** Local mode, books imported but none played: send them to the Library. */ + LOCAL_UNPLAYED, + + /** Server mode, empty library: the original server copy. */ + SERVER_NO_BOOKS, + + /** Server mode, books synced but none played: send them to the Library. */ + SERVER_UNPLAYED, +} + +internal fun homeEmptyKind(isLocalMode: Boolean, libraryHasBooks: Boolean): HomeEmptyKind = when { + isLocalMode && libraryHasBooks -> HomeEmptyKind.LOCAL_UNPLAYED + isLocalMode -> HomeEmptyKind.LOCAL_NO_BOOKS + libraryHasBooks -> HomeEmptyKind.SERVER_UNPLAYED + else -> HomeEmptyKind.SERVER_NO_BOOKS +} + +internal enum class HomeEmptyAction { OPEN_SETTINGS, OPEN_LIBRARY } + +/** + * Fixed copy for an empty Home. [body] is null where the screen picks a + * copy-mode flavor instead (the empty server library keeps its Ritual and + * Unhinged lines). + */ +internal data class HomeEmptyCopy( + val subtitle: String, + val body: String?, + val button: String, + val action: HomeEmptyAction, +) + +internal fun homeEmptyCopy(kind: HomeEmptyKind): HomeEmptyCopy = when (kind) { + HomeEmptyKind.LOCAL_NO_BOOKS -> HomeEmptyCopy( + subtitle = "No local audio yet", + body = "Add a folder of audiobooks in Settings to begin", + button = "Open Settings", + action = HomeEmptyAction.OPEN_SETTINGS, + ) + HomeEmptyKind.LOCAL_UNPLAYED -> HomeEmptyCopy( + subtitle = "Your books are here, untouched", + body = "Pick one in the Library and the first life begins", + button = "Open Library", + action = HomeEmptyAction.OPEN_LIBRARY, + ) + HomeEmptyKind.SERVER_NO_BOOKS -> HomeEmptyCopy( + subtitle = "The Archive stands empty", + body = null, + button = "Enter The Archive", + action = HomeEmptyAction.OPEN_LIBRARY, + ) + HomeEmptyKind.SERVER_UNPLAYED -> HomeEmptyCopy( + subtitle = "The Archive is full and untouched", + body = "Pick one in the Library and the first life begins", + button = "Enter The Archive", + action = HomeEmptyAction.OPEN_LIBRARY, + ) +} + internal fun isHomeReconnectAvailable( isLocalMode: Boolean, hasAuthToken: Boolean, diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt index e897e546..8c65fcbb 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryScreen.kt @@ -33,6 +33,10 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign @@ -45,6 +49,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ninelivesaudio.app.ui.components.ArchiveScreenHeader import com.ninelivesaudio.app.ui.components.BookCoverImage +import com.ninelivesaudio.app.ui.components.THUMBNAIL_COVER_WIDTH_PX import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.ui.components.ContainmentFrame @@ -79,12 +84,9 @@ fun LibraryScreen( // (i.e. each time the user navigates to the Library tab). LaunchedEffect(Unit) { viewModel.incrementWhisperEpoch() + viewModel.onScreenEntered() } - // Flatten grouped items only when groupedSections or expandedGroups change - val groupedListItems = remember(uiState.groupedSections, uiState.expandedGroups) { - flattenGroupedItems(uiState.groupedSections, uiState.expandedGroups) - } val shelfDecision = remember( uiState.lastSyncResult, uiState.lastSyncSequence, @@ -195,6 +197,16 @@ fun LibraryScreen( } } + // A load or filter that threw. Shown over every shelf state, + // full or empty, so a failed query is never silent. + uiState.errorMessage?.let { message -> + LibraryErrorBanner( + message = message, + onRetry = viewModel::refresh, + onDismiss = viewModel::dismissError, + ) + } + Spacer(modifier = Modifier.height(4.dp)) } @@ -253,7 +265,9 @@ fun LibraryScreen( ) } } - if (uiState.viewMode == ViewMode.ALL) { + // The mode in effect, not the stored choice: a grouped view + // whose unlock was lost shows the flat shelf. + if (uiState.effectiveViewMode == ViewMode.ALL) { // Flat list in ALL mode itemsIndexed( items = uiState.filteredBooks, @@ -269,7 +283,7 @@ fun LibraryScreen( } else { // Grouped expandable list in SERIES / AUTHOR / GENRE modes itemsIndexed( - items = groupedListItems, + items = uiState.groupedListItems, key = { _, item -> when (item) { is LibraryListItem.GroupHeader -> "header-${item.groupKey}" @@ -701,17 +715,36 @@ private fun LibraryFiltersRow( // ─── Group Header Row ──────────────────────────────────────────────────── +/** What TalkBack says a group header is: open or closed. */ +internal fun groupHeaderStateDescription(isExpanded: Boolean): String = + if (isExpanded) "Expanded" else "Collapsed" + +/** What TalkBack says a tap on a group header does. */ +internal fun groupHeaderClickLabel(isExpanded: Boolean): String = + if (isExpanded) "Collapse group" else "Expand group" + @Composable -private fun GroupHeaderRow( +internal fun GroupHeaderRow( title: String, count: Int, isExpanded: Boolean, onClick: () -> Unit, ) { + // A heading, so TalkBack can jump group to group, that says whether it is + // open and what a tap does. Thousands of groups were otherwise one long + // run of unlabeled buttons. Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .semantics(mergeDescendants = true) { + heading() + stateDescription = groupHeaderStateDescription(isExpanded) + } + .clickable( + onClickLabel = groupHeaderClickLabel(isExpanded), + role = Role.Button, + onClick = onClick, + ), shape = RoundedCornerShape(10.dp), color = NineLivesTheme.colors.archiveVoidElevated, border = BorderStroke(1.dp, NineLivesTheme.colors.archiveOutline), @@ -793,6 +826,7 @@ private fun ArchiveBookListItem( modifier = Modifier.fillMaxSize(), title = book.title, bookId = book.id, + thumbnailWidthPx = THUMBNAIL_COVER_WIDTH_PX, ) } @@ -1081,6 +1115,46 @@ private fun SyncWarningBanner( } } +/** A failed load or filter, with Retry and a way to dismiss it. */ +@Composable +internal fun LibraryErrorBanner( + message: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = NineLivesTheme.colors.archiveVoidSurface, + border = BorderStroke(1.dp, NineLivesTheme.colors.archiveOutline), + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier.padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = message, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextSecondary, + ) + TextButton(onClick = onRetry) { + Text("Retry") + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Dismiss", + tint = NineLivesTheme.colors.archiveTextMuted, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + // ─── Empty State ────────────────────────────────────────────────────────── @Composable diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt index bbfafdf3..185cd6c1 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryViewModel.kt @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.ui.library +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ninelivesaudio.app.entitlement.EntitlementRepository @@ -10,19 +11,25 @@ import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.data.remote.describeFailure import com.ninelivesaudio.app.data.remote.valueOrEmpty import com.ninelivesaudio.app.data.repository.AudioBookRepository +import com.ninelivesaudio.app.data.repository.LibraryRefreshOutcome import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.data.repository.ReconciledServerLibraryList +import com.ninelivesaudio.app.data.repository.itemCountResult import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.LastSyncRecord import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult +import com.ninelivesaudio.app.domain.util.mapCooperatively import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.PersistedSyncOutcome import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.service.SyncManager import com.ninelivesaudio.app.service.buildShelfSyncReport +import com.ninelivesaudio.app.service.buildShelfSyncReportFromCount +import com.ninelivesaudio.app.service.isSyncedLibrary import com.ninelivesaudio.app.service.lastSyncForCurrentServer import com.ninelivesaudio.app.service.persistActiveLibrarySelection import com.ninelivesaudio.app.service.persistSyncOutcome @@ -78,6 +85,7 @@ data class GroupedSection( val books: List, ) +private const val SHELF_REREAD_THROTTLE_MS = 1_000L private const val UNKNOWN_SERIES_GROUP = "Standalone/Unknown Series" private const val UNKNOWN_AUTHOR_GROUP = "Unknown Author" private const val UNKNOWN_GENRE_GROUP = "Uncategorized Genre" @@ -117,6 +125,27 @@ internal fun shouldReloadLibrariesAfterSync( ): Boolean = (selectedLibrary == null && cachedLibraryIds.isNotEmpty()) || cachedLibraryIds.toSet() != shownLibraryIds.toSet() +internal enum class AfterProgressPull { REFILTER, RELOAD_LIBRARIES } + +/** + * What the Library does when a check pulled progress. A check that put one + * library's full download off writes no sync record, yet it already saved + * the library list it fetched (one library removed, say), and later checks + * compare against that saved list and never report the change again. So the + * shown list is compared with the saved one here as well, not only when a + * record lands. + */ +internal fun afterProgressPull( + selectedLibrary: Library?, + shownLibraryIds: List, + cachedLibraryIds: List, +): AfterProgressPull = + if (shouldReloadLibrariesAfterSync(selectedLibrary, shownLibraryIds, cachedLibraryIds)) { + AfterProgressPull.RELOAD_LIBRARIES + } else { + AfterProgressPull.REFILTER + } + /** * Which local folders exist, which books each holds (live or archived), and * what the shelf shows for each. Progress stays out, or every position save @@ -229,6 +258,33 @@ internal suspend fun updateLibraryLoadStateIfActive(update: () -> Unit) { if (currentCoroutineContext().isActive) update() } +/** + * What a shelf load was started for. The tabs keep the Library alive while + * another tab is open, so a return only reloads when this changed underneath + * it (a server, account, mode, or library switch in Settings). + */ +internal data class ShelfIdentity( + val appMode: AppMode, + val serverUrl: String, + val username: String, + val activeLibraryId: String?, +) + +internal fun AppSettings.shelfIdentity() = + ShelfIdentity(appMode, serverUrl, username, activeLibraryId) + +/** + * A return to the Library reloads when what it shows no longer matches the + * settings, or when it has nothing to show because its last fetch failed, so + * the return doubles as a retry. + * Nothing loaded yet means the first load is still starting and owns the shelf. + */ +internal fun shouldReloadOnLibraryReturn( + loadedFor: ShelfIdentity?, + current: ShelfIdentity, + lastFetchFailed: Boolean, +): Boolean = loadedFor != null && (loadedFor != current || lastFetchFailed) + // ─── ViewModel ─────────────────────────────────────────────────────────── @HiltViewModel @@ -240,6 +296,7 @@ class LibraryViewModel @Inject constructor( private val settingsManager: SettingsManager, private val entitlements: EntitlementRepository, private val localFolderAccess: LocalFolderAccess, + private val syncManager: SyncManager, ) : ViewModel() { // ─── UI State ───────────────────────────────────────────────────────── @@ -251,12 +308,22 @@ class LibraryViewModel @Inject constructor( val selectedLibrary: Library? = null, val filteredBooks: List = emptyList(), val searchQuery: String = "", + // The user's chosen view. The shelf shows [effectiveViewMode], which + // is this clamped to ALL when grouping is locked, so a lost unlock + // shows the flat shelf instead of empty groups. val viewMode: ViewMode = ViewMode.ALL, + val effectiveViewMode: ViewMode = ViewMode.ALL, val sortMode: SortMode = SortMode.RECENTLY_PLAYED, + // No picker sets or applies these yet. The distinct-groups query that + // filled availableGroups ran on every grouped refilter (Genre view + // decoded every distinct genre list) and nothing rendered it, so it + // is gone until a picker exists. val selectedGroupFilter: String? = null, val availableGroups: List = emptyList(), val groupedSections: List = emptyList(), - val expandedGroups: Set = emptySet(), + // Headers and rows for the grouped views, built off the main thread + // whenever the groups or an expand choice change. See groupChoices. + val groupedListItems: List = emptyList(), val selectedTab: LibraryTab = LibraryTab.All, val isLocalMode: Boolean = false, // LOCAL mode shows the Archive tab val hideFinished: Boolean = false, @@ -295,6 +362,41 @@ class LibraryViewModel @Inject constructor( _whisperEpoch.update { it + 1 } } + // The settings the shown shelf was loaded for. See [onScreenEntered]. + private var shelfLoadedFor: ShelfIdentity? = null + + /** + * Called by LibraryScreen each time it enters composition. A tab return + * keeps the shelf it already has instead of downloading the library again, + * and re-reads it from Room so progress played elsewhere and a cache + * cleared in Settings show up. Room changes outside a sync are not watched. + */ + fun onScreenEntered() { + val state = _uiState.value + // Only a shelf with nothing saved retries on return: no library came + // back at all, or the selected one failed with no books cached. A + // saved shelf, even one filtered to nothing, keeps its books, and the + // periodic and reconnect syncs recover it. + val lastFetchFailed = state.selectedLibrary == null || ( + state.totalBookCount == 0 && ( + state.selectedLibraryFetchResult == SyncResult.FAILED || + state.lastSyncResult == SyncResult.FAILED || + state.errorMessage != null + ) + ) + if (shouldReloadOnLibraryReturn( + loadedFor = shelfLoadedFor, + current = settingsManager.currentSettings.shelfIdentity(), + lastFetchFailed = lastFetchFailed, + ) + ) { + // This can replace a refresh in the lane, so it owns that spinner. + libraryLoadLaunch.launch(viewModelScope) { loadLibrariesOwningRefresh() } + } else if (shelfLoadedFor != null && !state.isLoading) { + applyFilter() + } + } + // Search debounce private var searchJob: Job? = null private val filterPublication = LibraryFilterPublication() @@ -311,7 +413,30 @@ class LibraryViewModel @Inject constructor( // A library picked on this screen whose settings write has not finished. private var pendingLibraryPickId: String? = null + // The groups the user expanded or collapsed by hand, per view mode, kept + // for as long as this ViewModel lives (tab switches included). A group + // with no choice follows groupsStartExpanded. + private val groupChoices = MutableStateFlow>>(emptyMap()) + init { + // Flatten the grouped shelf on Default whenever its groups or the + // expand choices change. Doing it in composition ran on the main + // thread for every toggle, about 150,000 rows in a big Genre view. + viewModelScope.launch { + combine( + _uiState + .map { it.groupedSections to it.effectiveViewMode } + .distinctUntilChanged { old, new -> old.first === new.first && old.second == new.second }, + groupChoices, + ) { (sections, mode), choices -> Triple(sections, mode, choices[mode].orEmpty()) } + .collectLatest { (sections, _, choices) -> + val items = withContext(Dispatchers.Default) { flattenGroupedItems(sections, choices) } + _uiState.update { + if (it.groupedSections === sections) it.copy(groupedListItems = items) else it + } + } + } + // Observe connectivity and auto-filter to downloaded when offline viewModelScope.launch { connectivityMonitor.connectionStatus.collect { status -> @@ -381,6 +506,44 @@ class LibraryViewModel @Inject constructor( } } + // A check that found the book list unchanged, or put a download off, + // writes no sync record, so the collector above never sees it. + // Re-read the saved shelf so progress pulled from the server shows + // while the tab is open, and reload the libraries if the check saved + // a changed list (see afterProgressPull). + viewModelScope.launch { + syncManager.progressPulled.collect { + val state = _uiState.value + if (shelfLoadedFor != null && !state.isLoading && !state.isLocalMode) { + val cached = visibleCachedLibraries( + settings = settingsManager.currentSettings, + cached = libraryRepository.getAudiobookshelf(), + ) + when (afterProgressPull(state.selectedLibrary, state.libraries.map { it.id }, cached.map { it.id })) { + AfterProgressPull.RELOAD_LIBRARIES -> + libraryLoadLaunch.launch(viewModelScope) { loadLibrariesOwningRefresh() } + AfterProgressPull.REFILTER -> applyFilter() + } + } + } + } + + // A full download saves a page at a time, so a big library's first + // load fills in as pages land instead of after the last one. At most + // one re-read per second or so, whoever started the download. + viewModelScope.launch { + audioBookRepository.booksSaved + .filter { libraryId -> + val state = _uiState.value + shelfLoadedFor != null && !state.isLocalMode && state.selectedLibrary?.id == libraryId + } + .conflate() + .collect { + applyFilter()?.join() + delay(SHELF_REREAD_THROTTLE_MS) + } + } + // LOCAL mode has no sync record, so watch the local catalog itself. // A folder scan or rescan running in Settings lands here while the // tab is open, not only on the next visit. @@ -438,7 +601,14 @@ class LibraryViewModel @Inject constructor( // ─── Loading ────────────────────────────────────────────────────────── - private suspend fun loadLibraries(keepPendingPick: Boolean = false) { + /** + * [explicit] is a refresh the user asked for (pull to refresh, Retry): + * the selected library downloads in full. Otherwise (first load, a + * return, a reload after a background sync) it only asks the server what + * changed. See [loadAudioBooks]. + */ + private suspend fun loadLibraries(keepPendingPick: Boolean = false, explicit: Boolean = false) { + shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() _uiState.update { it.copy(isLoading = true, errorMessage = null) } localCatalogBaseline.await() @@ -450,10 +620,25 @@ class LibraryViewModel @Inject constructor( // The item load below reuses this probe so a dead server costs one // 5s wait on a cold start, not two back to back. var serverReachable: Boolean? = null + // Show the saved shelf before the /ping probe and the library list + // fetch. A gone server costs a 5s probe per check, and the gate + // runs them one after another (issue #53). + val saved = if (!isLocalMode) showSavedShelfBeforeNetwork(settings) else null + // The app's own change check ran under two minutes ago (the cold + // start or foreground entry check) and covered this shelf, so + // opening the Library sends nothing: no /ping, no library list, + // no change check. Pull to refresh still goes to the server. + val skipNetwork = !isLocalMode && saved != null && libraryLoadSkipsNetwork( + explicit = explicit, + checkDue = syncManager.isCheckDueNow(), + savedLibraryIsSynced = isSyncedLibrary(saved), + savedLibraryHasBooks = audioBookRepository.hasServerBooks(saved.id), + ) + if (skipNetwork) serverReachable = false val libs = if (isLocalMode) { libraryRepository.getLocalLibraries() } else { - if (shouldSyncOnLibraryLoad( + if (!skipNetwork && shouldSyncOnLibraryLoad( isLocalLibrary = false, isOnline = connectivityMonitor.isOnline.value, ) && connectivityMonitor.checkServerReachable().also { serverReachable = it } @@ -490,6 +675,9 @@ class LibraryViewModel @Inject constructor( updateSettings = settingsManager::updateSettings, ) val selected = selection.library + // The selection this load settled on, so a return does not + // mistake its own write for a change made in Settings. + shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() _uiState.update { it.withLibrarySelection( @@ -500,13 +688,18 @@ class LibraryViewModel @Inject constructor( } filterPublication.invalidate() - val itemResult = selected?.let { - loadAudioBooks(it.id, persistResult = false, serverReachable = serverReachable) + val itemLoad = selected?.let { + loadAudioBooks(it.id, persistResult = false, serverReachable = serverReachable, explicit = explicit) } - if (!isLocalMode) { - buildShelfSyncReport(libraryResult, selected, itemResult)?.let { report -> + val itemResult = itemLoad?.result + // A check that found nothing new, or a download put off until + // unmetered, has no news for the record. A library list failure + // still does. + val recordWorthy = itemLoad == null || itemLoad.recordWorthy || libraryResult !is RemoteResult.Ok + if (!isLocalMode && recordWorthy) { + buildShelfSyncReportFromCount(libraryResult, selected, itemResult)?.let { report -> val selectedLibraryFetchResult = selected?.let { - buildShelfSyncReport(libraries = null, selectedLibrary = it, items = itemResult)?.result + buildShelfSyncReportFromCount(libraries = null, selectedLibrary = it, items = itemResult)?.result } persistLastSync( report = report, @@ -518,7 +711,7 @@ class LibraryViewModel @Inject constructor( } catch (e: Exception) { rethrowLibraryLoadCancellation(e) _uiState.update { - it.copy(errorMessage = "Failed to load libraries: ${e.message}") + it.copy(errorMessage = loadFailureMessage(LibraryLoadFailure.LIBRARIES, e)) } } finally { updateLibraryLoadStateIfActive { @@ -527,15 +720,48 @@ class LibraryViewModel @Inject constructor( } } + /** + * Publishes the cached shelf of the saved library selection, if it is + * cached, and returns that library. The load that follows still resolves + * and persists the real selection and replaces this shelf. + */ + private suspend fun showSavedShelfBeforeNetwork(settings: AppSettings): Library? { + val cachedLibraries = visibleCachedLibraries( + settings = settings, + cached = libraryRepository.getAudiobookshelf(), + ) + val saved = cachedLibraries.firstOrNull { it.id == settings.activeLibraryId } ?: return null + _uiState.update { + it.withLibrarySelection( + libraries = cachedLibraries, + selectedLibrary = saved, + isLocalMode = false, + ) + } + applyFilter()?.join() + return saved + } + + /** + * Shows the saved shelf, then brings it up to date: a full download when + * [explicit], otherwise the change check, which may find nothing, fetch + * only added books, or fall back to a full download (deferred on a + * metered network). A check is shared with SyncManager's if both run at + * once, as is a full download. + */ private suspend fun loadAudioBooks( libraryId: String, persistResult: Boolean = true, serverReachable: Boolean? = null, - ): RemoteResult>? { - var itemResult: RemoteResult>? = null + explicit: Boolean = false, + ): ShelfItemLoad? { + var itemLoad: ShelfItemLoad? = null try { val serverUrlAtStart = settingsManager.currentSettings.serverUrl val selected = _uiState.value.selectedLibrary + // Show the saved shelf before the network. A full fetch of a big + // library takes minutes, and the spinner only covers an empty shelf. + if (selected?.isLocal != true) applyFilter()?.join() // Only hit the network when a remote library is selected AND we have // connectivity. In airplane mode the old code attempted syncLibraryItems // regardless, leaving the switch spinning on a doomed request until the @@ -547,15 +773,26 @@ class LibraryViewModel @Inject constructor( isOnline = connectivityMonitor.isOnline.value, ) && (serverReachable ?: connectivityMonitor.checkServerReachable()) ) { - itemResult = refreshSelectedLibraryItems( - libraryId = libraryId, - fetchRemote = audioBookRepository::syncLibraryItems, - ) + val load = if (explicit) { + ShelfItemLoad( + result = refreshSelectedLibraryItems( + libraryId = libraryId, + fetchRemote = audioBookRepository::syncLibraryItems, + ), + recordWorthy = true, + ) + } else { + checkSelectedLibraryItems(libraryId) { id -> + audioBookRepository.refreshLibraryItemsIfChanged(id, connectivityMonitor.refreshIsMetered()) + } + } + itemLoad = load + val itemResult = load.result // The selected library's own outcome, tracked separately // from the whole-account aggregate lastSyncResult (issue // #14, PR #30 review, finding A) — see decideLibraryShelf. val ownShelfReport = selected?.let { - buildShelfSyncReport(libraries = null, selectedLibrary = it, items = itemResult) + buildShelfSyncReportFromCount(libraries = null, selectedLibrary = it, items = itemResult) } _uiState.update { it.copy( @@ -564,7 +801,7 @@ class LibraryViewModel @Inject constructor( selectedLibraryFetchPersisted = false, ) } - if (persistResult && ownShelfReport != null) { + if (persistResult && load.recordWorthy && ownShelfReport != null) { persistLastSync( report = ownShelfReport, serverUrlAtStart = serverUrlAtStart, @@ -580,10 +817,10 @@ class LibraryViewModel @Inject constructor( } catch (e: Exception) { rethrowLibraryLoadCancellation(e) _uiState.update { - it.copy(errorMessage = "Failed to load audiobooks: ${e.message}") + it.copy(errorMessage = loadFailureMessage(LibraryLoadFailure.BOOKS, e)) } } - return itemResult + return itemLoad } private suspend fun persistLastSync( @@ -651,7 +888,9 @@ class LibraryViewModel @Inject constructor( } } if (pendingLibraryPickId == library.id) pendingLibraryPickId = null - // Full resync for the newly selected library + shelfLoadedFor = settingsManager.currentSettings.shelfIdentity() + // Brings the newly selected library up to date: a full download + // the first time, only what changed after that. loadAudioBooks(library.id) _uiState.update { it.copy(isLoading = false) } } @@ -694,11 +933,10 @@ class LibraryViewModel @Inject constructor( } fun onGroupExpansionToggled(groupKey: String) { - _uiState.update { state -> - val updated = state.expandedGroups.toMutableSet().apply { - if (!add(groupKey)) remove(groupKey) - } - state.copy(expandedGroups = updated) + val state = _uiState.value + val mode = state.effectiveViewMode + groupChoices.update { all -> + all + (mode to toggledGroupChoices(all[mode].orEmpty(), groupKey, state.groupedSections.size)) } } @@ -718,17 +956,18 @@ class LibraryViewModel @Inject constructor( } } + /** Pull to refresh and every Retry button: the selected library downloads in full. */ fun refresh() { libraryLoadLaunch.launch(viewModelScope) { _uiState.update { it.copy(isRefreshing = true) } - loadLibrariesOwningRefresh() + loadLibrariesOwningRefresh(explicit = true) } } /** Load, then clear the refresh spinner unless a newer load took over the lane. */ - private suspend fun loadLibrariesOwningRefresh(keepPendingPick: Boolean = false) { + private suspend fun loadLibrariesOwningRefresh(keepPendingPick: Boolean = false, explicit: Boolean = false) { try { - loadLibraries(keepPendingPick) + loadLibraries(keepPendingPick, explicit) } finally { updateLibraryLoadStateIfActive { _uiState.update { it.copy(isRefreshing = false) } @@ -740,6 +979,16 @@ class LibraryViewModel @Inject constructor( _uiState.update { it.copy(errorMessage = null) } } + /** The banner line for [failure]. The exception goes to the log, not the screen. */ + private fun loadFailureMessage(failure: LibraryLoadFailure, error: Throwable): String { + Log.w(TAG, "Library load failed: $failure", error) + return libraryErrorMessage(failure) + } + + private companion object { + const val TAG = "LibraryViewModel" + } + // ─── Filter/Sort Logic ──────────────────────────────────────────────── private data class FilterSnapshot( @@ -757,7 +1006,7 @@ class LibraryViewModel @Inject constructor( private data class FilterResult( val books: List, - val availableGroups: List, + val effectiveViewMode: ViewMode, val groupedSections: List, val totalBookCount: Int, ) @@ -765,14 +1014,6 @@ class LibraryViewModel @Inject constructor( private suspend fun buildFilterResult(snapshot: FilterSnapshot): FilterResult? { val libraryId = snapshot.request.libraryId - val groups = when (snapshot.viewMode) { - ViewMode.SERIES -> audioBookRepository.getDistinctSeries(libraryId) - ViewMode.AUTHOR -> audioBookRepository.getDistinctAuthors(libraryId) - ViewMode.GENRE -> audioBookRepository.getDistinctGenres(libraryId) - ViewMode.ALL -> emptyList() - } - if (!filterPublication.isCurrent(snapshot.request)) return null - // Push filters to SQL — only load the books that match val tab = when (snapshot.selectedTab) { LibraryTab.All -> 0 @@ -793,29 +1034,20 @@ class LibraryViewModel @Inject constructor( ) if (!filterPublication.isCurrent(snapshot.request)) return null val accessibleLocalIds = localFolderAccess.accessibleLibraryIds(snapshot.libraries) + // A newer filter cancels this one. The long loops below check for + // that, so two big builds never run side by side on fast chip taps. val books = storedBooks - .map { reconcileLocalBookAccess(it, accessibleLocalIds).book } + .mapCooperatively { reconcileLocalBookAccess(it, accessibleLocalIds).book } .filterNot { !it.isDownloaded && (snapshot.selectedTab == LibraryTab.Downloaded || snapshot.showDownloadedOnly) } - // Clamped at the point of consumption, not just in the UI. Gating the - // chips stops a free user CHOOSING a premium sort, but says nothing - // about one already selected before a downgrade, which would otherwise - // keep running behind a greyed control. - // - // The stored choice in uiState is left alone, so unlocking restores it. - val isUnlocked = entitlements.current.isUnlocked - val effectiveSort = FreeTier.effectiveSort(snapshot.sortMode, isUnlocked) - val effectiveViewMode = FreeTier.effectiveViewMode(snapshot.viewMode, isUnlocked) - - // Sort and group in-memory (complex logic stays in Kotlin) - val sortedBooks = sortBooks(books, effectiveSort) - val groupedSections = buildGroupedSections( - books = sortedBooks, - viewMode = effectiveViewMode, - sortMode = effectiveSort, + val shelf = arrangeShelf( + books = books, + storedSort = snapshot.sortMode, + storedViewMode = snapshot.viewMode, + isUnlocked = entitlements.current.isUnlocked, ) // Get total count from DB (not from filtered set) @@ -823,25 +1055,19 @@ class LibraryViewModel @Inject constructor( if (!filterPublication.isCurrent(snapshot.request)) return null return FilterResult( - books = sortedBooks, - availableGroups = groups, - groupedSections = groupedSections, + books = shelf.books, + effectiveViewMode = shelf.viewMode, + groupedSections = shelf.sections, totalBookCount = totalCount, ) } private fun publishFilterResult(result: FilterResult) { _uiState.update { - val groupKeys = result.groupedSections.map { section -> section.key }.toSet() - val previousKeys = it.groupedSections.map { section -> section.key }.toSet() - val expandedGroups = it.expandedGroups - .filterTo(mutableSetOf()) { key -> key in groupKeys } - .apply { addAll(groupKeys - previousKeys) } it.copy( filteredBooks = result.books, - availableGroups = result.availableGroups, + effectiveViewMode = result.effectiveViewMode, groupedSections = result.groupedSections, - expandedGroups = expandedGroups, totalBookCount = result.totalBookCount, ) } @@ -873,9 +1099,10 @@ class LibraryViewModel @Inject constructor( return filterPublication.launch( scope = viewModelScope, request = snapshot.request, - load = { buildFilterResult(snapshot) }, + // Decoding and sorting thousands of books stays off the main thread. + load = { withContext(Dispatchers.Default) { buildFilterResult(snapshot) } }, onFailure = { e -> - _uiState.update { it.copy(errorMessage = "Failed to load audiobooks: ${e.message}") } + _uiState.update { it.copy(errorMessage = loadFailureMessage(LibraryLoadFailure.SHELF, e)) } }, ) { result -> result?.let(::publishFilterResult) @@ -892,6 +1119,20 @@ class LibraryViewModel @Inject constructor( // ─── Load-path decisions (internal for testability) ─────────────────────── +/** What failed, for the Library's error banner. */ +internal enum class LibraryLoadFailure { LIBRARIES, BOOKS, SHELF } + +/** + * The banner line for a failed load, in the screen's own voice. The banner + * sits beside a Retry button. The exception text is for the log only, it + * means nothing to a listener. + */ +internal fun libraryErrorMessage(failure: LibraryLoadFailure): String = when (failure) { + LibraryLoadFailure.LIBRARIES -> "Libraries could not be loaded." + LibraryLoadFailure.BOOKS -> "Books could not be loaded." + LibraryLoadFailure.SHELF -> "Saved books could not be read." +} + internal sealed interface LibraryShelfDecision { data object Empty : LibraryShelfDecision data class LoadFailed(val result: SyncResult) : LibraryShelfDecision @@ -931,10 +1172,10 @@ internal suspend fun refreshRemoteLibraryList( ) } -internal suspend fun refreshSelectedLibraryItems( +internal suspend fun refreshSelectedLibraryItems( libraryId: String, - fetchRemote: suspend (String) -> RemoteResult>, -): RemoteResult> = try { + fetchRemote: suspend (String) -> RemoteResult, +): RemoteResult = try { fetchRemote(libraryId) } catch (e: CancellationException) { throw e @@ -942,6 +1183,35 @@ internal suspend fun refreshSelectedLibraryItems( RemoteResult.Failed(describeFailure(e)) } +/** + * What the selected library's load brought back: its book count result (null + * when there is no verdict, as for a download waiting on an unmetered + * network) and whether that is news worth a sync record. A check that found + * nothing new is a fine verdict for this screen but not a new record. + */ +internal data class ShelfItemLoad( + val result: RemoteResult?, + val recordWorthy: Boolean, +) + +/** The change check for the selected library, as a [ShelfItemLoad]. A thrown failure reads as a failed check. */ +internal suspend fun checkSelectedLibraryItems( + libraryId: String, + check: suspend (String) -> LibraryRefreshOutcome, +): ShelfItemLoad { + val outcome = try { + check(libraryId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LibraryRefreshOutcome.CheckFailed(describeFailure(e)) + } + return ShelfItemLoad( + result = outcome.itemCountResult(), + recordWorthy = outcome !is LibraryRefreshOutcome.Unchanged && outcome !is LibraryRefreshOutcome.Deferred, + ) +} + internal fun rethrowLibraryLoadCancellation(error: Exception) { if (error is CancellationException) throw error } @@ -957,7 +1227,7 @@ internal fun LibraryViewModel.UiState.withLibrarySelection( filteredBooks = if (selectedLibrary == null) emptyList() else filteredBooks, availableGroups = if (selectedLibrary == null) emptyList() else availableGroups, groupedSections = if (selectedLibrary == null) emptyList() else groupedSections, - expandedGroups = if (selectedLibrary == null) emptySet() else expandedGroups, + groupedListItems = if (selectedLibrary == null) emptyList() else groupedListItems, totalBookCount = if (selectedLibrary == null) 0 else totalBookCount, // A stale outcome from whatever was PREVIOUSLY selected must never be // read as this (possibly different) library's own verdict — cleared on @@ -1062,8 +1332,62 @@ internal fun selectedLibraryFetchSequence(outcome: PersistedSyncOutcome): Long? internal fun shouldSyncOnLibraryLoad(isLocalLibrary: Boolean, isOnline: Boolean): Boolean = !isLocalLibrary && isOnline +/** + * Whether opening the Library skips the server because the app's own change + * check ran under two minutes ago. It used to check again regardless, a + * second HEAD 73 seconds after the cold-start one. Only for a load the user + * did not ask for (pull to refresh and Retry always go out), and only for a + * saved library that check covers: a book library (podcast libraries are + * not checked in the background) with books already cached (a fresh + * sign-in or a new server has nothing to show yet). + */ +internal fun libraryLoadSkipsNetwork( + explicit: Boolean, + checkDue: Boolean, + savedLibraryIsSynced: Boolean, + savedLibraryHasBooks: Boolean, +): Boolean = !explicit && !checkDue && savedLibraryIsSynced && savedLibraryHasBooks + // ─── Grouping helpers (internal for testability) ────────────────────────── +/** The shelf as shown: sorted books, the view mode in effect, and its groups. */ +internal data class ArrangedShelf( + val books: List, + val viewMode: ViewMode, + val sections: List, +) + +/** + * Sorts and groups the filtered books. Sort and view mode are clamped here, + * at the point of use, not just in the UI. Gating the chips stops a free user + * CHOOSING a premium mode, but says nothing about one already selected before + * a downgrade, which would otherwise keep running behind a greyed control. + * The stored choice is left alone, so unlocking restores it, and the screen + * lays out by [ArrangedShelf.viewMode], never the stored one. + */ +internal suspend fun arrangeShelf( + books: List, + storedSort: SortMode, + storedViewMode: ViewMode, + isUnlocked: Boolean, +): ArrangedShelf { + val sort = FreeTier.effectiveSort(storedSort, isUnlocked) + val viewMode = FreeTier.effectiveViewMode(storedViewMode, isUnlocked) + currentCoroutineContext().ensureActive() + val sorted = sortBooks(books, sort) + currentCoroutineContext().ensureActive() + return ArrangedShelf( + books = sorted, + viewMode = viewMode, + sections = buildGroupedSections(books = sorted, viewMode = viewMode, sortMode = sort), + ) +} + +/** + * Groups shelf books. [books] must already be in [sortMode] order (the shelf + * sorts once before grouping), and each group keeps that order, so groups are + * not sorted again. + */ internal fun buildGroupedSections( books: List, viewMode: ViewMode, @@ -1072,25 +1396,51 @@ internal fun buildGroupedSections( if (viewMode == ViewMode.ALL) return emptyList() // Genre view uses multi-placement: a book appears in every genre group it belongs to. - val grouped = mutableMapOf>() + val grouped = linkedMapOf>() books.forEach { book -> val keys = groupingKeysForBook(book, viewMode) keys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } } return grouped.entries - .map { (key, values) -> - GroupedSection(key = key, title = key, books = sortBooks(values, sortMode)) - } + .map { (key, values) -> SectionSortEntry(GroupedSection(key = key, title = key, books = values)) } .sortedWith(groupedSectionComparator(sortMode)) + .map { it.section } } +/** Grouped views start collapsed when they have more groups than this. */ +internal const val COLLAPSE_GROUPS_ABOVE = 50 + +/** + * Whether a group the user has not touched starts open. A small library keeps + * every group open as before. A big one starts with headers only, or a Genre + * view of 50,000 books opened as one endless list. + */ +internal fun groupsStartExpanded(groupCount: Int): Boolean = groupCount <= COLLAPSE_GROUPS_ABOVE + +/** + * The user's choices after tapping [groupKey]: the opposite of what that + * header showed, which is their earlier choice or else the default for + * [groupCount] groups. + */ +internal fun toggledGroupChoices( + choices: Map, + groupKey: String, + groupCount: Int, +): Map = + choices + (groupKey to !(choices[groupKey] ?: groupsStartExpanded(groupCount))) + +/** + * Headers and rows for a grouped view. A group the user expanded or collapsed + * by hand ([choices]) stays that way, any other follows [groupsStartExpanded]. + */ internal fun flattenGroupedItems( groupedSections: List, - expandedGroups: Set, + choices: Map, ): List = buildList { + val startExpanded = groupsStartExpanded(groupedSections.size) groupedSections.forEach { section -> - val expanded = section.key in expandedGroups + val expanded = choices[section.key] ?: startExpanded add( LibraryListItem.GroupHeader( groupKey = section.key, @@ -1118,16 +1468,21 @@ private fun groupingKeysForBook(book: AudioBook, viewMode: ViewMode): List emptyList() } -private fun groupedSectionComparator(sortMode: SortMode): Comparator { - val alphaAsc = compareBy { it.title.lowercase() } +/** A section with its lowercased title worked out once, not on every compare. */ +private class SectionSortEntry(val section: GroupedSection) { + val titleKey: String = section.title.lowercase() +} + +private fun groupedSectionComparator(sortMode: SortMode): Comparator { + val alphaAsc = compareBy { it.titleKey } return when (sortMode) { SortMode.TITLE_ZA, SortMode.AUTHOR_ZA -> alphaAsc.reversed() SortMode.TITLE_AZ, SortMode.AUTHOR_AZ -> alphaAsc SortMode.PROGRESS_LOW, SortMode.DURATION_SHORT -> - compareBy { it.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MAX_VALUE } + compareBy { it.section.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MAX_VALUE } .then(alphaAsc) else -> - compareByDescending { it.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MIN_VALUE } + compareByDescending { it.section.books.firstOrNull()?.let { b -> sortSignal(b, sortMode) } ?: Long.MIN_VALUE } .then(alphaAsc) } } @@ -1141,29 +1496,41 @@ private fun sortSignal(book: AudioBook, sortMode: SortMode): Long = when (sortMo SortMode.TITLE_AZ, SortMode.TITLE_ZA, SortMode.AUTHOR_AZ, SortMode.AUTHOR_ZA -> 0L } +/** + * A book with its sort keys worked out once. Lowercasing inside a comparator + * ran about 1.5 million times per sort on a 50,000 book shelf. + */ +private class BookSortEntry(val book: AudioBook, needsAuthor: Boolean) { + val titleKey: String = book.title.lowercase() + val authorKey: String = if (needsAuthor) book.author.lowercase() else "" +} + +/** + * The shelf order. The query has no ORDER BY, since this sorts every time, so + * every mode ends on the raw title (the order the query used to return) and + * then the id, and the same shelf always comes out the same way. + */ internal fun sortBooks(books: List, sortMode: SortMode): List { - val sequence = books.asSequence() - return when (sortMode) { - SortMode.RECENTLY_ADDED -> sequence.sortedWith( - compareByDescending { it.addedAt ?: Long.MIN_VALUE } - .thenBy { it.title.lowercase() } - ) - SortMode.TITLE_AZ -> sequence.sortedBy { it.title.lowercase() } - SortMode.TITLE_ZA -> sequence.sortedByDescending { it.title.lowercase() } - SortMode.AUTHOR_AZ -> sequence.sortedWith(compareBy({ it.author.lowercase() }, { it.title.lowercase() })) - SortMode.AUTHOR_ZA -> sequence.sortedWith(compareByDescending { it.author.lowercase() }.thenByDescending { it.title.lowercase() }) - SortMode.PROGRESS_HIGH -> sequence.sortedByDescending { it.progressPercent } - SortMode.PROGRESS_LOW -> sequence.sortedBy { it.progressPercent } - SortMode.DURATION_LONG -> sequence.sortedByDescending { it.duration.inWholeSeconds } - SortMode.DURATION_SHORT -> sequence.sortedBy { it.duration.inWholeSeconds } - SortMode.RECENTLY_PLAYED -> sequence.sortedWith( - // Treat books with no playback history as oldest via Long.MIN_VALUE fallback. - compareByDescending { it.lastPlayedAt ?: Long.MIN_VALUE } - .thenBy { it.title.lowercase() } - ) - SortMode.UNPLAYED_FIRST -> sequence.sortedWith( - compareBy { if (it.hasProgress) 1 else 0 } - .thenBy { it.title.lowercase() } - ) - }.toList() + val needsAuthor = sortMode == SortMode.AUTHOR_AZ || sortMode == SortMode.AUTHOR_ZA + val entries = books.map { BookSortEntry(it, needsAuthor) } + val primary: Comparator = when (sortMode) { + SortMode.RECENTLY_ADDED -> + compareByDescending { it.book.addedAt ?: Long.MIN_VALUE }.thenBy { it.titleKey } + SortMode.TITLE_AZ -> compareBy { it.titleKey } + SortMode.TITLE_ZA -> compareByDescending { it.titleKey } + SortMode.AUTHOR_AZ -> compareBy { it.authorKey }.thenBy { it.titleKey } + SortMode.AUTHOR_ZA -> compareByDescending { it.authorKey }.thenByDescending { it.titleKey } + SortMode.PROGRESS_HIGH -> compareByDescending { it.book.progressPercent } + SortMode.PROGRESS_LOW -> compareBy { it.book.progressPercent } + SortMode.DURATION_LONG -> compareByDescending { it.book.duration.inWholeSeconds } + SortMode.DURATION_SHORT -> compareBy { it.book.duration.inWholeSeconds } + // Treat books with no playback history as oldest via Long.MIN_VALUE fallback. + SortMode.RECENTLY_PLAYED -> + compareByDescending { it.book.lastPlayedAt ?: Long.MIN_VALUE }.thenBy { it.titleKey } + SortMode.UNPLAYED_FIRST -> + compareBy { if (it.book.hasProgress) 1 else 0 }.thenBy { it.titleKey } + } + return entries + .sortedWith(primary.thenBy { it.book.title }.thenBy { it.book.id }) + .map { it.book } } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt index beb047a8..cebc839e 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/BottomNavBar.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.ui.theme.NineLivesTheme @@ -95,12 +94,7 @@ fun BottomNavBar( NavigationBarItem( selected = isSelected, onClick = { - navController.navigate(item.route) { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = false - } - launchSingleTop = true - } + navController.navigateToTab(item.route) }, icon = { Icon( diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt index 596209e6..4438eba2 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/LeftNavRail.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.ui.theme.NineLivesTheme @@ -51,12 +50,7 @@ fun LeftNavRail( item = item, isSelected = isSelected, onClick = { - navController.navigate(item.route) { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = false - } - launchSingleTop = true - } + navController.navigateToTab(item.route) } ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt index 1992579d..d6b38037 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/NineLivesNavHost.kt @@ -3,7 +3,6 @@ package com.ninelivesaudio.app.ui.navigation import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -61,10 +60,7 @@ fun NineLivesNavHost( val route = when (destination) { OnboardingDestination.SETTINGS -> Routes.SETTINGS } - navController.navigate(route) { - popUpTo(Routes.WELCOME) { inclusive = true } - launchSingleTop = true - } + navController.finishOnboarding(route) } WelcomeScreen( onChooseLocal = { @@ -78,10 +74,10 @@ fun NineLivesNavHost( composable(Routes.HOME) { HomeScreen( + // Tab targets go through navigateToTab so the tab comes back as + // the user left it instead of as a fresh second copy. onNavigateToLibrary = { - navController.navigate(Routes.LIBRARY) { - launchSingleTop = true - } + navController.navigateToTab(Routes.LIBRARY) }, onNavigateToBookDetail = { bookId -> navController.navigate(Routes.bookDetail(bookId)) @@ -97,9 +93,7 @@ fun NineLivesNavHost( } }, onNavigateToSettings = { - navController.navigate(Routes.SETTINGS) { - launchSingleTop = true - } + navController.navigateToTab(Routes.SETTINGS) }, ) } @@ -205,17 +199,10 @@ fun NineLivesNavHost( bookId = bookId, onNavigateBack = { navController.popBackStack() }, onNavigateToSettings = { - navController.navigate(Routes.SETTINGS) { - launchSingleTop = true - } + navController.navigateToTab(Routes.SETTINGS) }, onNavigateToPlayer = { - navController.navigate(Routes.PLAYER) { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = false - } - launchSingleTop = true - } + navController.navigateToTab(Routes.PLAYER) } ) } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/navigation/TabNavigation.kt b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/TabNavigation.kt new file mode 100644 index 00000000..83faec60 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/navigation/TabNavigation.kt @@ -0,0 +1,47 @@ +package com.ninelivesaudio.app.ui.navigation + +import androidx.navigation.NavController + +/** + * The screen every tab sits on top of, on every launch. The tabs used to pop + * to the graph's start destination, but on first run that is Welcome, which + * onboarding removes from the back stack. Navigation ignores a popUpTo whose + * target is not on the stack, so every tab tap pushed a new screen (#58). + * Home is always on the stack (onboarding puts it there), so popping to it + * keeps one entry per tab under Home on first run and every launch after. + */ +const val TAB_ROOT_ROUTE = Routes.HOME + +/** + * The back stack onboarding leaves behind when it lands on [target]: Home + * first, so Back from the landing tab goes Home and then out of the app, the + * same as a normal launch. + */ +fun postOnboardingRoutes(target: String): List = + if (target == TAB_ROOT_ROUTE) listOf(TAB_ROOT_ROUTE) else listOf(TAB_ROOT_ROUTE, target) + +/** + * Switch to the tab at [route]. Each tab keeps its screen, scroll, and loaded + * shelf while another tab is open. Without saveState and restoreState, + * leaving the Library threw it away and a big library re-downloaded on every + * return. + */ +fun NavController.navigateToTab(route: String) { + navigate(route) { + popUpTo(TAB_ROOT_ROUTE) { + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +/** Leave Welcome for [target] with the stack a normal launch would have. */ +fun NavController.finishOnboarding(target: String) { + val routes = postOnboardingRoutes(target) + navigate(routes.first()) { + popUpTo(Routes.WELCOME) { inclusive = true } + launchSingleTop = true + } + routes.drop(1).forEach { navigateToTab(it) } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt b/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt index f524e1fd..8fc4e905 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/player/BookmarkPublication.kt @@ -1,5 +1,7 @@ package com.ninelivesaudio.app.ui.player +import com.ninelivesaudio.app.data.remote.RemoteResult +import com.ninelivesaudio.app.data.remote.describeFailure import com.ninelivesaudio.app.domain.model.Bookmark import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -35,8 +37,8 @@ internal class BookmarkPublication { fun launch( scope: CoroutineScope, request: BookmarkLoadRequest, - load: suspend (String) -> List, - publish: (List) -> Unit, + load: suspend (String) -> RemoteResult>, + publish: (RemoteResult>) -> Unit, ): Job? { val itemId = request.itemId ?: return null return scope.launch { @@ -44,10 +46,30 @@ internal class BookmarkPublication { load(itemId) } catch (cancelled: CancellationException) { throw cancelled - } catch (_: Exception) { - emptyList() + } catch (e: Exception) { + RemoteResult.Failed(describeFailure(e)) } if (current == request) publish(bookmarks) }.also { job = it } } } + +/** What the player shows after a bookmark load: the list, and whether it failed. */ +internal data class BookmarkListState( + val bookmarks: List, + val loadFailed: Boolean, +) + +/** + * A failed load keeps whatever list is on screen and raises the failure flag, + * so a network blip reads as "didn't load" instead of "all your bookmarks are + * gone". + */ +internal fun bookmarkListState( + current: List, + result: RemoteResult>, +): BookmarkListState = when (result) { + is RemoteResult.Ok -> BookmarkListState(result.value, loadFailed = false) + is RemoteResult.Partial -> BookmarkListState(result.value, loadFailed = false) + is RemoteResult.Failed -> BookmarkListState(current, loadFailed = true) +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt index cb11e4a4..c49b3a2f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerScreen.kt @@ -78,6 +78,8 @@ fun PlayerScreen( if (uiState.showBookmarks) { BookmarkSheet( bookmarks = uiState.bookmarks, + loadFailed = uiState.bookmarksLoadFailed, + onRetry = { viewModel.retryBookmarks() }, positionText = uiState.positionText, onAddBookmark = { title -> viewModel.addBookmark(title) }, onSeekToBookmark = { bookmark -> viewModel.seekToBookmark(bookmark) }, @@ -1009,10 +1011,14 @@ private fun EmptyPlayerState() { // ─── Bookmark Sheet ────────────────────────────────────────────────────── +internal const val BOOKMARKS_LOAD_FAILED_COPY = "Bookmarks didn't load. Tap to try again." + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun BookmarkSheet( bookmarks: List, + loadFailed: Boolean, + onRetry: () -> Unit, positionText: String, onAddBookmark: (String) -> Unit, onSeekToBookmark: (Bookmark) -> Unit, @@ -1092,17 +1098,34 @@ private fun BookmarkSheet( Spacer(modifier = Modifier.height(16.dp)) - // Bookmark list - if (bookmarks.isEmpty()) { + if (loadFailed) { Text( - text = "No bookmarks yet", + text = BOOKMARKS_LOAD_FAILED_COPY, style = MaterialTheme.typography.bodySmall, - color = NineLivesTheme.colors.archiveTextMuted, + color = NineLivesTheme.colors.goldFilament, modifier = Modifier .fillMaxWidth() - .padding(vertical = 24.dp), + .clickable(onClick = onRetry) + .padding(vertical = 12.dp), textAlign = TextAlign.Center, ) + } + + // Bookmark list + if (bookmarks.isEmpty()) { + // After a failed load the line above already speaks. "No + // bookmarks yet" would claim the book has none. + if (!loadFailed) { + Text( + text = "No bookmarks yet", + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextMuted, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + textAlign = TextAlign.Center, + ) + } } else { LazyColumn( modifier = Modifier.heightIn(max = 300.dp), diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt index da7928ae..5ffe5d92 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/player/PlayerViewModel.kt @@ -79,6 +79,7 @@ class PlayerViewModel @Inject constructor( // Bookmarks val bookmarks: List = emptyList(), + val bookmarksLoadFailed: Boolean = false, val showBookmarks: Boolean = false, val bookmarkItemId: String? = null, @@ -279,6 +280,7 @@ class PlayerViewModel @Inject constructor( seriesName = book?.seriesName, bookmarkItemId = book?.id, bookmarks = emptyList(), + bookmarksLoadFailed = false, ) } if (book != null) { @@ -388,11 +390,19 @@ class PlayerViewModel @Inject constructor( scope = viewModelScope, request = request, load = bookmarkRepository::getBookmarks, - ) { bookmarks -> - _uiState.update { it.copy(bookmarks = bookmarks) } + ) { result -> + _uiState.update { + val next = bookmarkListState(it.bookmarks, result) + it.copy(bookmarks = next.bookmarks, bookmarksLoadFailed = next.loadFailed) + } } } + fun retryBookmarks() { + val itemId = _uiState.value.bookmarkItemId ?: return + bookmarkPublication.refreshCurrent(itemId)?.let(::loadBookmarks) + } + fun addBookmark(title: String) { val itemId = _uiState.value.bookmarkItemId ?: return val currentTimeSeconds = _uiState.value.position.toDouble(kotlin.time.DurationUnit.SECONDS) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt index f3512cd0..fc05c69b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt @@ -23,6 +23,7 @@ import com.ninelivesaudio.app.data.remote.TokenValidationResult import com.ninelivesaudio.app.data.repository.AudioBookRepository import com.ninelivesaudio.app.data.repository.LibraryRepository import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings import com.ninelivesaudio.app.domain.model.LastSyncRecord import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult @@ -34,8 +35,10 @@ import com.ninelivesaudio.app.service.SettingsManager import com.ninelivesaudio.app.service.SyncManager import com.ninelivesaudio.app.service.local.LocalLibraryScanner import com.ninelivesaudio.app.service.local.LocalFolderAccess -import com.ninelivesaudio.app.service.local.LocalMetadataExtractor -import com.ninelivesaudio.app.service.local.toAudioBook +import com.ninelivesaudio.app.service.local.LocalScanCoordinator +import com.ninelivesaudio.app.service.local.LocalScanImporter +import com.ninelivesaudio.app.service.local.LocalScanRequest +import com.ninelivesaudio.app.service.local.LocalScanState import com.ninelivesaudio.app.settings.unhinged.UnhingedSettingsRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -192,8 +195,8 @@ class SettingsViewModel @Inject constructor( private val unhingedRepository: UnhingedSettingsRepository, private val syncManager: SyncManager, private val playbackManager: PlaybackManager, - private val localScanner: LocalLibraryScanner, - private val localMetadataExtractor: LocalMetadataExtractor, + private val localScanImporter: LocalScanImporter, + private val localScanCoordinator: LocalScanCoordinator, private val localFolderAccess: LocalFolderAccess, ) : ViewModel() { @@ -366,6 +369,25 @@ class SettingsViewModel @Inject constructor( } } + // Follow the saved library selection. The tabs keep this screen alive + // while another tab is open, and the Library tab can pick a different + // library meanwhile. Without this, Settings kept showing the old one + // and the local cleanup actions targeted it. + viewModelScope.launch { + settingsManager.settings.collect { settings -> + _uiState.update { it.withSavedSelections(settings) } + } + } + + // Follow the app-wide folder scan. A result that landed while + // Settings was closed shows once here, then is acknowledged. + viewModelScope.launch { + localScanCoordinator.state.collect { scan -> + _uiState.update { it.withLocalScan(scan) } + if (scan is LocalScanState.Finished) localScanCoordinator.acknowledge(scan.id) + } + } + // Load settings on init viewModelScope.launch { initialize() @@ -377,7 +399,7 @@ class SettingsViewModel @Inject constructor( val settings = settingsManager.currentSettings _uiState.update { state -> - val configuredHost = extractHost(settings.serverUrl) + val configuredHost = extractServerHost(settings.serverUrl) state.copy( appMode = settings.appMode, serverUrl = settings.serverUrl, @@ -537,86 +559,14 @@ class SettingsViewModel @Inject constructor( * before passing the URI string here. */ fun onLocalFolderPicked(uriString: String) { - viewModelScope.launch { - _uiState.update { it.copy(isScanning = true, lastScanMessage = null, errorMessage = null) } - try { - // Derive a display name from the URI path - val uri = Uri.parse(uriString) - val displayName = uri.lastPathSegment - ?.substringAfterLast(':') - ?.substringAfterLast('/') - ?.ifBlank { null } - ?: "Local Library" - - val scanResult = withContext(Dispatchers.IO) { - localScanner.scan(uri) - } - failEmptyScanWithErrors(scanResult) - - // Create or reuse the local library row after confirming the folder is readable. - val library = libraryRepository.createLocalLibrary(displayName, uriString) - - importAndReconcile(library.id, scanResult) - - // Select this library - settingsManager.updateSettings { - it.copy(selectedLocalLibraryId = library.id) - } - - val outcome = buildScanOutcome(scanResult, countedAs = "imported") - - _uiState.update { - it.copy( - isScanning = false, - lastScanMessage = outcome.lastScanMessage, - selectedLocalLibrary = library, - inaccessibleLocalLibraryIds = it.inaccessibleLocalLibraryIds - library.id, - successMessage = outcome.successMessage, - errorMessage = outcome.errorMessage, - ) - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isScanning = false, - errorMessage = "Scan failed: ${e.message}", - ) - } - } - } + // The scan runs app-wide so leaving Settings cannot cancel it (#57). + // The state collector in init shows its progress and result. + localScanCoordinator.start(LocalScanRequest.AddFolder(uriString)) } fun rescanLocalLibrary(library: Library) { - val folderUri = library.folderUri ?: return - viewModelScope.launch { - _uiState.update { it.copy(isScanning = true, lastScanMessage = null, errorMessage = null) } - try { - val scanResult = withContext(Dispatchers.IO) { - localScanner.scan(Uri.parse(folderUri)) - } - failEmptyScanWithErrors(scanResult) - - importAndReconcile(library.id, scanResult) - - val outcome = buildScanOutcome(scanResult, countedAs = "found") { "Rescan complete: $it" } - - _uiState.update { - it.copy( - isScanning = false, - lastScanMessage = outcome.lastScanMessage, - successMessage = outcome.successMessage, - errorMessage = outcome.errorMessage, - ) - } - } catch (e: Exception) { - _uiState.update { - it.copy( - isScanning = false, - errorMessage = "Rescan failed: ${e.message}", - ) - } - } - } + if (library.folderUri == null) return + localScanCoordinator.start(LocalScanRequest.Rescan(library)) } fun removeLocalLibrary(library: Library) { @@ -630,7 +580,7 @@ class SettingsViewModel @Inject constructor( // re-adding the same folder re-imports and clears ArchivedAt // (restore). "Delete forever" / the archive sweep is the path // to actually remove them. - archiveMissingBooks(library.id, scannedIds = emptyList()) + localScanImporter.archiveMissingBooks(library.id, scannedIds = emptyList()) releaseSafPermission(library.folderUri) moveSelectionOffLibrary(library) @@ -786,80 +736,16 @@ class SettingsViewModel @Inject constructor( return localLibraries.firstOrNull { it.id == savedLocalId } ?: localLibraries.firstOrNull() } - /** - * Import what the scan found, then reconcile the library against it: any - * local book whose folder was not seen in this scan leaves the library - * (issue #20). Without this a rescan only ever adds, so moving a folder one - * level deeper leaves a ghost entry pointing at files that are gone, and - * playing it fails. - * - * Removal is gated on [LocalLibraryScanner.ScanResult.coverageComplete], NOT - * on "the scan reported no warnings". The distinction is the whole safety - * argument: a folder that could not be listed, a revoked permission, or a - * traversal cut short by the depth or folder cap all mean books exist that - * this scan never saw, and absence from a partial inventory is not evidence - * a book is gone. Those scans import and reconcile nothing. A warning that - * is not a coverage gap (a single unreadable file, whose id the scan hands - * back as retained) no longer freezes the whole library forever. - * - * "Leaves the library" means archived, not deleted. A folder can come back: - * the card is out of the library and its Archive tab entry keeps the cover, - * progress, and history, and re-adding the folder restores it in place. The - * Orphaned Books sweep in this screen stays the one path that actually - * deletes rows, and it already cascades progress, sessions, bookmarks, and - * the local_covers file. - */ - private suspend fun importAndReconcile( - libraryId: String, - scanResult: LocalLibraryScanner.ScanResult, - ) { - val books = scanResult.books.map { it.toAudioBook(libraryId) } - val seenIds = scanResult.seenBookIds() - // Captured before the import, so a book that arrived in THIS scan is - // distinguishable from one that was already in the library. - val existingIds = if (scanResult.coverageComplete) { - audioBookRepository.getLiveLocalIds(libraryId) - } else { - emptyList() - } - - audioBookRepository.importLocalBooksCarryingMoves( - libraryId = libraryId, - books = books, - existingIds = existingIds, - seenIds = seenIds, - ) - - if (!scanResult.coverageComplete) return - - archiveMissingBooks(libraryId, seenIds) - } - - /** - * The single archive choke point: before soft-deleting the books missing - * from [scannedIds], copy any still-readable content:// folder cover to - * durable storage. Both archive triggers (whole-folder Remove and the - * missing-books pass after a rescan) route through here, so a book scanned - * under an older build keeps its cover once archived — the folder is still - * accessible at this point (permission not yet released; a rescan only - * archives what a clean scan reported missing). Already-durable and - * unreadable covers are left as-is (best-effort). - */ - private suspend fun archiveMissingBooks(libraryId: String, scannedIds: List) { - audioBookRepository.persistFolderCovers(libraryId) { uri, id -> - localMetadataExtractor.persistFolderCover(uri, id) - } - audioBookRepository.removeMissingLocalBooks(libraryId, scannedIds) - } - // ─── User Actions ───────────────────────────────────────────────────── + // The server URL and username fields keep exactly what was typed. Trimming + // here ate every space as it was typed, so "john smith" could not be + // entered. Connect trims both ends instead (see [trimmedForConnect]). fun onServerUrlChanged(value: String) { - val serverUrl = value.trim() - val host = extractHost(serverUrl) + val host = extractServerHost(value) _uiState.update { it.copy( - serverUrl = serverUrl, + serverUrl = value, errorMessage = null, trustedFingerprintHost = host, hasTrustedFingerprint = host?.let { configuredHost -> @@ -870,7 +756,7 @@ class SettingsViewModel @Inject constructor( } fun onUsernameChanged(value: String) { - _uiState.update { it.copy(username = value.trim(), errorMessage = null) } + _uiState.update { it.copy(username = value, errorMessage = null) } } fun onPasswordChanged(value: String) { @@ -986,7 +872,7 @@ class SettingsViewModel @Inject constructor( } fun connect() { - val state = _uiState.value + val state = _uiState.value.trimmedForConnect() if (state.serverUrl.isBlank()) { _uiState.update { it.copy(errorMessage = "Please enter a server URL") } @@ -1033,7 +919,7 @@ class SettingsViewModel @Inject constructor( // captured before launch can be stale if the user edited the URL, // username, or token between tapping Connect and this point, which // would otherwise log in to (and persist) the wrong server. - val s = _uiState.value + val s = _uiState.value.trimmedForConnect() try { val passwordOutcome = if (s.useApiToken) { @@ -1570,15 +1456,6 @@ class SettingsViewModel @Inject constructor( } } - private fun extractHost(serverUrl: String): String? { - if (serverUrl.isBlank()) return null - return try { - URI(serverUrl).host?.lowercase() - } catch (_: Exception) { - null - } - } - // ─── Library Selection ────────────────────────────────────────────── private suspend fun loadLibraries() { @@ -1745,6 +1622,11 @@ internal fun syncNowOutcome(result: SyncNowResult): SyncNowOutcome = when (resul successMessage = null, errorMessage = "Sync did not run. Check your connection and try again.", ) + // Only a background check returns this. Sync Now always runs a full sync. + SyncAttempt.CHECKED_NOTHING_TO_RECORD -> SyncNowOutcome( + successMessage = "Library is up to date", + errorMessage = null, + ) SyncAttempt.DISCARDED_SERVER_CHANGED -> SyncNowOutcome( successMessage = null, errorMessage = "The server changed while syncing. Sync again to refresh the new server.", @@ -1858,6 +1740,28 @@ internal fun sessionIdentityForOutcome( typedServerUrl to typedUsername } +/** + * The fields Connect sends: the typed server URL and username with stray spaces + * cut from both ends. Spaces inside a username stay. The password is sent as + * typed, since a space can be part of it. + */ +internal fun SettingsViewModel.UiState.trimmedForConnect(): SettingsViewModel.UiState = + copy(serverUrl = serverUrl.trim(), username = username.trim()) + +/** + * The host a trusted certificate fingerprint belongs to, from the server URL as + * typed. Leading or trailing spaces in the field do not change the host. + */ +internal fun extractServerHost(serverUrl: String): String? { + val trimmed = serverUrl.trim() + if (trimmed.isEmpty()) return null + return try { + URI(trimmed).host?.lowercase() + } catch (_: Exception) { + null + } +} + internal enum class PasswordLoginOutcome { NEW_SESSION, RETAINED_SESSION, @@ -1982,6 +1886,64 @@ internal fun buildScanOutcome( ) } +/** + * Points both library selectors at the libraries [settings] has saved, so a + * pick made on another tab shows here and the local cleanup actions target + * it. A saved id this screen does not know yet (a list still loading, or a + * selection just cleared) leaves the current pick alone, and the list + * observers resolve it when they next emit. + */ +internal fun SettingsViewModel.UiState.withSavedSelections( + settings: AppSettings, +): SettingsViewModel.UiState = copy( + selectedLibrary = libraries.firstOrNull { it.id == settings.selectedLibraryId } ?: selectedLibrary, + selectedLocalLibrary = localLibraries.firstOrNull { it.id == settings.selectedLocalLibraryId } + ?: selectedLocalLibrary, +) + +/** + * How the app-wide folder scan shows in Settings (#57). Running clears the + * last result and shows "Scanning folder...". Finished shows the same + * summary and banners the scan showed when it ran inside this screen, and + * Add Folder also selects the library it just imported. + */ +internal fun SettingsViewModel.UiState.withLocalScan( + scan: LocalScanState, +): SettingsViewModel.UiState = when (scan) { + LocalScanState.Idle -> copy(isScanning = false) + is LocalScanState.Running -> copy(isScanning = true, lastScanMessage = null, errorMessage = null) + is LocalScanState.Finished -> { + val rescan = scan.request is LocalScanRequest.Rescan + val imported = scan.imported + if (imported == null) { + copy( + isScanning = false, + errorMessage = (if (rescan) "Rescan failed: " else "Scan failed: ") + scan.failure, + ) + } else { + val outcome = if (rescan) { + buildScanOutcome(imported.scanResult, countedAs = "found") { "Rescan complete: $it" } + } else { + buildScanOutcome(imported.scanResult, countedAs = "imported") + } + val shown = copy( + isScanning = false, + lastScanMessage = outcome.lastScanMessage, + successMessage = outcome.successMessage, + errorMessage = outcome.errorMessage, + ) + if (rescan) { + shown + } else { + shown.copy( + selectedLocalLibrary = imported.library, + inaccessibleLocalLibraryIds = inaccessibleLocalLibraryIds - imported.library.id, + ) + } + } + } +} + private fun withArchiveHint(message: String, archiveFileCount: Int): String { val hint = when (archiveFileCount) { 0 -> return message diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/ShelfBookRowTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/ShelfBookRowTest.kt new file mode 100644 index 00000000..ba1b18fc --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/ShelfBookRowTest.kt @@ -0,0 +1,117 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.data.local.converter.toDomain +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.SHELF_BOOK_COLUMNS +import com.ninelivesaudio.app.data.local.entity.ShelfBookRow +import com.ninelivesaudio.app.data.local.entity.toShelfBook +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.domain.util.toEpochMillis +import java.lang.reflect.Modifier +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The Library shelf reads light rows, and they show the same book the full row does. */ +class ShelfBookRowTest { + + private val chaptersJson = + """[{"Id":1,"Start":0.0,"End":600.0,"Title":"One"},{"Id":2,"Start":600.0,"End":1200.0,"Title":"Two"}]""" + + private val entity = AudioBookEntity( + id = "book-1", + libraryId = "lib-1", + isLocal = 0, + title = "The Long Hum", + author = "Static", + narrator = "A Voice", + description = "A very long description that the shelf never shows.", + coverPath = "https://abs.example/api/items/book-1/cover", + durationSeconds = 1200.0, + addedAt = "2026-09-01T12:00:00Z", + audioFilesJson = """[{"Id":"f1","Ino":"1","Index":1,"Duration":1200.0,"Filename":"a.mp3"}]""", + currentTimeSeconds = 700.0, + progress = 0.58, + isFinished = 0, + isDownloaded = 1, + localPath = "/storage/books/The Long Hum", + localCoverPath = "file:///storage/books/The Long Hum/cover.jpg", + archivedAt = null, + seriesName = "Hum Cycle", + seriesSequence = "2", + genresJson = """["Horror","Mystery"]""", + tagsJson = """["tag-a"]""", + chaptersJson = chaptersJson, + ) + + private fun rowOf(e: AudioBookEntity, lastPlayedAt: String?) = ShelfBookRow( + id = e.id, + libraryId = e.libraryId, + isLocal = e.isLocal, + title = e.title, + author = e.author, + narrator = e.narrator, + coverPath = e.coverPath, + durationSeconds = e.durationSeconds, + addedAt = e.addedAt, + currentTimeSeconds = e.currentTimeSeconds, + progress = e.progress, + isFinished = e.isFinished, + isDownloaded = e.isDownloaded, + localPath = e.localPath, + localCoverPath = e.localCoverPath, + archivedAt = e.archivedAt, + seriesName = e.seriesName, + seriesSequence = e.seriesSequence, + genresJson = e.genresJson, + chaptersJson = e.chaptersJson, + lastPlayedAt = lastPlayedAt, + ) + + @Test + fun `a shelf book equals the full book minus description, audio files, and tags`() { + val lastPlayed = "2026-09-20T08:30:00Z" + val full = entity.toDomain().copy(lastPlayedAt = lastPlayed.toEpochMillis()) + + val shelf = rowOf(entity, lastPlayed).toShelfBook() + + assertEquals(full.copy(description = null, audioFiles = emptyList(), tags = emptyList()), shelf) + // The row's "Ch x/y" label still works. + assertEquals("58% • Ch 2/2", shelf.progressText) + } + + @Test + fun `empty lists and nulls map the way the full row maps them`() { + val bare = entity.copy(genresJson = "[]", chaptersJson = null, author = null, addedAt = null) + val shelf = rowOf(bare, lastPlayedAt = null).toShelfBook() + val full = bare.toDomain() + + assertEquals(full.genres, shelf.genres) + assertEquals(full.chapters, shelf.chapters) + assertEquals("", shelf.author) + assertEquals(null, shelf.addedAt) + assertEquals(null, shelf.lastPlayedAt) + } + + @Test + fun `the shelf query selects every row field by name and none of the heavy columns`() { + val sql = buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = false) + assertTrue(sql.contains(SHELF_BOOK_COLUMNS)) + assertFalse(sql.contains("ab.*")) + for (heavy in listOf("Description", "AudioFilesJson", "TagsJson")) { + assertFalse("$heavy is read", sql.contains(heavy)) + } + + // Room maps a raw query by column name, so a field without a matching + // alias would read as a default. Compare names case-insensitively. + val aliases = SHELF_BOOK_COLUMNS.split(",") + .map { it.trim().substringAfter(" AS ").lowercase() } + .toSet() + val fields = ShelfBookRow::class.java.declaredFields + .filterNot { Modifier.isStatic(it.modifiers) || it.isSynthetic } + .map { it.name.lowercase() } + .toSet() + assertEquals(fields, aliases) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt new file mode 100644 index 00000000..152b7f81 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt @@ -0,0 +1,39 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.data.repository.buildLibrarySqlArgs +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** "%" and "_" typed in the Library search match themselves, not anything. */ +class SqlLikeTest { + + @Test + fun `wildcards and the escape character are escaped`() { + assertEquals("100\\%", escapeLike("100%")) + assertEquals("a\\_b", escapeLike("a_b")) + assertEquals("C:\\\\books", escapeLike("C:\\books")) + assertEquals("plain words", escapeLike("plain words")) + assertEquals("%\\%\\_%", containsLikePattern("%_")) + } + + @Test + fun `every searched column declares the escape character`() { + val sql = buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = true) + val likes = Regex("LIKE \\?").findAll(sql).count() + val escaped = Regex("LIKE \\? ESCAPE '\\\\'").findAll(sql).count() + assertEquals(4, likes) + assertEquals(likes, escaped) + } + + @Test + fun `the search binds escaped patterns, one per column`() { + val pattern = "%50\\% off\\_sale%" + assertArrayEquals( + arrayOf("lib", pattern, pattern, pattern, pattern), + buildLibrarySqlArgs("lib", "50% off_sale"), + ) + assertArrayEquals(arrayOf("lib"), buildLibrarySqlArgs("lib", " ")) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt new file mode 100644 index 00000000..c3141a69 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/migration/MigrationsTest.kt @@ -0,0 +1,59 @@ +package com.ninelivesaudio.app.data.local.migration + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Proxy + +/** + * Room refuses to open an upgraded database when a migration is missing or + * builds a different schema than the entities describe. Neither shows up until + * a real install upgrades, so these pin the chain and the 8 to 9 SQL here. + */ +class MigrationsTest { + + private val schemaDir = File("schemas/com.ninelivesaudio.app.data.local.AppDatabase") + + @Test + fun `migrations run 1 to the newest exported schema with no gaps`() { + val steps = ALL_MIGRATIONS.map { it.startVersion to it.endVersion } + val newestSchema = schemaDir.listFiles().orEmpty() + .mapNotNull { it.name.removeSuffix(".json").toIntOrNull() } + .maxOrNull() + + assertEquals((1 until 9).map { it to it + 1 }, steps) + assertEquals(newestSchema, steps.last().second) + } + + @Test + fun `8 to 9 only creates the recently played index Room expects`() { + val executed = mutableListOf() + MIGRATION_8_9.migrate(recordingDatabase(executed)) + + val expected = Regex("\"createSql\":\\s*\"(CREATE INDEX IF NOT EXISTS `idx_playback_progress_updated`[^\"]*)\"") + .find(File(schemaDir, "9.json").readText()) + ?.groupValues?.get(1) + ?.replace("\${TABLE_NAME}", "PlaybackProgress") + + assertTrue("index missing from the exported v9 schema", expected != null) + assertEquals(listOf(expected), executed) + } + + /** A database that records execSQL and fails on anything else. */ + private fun recordingDatabase(executed: MutableList): SupportSQLiteDatabase = + Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + executed += args!![0] as String + null + } + "toString" -> "RecordingDatabase" + else -> error("migration 8 to 9 should only run SQL, it called ${method.name}") + } + } as SupportSQLiteDatabase +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt new file mode 100644 index 00000000..2b487b00 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/BookmarkFetchTest.kt @@ -0,0 +1,121 @@ +package com.ninelivesaudio.app.data.remote + +import com.ninelivesaudio.app.domain.model.Bookmark +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * Opening a book used to download all of /api/me (every progress row the + * account owns) just to read that book's bookmarks, and any failure showed up + * as an empty list. These pin the narrow route first, a short-lived shared + * /api/me fallback for older servers, and failures that stay failures. + */ +class BookmarkFetchTest { + + private val a1 = Bookmark(id = "1", libraryItemId = "book-a", time = 90.0) + private val a2 = Bookmark(id = "2", libraryItemId = "book-a", time = 30.0) + private val b1 = Bookmark(id = "3", libraryItemId = "book-b", time = 10.0) + + private var clock = 0L + private val cache = MeBookmarksCache(ttlMs = 1_000L, now = { clock }) + private var meCalls = 0 + + private fun allFromMe(): RemoteResult> { + meCalls++ + return RemoteResult.Ok(listOf(a1, a2, b1)) + } + + private suspend fun load( + itemId: String, + session: String = "s1", + item: ItemBookmarksOutcome = ItemBookmarksOutcome.Unsupported, + all: suspend () -> RemoteResult> = ::allFromMe, + ) = loadItemBookmarks(itemId, session, cache, { item }, all) + + @Test + fun `a server with the per-book route never touches api me`() = runBlocking { + val result = load("book-a", item = ItemBookmarksOutcome.Found(listOf(a1, a2))) + assertEquals(RemoteResult.Ok(listOf(a2, a1)), result) + assertEquals(0, meCalls) + } + + @Test + fun `an older server falls back to api me once and shares it across books`() = runBlocking { + assertEquals(RemoteResult.Ok(listOf(a2, a1)), load("book-a")) + assertEquals(RemoteResult.Ok(listOf(b1)), load("book-b")) + assertEquals(1, meCalls) + } + + @Test + fun `the cached profile refreshes after the window`() = runBlocking { + load("book-a") + clock = 1_000L + load("book-a") + assertEquals(2, meCalls) + } + + @Test + fun `a new server or account does not reuse the last one's bookmarks`() = runBlocking { + load("book-a", session = "s1") + load("book-a", session = "s2") + assertEquals(2, meCalls) + } + + @Test + fun `adding or removing a bookmark drops the cached list`() = runBlocking { + load("book-a") + cache.invalidate() + load("book-a") + assertEquals(2, meCalls) + } + + @Test + fun `a change during the fetch is not cached as current`() = runBlocking { + load("book-a", all = { + cache.invalidate() + allFromMe() + }) + load("book-a") + assertEquals(2, meCalls) + } + + @Test + fun `a change landing between the staleness check and the store is not cached`() = runBlocking { + // The store reads the clock right after the staleness check passes. + // A bookmark change from another thread lands at exactly that moment. + lateinit var racing: MeBookmarksCache + var change: Thread? = null + racing = MeBookmarksCache(ttlMs = 1_000L, now = { + if (change == null) { + change = Thread { racing.invalidate() }.also { + it.start() + it.join(300L) + } + } + clock + }) + loadItemBookmarks("book-a", "s1", racing, { ItemBookmarksOutcome.Unsupported }, ::allFromMe) + assertTrue(change != null) + change?.join() + loadItemBookmarks("book-a", "s1", racing, { ItemBookmarksOutcome.Unsupported }, ::allFromMe) + assertEquals(2, meCalls) + } + + @Test + fun `a failed per-book call is a failure, not an empty list`() = runBlocking { + val result = load("book-a", item = ItemBookmarksOutcome.Failed("HTTP 500 loading bookmarks")) + assertEquals(RemoteResult.Failed("HTTP 500 loading bookmarks"), result) + assertEquals(0, meCalls) + } + + @Test + fun `a thrown api me fallback is a failure and is not cached`() = runBlocking { + val result = load("book-a", all = { throw IOException("timeout") }) + assertTrue(result is RemoteResult.Failed) + assertEquals(RemoteResult.Ok(listOf(a2, a1)), load("book-a")) + assertEquals(1, meCalls) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/ImageHttpClientTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/ImageHttpClientTest.kt new file mode 100644 index 00000000..121c9612 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/ImageHttpClientTest.kt @@ -0,0 +1,56 @@ +package com.ninelivesaudio.app.data.remote + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * Covers load through their own request dispatcher so a burst of them never + * holds the per-host slots that play, session and progress calls need. Auth, + * base URL rewriting, timeouts and the connection pool all carry over. + */ +class ImageHttpClientTest { + + private val auth = Interceptor { chain -> chain.proceed(chain.request()) } + private val baseUrl = Interceptor { chain -> chain.proceed(chain.request()) } + private val app = OkHttpClient.Builder() + .addInterceptor(baseUrl) + .addNetworkInterceptor(auth) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + @Test + fun `covers get their own dispatcher`() { + val images = imageLoaderHttpClient(app) + + assertNotSame(app.dispatcher, images.dispatcher) + } + + @Test + fun `cover slots do not count against API calls`() { + val images = imageLoaderHttpClient(app) + images.dispatcher.maxRequestsPerHost = 1 + + assertEquals(5, app.dispatcher.maxRequestsPerHost) + } + + @Test + fun `auth and base URL interceptors carry over`() { + val images = imageLoaderHttpClient(app) + + assertEquals(listOf(baseUrl), images.interceptors) + assertEquals(listOf(auth), images.networkInterceptors) + } + + @Test + fun `timeouts and the connection pool carry over`() { + val images = imageLoaderHttpClient(app) + + assertEquals(app.readTimeoutMillis, images.readTimeoutMillis) + assertSame(app.connectionPool, images.connectionPool) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/IncrementalFetchTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/IncrementalFetchTest.kt new file mode 100644 index 00000000..96117b93 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/IncrementalFetchTest.kt @@ -0,0 +1,134 @@ +package com.ninelivesaudio.app.data.remote + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Audiobookshelf can be asked for books newest-added first but not "updated + * since", so the incremental sync pages down until it passes the stored + * watermark. These pin where it stops, and when it refuses to guess. + */ +class IncrementalFetchTest { + + private data class Row(val id: String, val addedAt: Long?) + + private fun page(vararg rows: Row, total: Int) = PageOutcome.Page(rows.toList(), total) + + private suspend fun fetch( + cutoff: Long, + pageSize: Int = 3, + maxPages: Int = 5, + pages: (Int) -> PageOutcome, + ): RemoteResult> = runIncrementalFetch( + pageSize = pageSize, + maxPages = maxPages, + cutoff = cutoff, + addedAt = { it.addedAt }, + itemKey = { it.id }, + fetchPage = { pages(it) }, + ) + + @Test + fun `a page that crosses the watermark ends the fetch`() = runBlocking { + val requested = mutableListOf() + val result = fetch(cutoff = 100) { p -> + requested += p + when (p) { + 0 -> page(Row("e", 500), Row("d", 400), Row("c", 300), total = 10) + 1 -> page(Row("b", 200), Row("a", 90), Row("z", 80), total = 10) + else -> error("must not fetch page $p") + } + } + assertEquals(listOf(0, 1), requested) + val value = (result as RemoteResult.Ok).value + assertEquals(listOf("e", "d", "c", "b", "a", "z"), value.items.map { it.id }) + assertEquals(10, value.total) + } + + @Test + fun `a short page is the end of the list`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + if (p == 0) page(Row("b", 200), Row("a", 100), total = 2) else error("no page $p") + } + assertEquals(2, (result as RemoteResult.Ok).value.items.size) + } + + @Test + fun `a server that ignored the sort gives up instead of missing books`() = runBlocking { + val result = fetch(cutoff = 100) { page(Row("a", 300), Row("b", 900), Row("c", 50), total = 3) } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `the order is checked across pages too`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + when (p) { + 0 -> page(Row("c", 300), Row("b", 200), Row("a", 150), total = 6) + else -> page(Row("x", 160), Row("y", 140), total = 6) + } + } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `a book repeated across pages counts once and equal times are fine`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + when (p) { + 0 -> page(Row("c", 300), Row("b", 200), Row("a", 200), total = 4) + else -> page(Row("a", 200), Row("z", 100), total = 4) + } + } + assertEquals(listOf("c", "b", "a", "z"), (result as RemoteResult.Ok).value.items.map { it.id }) + } + + @Test + fun `a book with no added date gives up`() = runBlocking { + val result = fetch(cutoff = 0) { page(Row("a", null), total = 1) } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `too many pages of new books hands over to the full download`() = runBlocking { + val result = fetch(cutoff = 0, pageSize = 1, maxPages = 3) { p -> page(Row("b$p", 1000L - p), total = 50) } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `a count that moves between pages gives up`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + when (p) { + 0 -> page(Row("c", 300), Row("b", 200), Row("a", 150), total = 6) + else -> page(Row("z", 100), total = 7) + } + } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `a failed page is a failed fetch, never a partial one`() = runBlocking { + val result = fetch(cutoff = 0) { p -> + if (p == 0) page(Row("c", 300), Row("b", 200), Row("a", 150), total = 6) else PageOutcome.Stopped("HTTP 500") + } + assertTrue(result is RemoteResult.Failed) + } + + @Test + fun `an empty library comes back empty and complete`() = runBlocking { + val result = fetch(cutoff = 0) { page(total = 0) } + val value = (result as RemoteResult.Ok).value + assertEquals(0, value.items.size) + assertEquals(0, value.total) + } + + @Test + fun `page step decisions`() { + assertEquals(IncrementalPageStep.Done, incrementalPageStep(emptyList(), null, 0, 3, 0, 5)) + assertEquals(IncrementalPageStep.Continue, incrementalPageStep(listOf(9L, 8L, 7L), null, 5, 3, 0, 5)) + assertEquals(IncrementalPageStep.Done, incrementalPageStep(listOf(9L, 8L, 4L), null, 5, 3, 0, 5)) + assertEquals(IncrementalPageStep.Done, incrementalPageStep(listOf(9L, 8L), null, 5, 3, 0, 5)) + assertTrue(incrementalPageStep(listOf(9L, 8L, 7L), null, 5, 3, 4, 5) is IncrementalPageStep.GiveUp) + assertTrue(incrementalPageStep(listOf(9L, 8L, 7L), 8L, 5, 3, 1, 5) is IncrementalPageStep.GiveUp) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/PageRetryAndStreamingTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/PageRetryAndStreamingTest.kt new file mode 100644 index 00000000..f844e56f --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/PageRetryAndStreamingTest.kt @@ -0,0 +1,108 @@ +package com.ninelivesaudio.app.data.remote + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * A full download of a big library is a long run of pages against a slow + * home server. One bad page used to end it short with no second try, and + * every page sat in memory until the last one landed. + */ +class PageRetryAndStreamingTest { + + @Test + fun `a thrown network error is retried and the page still arrives`() = runBlocking { + var calls = 0 + val slept = mutableListOf() + val outcome = fetchPageWithRetry(delaysMs = listOf(10L, 20L), sleep = { slept += it }) { + calls++ + if (calls < 3) throw IOException("timeout") else PageOutcome.Page(listOf("a"), 1) + } + assertEquals(3, calls) + assertEquals(listOf(10L, 20L), slept) + assertTrue(outcome is PageOutcome.Page) + } + + @Test + fun `a retryable stop is retried, then returned as is when tries run out`() = runBlocking { + var calls = 0 + val outcome = fetchPageWithRetry(delaysMs = listOf(1L, 1L), sleep = {}) { + calls++ + PageOutcome.Stopped("HTTP 503", retryable = true) + } + assertEquals(3, calls) + assertEquals("HTTP 503", (outcome as PageOutcome.Stopped).reason) + } + + @Test + fun `a 401 is not retried`() = runBlocking { + var calls = 0 + fetchPageWithRetry(delaysMs = listOf(1L, 1L), sleep = {}) { + calls++ + PageOutcome.Stopped("HTTP 401", retryable = isRetryableHttpStatus(401)) + } + assertEquals(1, calls) + } + + @Test(expected = IOException::class) + fun `the last thrown error escapes for the paging loop to report`() { + runBlocking { + fetchPageWithRetry(delaysMs = listOf(1L), sleep = {}) { throw IOException("down") } + } + } + + @Test + fun `retryable statuses are server errors, timeouts and rate limits`() { + assertTrue(isRetryableHttpStatus(500)) + assertTrue(isRetryableHttpStatus(503)) + assertTrue(isRetryableHttpStatus(408)) + assertTrue(isRetryableHttpStatus(429)) + assertTrue(!isRetryableHttpStatus(401)) + assertTrue(!isRetryableHttpStatus(404)) + } + + @Test + fun `streaming hands each page over and reports the distinct count`() = runBlocking { + val delivered = mutableListOf>() + val result = runPaginatedFetchStreaming( + limit = 2, + maxPages = 10, + itemKey = { it }, + onPage = { delivered += it }, + ) { page -> + when (page) { + 0 -> PageOutcome.Page(listOf("a", "b"), 3) + else -> PageOutcome.Page(listOf("b", "c"), 3) + } + } + assertEquals(listOf(listOf("a", "b"), listOf("c")), delivered) + assertEquals(RemoteResult.Ok(3), result) + } + + @Test + fun `a page that fails after others were handed over is partial by count`() = runBlocking { + val delivered = mutableListOf() + val result = runPaginatedFetchStreaming( + limit = 2, + maxPages = 10, + onPage = { delivered += it }, + ) { page -> + if (page == 0) PageOutcome.Page(listOf("a", "b"), 5) else PageOutcome.Stopped("HTTP 500") + } + assertEquals(listOf("a", "b"), delivered) + assertEquals(RemoteResult.Partial(2, "HTTP 500"), result) + } + + @Test + fun `a save that throws stops the fetch short like a failed page`() = runBlocking { + val result = runPaginatedFetchStreaming( + limit = 2, + maxPages = 10, + onPage = { throw IllegalStateException("disk full") }, + ) { PageOutcome.Page(listOf("a", "b"), 4) } + assertTrue(result is RemoteResult.Failed) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/SeriesNameParserTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/SeriesNameParserTest.kt new file mode 100644 index 00000000..bf698337 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/SeriesNameParserTest.kt @@ -0,0 +1,46 @@ +package com.ninelivesaudio.app.data.remote + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeriesNameParserTest { + + @Test + fun `a single series keeps its name and numeric sequence`() { + assertEquals("Dungeon Crawler Carl" to "7", parseSeriesNameField("Dungeon Crawler Carl #7")) + assertEquals("Series Name" to "1.5", parseSeriesNameField("Series Name #1.5")) + } + + @Test + fun `a multi series book groups under its first series only`() { + assertEquals("S1" to "1", parseSeriesNameField("S1 #1, S2 #3")) + assertEquals("The Expanse" to "4", parseSeriesNameField("The Expanse #4, Space Operas #12, Hugo Nominees #2")) + } + + @Test + fun `a comma inside the series name stays in the name`() { + assertEquals("Love, Death" to "2", parseSeriesNameField("Love, Death #2")) + } + + @Test + fun `a non numeric sequence is still split from the name`() { + assertEquals("Saga" to "1-3", parseSeriesNameField("Saga #1-3")) + assertEquals("Discworld" to "Book 2", parseSeriesNameField("Discworld #Book 2")) + } + + @Test + fun `a hash inside the series name stays in the name`() { + assertEquals("C# Recipes" to "2", parseSeriesNameField("C# Recipes #2")) + } + + @Test + fun `a plain series name has no sequence`() { + assertEquals("Saga" to null, parseSeriesNameField("Saga")) + assertEquals("Saga" to null, parseSeriesNameField(" Saga ")) + } + + @Test + fun `blank input is no series`() { + assertEquals(null to null, parseSeriesNameField(" ")) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlTest.kt new file mode 100644 index 00000000..1edc0995 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/remote/ServerCoverUrlTest.kt @@ -0,0 +1,90 @@ +package com.ninelivesaudio.app.data.remote + +import com.ninelivesaudio.app.ui.components.THUMBNAIL_COVER_WIDTH_PX +import com.ninelivesaudio.app.ui.components.thumbnailCoverUrl +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Book rows store absolute cover URLs with the syncing server's host. After a + * switch between a LAN and a VPN address every cover pointed at the old host + * and failed until a full resync. Cover requests now go to the current server. + */ +class ServerCoverUrlTest { + + private var server = "https://vpn.example/abs" + + private fun sent(url: String): String { + val client = OkHttpClient.Builder() + .addInterceptor(DynamicBaseUrlInterceptor { server }) + .addInterceptor(ServerCoverUrlInterceptor { server }) + .addInterceptor { chain -> + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1) + .code(200).message("fixture").body("".toResponseBody()).build() + }.build() + return client.newCall(Request.Builder().url(url).build()).execute().use { it.request.url.toString() } + } + + @Test fun `a cover stored with the LAN address goes to the current VPN address`() { + assertEquals( + "https://vpn.example/abs/api/items/li_1/cover", + sent("http://192.168.1.5:13378/api/items/li_1/cover"), + ) + } + + @Test fun `switching back to the LAN address follows too`() { + server = "http://192.168.1.5:13378" + assertEquals( + "http://192.168.1.5:13378/api/items/li_1/cover", + sent("https://vpn.example/abs/api/items/li_1/cover"), + ) + } + + @Test fun `the list row width survives the move`() { + val stored = thumbnailCoverUrl("http://192.168.1.5:13378/api/items/li_1/cover", THUMBNAIL_COVER_WIDTH_PX) + assertEquals("https://vpn.example/abs/api/items/li_1/cover?width=240", sent(stored)) + } + + @Test fun `the Android Auto width survives the move`() { + assertEquals( + "https://vpn.example/abs/api/items/li_1/cover?width=256", + sent("http://192.168.1.5:13378/api/items/li_1/cover?width=256"), + ) + } + + @Test fun `an encoded item id stays encoded`() { + assertEquals( + "https://vpn.example/abs/api/items/a%20b/cover", + sent("http://192.168.1.5:13378/api/items/a%20b/cover"), + ) + } + + @Test fun `a cover already on the current server is untouched`() { + val url = "https://vpn.example/abs/api/items/li_1/cover?width=240" + assertEquals(url, sent(url)) + } + + @Test fun `the retrofit cover call still routes through the placeholder`() { + assertEquals("https://vpn.example/abs/api/items/li_1/cover", sent("http://localhost/api/items/li_1/cover")) + } + + @Test fun `audio files and other calls are never rewritten`() { + listOf( + "http://192.168.1.5:13378/api/items/li_1/file/3", + "http://192.168.1.5:13378/api/items/li_1", + "http://192.168.1.5:13378/cover", + "https://images.example/covers/li_1.jpg", + ).forEach { assertEquals(it, sent(it)) } + } + + @Test fun `no configured server leaves the request alone`() { + server = "" + val url = "http://192.168.1.5:13378/api/items/li_1/cover" + assertEquals(url, sent(url)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt index 8810b6fa..22f9cbe4 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibraryItemFetchSerializationTest.kt @@ -14,15 +14,26 @@ class LibraryItemFetchSerializationTest { private fun book(id: String) = AudioBook(id = id, title = id) + /** A fetch that hands its whole response over as one page, then reports how many books it had. */ + private fun pagesOf( + fetchItems: suspend () -> RemoteResult>, + ): suspend (suspend (List) -> Unit) -> RemoteResult = { onPage -> + when (val result = fetchItems()) { + is RemoteResult.Ok -> { onPage(result.value); RemoteResult.Ok(result.value.size) } + is RemoteResult.Partial -> { onPage(result.value); RemoteResult.Partial(result.value.size, result.reason) } + is RemoteResult.Failed -> result + } + } + private fun fakeSync( mutex: Mutex, cache: MutableList, fetchItems: suspend () -> RemoteResult>, - ): suspend () -> RemoteResult> = { + ): suspend () -> RemoteResult = { runSerializedLibraryItemSync( mutex = mutex, libraryId = "lib-a", - fetchItems = fetchItems, + fetchPages = pagesOf(fetchItems), mergeItems = { it }, upsertAll = { books -> books.forEach { if (it.id !in cache) cache.add(it.id) } }, cachedNonDownloadedIds = { cache.toList() }, @@ -99,7 +110,7 @@ class LibraryItemFetchSerializationTest { runSerializedLibraryItemSync( mutex = mutex, libraryId = "lib-a", - fetchItems = { RemoteResult.Ok(listOf(book("current-book"))) }, + fetchPages = pagesOf { RemoteResult.Ok(listOf(book("current-book"))) }, mergeItems = { it }, upsertAll = { books -> cache.addAll(books.map { it.id }) diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt new file mode 100644 index 00000000..ecb10df5 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncPlannerTest.kt @@ -0,0 +1,275 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.data.remote.LibraryHead +import com.ninelivesaudio.app.data.remote.RemoteResult +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.LibrarySyncWatermark +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A big library on a slow server used to download in full on every launch. + * The change check compares one tiny head request against the last good + * sync and does the least work that keeps the shelf truthful. + */ +class LibrarySyncPlannerTest { + + private val key = SyncAccountKey("https://abs.example.net", "jeff") + private val day = 24 * 60 * 60_000L + + private fun watermark( + newestAddedAt: Long? = 5_000L, + newestItemId: String? = "li_newest", + itemCount: Int = 100, + lastFullSyncAtMs: Long = 0L, + ) = LibrarySyncWatermark( + serverUrl = key.serverUrl, + username = key.username, + libraryId = "lib", + newestAddedAt = newestAddedAt, + newestItemId = newestItemId, + itemCount = itemCount, + lastFullSyncAtMs = lastFullSyncAtMs, + ) + + private fun head(total: Int = 100, id: String? = "li_newest", addedAt: Long? = 5_000L) = + LibraryHead(total = total, newestItemId = id, newestAddedAt = addedAt) + + // ─── The change check ───────────────────────────────────────────────── + + @Test + fun `same newest book and same count is no change`() { + assertEquals(LibraryChange.NoChange, decideLibraryChange(watermark(), head(), localServerRowCount = 100)) + } + + @Test + fun `a newer newest book is an incremental sync`() { + assertEquals( + LibraryChange.Incremental, + decideLibraryChange(watermark(), head(total = 103, id = "li_new", addedAt = 9_000L), 100), + ) + } + + @Test + fun `no watermark means the first sync is full`() { + assertTrue(decideLibraryChange(null, head(), 100) is LibraryChange.Full) + } + + @Test + fun `fewer books with the same newest one means a removal and a full sync`() { + assertTrue(decideLibraryChange(watermark(), head(total = 99), 100) is LibraryChange.Full) + } + + @Test + fun `the newest book gone means a full sync`() { + assertTrue(decideLibraryChange(watermark(), head(total = 99, id = "li_older", addedAt = 4_000L), 100) is LibraryChange.Full) + } + + @Test + fun `a cache missing rows the watermark promised means a full sync`() { + assertTrue(decideLibraryChange(watermark(itemCount = 100), head(), localServerRowCount = 0) is LibraryChange.Full) + } + + @Test + fun `an emptied library is a full sync so the prune can run`() { + assertTrue(decideLibraryChange(watermark(), head(total = 0, id = null, addedAt = null), 100) is LibraryChange.Full) + assertEquals( + LibraryChange.NoChange, + decideLibraryChange(watermark(newestAddedAt = null, newestItemId = null, itemCount = 0), head(0, null, null), 0), + ) + } + + @Test + fun `a head without an added date is not trusted`() { + assertTrue(decideLibraryChange(watermark(), head(addedAt = null), 100) is LibraryChange.Full) + } + + @Test + fun `a library that was empty and now has books fetches them incrementally`() { + assertEquals( + LibraryChange.Incremental, + decideLibraryChange(watermark(newestAddedAt = null, newestItemId = null, itemCount = 0), head(total = 2), 0), + ) + } + + // ─── Plans, staleness, and metered deferral ─────────────────────────── + + @Test + fun `a full sync found by a check waits for unmetered when a shelf is cached`() { + val full = LibraryChange.Full("removed") + assertEquals(LibrarySyncPlan.DEFER_FULL, planLibrarySync(full, isStale = false, isMetered = true, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.FULL, planLibrarySync(full, isStale = false, isMetered = false, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.FULL, planLibrarySync(full, isStale = false, isMetered = true, hasCachedRows = false)) + } + + @Test + fun `an explicit refresh never waits for unmetered`() { + assertTrue(shouldDeferFullSync(isMetered = true, explicit = false, hasCachedRows = true)) + assertFalse(shouldDeferFullSync(isMetered = true, explicit = true, hasCachedRows = true)) + assertFalse(shouldDeferFullSync(isMetered = false, explicit = false, hasCachedRows = true)) + } + + @Test + fun `incremental updates run on metered networks`() { + assertEquals( + LibrarySyncPlan.INCREMENTAL, + planLibrarySync(LibraryChange.Incremental, isStale = false, isMetered = true, hasCachedRows = true), + ) + } + + @Test + fun `a day-old full sync refreshes everything on unmetered and steps down on metered`() { + assertEquals(LibrarySyncPlan.FULL, planLibrarySync(LibraryChange.NoChange, isStale = true, isMetered = false, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.SKIP, planLibrarySync(LibraryChange.NoChange, isStale = true, isMetered = true, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.INCREMENTAL, planLibrarySync(LibraryChange.Incremental, isStale = true, isMetered = true, hasCachedRows = true)) + assertEquals(LibrarySyncPlan.SKIP, planLibrarySync(LibraryChange.NoChange, isStale = false, isMetered = false, hasCachedRows = true)) + } + + @Test + fun `staleness is a day, and a clock that went backward counts as stale`() { + val w = watermark(lastFullSyncAtMs = 10 * day) + assertFalse(isWatermarkStale(w, nowMs = 10 * day + day - 1)) + assertTrue(isWatermarkStale(w, nowMs = 11 * day)) + assertTrue(isWatermarkStale(w, nowMs = 9 * day)) + } + + // ─── The added-since fetch and its deletion check ───────────────────── + + @Test + fun `the cutoff reaches one minute before the stored newest book`() { + assertEquals(5_000L - 60_000L, incrementalCutoff(watermark(newestAddedAt = 5_000L))) + assertEquals(Long.MIN_VALUE, incrementalCutoff(watermark(newestAddedAt = null))) + } + + @Test + fun `counts that disagree after the added books mean something was removed`() { + assertTrue(incrementalCountsAgree(previousCount = 100, newBookCount = 3, serverTotal = 103)) + assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = 3, serverTotal = 102)) + assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = 1, serverTotal = 100)) + } + + @Test + fun `a retry after one removed and one added still disagrees when the added book is already cached`() { + // Server deleted one book and added li_new, keeping 100. The first + // check saved li_new and deferred. The retry finds it cached. + val fetched = listOf(AudioBook(id = "li_new", addedAt = 9_000L), AudioBook(id = "li_newest", addedAt = 5_000L)) + val newCount = newBooksSinceWatermark(watermark(), fetched, alreadyCached = setOf("li_new", "li_newest")) + assertEquals(1, newCount) + assertFalse(incrementalCountsAgree(previousCount = 100, newBookCount = newCount!!, serverTotal = 100)) + } + + @Test + fun `a book inside the overlap window is new unless the watermark counted it`() { + val overlap = listOf(AudioBook(id = "li_late", addedAt = 4_990L)) + val counted = watermark().copy(overlapItemIds = listOf("li_late", "li_newest")) + val notCounted = watermark().copy(overlapItemIds = listOf("li_newest")) + // Cache presence no longer decides it, either way round. + assertEquals(0, newBooksSinceWatermark(counted, overlap, alreadyCached = setOf("li_late"))) + assertEquals(0, newBooksSinceWatermark(counted, overlap, alreadyCached = emptySet())) + assertEquals(1, newBooksSinceWatermark(notCounted, overlap, alreadyCached = setOf("li_late"))) + assertEquals(1, newBooksSinceWatermark(notCounted, overlap, alreadyCached = emptySet())) + // A watermark from before the list cannot tell a cached one apart. + assertNull(newBooksSinceWatermark(watermark(), overlap, alreadyCached = setOf("li_late"))) + assertEquals(1, newBooksSinceWatermark(watermark(), overlap, alreadyCached = emptySet())) + } + + @Test + fun `an already cached book stamped just before the newest cannot hide a removal`() { + // Watermark: 100 books, newest li_newest at 5_000. A delayed scan adds + // li_early stamped at 4_990 and li_new at 9_000, and removes li_gone, + // so the server has 101. A progress pull cached li_early first. + val tally = FullSyncTally().apply { + add((1..98).map { AudioBook(id = "li_$it", addedAt = it.toLong() * -100_000L) }) + add(listOf(AudioBook(id = "li_gone", addedAt = 4_000L), AudioBook(id = "li_newest", addedAt = 5_000L))) + } + val stored = watermarkAfterFullSync(key, "lib", tally, RemoteResult.Ok(100), nowMs = 1L)!! + assertEquals(setOf("li_gone", "li_newest"), stored.overlapItemIds!!.toSet()) + val fetched = listOf( + AudioBook(id = "li_new", addedAt = 9_000L), + AudioBook(id = "li_newest", addedAt = 5_000L), + AudioBook(id = "li_early", addedAt = 4_990L), + AudioBook(id = "li_1", addedAt = -100_000L), + ) + val newCount = newBooksSinceWatermark(stored, fetched, alreadyCached = setOf("li_newest", "li_early", "li_1")) + assertEquals(2, newCount) + assertFalse(incrementalCountsAgree(stored.itemCount, newCount!!, serverTotal = 101)) + } + + @Test + fun `an incremental sync records the books in its new overlap window`() { + val fetched = listOf( + AudioBook(id = "li_new", addedAt = 70_000L), + AudioBook(id = "li_near", addedAt = 30_000L), + AudioBook(id = "li_newest", addedAt = 5_000L), + ) + val next = watermarkAfterIncremental(watermark(), fetched, serverTotal = 102, nowMs = 50L) + assertEquals(listOf("li_new", "li_near"), next.overlapItemIds) + } + + @Test + fun `an incremental sync moves the watermark to the server's newest book`() { + val fetched = listOf(AudioBook(id = "li_new", addedAt = 9_000L), AudioBook(id = "li_newest", addedAt = 5_000L)) + val next = watermarkAfterIncremental(watermark(lastFullSyncAtMs = 7L), fetched, serverTotal = 101, nowMs = 50L) + assertEquals(9_000L, next.newestAddedAt) + assertEquals("li_new", next.newestItemId) + assertEquals(101, next.itemCount) + assertEquals(7L, next.lastFullSyncAtMs) + } + + @Test + fun `a full sync tally finds the newest book and refuses a watermark without added dates`() { + val tally = FullSyncTally() + tally.add(listOf(AudioBook(id = "a", addedAt = 3L), AudioBook(id = "b", addedAt = 9L))) + tally.add(listOf(AudioBook(id = "b", addedAt = 9L), AudioBook(id = "c", addedAt = 5L))) + val w = watermarkAfterFullSync(key, "lib", tally, RemoteResult.Ok(3), nowMs = 42L)!! + assertEquals(3, w.itemCount) + assertEquals("b", w.newestItemId) + assertEquals(9L, w.newestAddedAt) + assertEquals(42L, w.lastFullSyncAtMs) + + val missing = FullSyncTally().apply { add(listOf(AudioBook(id = "x", addedAt = null))) } + assertNull(watermarkAfterFullSync(key, "lib", missing, RemoteResult.Ok(1), nowMs = 42L)) + } + + @Test + fun `a full download that stops short forgets the watermark so the next check downloads again`() { + val tally = FullSyncTally().apply { add(listOf(AudioBook(id = "a", addedAt = 3L))) } + assertNull(watermarkAfterFullSync(key, "lib", tally, RemoteResult.Partial(1, "page 2: HTTP 500"), nowMs = 42L)) + assertNull(watermarkAfterFullSync(key, "lib", tally, RemoteResult.Failed("timeout"), nowMs = 42L)) + // With no watermark the next check cannot answer "nothing new". + assertTrue(decideLibraryChange(null, head(), localServerRowCount = 100) is LibraryChange.Full) + } + + @Test + fun `watermarks are kept per server, account, and library`() { + val mine = watermark() + val other = mine.copy(username = "guest", itemCount = 5) + val list = emptyList().withWatermark(mine).withWatermark(other) + assertEquals(100, list.watermarkFor(key, "lib")!!.itemCount) + assertEquals(5, list.watermarkFor(key.copy(username = "guest"), "lib")!!.itemCount) + assertNull(list.watermarkFor(key.copy(serverUrl = "https://other"), "lib")) + assertEquals(1, list.withoutWatermark(key, "lib").size) + val capped = (1..10).fold(emptyList()) { acc, i -> + acc.withWatermark(mine.copy(libraryId = "lib$i", updatedAtMs = i.toLong()), cap = 3) + } + assertEquals(listOf("lib10", "lib9", "lib8"), capped.map { it.libraryId }) + } + + // ─── Failure backoff ────────────────────────────────────────────────── + + @Test + fun `background full downloads back off after failures and cap at four hours`() { + val minute = 60_000L + assertEquals(15 * minute, fullSyncBackoffMs(1)) + assertEquals(30 * minute, fullSyncBackoffMs(2)) + assertEquals(4 * 60 * minute, fullSyncBackoffMs(20)) + val failures = FullSyncFailures(count = 1, lastFailureAtMs = 1_000L) + assertTrue(isFullSyncBackedOff(failures, nowMs = 1_000L + 14 * minute)) + assertFalse(isFullSyncBackedOff(failures, nowMs = 1_000L + 15 * minute)) + assertFalse(isFullSyncBackedOff(null, nowMs = 0L)) + assertFalse(isFullSyncBackedOff(failures, nowMs = 0L)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt new file mode 100644 index 00000000..c961095c --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/LibrarySyncSingleFlightTest.kt @@ -0,0 +1,369 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.data.remote.RemoteResult +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +/** + * Sign-in's sync and the Library's first load each downloaded the whole + * library, one after the other (214 s instead of 107 s on a 51-page shelf). + * A request that arrives while a sync of the same library runs now shares it. + */ +class LibrarySyncSingleFlightTest { + + private fun flightScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Test + fun `a second request while a sync runs shares its result and fetches nothing`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val fetches = AtomicInteger() + val started = CompletableDeferred() + val release = CompletableDeferred() + val fetcher: suspend () -> Int = { + fetches.incrementAndGet() + started.complete(Unit) + release.await() + 51 + } + + val first = async { flights.run("lib", LibrarySyncKind.FULL, fetcher) } + started.await() + val second = async { flights.run("lib", LibrarySyncKind.FULL, fetcher) } + val check = async { flights.run("lib", LibrarySyncKind.CHECK, fetcher) } + yield() + release.complete(Unit) + + assertEquals(51, first.await()) + assertEquals(51, second.await()) + assertEquals(51, check.await()) + assertEquals(1, fetches.get()) + scope.cancel() + } + + @Test + fun `a full refresh asked for during a check waits and then runs its own`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val order = mutableListOf() + val checkStarted = CompletableDeferred() + val releaseCheck = CompletableDeferred() + + val check = async { + flights.run("lib", LibrarySyncKind.CHECK) { + checkStarted.complete(Unit) + releaseCheck.await() + synchronized(order) { order += "check" } + "unchanged" + } + } + checkStarted.await() + val full = async { + flights.run("lib", LibrarySyncKind.FULL) { + synchronized(order) { order += "full" } + "downloaded" + } + } + yield() + assertFalse(full.isCompleted) + releaseCheck.complete(Unit) + + assertEquals("unchanged", check.await()) + assertEquals("downloaded", full.await()) + assertEquals(listOf("check", "full"), order) + scope.cancel() + } + + @Test + fun `a check that turns into a full download is shared with a full refresh already waiting`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val fetches = AtomicInteger() + val checkStarted = CompletableDeferred() + val upgraded = CompletableDeferred() + val releaseDownload = CompletableDeferred() + + val check = async { + flights.run("lib", LibrarySyncKind.CHECK) { + checkStarted.complete(Unit) + upgraded.await() + // No watermark: the check decides on a full download. + flights.upgradeToFull("lib") + fetches.incrementAndGet() + releaseDownload.await() + "51 pages" + } + } + checkStarted.await() + val signIn = async { flights.run("lib", LibrarySyncKind.FULL) { fetches.incrementAndGet(); "second download" } } + yield() + upgraded.complete(Unit) + releaseDownload.complete(Unit) + + assertEquals("51 pages", check.await()) + assertEquals("51 pages", signIn.await()) + assertEquals(1, fetches.get()) + scope.cancel() + } + + @Test + fun `different libraries do not share`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val a = async { flights.run("a", LibrarySyncKind.FULL) { "a" } } + val b = async { flights.run("b", LibrarySyncKind.FULL) { "b" } } + assertEquals("a", a.await()) + assertEquals("b", b.await()) + scope.cancel() + } + + @Test + fun `a new account never joins the previous account's sync of the same library`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val server = "https://abs.example.net" + val accountA = LibrarySyncIdentity(SyncAccountKey(server, "alice"), sessionHash = 11) + val accountB = LibrarySyncIdentity(SyncAccountKey(server, "bob"), sessionHash = 22) + // Two token sign-ins keep an empty username and differ only by session. + val tokenA = LibrarySyncIdentity(SyncAccountKey(server, ""), sessionHash = 33) + val tokenB = LibrarySyncIdentity(SyncAccountKey(server, ""), sessionHash = 44) + val started = CompletableDeferred() + val release = CompletableDeferred() + + val slowA = async { + flights.run(accountA.flightKey("lib"), LibrarySyncKind.FULL) { + started.complete(Unit) + release.await() + "alice's catalog" + } + } + started.await() + try { + // Joining alice's flight would wait on her download, so time out. + val bob = withTimeout(5_000) { + flights.run(accountB.flightKey("lib"), LibrarySyncKind.FULL) { "bob's catalog" } + } + assertEquals("bob's catalog", bob) + assertFalse(slowA.isCompleted) + } finally { + release.complete(Unit) + } + assertEquals("alice's catalog", slowA.await()) + assertTrue(tokenA.flightKey("lib") != tokenB.flightKey("lib")) + scope.cancel() + } + + @Test + fun `a download that outlives its account stops writing and prunes nothing`() = runBlocking { + val cache = mutableListOf("bob-book") + var signedIn = "alice" + val result = runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "alice-1"))) + signedIn = "bob" + onPage(listOf(AudioBook(id = "alice-2"))) + RemoteResult.Ok(2) + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { cache += it.id } }, + cachedNonDownloadedIds = { cache.toList() }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + writeIfCurrent = { write -> if (signedIn == "alice") { write(); true } else false }, + ) + assertEquals(RemoteResult.Failed(SYNC_ACCOUNT_CHANGED), result) + // Page one landed before the switch. Nothing after it, and bob's + // book was not pruned by alice's catalog. + assertEquals(listOf("bob-book", "alice-1"), cache) + } + + @Test + fun `a sign-in waits for a prune already past its account check`() = runBlocking { + // Alice's prune passes the account check, then suspends reading the + // cached ids. Bob signs in and his sync saves a book. When alice + // resumes, her prune must not delete it: the sign-in has to wait + // for the whole prune, not just for the check in front of it. + val authLock = Mutex() + var signedIn = "alice" + val cache = mutableListOf("shared", "alice-gone") + val pruneReading = CompletableDeferred() + val releasePrune = CompletableDeferred() + val gate: suspend (suspend () -> Unit) -> Boolean = { write -> + authLock.withLock { if (signedIn == "alice") { write(); true } else false } + } + val alice = async { + runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "shared"))) + RemoteResult.Ok(1) + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { if (it.id !in cache) cache += it.id } }, + cachedNonDownloadedIds = { + pruneReading.complete(Unit) + releasePrune.await() + cache.toList() + }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + writeIfCurrent = gate, + ) + } + pruneReading.await() + val bob = launch { + authLock.withLock { signedIn = "bob" } + cache += "bob-book" + } + // Give bob every chance to get in while alice is suspended. + withTimeoutOrNull(300) { bob.join() } + releasePrune.complete(Unit) + assertEquals(RemoteResult.Ok(1), alice.await()) + bob.join() + assertEquals(listOf("shared", "bob-book"), cache) + } + + @Test + fun `a caller that leaves does not cancel the sync others are waiting on`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val started = CompletableDeferred() + val release = CompletableDeferred() + val saved = AtomicInteger() + + val leaving = launch { + flights.run("lib", LibrarySyncKind.FULL) { + started.complete(Unit) + release.await() + saved.incrementAndGet() + 7 + } + } + started.await() + val staying = async { flights.run("lib", LibrarySyncKind.CHECK) { error("must join, not fetch") } } + yield() + leaving.cancelAndJoin() + release.complete(Unit) + + assertEquals(7, staying.await()) + assertEquals(1, saved.get()) + scope.cancel() + } + + @Test + fun `a failed sync fails every waiter and the next request starts fresh`() = runBlocking { + val scope = flightScope() + val flights = LibrarySyncSingleFlight(scope) + val failure = runCatching { flights.run("lib", LibrarySyncKind.FULL) { throw IllegalStateException("boom") } } + assertTrue(failure.exceptionOrNull() is IllegalStateException) + assertEquals(3, flights.run("lib", LibrarySyncKind.FULL) { 3 }) + scope.cancel() + } + + @Test + fun `joining rules`() { + assertTrue(canJoinInFlight(LibrarySyncKind.FULL, LibrarySyncKind.FULL)) + assertTrue(canJoinInFlight(LibrarySyncKind.FULL, LibrarySyncKind.CHECK)) + assertTrue(canJoinInFlight(LibrarySyncKind.CHECK, LibrarySyncKind.CHECK)) + assertFalse(canJoinInFlight(LibrarySyncKind.CHECK, LibrarySyncKind.FULL)) + } + + @Test + fun `pages are saved as they arrive and only a complete fetch prunes`() = runBlocking { + val cache = mutableListOf("stale", "kept") + val savedPages = mutableListOf>() + val complete = runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "kept"), AudioBook(id = "new1"))) + onPage(listOf(AudioBook(id = "new2"))) + RemoteResult.Ok(3) + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { if (it.id !in cache) cache += it.id } }, + cachedNonDownloadedIds = { cache.toList() }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + onPageSaved = { page -> savedPages += page.map { it.id } }, + ) + assertEquals(RemoteResult.Ok(3), complete) + assertEquals(listOf(listOf("kept", "new1"), listOf("new2")), savedPages) + assertEquals(setOf("kept", "new1", "new2"), cache.toSet()) + + val partialCache = mutableListOf("stale") + runLibraryItemSyncPass( + libraryId = "lib", + fetchPages = { onPage -> + onPage(listOf(AudioBook(id = "new1"))) + RemoteResult.Partial(1, "page 1: HTTP 500") + }, + mergeItems = { it }, + upsertAll = { books -> books.forEach { partialCache += it.id } }, + cachedNonDownloadedIds = { partialCache.toList() }, + deleteByIds = { _, ids -> partialCache.removeAll(ids) }, + deleteAllServerBooks = { partialCache.clear() }, + ) + assertEquals(listOf("stale", "new1"), partialCache) + } + + @Test + fun `a stale response still cannot resurrect a pruned book once flights serialize behind the lock`() = runBlocking { + // A full refresh that could not join a running check queues behind it + // on the library lock, so its newer prune lands after the older save. + val scope = flightScope() + val flights = LibrarySyncSingleFlight>(scope) + val lock = Mutex() + val cache = mutableListOf("book-a", "book-b") + val oldStarted = CompletableDeferred() + val releaseOld = CompletableDeferred() + fun pass(books: List, gate: suspend () -> Unit): suspend () -> RemoteResult = { + runSerializedLibraryItemSync( + mutex = lock, + libraryId = "lib", + fetchPages = { onPage -> + gate() + onPage(books.map { AudioBook(id = it) }) + RemoteResult.Ok(books.size) + }, + mergeItems = { it }, + upsertAll = { list -> list.forEach { if (it.id !in cache) cache += it.id } }, + cachedNonDownloadedIds = { cache.toList() }, + deleteByIds = { _, ids -> cache.removeAll(ids) }, + deleteAllServerBooks = { cache.clear() }, + ) + } + val old = async { + flights.run("lib", LibrarySyncKind.CHECK, pass(listOf("book-a", "book-b")) { + oldStarted.complete(Unit) + releaseOld.await() + }) + } + oldStarted.await() + val newer = async { flights.run("lib", LibrarySyncKind.FULL, pass(listOf("book-a")) {}) } + yield() + releaseOld.complete(Unit) + old.await() + newer.await() + assertEquals(listOf("book-a"), cache) + scope.cancel() + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt index 4bedfc01..d0d7658c 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/SyncMergeTest.kt @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.data.repository import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.AudioFile @@ -132,6 +133,103 @@ class SyncMergeTest { assertEquals(remote.chapters, merged.chapters) } + @Test + fun `the lean merge reads whole rows only for downloaded books missing tracks`() = kotlinx.coroutines.runBlocking { + val downloaded = downloadedDetailedBook().toEntity() + val plain = localEntity(isDownloaded = 0, localPath = null, localCoverPath = null, currentTimeSeconds = 50.0, progress = 0.5) + .copy(id = "2") + val fullRowLookups = mutableListOf>() + + val merged = mergeSyncedBooksLean( + remote = listOf( + AudioBook(id = "1", title = "Server title"), + AudioBook(id = "2", title = "Other"), + AudioBook(id = "3", title = "New"), + ), + getMergeStates = { ids -> listOf(downloaded, plain).filter { it.id in ids }.map { it.toSyncMergeState() } }, + getFullRows = { ids -> fullRowLookups += ids; listOf(downloaded).filter { it.id in ids } }, + getProgressRows = { emptyList() }, + ) + + assertEquals(listOf(listOf("1")), fullRowLookups) + assertEquals(downloadedDetailedBook().audioFiles, merged[0].audioFiles) + assertEquals(downloadedDetailedBook().chapters, merged[0].chapters) + assertTrue(merged[0].isDownloaded) + assertEquals(50.0, merged[1].currentTime.inWholeMilliseconds / 1000.0, 0.0001) + assertEquals(AudioBook(id = "3", title = "New"), merged[2]) + } + + @Test + fun `a book new to the cache takes the progress the pull already saved for it`() = kotlinx.coroutines.runBlocking { + // The library list carries no progress. The pull fetched only 12 + // unknown books one by one, and saved rows for these two anyway. + val progressLookups = mutableListOf>() + val merged = mergeSyncedBooksLean( + remote = listOf( + AudioBook(id = "listening", duration = 1000.seconds), + AudioBook(id = "done", duration = 500.seconds), + AudioBook(id = "untouched", duration = 300.seconds), + ), + getMergeStates = { emptyList() }, + getFullRows = { emptyList() }, + getProgressRows = { ids -> + progressLookups += ids + listOf( + PlaybackProgressEntity(audioBookId = "listening", positionSeconds = 250.0), + PlaybackProgressEntity(audioBookId = "done", positionSeconds = 0.0, isFinished = 1), + ).filter { it.audioBookId in ids } + }, + ) + + assertEquals(listOf(listOf("listening", "done", "untouched")), progressLookups) + assertEquals(250.0, merged[0].currentTime.inWholeMilliseconds / 1000.0, 0.0001) + assertEquals(0.25, merged[0].progress, 0.0001) + assertFalse(merged[0].isFinished) + assertTrue(merged[1].isFinished) + assertEquals(1.0, merged[1].progress, 0.0001) + assertEquals(AudioBook(id = "untouched", duration = 300.seconds), merged[2]) + } + + @Test + fun `a later refresh keeps a completion imported at position zero`() = kotlinx.coroutines.runBlocking { + // First sync: the pull saved "done" finished at zero, so the new row + // lands finished. Pull to refresh lists it again with the list's + // unfinished defaults, and now the row exists so the merge runs. + val listed = AudioBook(id = "done", duration = 500.seconds) + val first = mergeSyncedBooksLean( + remote = listOf(listed), + getMergeStates = { emptyList() }, + getFullRows = { emptyList() }, + getProgressRows = { listOf(PlaybackProgressEntity(audioBookId = "done", positionSeconds = 0.0, isFinished = 1)) }, + ).single() + val cached = first.toEntity() + + val refreshed = mergeSyncedBooksLean( + remote = listOf(listed), + getMergeStates = { listOf(cached.toSyncMergeState()) }, + getFullRows = { listOf(cached) }, + getProgressRows = { error("a cached book does not read pulled progress") }, + ).single() + + assertTrue(refreshed.isFinished) + assertEquals(1.0, refreshed.progress, 0.0001) + // Progress 1.0 alone counts as done too. + val doneByProgress = mergeSyncedBook(listed, localEntity(isDownloaded = 0, progress = 1.0)) + assertEquals(1.0, doneByProgress.progress, 0.0001) + } + + @Test + fun `the lean merge matches the whole-row merge`() { + val local = localEntity(currentTimeSeconds = 90.0, progress = 0.4, isFinished = 0, archivedAt = 7L) + val remote = AudioBook( + id = "1", + title = "Server", + audioFiles = listOf(AudioFile(id = "a", index = 0, filename = "a.mp3")), + chapters = listOf(Chapter(id = 1, start = 0.0, end = 5.0, title = "One")), + ) + assertEquals(mergeSyncedBook(remote, local), mergeSyncedBook(remote, local.toSyncMergeState(), detail = null)) + } + private fun downloadedDetailedBook() = AudioBook( id = "1", title = "Downloaded title", diff --git a/app/src/test/java/com/ninelivesaudio/app/domain/util/CooperativeMapTest.kt b/app/src/test/java/com/ninelivesaudio/app/domain/util/CooperativeMapTest.kt new file mode 100644 index 00000000..56a3be7d --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/domain/util/CooperativeMapTest.kt @@ -0,0 +1,38 @@ +package com.ninelivesaudio.app.domain.util + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.coroutines.coroutineContext + +/** A cancelled shelf build stops decoding instead of running to the end. */ +class CooperativeMapTest { + + @Test + fun `maps every item when nothing cancels`() = runBlocking { + val out = (1..1_000).toList().mapCooperatively { it * 2 } + assertEquals((1..1_000).map { it * 2 }, out) + } + + @Test + fun `a cancel mid-loop stops at the next check`() = runBlocking { + var mapped = 0 + val build = async(Dispatchers.Default) { + (0 until 10_000).toList().mapCooperatively { item -> + if (item == 10) coroutineContext.job.cancel() + mapped++ + item + } + } + val error = runCatching { build.await() }.exceptionOrNull() + + assertTrue(error is CancellationException) + // Items 0 to 255 run, the check before item 256 throws. + assertEquals(COOPERATIVE_CHECK_EVERY, mapped) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt new file mode 100644 index 00000000..5acd9ee7 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/AutoBrowsePageTest.kt @@ -0,0 +1,107 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Android Auto's Library and Downloaded lists used to load, decode and sort + * the whole library, then keep 50. They now hand SQL a LIMIT and OFFSET, so + * these pin what gets asked for. + */ +class AutoBrowsePageTest { + + private val server = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "lib") + + private data class Ask(val libraryId: String, val isLocal: Boolean, val limit: Int, val offset: Int) + + private suspend fun ask(settings: AppSettings, page: Int, pageSize: Int): Ask? { + var asked: Ask? = null + autoBrowsePage(settings, page, pageSize) { libraryId, isLocal, limit, offset -> + asked = Ask(libraryId, isLocal, limit, offset) + emptyList() + } + return asked + } + + @Test + fun `a page asks SQL for exactly that page`() = runBlocking { + assertEquals(Ask("lib", false, 50, 0), ask(server, page = 0, pageSize = 50)) + assertEquals(Ask("lib", false, 50, 150), ask(server, page = 3, pageSize = 50)) + } + + @Test + fun `local mode pages the local library`() = runBlocking { + val local = AppSettings( + appMode = AppMode.LOCAL, + selectedLibraryId = "server-lib", + selectedLocalLibraryId = "local-lib", + ) + assertEquals(Ask("local-lib", true, 20, 20), ask(local, page = 1, pageSize = 20)) + } + + @Test + fun `a browser that does not page still reads a bounded page`() = runBlocking { + assertEquals( + Ask("lib", false, AUTO_BROWSE_MAX_PAGE_SIZE, 0), + ask(server, page = 0, pageSize = Int.MAX_VALUE), + ) + } + + @Test + fun `an oversized page never skips books past the cap`() = runBlocking { + // Capping the limit but not the offset used to read books 1 to 500 + // then 1001 to 1500, so 501 to 1000 were never listed. + assertEquals(Ask("lib", false, AUTO_BROWSE_MAX_PAGE_SIZE, 0), ask(server, page = 0, pageSize = 1000)) + assertNull(ask(server, page = 1, pageSize = 1000)) + } + + @Test + fun `nonsense paging reads nothing`() = runBlocking { + assertNull(ask(server, page = -1, pageSize = 50)) + assertNull(ask(server, page = 0, pageSize = 0)) + assertNull(ask(server, page = Int.MAX_VALUE, pageSize = Int.MAX_VALUE)) + } + + @Test + fun `no active library reads nothing`() = runBlocking { + assertNull(ask(AppSettings(appMode = AppMode.AUDIOBOOKSHELF), page = 0, pageSize = 50)) + } + + @Test + fun `the page comes back as SQL returned it`() = runBlocking { + val rows = autoBrowsePage(server, page = 0, pageSize = 2) { _, _, limit, _ -> + listOf("b", "a", "c").take(limit) + } + assertEquals(listOf("b", "a"), rows) + } + + @Test + fun `a light row keeps the first genre and its covers`() { + val item = AutoBrowseRow( + id = "x", + title = "Title", + author = null, + narrator = "Reader", + coverPath = "https://abs.test/api/items/x/cover", + localCoverPath = "file:///covers/x.jpg", + genresJson = """["Mystery","Noir"]""", + ).toAutoBookItem() + + assertEquals("Mystery", item.genre) + assertEquals("", item.author) + assertEquals("file:///covers/x.jpg", item.effectiveCoverPath) + } + + @Test + fun `broken or empty genres read as none`() { + assertNull(AutoBrowseRow(id = "x", title = "T", genresJson = "not json").toAutoBookItem().genre) + assertNull(AutoBrowseRow(id = "x", title = "T", genresJson = "[]").toAutoBookItem().genre) + assertTrue(AutoBrowseRow(id = "x", title = "T").toAutoBookItem().genre == null) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt new file mode 100644 index 00000000..ceb22688 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt @@ -0,0 +1,99 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Auto search used to scan every library, return the whole catalog for an + * empty query, and build a row and fetch a full-size cover for every match on + * every page request. It now asks SQL for one capped page in one library. + */ +class AutoSearchTest { + + private val server = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "lib") + + private data class Ask(val libraryId: String, val isLocal: Boolean, val pattern: String, val limit: Int, val offset: Int) + + private suspend fun ask(settings: AppSettings, query: String, page: Int, pageSize: Int): Ask? { + var asked: Ask? = null + autoSearchPage(settings, query, page, pageSize) { libraryId, isLocal, pattern, limit, offset -> + asked = Ask(libraryId, isLocal, pattern, limit, offset) + emptyList() + } + return asked + } + + @Test + fun `the query becomes a contains pattern that matches wildcards literally`() { + assertEquals("%dune%", autoSearchPattern(" dune ")) + assertEquals("%100\\%%", autoSearchPattern("100%")) + assertEquals("%a\\_b%", autoSearchPattern("a_b")) + assertEquals("%c:\\\\x%", autoSearchPattern("c:\\x")) + } + + @Test + fun `a blank query searches nothing`() = runBlocking { + assertNull(autoSearchPattern("")) + assertNull(autoSearchPattern(" ")) + assertNull(ask(server, "", page = 0, pageSize = 50)) + assertEquals(0, autoSearchCount(server, " ") { _, _, _, _ -> error("must not count") }) + } + + @Test + fun `search stays in the active library and source`() = runBlocking { + val local = AppSettings( + appMode = AppMode.LOCAL, + selectedLibraryId = "server-lib", + selectedLocalLibraryId = "local-lib", + ) + assertEquals(Ask("lib", false, "%x%", 20, 0), ask(server, "x", page = 0, pageSize = 20)) + assertEquals(Ask("local-lib", true, "%x%", 20, 0), ask(local, "x", page = 0, pageSize = 20)) + assertNull(ask(AppSettings(appMode = AppMode.AUDIOBOOKSHELF), "x", page = 0, pageSize = 20)) + } + + @Test + fun `pages stop at the result cap`() = runBlocking { + assertEquals(Ask("lib", false, "%a%", 50, 50), ask(server, "a", page = 1, pageSize = 50)) + assertEquals(Ask("lib", false, "%a%", 40, 60), ask(server, "a", page = 1, pageSize = 60)) + assertNull(ask(server, "a", page = 2, pageSize = 50)) + assertEquals( + Ask("lib", false, "%a%", AUTO_SEARCH_RESULT_CAP, 0), + ask(server, "a", page = 0, pageSize = Int.MAX_VALUE), + ) + assertNull(ask(server, "a", page = -1, pageSize = 50)) + assertNull(ask(server, "a", page = 0, pageSize = 0)) + } + + @Test + fun `the count never promises more than the cap`() = runBlocking { + var askedCap = -1 + val count = autoSearchCount(server, "a") { libraryId, isLocal, pattern, cap -> + assertEquals("lib", libraryId) + assertEquals(false, isLocal) + assertEquals("%a%", pattern) + askedCap = cap + 5_000 + } + assertEquals(AUTO_SEARCH_RESULT_CAP, askedCap) + assertEquals(AUTO_SEARCH_RESULT_CAP, count) + assertEquals(7, autoSearchCount(server, "a") { _, _, _, _ -> 7 }) + } + + @Test + fun `Auto asks the server for browse-size covers`() { + assertEquals( + "https://abs.test/api/items/x/cover?width=256", + autoArtworkRemoteUrl("https://abs.test/api/items/x/cover", 256), + ) + assertEquals( + "https://abs.test/api/items/x/cover?ts=1&width=256", + autoArtworkRemoteUrl("https://abs.test/api/items/x/cover?ts=1", 256), + ) + assertNull(autoArtworkRemoteUrl("file:///covers/x.jpg", 256)) + assertNull(autoArtworkRemoteUrl(null, 256)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt new file mode 100644 index 00000000..b7852212 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundNetworkRecoveryTest.kt @@ -0,0 +1,137 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.domain.model.SyncResult +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A network change while the app sat in the background (Android blocks its + * network there) used to leave it stuck "Offline" on return, with a streaming + * user's Library flipped to Downloaded only, until another network change or + * a manual reconnect tap. + */ +class ForegroundNetworkRecoveryTest { + + @Test + fun `an onLost for the old network does not mark the app offline while another is up`() { + // Backgrounded and network-blocked: the OS default reads null, but the + // new Wi-Fi the callbacks reported is still there. + assertTrue(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = listOf(true))) + assertTrue(networkStateSaysOnline(defaultHasInternet = true, knownNetworksHaveInternet = emptyList())) + } + + @Test + fun `no network anywhere is offline`() { + assertFalse(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = emptyList())) + assertFalse(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = listOf(false))) + } + + @Test + fun `a VPN that went away does not keep the app online after every real network drops`() { + // The callbacks never report a VPN, so they can never report it lost. + val known = mutableMapOf() + known["wifi"] = true // onAvailable + // Tailscale is the OS default while it runs. + if (defaultNetworkIsRemembered(hasInternet = true, isVpn = true)) known["tailscale"] = true + assertTrue( + "a connected VPN default still counts", + networkStateSaysOnline(defaultHasInternet = true, knownNetworksHaveInternet = known.values), + ) + // The VPN disconnects (no callback), then Wi-Fi drops (onLost). + known.remove("wifi") + assertFalse(networkStateSaysOnline(defaultHasInternet = false, knownNetworksHaveInternet = known.values)) + } + + @Test + fun `a real default network is remembered`() { + assertTrue(defaultNetworkIsRemembered(hasInternet = true, isVpn = false)) + assertFalse(defaultNetworkIsRemembered(hasInternet = false, isVpn = false)) + } + + @Test + fun `a foreground entry that finds the network again probes at once`() { + // The stuck case: stale offline flag, the re-read finds Wi-Fi. + assertTrue(foregroundEntryProbes(wasOnline = false, isOnline = true, isServerReachable = false, backgroundForMs = 70_000L)) + // Even after a quick flip. + assertTrue(foregroundEntryProbes(wasOnline = false, isOnline = true, isServerReachable = false, backgroundForMs = 1_000L)) + } + + @Test + fun `a foreground entry probes a server not marked reachable`() { + assertTrue(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = false, backgroundForMs = 1_000L)) + } + + @Test + fun `a foreground entry with no network sends nothing`() { + assertFalse(foregroundEntryProbes(wasOnline = true, isOnline = false, isServerReachable = false, backgroundForMs = 70_000L)) + assertFalse(foregroundEntryProbes(wasOnline = false, isOnline = false, isServerReachable = false, backgroundForMs = 70_000L)) + } + + @Test + fun `a connected app re-probes only after a real trip to the background`() { + assertTrue(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = true, backgroundForMs = FOREGROUND_REPROBE_AFTER_MS)) + assertFalse(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = true, backgroundForMs = 1_000L)) + // Cold start: startMonitoring already probes. + assertFalse(foregroundEntryProbes(wasOnline = true, isOnline = true, isServerReachable = true, backgroundForMs = null)) + } + + @Test + fun `settle rereads stop at the first one that finds a network`() = runBlocking { + val waits = mutableListOf() + val answers = ArrayDeque(listOf(false, true, true)) + val reads = rereadUntilOnline(FOREGROUND_SETTLE_REREADS_MS, sleep = { waits += it }) { answers.removeFirst() } + assertEquals(2, reads) + assertEquals(FOREGROUND_SETTLE_REREADS_MS.take(2), waits) + } + + @Test + fun `settle rereads give up after the last delay`() = runBlocking { + val reads = rereadUntilOnline(FOREGROUND_SETTLE_REREADS_MS, sleep = {}) { false } + assertEquals(FOREGROUND_SETTLE_REREADS_MS.size, reads) + assertTrue("bounded, a few seconds at most", FOREGROUND_SETTLE_REREADS_MS.sum() <= 10_000L) + } + + @Test + fun `an entry check skipped while offline runs once the server is back`() { + assertTrue( + shouldCheckOnServerReturn( + inForeground = true, + checkDue = true, + missedCheckWhileNotReady = true, + lastResult = SyncResult.SUCCESS, + ), + ) + } + + @Test + fun `a server return after a clean sync and no missed check stays quiet`() { + assertFalse( + shouldCheckOnServerReturn( + inForeground = true, + checkDue = true, + missedCheckWhileNotReady = false, + lastResult = SyncResult.SUCCESS, + ), + ) + } + + @Test + fun `the missed check still honors the foreground rule and the debounce`() { + assertFalse( + shouldCheckOnServerReturn(inForeground = false, checkDue = true, missedCheckWhileNotReady = true, lastResult = SyncResult.SUCCESS), + ) + assertFalse( + shouldCheckOnServerReturn(inForeground = true, checkDue = false, missedCheckWhileNotReady = true, lastResult = SyncResult.SUCCESS), + ) + } + + @Test + fun `a failed last sync still resyncs on server return`() { + assertTrue( + shouldCheckOnServerReturn(inForeground = true, checkDue = true, missedCheckWhileNotReady = false, lastResult = SyncResult.FAILED), + ) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt new file mode 100644 index 00000000..3eefb984 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundPingTest.kt @@ -0,0 +1,122 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ForegroundPingTest { + + private suspend fun settle() = repeat(10) { yield() } + + @Test + fun `the periodic ping runs only while the app is in the foreground`() = runBlocking { + val foreground = MutableStateFlow(false) + // Rendezvous: a tick is taken only when the loop is waiting out a delay. + val ticks = Channel() + var checks = 0 + val loop = launch(start = CoroutineStart.UNDISPATCHED) { + runForegroundPing( + appInForeground = foreground, + intervalMs = MutableStateFlow(PING_INTERVAL_MS), + sleep = { ticks.receive() }, + check = { checks += 1 }, + ) + } + + settle() + assertFalse("no ping waits in the background", ticks.trySend(Unit).isSuccess) + assertEquals(0, checks) + + foreground.value = true + settle() + assertTrue(ticks.trySend(Unit).isSuccess) + settle() + assertEquals(1, checks) + assertTrue(ticks.trySend(Unit).isSuccess) + settle() + assertEquals(2, checks) + + foreground.value = false + settle() + assertFalse("leaving the foreground stops the ping", ticks.trySend(Unit).isSuccess) + assertEquals(2, checks) + + foreground.value = true + settle() + assertTrue("coming back starts it again", ticks.trySend(Unit).isSuccess) + settle() + assertEquals(3, checks) + + loop.cancel() + } + + @Test + fun `each wait uses the delay chosen at that moment`() = runBlocking { + val foreground = MutableStateFlow(true) + val ticks = Channel() + val waits = mutableListOf() + val reachable = MutableStateFlow(true) + val loop = launch(start = CoroutineStart.UNDISPATCHED) { + runForegroundPing( + appInForeground = foreground, + intervalMs = reachable.map { nextPingDelayMs(isOnline = true, isServerReachable = it) }, + sleep = { waits += it; ticks.receive() }, + check = { reachable.value = false }, + ) + } + settle() + assertEquals(listOf(PING_INTERVAL_MS), waits) + ticks.send(Unit) + settle() + // The wait running after a failed check is the 15 s retry. A 60 s + // wait the status change cancels at once may show up before it. + assertEquals(PING_RETRY_INTERVAL_MS, waits.last()) + loop.cancel() + } + + @Test + fun `the server going unreachable mid-wait restarts the wait at the retry interval`() = runBlocking { + val foreground = MutableStateFlow(true) + val ticks = Channel() + val waits = mutableListOf() + var checks = 0 + val reachable = MutableStateFlow(true) + val loop = launch(start = CoroutineStart.UNDISPATCHED) { + runForegroundPing( + appInForeground = foreground, + intervalMs = reachable.map { nextPingDelayMs(isOnline = true, isServerReachable = it) }, + sleep = { waits += it; ticks.receive() }, + check = { checks += 1 }, + ) + } + settle() + assertEquals(listOf(PING_INTERVAL_MS), waits) + + // Another probe (a 502 from the foreground check) marks it unreachable + // while the 60 second wait is running. + reachable.value = false + settle() + assertEquals("the 60 s wait is dropped, not waited out", listOf(PING_INTERVAL_MS, PING_RETRY_INTERVAL_MS), waits) + assertEquals(0, checks) + + ticks.send(Unit) + settle() + assertEquals("the re-probe comes after the 15 s wait", 1, checks) + loop.cancel() + } + + @Test + fun `a successful request marks the server reachable only with a network`() { + assertTrue(serverAnswerMarksReachable(isOnline = true, isServerReachable = false)) + assertFalse(serverAnswerMarksReachable(isOnline = false, isServerReachable = false)) + assertFalse(serverAnswerMarksReachable(isOnline = true, isServerReachable = true)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt new file mode 100644 index 00000000..522daa58 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ForegroundSyncPolicyTest.kt @@ -0,0 +1,141 @@ +package com.ninelivesaudio.app.service + +import com.ninelivesaudio.app.data.local.entity.BookProgressState +import com.ninelivesaudio.app.data.local.entity.PlaybackProgressEntity +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.domain.model.SyncResult +import com.ninelivesaudio.app.domain.model.UserProgress +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** + * The sync timer runs only in the foreground: a check on every entry (unless + * one just ran) and every 15 minutes after. A check that found nothing writes + * nothing, and one that failed never claims success. + */ +class ForegroundSyncPolicyTest { + + private val minute = 60_000L + + // ─── Foreground timer ───────────────────────────────────────────────── + + @Test + fun `the first foreground entry checks right away`() { + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = null, nowMs = 5_000L)) + } + + @Test + fun `an entry within two minutes of the last check waits out the interval`() { + assertEquals(14 * minute, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 1 * minute)) + assertEquals(13 * minute + 1, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 2 * minute - 1)) + } + + @Test + fun `an entry two minutes or more after the last check checks right away`() { + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 2 * minute)) + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = 0L, nowMs = 90 * minute)) + } + + @Test + fun `a clock reading before the last check checks right away`() { + assertEquals(0L, delayBeforeEntryCheck(lastCheckAtMs = 10 * minute, nowMs = 5 * minute)) + assertTrue(isCheckDue(lastCheckAtMs = 10 * minute, nowMs = 5 * minute)) + } + + @Test + fun `a tick or a server return skips a check when a sync just ran`() { + assertTrue(isCheckDue(lastCheckAtMs = null, nowMs = 0L)) + assertFalse(isCheckDue(lastCheckAtMs = 0L, nowMs = 2 * minute - 1)) + assertTrue(isCheckDue(lastCheckAtMs = 0L, nowMs = 2 * minute)) + } + + // ─── Check records ──────────────────────────────────────────────────── + + @Test + fun `a check that found nothing writes no record over a success`() { + assertFalse(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(), previousResult = SyncResult.SUCCESS)) + } + + @Test + fun `a clean check heals a failure banner or a missing record`() { + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(), previousResult = SyncResult.FAILED)) + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(), previousResult = null)) + } + + @Test + fun `a failed check is written as the failure it is`() { + assertTrue(shouldPersistCheckReport(SyncResult.FAILED, CheckTally(), previousResult = SyncResult.SUCCESS)) + assertTrue(shouldPersistCheckReport(SyncResult.PARTIAL, CheckTally(anyDeferred = true), previousResult = SyncResult.SUCCESS)) + } + + @Test + fun `a deferred full download never claims success`() { + assertFalse(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(anyDeferred = true), previousResult = SyncResult.FAILED)) + assertFalse( + shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(anyDeferred = true, wroteBooks = true), SyncResult.SUCCESS), + ) + } + + @Test + fun `saved books or a changed library list are written so the Library re-reads`() { + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(wroteBooks = true), SyncResult.SUCCESS)) + assertTrue(shouldPersistCheckReport(SyncResult.SUCCESS, CheckTally(libraryListChanged = true), SyncResult.SUCCESS)) + } + + @Test + fun `a library list change means an added, removed, or renamed library`() { + val books = Library(id = "a", name = "Books") + assertFalse(libraryListChanged(listOf(books), listOf(books.copy(displayOrder = 9)))) + assertTrue(libraryListChanged(listOf(books), listOf(books.copy(name = "Audiobooks")))) + assertTrue(libraryListChanged(listOf(books), listOf(books, Library(id = "b", name = "More")))) + assertTrue(libraryListChanged(listOf(books), emptyList())) + } + + @Test + fun `background syncs skip podcast libraries`() { + assertTrue(isSyncedLibrary(Library(id = "a", mediaType = "book"))) + assertFalse(isSyncedLibrary(Library(id = "p", mediaType = "podcast"))) + } + + // ─── Progress pull ──────────────────────────────────────────────────── + + @Test + fun `only the most recently listened unknown books are fetched one by one`() { + val progress = (1..40).map { i -> UserProgress(libraryItemId = "li_$i", lastUpdate = i.toLong()) } + + UserProgress(libraryItemId = "li_known", lastUpdate = 1_000L) + val picked = unknownBooksToFetch(progress, knownIds = setOf("li_known"), cap = 3) + assertEquals(setOf("li_40", "li_39", "li_38"), picked) + } + + @Test + fun `a progress record that matches the cache is skipped`() { + val row = PlaybackProgressEntity(audioBookId = "a", positionSeconds = 120.0, isFinished = 0, updatedAt = "t1") + val book = BookProgressState(id = "a", currentTimeSeconds = 120.2, progress = 0.25, isFinished = 0) + assertTrue(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t1", row, book)) + assertTrue(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t1", row, localBook = null)) + } + + @Test + fun `a progress record that moved anything is imported`() { + val row = PlaybackProgressEntity(audioBookId = "a", positionSeconds = 120.0, isFinished = 0, updatedAt = "t1") + val book = BookProgressState(id = "a", currentTimeSeconds = 120.0, progress = 0.25, isFinished = 0) + assertFalse(serverProgressIsAlreadyLocal(300.0, 0.25, false, "t1", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.25, true, "t1", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t2", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.30, false, "t1", row, book)) + assertFalse(serverProgressIsAlreadyLocal(120.0, 0.25, false, "t1", localRow = null, localBook = book)) + } + + @Test + fun `unknown ids exclude blanks and repeats`() { + val progress = listOf( + UserProgress(libraryItemId = "", currentTime = 1.seconds), + UserProgress(libraryItemId = "x", lastUpdate = 2L), + UserProgress(libraryItemId = "x", lastUpdate = 1L), + ) + assertEquals(setOf("x"), unknownBooksToFetch(progress, knownIds = emptySet())) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/LocalPositionWriteCadenceTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/LocalPositionWriteCadenceTest.kt new file mode 100644 index 00000000..e7822cb4 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/LocalPositionWriteCadenceTest.kt @@ -0,0 +1,186 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalPositionWriteCadenceTest { + + /** Feeds 500 ms poll samples through the real decision and cadence store. */ + private class FakePoll( + val cadence: LocalPositionWriteCadence = LocalPositionWriteCadence(), + var nowMs: Long = 1_000_000L, + ) { + val writesAt = mutableListOf() + + fun sample( + force: Boolean = false, + isFinished: Boolean = false, + pushToServer: Boolean = false, + pushSucceeds: Boolean = true, + ) { + val write = shouldWriteLocalPosition( + cadence = cadence.snapshot("book"), + nowMs = nowMs, + force = force, + isFinished = isFinished, + pushToServer = pushToServer, + ) + if (write) { + writesAt += nowMs + cadence.recordWrite("book", nowMs, pushFailed = pushToServer && !pushSucceeds) + } + nowMs += 500L + } + } + + @Test + fun `a minute of playback saves every 10 seconds instead of every 500 ms`() { + val poll = FakePoll() + val start = poll.nowMs + repeat(120) { poll.sample() } + + assertEquals( + listOf(0L, 10_000L, 20_000L, 30_000L, 40_000L, 50_000L), + poll.writesAt.map { it - start }, + ) + } + + @Test + fun `the first sample of a book is saved`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(), + nowMs = 5L, + force = false, + isFinished = false, + pushToServer = false, + ), + ) + } + + @Test + fun `a forced sample is saved at once, and the 10 seconds restart from it`() { + val poll = FakePoll() + val start = poll.nowMs + poll.sample() // t=0, saved + repeat(5) { poll.sample() } // to t=2.5 s + poll.sample(force = true) // t=3 s, a seek + repeat(30) { poll.sample() } + + assertEquals(listOf(0L, 3_000L, 13_000L), poll.writesAt.map { it - start }) + } + + @Test + fun `a finished book is saved at once`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 1_000L), + nowMs = 1_500L, + force = false, + isFinished = true, + pushToServer = false, + ), + ) + } + + @Test + fun `a due server push goes out with a save at once`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 1_000L, lastPushFailed = false), + nowMs = 1_500L, + force = false, + isFinished = false, + pushToServer = true, + ), + ) + } + + @Test + fun `after a failed push the retry waits for the 10 second save`() { + val poll = FakePoll() + val start = poll.nowMs + // The push throttle stays due until a push lands, so every sample + // asks to push. A server refusing connections fails each one. + repeat(40) { poll.sample(pushToServer = true, pushSucceeds = false) } + + assertEquals(listOf(0L, 10_000L), poll.writesAt.map { it - start }) + } + + @Test + fun `a clock that went backwards saves instead of waiting`() { + assertTrue( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 50_000L), + nowMs = 10_000L, + force = false, + isFinished = false, + pushToServer = false, + ), + ) + } + + @Test + fun `nothing is saved between cadence points`() { + assertFalse( + shouldWriteLocalPosition( + cadence = LocalPositionCadenceSnapshot(lastWriteAtMs = 1_000L), + nowMs = 1_000L + LOCAL_POSITION_WRITE_INTERVAL_MS - 1, + force = false, + isFinished = false, + pushToServer = false, + ), + ) + assertEquals(10_000L, LOCAL_POSITION_WRITE_INTERVAL_MS) + } + + @Test + fun `the shelf write touches only the progress columns and skips the read when the duration is known`() = runBlocking { + var reads = 0 + val updates = mutableListOf>() + + writeShelfProgressColumns( + currentTime = 300.0, + duration = 1_200.0, + isFinished = false, + readExistingProgress = { reads += 1; 0.9 }, + update = { time, progress, finished -> updates += Triple(time, progress, finished) }, + ) + + assertEquals(0, reads) + assertEquals(listOf(Triple(300.0, 0.25, 0)), updates) + } + + @Test + fun `an unknown duration keeps the stored progress`() = runBlocking { + val updates = mutableListOf>() + + writeShelfProgressColumns( + currentTime = 300.0, + duration = 0.0, + isFinished = false, + readExistingProgress = { 0.4 }, + update = { time, progress, finished -> updates += Triple(time, progress, finished) }, + ) + + assertEquals(listOf(Triple(300.0, 0.4, 0)), updates) + } + + @Test + fun `a finished book writes full progress and the finished flag`() = runBlocking { + val updates = mutableListOf>() + + writeShelfProgressColumns( + currentTime = 1_199.5, + duration = 1_200.0, + isFinished = true, + readExistingProgress = { error("no read for a finished book") }, + update = { time, progress, finished -> updates += Triple(time, progress, finished) }, + ) + + assertEquals(listOf(Triple(1_199.5, 1.0, 1)), updates) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt index 69005fd7..bb80d881 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/MediaBrowseScopeTest.kt @@ -55,23 +55,20 @@ class MediaBrowseScopeTest { } @Test - fun `downloaded shelf stays inside the selected server library`() { + fun `downloaded shelf stays inside the selected server library`() = runBlocking { val settings = AppSettings( appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "server-active", + selectedLocalLibraryId = "local", ) - val books = listOf( - AudioBook(id = "active", libraryId = "server-active", isDownloaded = true), - AudioBook(id = "other", libraryId = "server-other", isDownloaded = true), - AudioBook(id = "stream-only", libraryId = "server-active", isDownloaded = false), - AudioBook(id = "local", libraryId = "local", isLocal = true, isDownloaded = true), - AudioBook(id = "archived", libraryId = "server-active", isDownloaded = true, archivedAt = 1L), - ) + var asked: Pair? = null - assertEquals( - listOf("active"), - downloadedBooksForAuto(books, settings).map { it.id }, - ) + autoBrowsePage(settings, page = 0, pageSize = 50) { libraryId, isLocal, _, _ -> + asked = libraryId to isLocal + emptyList() + } + + assertEquals("server-active" to false, asked) } @Test diff --git a/app/src/test/java/com/ninelivesaudio/app/service/PolledProgressSaveTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/PolledProgressSaveTest.kt new file mode 100644 index 00000000..e493afaa --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/PolledProgressSaveTest.kt @@ -0,0 +1,49 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PolledProgressSaveTest { + + @Test + fun `the first sample of a play is saved at once`() { + assertTrue(polledReportForcesSave(saveRequest = 0L, lastDeliveredSaveRequest = null)) + assertTrue(polledReportForcesSave(saveRequest = 7L, lastDeliveredSaveRequest = null)) + } + + @Test + fun `a sample with no new save request rides the cadence`() { + assertFalse(polledReportForcesSave(saveRequest = 3L, lastDeliveredSaveRequest = 3L)) + } + + @Test + fun `a sample taken after a seek or chapter change is saved at once`() { + assertTrue(polledReportForcesSave(saveRequest = 4L, lastDeliveredSaveRequest = 3L)) + } + + @Test + fun `a dropped sample does not lose the save request it carried`() = runBlocking { + // The poll channel keeps only the newest unsent sample. A seek bumps the + // request, its sample is replaced before the worker reads it, and the + // replacing sample must still force the save. + val channel = newPolledProgressReportChannel() + channel.send(PolledProgressReport("book", currentTime = 10.0, duration = 100.0, saveRequest = 0L)) + val first = channel.receive() + var lastDelivered: Long? = null + assertTrue(polledReportForcesSave(first.saveRequest, lastDelivered)) + lastDelivered = first.saveRequest + + // Seek: request 1. Its sample is overwritten by the next tick's. + channel.trySend(PolledProgressReport("book", currentTime = 60.0, duration = 100.0, saveRequest = 1L)) + channel.trySend(PolledProgressReport("book", currentTime = 60.5, duration = 100.0, saveRequest = 1L)) + val delivered = channel.receive() + + assertEquals(60.5, delivered.currentTime, 0.0) + assertTrue(polledReportForcesSave(delivered.saveRequest, lastDelivered)) + lastDelivered = delivered.saveRequest + assertFalse(polledReportForcesSave(1L, lastDelivered)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/PositionCountsAsFinishedTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/PositionCountsAsFinishedTest.kt new file mode 100644 index 00000000..193d3cd4 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/PositionCountsAsFinishedTest.kt @@ -0,0 +1,34 @@ +package com.ninelivesaudio.app.service + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A pause save overtaken by a paused seek saves the seek's position. If that + * position sits at the very end, the seek's own save marked the book finished, + * and the pause save must apply the same rule or it marks the book unfinished. + */ +class PositionCountsAsFinishedTest { + + @Test + fun `within one second of the end counts as finished`() { + assertTrue(positionCountsAsFinished(currentTimeSec = 3599.6, durationSec = 3600.0)) + } + + @Test + fun `one second or more from the end does not`() { + assertFalse(positionCountsAsFinished(currentTimeSec = 3599.0, durationSec = 3600.0)) + assertFalse(positionCountsAsFinished(currentTimeSec = 50.0, durationSec = 3600.0)) + } + + @Test + fun `an unknown duration never counts as finished`() { + assertFalse(positionCountsAsFinished(currentTimeSec = 0.0, durationSec = 0.0)) + } + + @Test + fun `a position past the end still counts as finished`() { + assertTrue(positionCountsAsFinished(currentTimeSec = 3605.0, durationSec = 3600.0)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt index 8c888b25..138e824b 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ProgressSyncGateTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -208,6 +209,67 @@ class PlaybackProgressOwnerTest { terminal.join() bookB.join() } + + @Test + fun `a delayed pause save cannot overwrite a newer paused seek`() = runBlocking { + val owner = PlaybackProgressOwner() + var saved: Duration? = null + + // Paused at 100 s. The pause save is queued but has not reached the lock. + val pauseToken = owner.snapshotToken("book-a") + // A seek to 50 s while still paused, and its save lands first. + owner.launchPausedSeekSave(this, "book-a") { saved = 50.seconds }.join() + // The delayed pause save finally runs. + owner.syncSnapshot(pauseToken) { saved = 100.seconds } + + assertEquals(50.seconds, saved) + } + + @Test + fun `a paused seek retires the pause save's position but keeps its listening time`() = runBlocking { + val owner = PlaybackProgressOwner() + val effects = mutableListOf() + + // Paused at 100 s with listening time to record. The save is queued. + val pauseToken = owner.snapshotToken("book-a") + owner.launchPausedSeekSave(this, "book-a") { effects += "seek-position" }.join() + owner.syncSnapshot(pauseToken, onPositionSuperseded = { effects += "pause-listening" }) { + effects += "pause-position" + } + // Resuming retires the whole snapshot, listening time included, since + // playback carries that time forward itself. + val resumedToken = owner.snapshotToken("book-a") + owner.invalidateSnapshots("book-a") + owner.syncSnapshot(resumedToken, onPositionSuperseded = { effects += "stale-listening" }) { + effects += "stale-position" + } + + assertEquals(listOf("seek-position", "pause-listening"), effects) + } + + @Test + fun `a pause save already writing finishes before the paused seek saves`() = runBlocking { + val owner = PlaybackProgressOwner() + val writes = mutableListOf() + val pauseEntered = CompletableDeferred() + val releasePause = CompletableDeferred() + + val pauseToken = owner.snapshotToken("book-a") + val pause = launch { + owner.syncSnapshot(pauseToken) { + pauseEntered.complete(Unit) + releasePause.await() + writes += 100.seconds + } + } + pauseEntered.await() + val seek = owner.launchPausedSeekSave(this, "book-a") { writes += 50.seconds } + releasePause.complete(Unit) + pause.join() + seek.join() + + assertEquals(listOf(100.seconds, 50.seconds), writes) + } } class PendingTerminalOwnerTest { diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt index 72f343e9..93343da4 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ReachabilityCheckGateTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -37,4 +38,108 @@ class ReachabilityCheckGateTest { assertTrue(second.await()) assertTrue(secondEntered.isCompleted) } + + @Test + fun `callers asking about the same network share a probe that just finished`() = runBlocking { + var now = 1_000L + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { now }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { probes += 1; true } + val key = ProbeReuseKey(generation = 3, serverUrl = "http://abs") + + assertTrue(gate.run(key)) + now += 1_500L + assertTrue(gate.run(key)) + now += 1_000L + assertTrue(gate.run(key)) + assertEquals("cold start's burst is one /ping", 1, probes) + } + + @Test + fun `a waiting caller gets the answer of the probe it waited on`() = runBlocking { + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { 0L }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { + probes += 1 + firstEntered.complete(Unit) + releaseFirst.await() + false + } + val key = ProbeReuseKey(generation = 1, serverUrl = "http://abs") + val first = async(start = CoroutineStart.UNDISPATCHED) { gate.run(key) } + firstEntered.await() + val second = async(start = CoroutineStart.UNDISPATCHED) { gate.run(key) } + releaseFirst.complete(Unit) + assertFalse(first.await()) + assertFalse(second.await()) + assertEquals(1, probes) + } + + @Test + fun `a new network, another server, or an old answer means a new probe`() = runBlocking { + var now = 0L + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { now }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { probes += 1; true } + gate.run(ProbeReuseKey(1, "http://abs")) + gate.run(ProbeReuseKey(2, "http://abs")) + assertEquals("network changed", 2, probes) + gate.run(ProbeReuseKey(2, "http://other")) + assertEquals("server changed", 3, probes) + now += PROBE_REUSE_WINDOW_MS + gate.run(ProbeReuseKey(2, "http://other")) + assertEquals("window passed", 4, probes) + gate.run(null) + assertEquals("no key never shares", 5, probes) + } + + @Test + fun `a server answer after a failed probe means the next caller probes again`() = runBlocking { + var probes = 0 + var answer = false + val gate = ReachabilityCheckGate(nowMs = { 0L }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { + probes += 1 + answer + } + val key = ProbeReuseKey(generation = 1, serverUrl = "http://abs") + assertFalse(gate.run(key)) + // A playback push lands inside the reuse window. + gate.serverAnswered() + answer = true + assertTrue("the failure is not shared after the server answered", gate.run(key)) + assertEquals(2, probes) + // A success is still shared. + assertTrue(gate.run(key)) + assertEquals(2, probes) + } + + @Test + fun `a server answer during a failing probe keeps that failure from being shared`() = runBlocking { + val entered = CompletableDeferred() + val release = CompletableDeferred() + var probes = 0 + val gate = ReachabilityCheckGate(nowMs = { 0L }, reuseWindowMs = PROBE_REUSE_WINDOW_MS) { + probes += 1 + if (probes == 1) { + entered.complete(Unit) + release.await() + false + } else { + true + } + } + val key = ProbeReuseKey(generation = 1, serverUrl = "http://abs") + val first = async(start = CoroutineStart.UNDISPATCHED) { gate.run(key) } + entered.await() + gate.serverAnswered() + release.complete(Unit) + assertFalse(first.await()) + assertTrue(gate.run(key)) + assertEquals(2, probes) + } + + @Test + fun `a clock reading before the finish does not share`() { + assertFalse(canReuseProbe("k", "k", finishedAtMs = 5_000L, nowMs = 4_000L, windowMs = PROBE_REUSE_WINDOW_MS)) + assertTrue(canReuseProbe("k", "k", finishedAtMs = 5_000L, nowMs = 5_000L, windowMs = PROBE_REUSE_WINDOW_MS)) + } } diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ServerProbeRetryTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ServerProbeRetryTest.kt new file mode 100644 index 00000000..67473f96 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/ServerProbeRetryTest.kt @@ -0,0 +1,135 @@ +package com.ninelivesaudio.app.service + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ServerProbeRetryTest { + + private class FakeProbe(vararg answers: Boolean) { + private val queue = ArrayDeque(answers.toList()) + val budgets = mutableListOf() + suspend fun probe(budgetMs: Long): Boolean { + budgets += budgetMs + return queue.removeFirst() + } + } + + @Test + fun `a 5xx from a proxy means the server is down`() { + assertFalse(pingStatusMeansReachable(502)) + assertFalse(pingStatusMeansReachable(503)) + assertFalse(pingStatusMeansReachable(500)) + } + + @Test + fun `any answer below 500 means the server is up`() { + assertTrue(pingStatusMeansReachable(200)) + assertTrue(pingStatusMeansReachable(204)) + assertTrue(pingStatusMeansReachable(401)) + assertTrue(pingStatusMeansReachable(404)) + } + + @Test + fun `one slow probe on a reachable server is retried and stays reachable`() = runBlocking { + val probe = FakeProbe(false, true) + val sleeps = mutableListOf() + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { true }, + probe = probe::probe, + sleep = { sleeps += it }, + ) + + assertTrue(reachable) + assertEquals(listOf(PROBE_TIMEOUT_MS, PROBE_RETRY_TIMEOUT_MS), probe.budgets) + assertEquals(listOf(PROBE_RETRY_DELAY_MS), sleeps) + } + + @Test + fun `two failed probes call a reachable server unreachable`() = runBlocking { + val probe = FakeProbe(false, false) + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { true }, + probe = probe::probe, + sleep = {}, + ) + + assertFalse(reachable) + assertEquals(2, probe.budgets.size) + } + + @Test + fun `a server already unreachable is not retried`() = runBlocking { + val probe = FakeProbe(false) + + val reachable = probeServerWithRetry( + wasReachable = false, + stillOnline = { true }, + probe = probe::probe, + sleep = { error("no retry wait for a server already unreachable") }, + ) + + assertFalse(reachable) + assertEquals(listOf(PROBE_TIMEOUT_MS), probe.budgets) + } + + @Test + fun `no retry once the network is gone`() = runBlocking { + val probe = FakeProbe(false) + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { false }, + probe = probe::probe, + sleep = { error("no retry wait without a network") }, + ) + + assertFalse(reachable) + assertEquals(1, probe.budgets.size) + } + + @Test + fun `a network lost during the retry wait skips the second probe`() = runBlocking { + val probe = FakeProbe(false) + var online = true + + val reachable = probeServerWithRetry( + wasReachable = true, + stillOnline = { online }, + probe = probe::probe, + sleep = { online = false }, + ) + + assertFalse(reachable) + assertEquals(1, probe.budgets.size) + } + + @Test + fun `a first probe that answers needs no retry`() = runBlocking { + val probe = FakeProbe(true) + + assertTrue( + probeServerWithRetry( + wasReachable = true, + stillOnline = { true }, + probe = probe::probe, + sleep = { error("no retry after a good answer") }, + ), + ) + assertEquals(1, probe.budgets.size) + } + + @Test + fun `an unreachable server is pinged again in 15 seconds, otherwise every minute`() { + assertEquals(PING_RETRY_INTERVAL_MS, nextPingDelayMs(isOnline = true, isServerReachable = false)) + assertEquals(PING_INTERVAL_MS, nextPingDelayMs(isOnline = true, isServerReachable = true)) + assertEquals(PING_INTERVAL_MS, nextPingDelayMs(isOnline = false, isServerReachable = false)) + assertEquals(15_000L, PING_RETRY_INTERVAL_MS) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt index eaf16095..5a3a02b2 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/ServerReturnResyncTest.kt @@ -3,6 +3,11 @@ package com.ninelivesaudio.app.service import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -10,6 +15,63 @@ import org.junit.Test class ServerReturnResyncTest { + // Lets the listener's combine pipeline catch up on the single runBlocking thread. + private suspend fun settle() = repeat(20) { yield() } + + @Test + fun `a missed check flagged after the server already turned Connected still runs`() = runBlocking { + // The entry check read offline, then the status turned Connected and + // the listener found nothing to do (last sync clean, flag not set yet). + val live = MutableStateFlow(false) + val missed = MutableStateFlow(false) + var checks = 0 + val listener = launch(start = CoroutineStart.UNDISPATCHED) { + runServerReturnChecks( + serverLive = live, + missedCheckWhileNotReady = missed, + shouldCheck = { flagged -> + shouldCheckOnServerReturn( + inForeground = true, + checkDue = checks == 0, + missedCheckWhileNotReady = flagged, + lastResult = SyncResult.SUCCESS, + ) + }, + check = { checks += 1 }, + ) + } + live.value = true + settle() + assertEquals("nothing to do on the edge itself", 0, checks) + // The skipped entry check returns and sets the flag. + missed.value = true + settle() + assertEquals(1, checks) + listener.cancel() + } + + @Test + fun `a missed check flagged while offline runs when the server returns`() = runBlocking { + val live = MutableStateFlow(false) + val missed = MutableStateFlow(false) + var checks = 0 + val listener = launch(start = CoroutineStart.UNDISPATCHED) { + runServerReturnChecks( + serverLive = live, + missedCheckWhileNotReady = missed, + shouldCheck = { flagged -> flagged }, + check = { checks += 1 }, + ) + } + missed.value = true + settle() + assertEquals("offline runs nothing", 0, checks) + live.value = true + settle() + assertEquals(1, checks) + listener.cancel() + } + @Test fun `a returning server resyncs after a failed or partial sync`() { assertTrue(shouldResyncOnServerReturn(SyncResult.FAILED)) diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt new file mode 100644 index 00000000..06b81919 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt @@ -0,0 +1,335 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.DownloadStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files + +/** + * A cancelled download cleans up its partial folder (#51), and only that + * folder, and only when nothing else claims it (#49). Every doubt keeps files. + */ +class CancelCleanupTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun root(): File = tempFolder.newFolder("Audiobookshelf") + + private fun bookFolder(root: File, vararg files: String): File = + File(root, "Herman Melville - Moby-Dick").apply { + mkdirs() + files.forEach { File(this, it).writeBytes(byteArrayOf(1, 2, 3)) } + } + + private fun decide( + root: File, + folder: File, + appOwned: Boolean = true, + downloaded: Boolean = false, + otherBookPaths: List = emptyList(), + otherDownloadFolders: List = emptyList(), + ) = decideCancelCleanup( + DownloadLocation(root, folder, appOwned), + downloaded, + otherBookPaths, + otherDownloadFolders, + ) + + // ─── Deletes ───────────────────────────────────────────────────────────── + + @Test + fun `a contained folder in app storage is deleted whole`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3.part", "cover.jpg") + + val decision = decide(root, folder) as CancelCleanupDecision.Delete + assertEquals(setOf("01.mp3", "02.mp3.part", "cover.jpg"), decision.files.map { it.name }.toSet()) + + assertEquals(3, applyCancelCleanup(decision)) + assertFalse(folder.exists()) + assertTrue(root.exists()) + } + + @Test + fun `a sibling download's folder survives the cleanup`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + val sibling = File(root, "Other Author - Other Book").apply { mkdirs() } + File(sibling, "01.mp3").writeBytes(byteArrayOf(9)) + + val decision = decide(root, folder, otherBookPaths = listOf(sibling.absolutePath)) + applyCancelCleanup(decision as CancelCleanupDecision.Delete) + + assertFalse(folder.exists()) + assertTrue(File(sibling, "01.mp3").exists()) + } + + // ─── User-picked folder ────────────────────────────────────────────────── + + @Test + fun `a user-chosen root keeps every file, part files included`() { + val root = root() + // A .part from a browser or sync app looks exactly like one of ours. + val folder = bookFolder(root, "01.mp3", "02.mp3.part", "someone-elses.zip.part") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.USER_FOLDER), + decide(root, folder, appOwned = false), + ) + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3.part").exists()) + assertTrue(File(folder, "someone-elses.zip.part").exists()) + } + + @Test + fun `a file that shows up after the decision keeps the folder`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + val decision = decide(root, folder) as CancelCleanupDecision.Delete + + File(folder, "late.mp3").writeBytes(byteArrayOf(4)) + applyCancelCleanup(decision) + + assertTrue(File(folder, "late.mp3").exists()) + assertFalse(File(folder, "01.mp3").exists()) + } + + // ─── Shared path (#49) ─────────────────────────────────────────────────── + + @Test + fun `a folder another book row points at is kept`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(folder.absolutePath)), + ) + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a shared folder written as a file uri or with a trailing slash is still kept`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(folder.toURI().toString())), + ) + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(folder.absolutePath + "/")), + ) + } + + @Test + fun `a book path inside the folder or above it keeps the folder`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(File(folder, "01.mp3").absolutePath)), + ) + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherBookPaths = listOf(root.absolutePath)), + ) + } + + @Test + fun `a folder another queued download would write to is kept`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.SHARED), + decide(root, folder, otherDownloadFolders = listOf(File(root, folder.name))), + ) + } + + @Test + fun `content uris from a local import do not block the cleanup`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + val decision = decide( + root, + folder, + otherBookPaths = listOf("content://com.android.externalstorage.documents/tree/primary%3AAudiobooks"), + ) + assertTrue(decision is CancelCleanupDecision.Delete) + } + + // ─── Containment ───────────────────────────────────────────────────────── + + @Test + fun `a folder that escapes the root is never deleted`() { + val root = root() + val outside = tempFolder.newFolder("Music", "My Own Audiobooks") + File(outside, "keep.mp3").writeBytes(byteArrayOf(7)) + + // A title of ".." sanitizes to "..", so the engine path climbs out. + val climbed = File(root, "../Music/My Own Audiobooks") + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, climbed)) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, outside)) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, File(root, ".."))) + assertTrue(File(outside, "keep.mp3").exists()) + } + + @Test + fun `the root itself is never deleted`() { + val root = root() + File(root, "loose.mp3").writeBytes(byteArrayOf(1)) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, File(root, "."))) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, root)) + } + + @Test + fun `a folder nested deeper than one level is not touched`() { + val root = root() + val nested = File(root, "a/b").apply { mkdirs() } + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, nested)) + } + + @Test + fun `a symlinked book folder pointing outside the root is never followed`() { + val root = root() + val outside = tempFolder.newFolder("elsewhere") + File(outside, "keep.mp3").writeBytes(byteArrayOf(7)) + val link = File(root, "Herman Melville - Moby-Dick") + Files.createSymbolicLink(link.toPath(), outside.toPath()) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decide(root, link)) + assertTrue(File(outside, "keep.mp3").exists()) + } + + @Test + fun `a symlink or subfolder inside the book folder keeps everything`() { + val root = root() + val outside = tempFolder.newFolder("elsewhere") + File(outside, "keep.mp3").writeBytes(byteArrayOf(7)) + + val withLink = bookFolder(root, "01.mp3") + Files.createSymbolicLink(File(withLink, "sneaky").toPath(), outside.toPath()) + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS), + decide(root, withLink), + ) + + val withSubdir = File(root, "Another - Book").apply { mkdirs() } + File(withSubdir, "extras").mkdirs() + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS), + decide(root, withSubdir), + ) + assertTrue(File(outside, "keep.mp3").exists()) + } + + // ─── Nothing to do ─────────────────────────────────────────────────────── + + @Test + fun `a downloaded book is left alone`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.BOOK_DOWNLOADED), + decide(root, folder, downloaded = true), + ) + } + + @Test + fun `a missing folder is a no-op`() { + val root = root() + + assertEquals( + CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER), + decide(root, File(root, "Never Started")), + ) + } + + // ─── When cancel may touch files at all ────────────────────────────────── + + @Test + fun `files are touched only once the engine is provably off the book`() { + val downloading = DownloadStatus.Downloading.ordinal + assertTrue(cancelMayTouchFiles(downloading, wasDownloading = true, stopConfirmed = true)) + assertFalse(cancelMayTouchFiles(downloading, wasDownloading = true, stopConfirmed = false)) + assertTrue(cancelMayTouchFiles(DownloadStatus.Paused.ordinal, wasDownloading = false, stopConfirmed = false)) + assertTrue(cancelMayTouchFiles(DownloadStatus.Failed.ordinal, wasDownloading = false, stopConfirmed = false)) + assertFalse(cancelMayTouchFiles(DownloadStatus.Queued.ordinal, wasDownloading = false, stopConfirmed = false)) + assertFalse(cancelMayTouchFiles(DownloadStatus.Preparing.ordinal, wasDownloading = false, stopConfirmed = false)) + assertFalse(cancelMayTouchFiles(DownloadStatus.Completed.ordinal, wasDownloading = false, stopConfirmed = false)) + } + + // ─── Last look before deleting ─────────────────────────────────────────── + + @Test + fun `cleanup goes ahead only when no row and no engine came back for the book`() { + val root = root() + val folder = bookFolder(root, "01.mp3.part") + val elsewhere = File(root, "Other Author - Other Book") + val book = "moby" + + assertTrue(cancelCleanupStillClear(book, folder, listOf("other"), listOf(elsewhere), engineBookId = "other")) + assertTrue(cancelCleanupStillClear(book, folder, emptyList(), emptyList(), engineBookId = null)) + } + + @Test + fun `a resumed row for the book keeps its files`() { + val root = root() + val folder = bookFolder(root, "01.mp3.part") + + // Resume wrote the row back between the snapshot and the delete. + assertFalse(cancelCleanupStillClear("moby", folder, listOf("moby"), listOf(folder), engineBookId = null)) + } + + @Test + fun `another row writing to the same folder keeps its files`() { + val root = root() + val folder = bookFolder(root, "01.mp3.part") + + assertFalse( + cancelCleanupStillClear("moby", folder, listOf("twin"), listOf(File(root, folder.name)), engineBookId = null), + ) + } + + @Test + fun `the engine running on the book keeps its files`() { + val root = root() + val folder = bookFolder(root, "01.mp3.part") + + assertFalse(cancelCleanupStillClear("moby", folder, emptyList(), emptyList(), engineBookId = "moby")) + } + + // ─── Root resolution ───────────────────────────────────────────────────── + + @Test + fun `the default root is app owned and a configured one is not`() { + val defaultRoot = tempFolder.newFolder("app", "Audiobookshelf") + val chosen = tempFolder.newFolder("sdcard", "Audiobooks") + + assertEquals(defaultRoot to true, resolveDownloadRoot("", defaultRoot)) + assertEquals(chosen.canonicalFile to false, resolveDownloadRoot(chosen.absolutePath, defaultRoot)) + assertEquals(defaultRoot.canonicalFile to true, resolveDownloadRoot(defaultRoot.absolutePath, defaultRoot)) + } + + @Test + fun `a configured system dir falls back to the app owned default`() { + val defaultRoot = tempFolder.newFolder("app", "Audiobookshelf") + + val (root, appOwned) = resolveDownloadRoot("/proc/self", defaultRoot) + assertTrue(root === defaultRoot) + assertTrue(appOwned) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/CleanupLookupChunkTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/CleanupLookupChunkTest.kt new file mode 100644 index 00000000..05938cd7 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/CleanupLookupChunkTest.kt @@ -0,0 +1,42 @@ +package com.ninelivesaudio.app.service.download + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Cancel cleanup looks up the book behind every download row. Android 11's + * SQLite refuses more than 999 bound values, so the lookup has to go in chunks + * or cleanup silently keeps the files once a user has 1,000 download rows. + */ +class CleanupLookupChunkTest { + + @Test + fun `1,200 ids never bind more than 500 at once and all come back`() = runBlocking { + val ids = (1..1_200).map { "book-$it" } + val batchSizes = mutableListOf() + + val found = lookUpInChunks(ids) { chunk -> + batchSizes += chunk.size + chunk.map { "row-$it" } + } + + assertTrue("largest batch was ${batchSizes.max()}", batchSizes.all { it <= 500 }) + assertTrue(batchSizes.max() < 999) + assertEquals(ids.map { "row-$it" }, found) + } + + @Test + fun `no ids means no lookup at all`() = runBlocking { + var calls = 0 + + val found = lookUpInChunks(emptyList()) { chunk -> + calls++ + chunk + } + + assertEquals(0, calls) + assertEquals(emptyList(), found) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadCompletionManifestTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadCompletionManifestTest.kt new file mode 100644 index 00000000..4acb8892 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadCompletionManifestTest.kt @@ -0,0 +1,125 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.data.local.converter.toDomain +import com.ninelivesaudio.app.data.local.converter.toEntity +import com.ninelivesaudio.app.data.remote.dto.ApiChapter +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.Chapter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** + * A finished download has to leave its track list and chapters in the book row. + * Library sync stores an empty manifest, and without one offline playback sorts + * the files by name, so "Chapter 10.mp3" plays before "Chapter 2.mp3". + */ +class DownloadCompletionManifestTest { + + private val tracks = listOf( + AudioFile(id = "a", ino = "a", index = 0, duration = 60.seconds, filename = "Chapter 2.mp3"), + AudioFile(id = "b", ino = "b", index = 1, duration = 60.seconds, filename = "Chapter 10.mp3"), + ) + private val chapters = listOf( + Chapter(id = 0, start = 0.0, end = 60.0, title = "Two"), + Chapter(id = 1, start = 60.0, end = 120.0, title = "Ten"), + ) + + /** What a library sync leaves behind: no tracks, no chapters. */ + private val syncedRow = AudioBook(id = "book", title = "Book", author = "Author", currentTime = 42.seconds) + + @Test + fun `completion saves the downloaded tracks and chapters over an empty row`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(audioFiles = tracks, chapters = chapters), + localPath = "/downloads/Author - Book", + localCoverUri = "file:///downloads/Author - Book/cover.jpg", + ) + + assertEquals(tracks, saved.audioFiles) + assertEquals(chapters, saved.chapters) + assertTrue(saved.isDownloaded) + assertEquals("/downloads/Author - Book", saved.localPath) + assertEquals("file:///downloads/Author - Book/cover.jpg", saved.localCoverPath) + } + + @Test + fun `completion keeps the row's own progress`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(currentTime = 0.seconds, audioFiles = tracks), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals(42.seconds, saved.currentTime) + } + + @Test + fun `a download with no chapters leaves the row's chapters alone`() { + val saved = completedDownloadBook( + row = syncedRow.copy(chapters = chapters), + downloaded = syncedRow.copy(audioFiles = tracks), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals(chapters, saved.chapters) + } + + @Test + fun `a failed cover save keeps the cover the row already had`() { + val saved = completedDownloadBook( + row = syncedRow.copy(localCoverPath = "file:///old/cover.jpg"), + downloaded = syncedRow.copy(audioFiles = tracks), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals("file:///old/cover.jpg", saved.localCoverPath) + } + + @Test + fun `the saved manifest plays in server order offline, not name order`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(audioFiles = tracks.reversed()), + localPath = "/d", + localCoverUri = null, + ) + // Through the Room column and back, the way PlaybackManager reads it. + val reloaded = saved.toEntity().toDomain() + val ordered = reloaded.audioFiles.sortedBy { it.index } + + assertEquals(listOf("Chapter 2.mp3", "Chapter 10.mp3"), resolveDownloadFileNames(ordered)) + } + + @Test + fun `saved chapters survive the Room column`() { + val saved = completedDownloadBook( + row = syncedRow, + downloaded = syncedRow.copy(audioFiles = tracks, chapters = chapters), + localPath = "/d", + localCoverUri = null, + ) + + assertEquals(chapters, saved.toEntity().toDomain().chapters) + } + + @Test + fun `server chapters map with the same filter and fallback title as the item mapping`() { + val mapped = toDomainChapters( + listOf( + ApiChapter(id = 0, start = 0.0, end = 10.0, title = "Opening"), + ApiChapter(id = 1, start = 10.0, end = 10.0, title = "Empty"), + ApiChapter(id = 2, start = -1.0, end = 5.0, title = "Negative"), + ApiChapter(id = 3, start = 10.0, end = 20.0, title = " "), + ), + ) + + assertEquals(listOf("Opening", "Chapter 3"), mapped.map { it.title }) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadSlotCandidatesTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadSlotCandidatesTest.kt new file mode 100644 index 00000000..af4aad65 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadSlotCandidatesTest.kt @@ -0,0 +1,147 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.data.local.entity.SlotBookRow +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import com.ninelivesaudio.app.entitlement.SlotCandidate +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The free-tier slot check used to read and decode every book in every + * library, at cold start and on every download claim. It now reads only the + * downloaded books and the books named by download rows, and only four + * columns of each. These pin that the candidates come out the same. + */ +class DownloadSlotCandidatesTest { + + @Test + fun `only downloaded books and books with rows are read`() = runBlocking { + val askedById = mutableListOf>() + var downloadedCalls = 0 + + val books = loadSlotBooks( + rowBookIds = listOf("queued", "done", "queued"), + getDownloaded = { + downloadedCalls++ + listOf(slot("done", downloaded = true), slot("kept-offline", downloaded = true)) + }, + getByIds = { ids -> + askedById += ids + ids.filter { it != "gone" }.map { slot(it) } + }, + ) + + assertEquals(1, downloadedCalls) + // "done" already came back as downloaded, so only "queued" is looked up. + assertEquals(listOf(listOf("queued")), askedById) + assertEquals(setOf("done", "kept-offline", "queued"), books.keys) + } + + @Test + fun `row lookups are chunked under the bind limit`() = runBlocking { + val chunks = mutableListOf() + loadSlotBooks( + rowBookIds = (0 until 1_201).map { "b$it" }, + getDownloaded = { emptyList() }, + getByIds = { ids -> + chunks += ids.size + emptyList() + }, + ) + + assertEquals(listOf(500, 500, 201), chunks) + } + + @Test + fun `no download rows means no id lookup`() = runBlocking { + var byIdCalls = 0 + loadSlotBooks( + rowBookIds = emptyList(), + getDownloaded = { emptyList() }, + getByIds = { + byIdCalls++ + emptyList() + }, + ) + assertEquals(0, byIdCalls) + } + + @Test + fun `candidates match the former whole-library builder`() = runBlocking { + // Every case the old builder distinguished: downloaded with and without + // a row, a row whose book is gone, a local book, a stream-only book + // with a row, duplicate rows, and a plain shelf book that must not show. + val library = listOf( + slot("downloaded-no-row", downloaded = true, path = "/b/1"), + slot("downloaded-with-row", downloaded = true, path = "/b/2"), + slot("local", local = true, downloaded = true, path = "/b/3"), + slot("queued-not-downloaded"), + slot("shelf-only"), + slot("blank-path", downloaded = true, path = " "), + ) + val rows = listOf( + row("downloaded-with-row", DownloadStatus.Completed, startedAt = 10, completedAt = 20), + row("queued-not-downloaded", DownloadStatus.Queued, startedAt = 30), + row("book-gone", DownloadStatus.Paused, startedAt = 5), + row("downloaded-with-row", DownloadStatus.Failed, startedAt = 40), + ) + val rowsByBook = rows.groupBy { it.audioBookId } + val filesExist: (String?) -> Boolean = { it == "/b/1" || it == "/b/3" } + val progress: suspend (String) -> Long? = { id -> if (id == "local") 99L else null } + + val narrowed = loadSlotBooks( + rowBookIds = rowsByBook.keys, + getDownloaded = { library.filter { it.isDownloaded == 1 } }, + getByIds = { ids -> library.filter { it.id in ids } }, + ) + val actual = assembleSlotCandidates(rowsByBook, narrowed, filesExist, progress) + val former = formerBuilder(library, rowsByBook, filesExist, progress) + + assertEquals(former.sortedBy { it.audioBookId }, actual.sortedBy { it.audioBookId }) + assertTrue(actual.none { it.audioBookId == "shelf-only" }) + assertEquals(DownloadStatus.Failed, actual.single { it.audioBookId == "downloaded-with-row" }.downloadStatus) + assertEquals(false, actual.single { it.audioBookId == "book-gone" }.hasAudioBookRecord) + } + + /** The pre-change builder, fed the whole library, kept here as the oracle. */ + private suspend fun formerBuilder( + allBooks: List, + rowsByBook: Map>, + filesExist: (String?) -> Boolean, + progressMillis: suspend (String) -> Long?, + ): List { + val offlineBooks = allBooks.filter { it.isDownloaded == 1 || rowsByBook.containsKey(it.id) } + val bookIds = (offlineBooks.map { it.id } + rowsByBook.keys).distinct() + return bookIds.map { bookId -> + val book = offlineBooks.firstOrNull { it.id == bookId } + val row = rowsByBook[bookId]?.maxByOrNull { it.startedAt ?: Long.MIN_VALUE } + SlotCandidate( + audioBookId = bookId, + isLocal = book?.isLocal == 1, + hasAudioBookRecord = book != null, + downloadStatus = row?.status, + isDownloaded = book?.isDownloaded == 1, + hasLocalPath = !book?.localPath.isNullOrBlank(), + filesExist = filesExist(book?.localPath), + progressUpdatedAt = progressMillis(bookId), + completedAt = row?.completedAt, + startedAt = row?.startedAt, + ) + } + } + + private fun slot(id: String, local: Boolean = false, downloaded: Boolean = false, path: String? = null) = + SlotBookRow(id = id, isLocal = if (local) 1 else 0, isDownloaded = if (downloaded) 1 else 0, localPath = path) + + private fun row(bookId: String, status: DownloadStatus, startedAt: Long? = null, completedAt: Long? = null) = + DownloadItem( + id = "row-$bookId-$startedAt", + audioBookId = bookId, + status = status, + startedAt = startedAt, + completedAt = completedAt, + ) +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/ResumeDecisionTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/ResumeDecisionTest.kt new file mode 100644 index 00000000..1b59bd1b --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/ResumeDecisionTest.kt @@ -0,0 +1,55 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.DownloadStatus +import com.ninelivesaudio.app.entitlement.FreeTier +import com.ninelivesaudio.app.service.DownloadManager +import com.ninelivesaudio.app.ui.downloads.resumeNotice +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * On the free tier a failed download gives up the slot, so another book can + * download meanwhile. Retry on the failed one used to requeue it without + * asking, and it sat in Queued forever because the drain only runs the slot + * winner. Retry now asks the slot first and says so when it is taken. + */ +class ResumeDecisionTest { + + @Test + fun `retrying a failed download while another book holds the slot is refused`() = runBlocking { + assertEquals(ResumeDecision.BLOCKED_BY_FREE_SLOT, decideResume(DownloadStatus.Failed) { false }) + } + + @Test + fun `retrying a failed download with the slot free requeues it`() = runBlocking { + assertEquals(ResumeDecision.REQUEUE, decideResume(DownloadStatus.Failed) { true }) + } + + @Test + fun `a paused download the slot no longer favours is refused too`() = runBlocking { + assertEquals(ResumeDecision.BLOCKED_BY_FREE_SLOT, decideResume(DownloadStatus.Paused) { false }) + assertEquals(ResumeDecision.REQUEUE, decideResume(DownloadStatus.Paused) { true }) + } + + @Test + fun `other statuses are left alone without asking the slot`() = runBlocking { + for (status in listOf( + DownloadStatus.Queued, + DownloadStatus.Downloading, + DownloadStatus.Completed, + DownloadStatus.Cancelled, + DownloadStatus.Preparing, + )) { + assertEquals(ResumeDecision.IGNORE, decideResume(status) { error("slot asked for $status") }) + } + } + + @Test + fun `a refused retry tells the user why`() { + assertEquals(FreeTier.DOWNLOAD_SLOT_NOTICE, resumeNotice(DownloadManager.ResumeResult.BLOCKED_BY_FREE_SLOT)) + assertNull(resumeNotice(DownloadManager.ResumeResult.RESUMED)) + assertNull(resumeNotice(DownloadManager.ResumeResult.NOT_RESUMABLE)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/local/LocalScanCoordinatorTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/local/LocalScanCoordinatorTest.kt new file mode 100644 index 00000000..6abb3322 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/local/LocalScanCoordinatorTest.kt @@ -0,0 +1,114 @@ +package com.ninelivesaudio.app.service.local + +import com.ninelivesaudio.app.domain.model.Library +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Leaving Settings mid-scan used to cancel the scan, so nothing imported + * (#57). The coordinator runs the scan in its own app-wide scope and keeps + * the result until Settings shows it. + */ +class LocalScanCoordinatorTest { + + private val library = Library(id = "folder", name = "Audiobooks", isLocal = true, folderUri = "content://tree/a") + private val scanResult = LocalLibraryScanner.ScanResult( + books = emptyList(), + skippedCount = 0, + errorMessages = emptyList(), + foldersScanned = 1, + ) + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private fun coordinator(work: suspend (LocalScanRequest) -> LocalScanImport) = + LocalScanCoordinator(appScope, work) + + private suspend fun LocalScanCoordinator.awaitFinished(): LocalScanState.Finished = + withTimeout(5_000) { state.first { it is LocalScanState.Finished } as LocalScanState.Finished } + + @Test + fun `a scan started from a screen finishes after that screen is gone`() = runBlocking { + val release = CompletableDeferred() + var imported = false + val coordinator = coordinator { + release.await() + imported = true + LocalScanImport(library, scanResult) + } + + // Settings' own scope starts the scan, then is cleared as the user leaves. + val screenScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + screenScope.launch { coordinator.start(LocalScanRequest.Rescan(library)) }.join() + screenScope.cancel() + release.complete(Unit) + + val finished = coordinator.awaitFinished() + assertTrue(imported) + assertEquals(library, finished.imported?.library) + assertNull(finished.failure) + } + + @Test + fun `the scan shows as running until it ends`() = runBlocking { + val release = CompletableDeferred() + val coordinator = coordinator { release.await(); LocalScanImport(library, scanResult) } + val request = LocalScanRequest.AddFolder("content://tree/a") + + assertTrue(coordinator.start(request)) + assertEquals(LocalScanState.Running(request), coordinator.state.value) + + release.complete(Unit) + assertEquals(request, coordinator.awaitFinished().request) + } + + @Test + fun `a second scan while one runs is refused`() = runBlocking { + val release = CompletableDeferred() + var runs = 0 + val coordinator = coordinator { runs++; release.await(); LocalScanImport(library, scanResult) } + + assertTrue(coordinator.start(LocalScanRequest.Rescan(library))) + assertFalse(coordinator.start(LocalScanRequest.AddFolder("content://tree/b"))) + + release.complete(Unit) + coordinator.awaitFinished() + assertEquals(1, runs) + assertTrue(coordinator.start(LocalScanRequest.Rescan(library))) + } + + @Test + fun `a failed scan reports why`() = runBlocking { + val coordinator = coordinator { throw IllegalStateException("Permission revoked") } + + coordinator.start(LocalScanRequest.Rescan(library)) + + val finished = coordinator.awaitFinished() + assertNull(finished.imported) + assertEquals("Permission revoked", finished.failure) + } + + @Test + fun `a result stays until Settings acknowledges it`() = runBlocking { + val coordinator = coordinator { LocalScanImport(library, scanResult) } + coordinator.start(LocalScanRequest.Rescan(library)) + val finished = coordinator.awaitFinished() + + coordinator.acknowledge(finished.id + 1) + assertEquals(finished, coordinator.state.value) + + coordinator.acknowledge(finished.id) + assertEquals(LocalScanState.Idle, coordinator.state.value) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/bookdetail/DetailChaptersTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/bookdetail/DetailChaptersTest.kt new file mode 100644 index 00000000..ec676fd2 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/bookdetail/DetailChaptersTest.kt @@ -0,0 +1,62 @@ +package com.ninelivesaudio.app.ui.bookdetail + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.Chapter +import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The library list sync carries no chapters, so the detail screen hid its + * Chapters section for nearly every server book. It now asks the server for + * the single item when the row has none, and shows them without saving them. + */ +class DetailChaptersTest { + + private val listSynced = AudioBook(id = "b1", title = "Book") + private val one = Chapter(id = 0, start = 0.0, end = 60.0, title = "One") + private val two = Chapter(id = 1, start = 60.0, end = 120.0, title = "Two") + + @Test + fun `a list-synced server book asks for its chapters while connected`() { + assertTrue(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.CONNECTED)) + assertTrue(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.SYNCING)) + } + + @Test + fun `no fetch while offline or the server is unreachable`() { + assertFalse(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.OFFLINE)) + assertFalse(shouldFetchDetailChapters(listSynced, AppMode.AUDIOBOOKSHELF, ConnectionStatus.SERVER_UNREACHABLE)) + } + + @Test + fun `a row that already has chapters does not refetch`() { + val withChapters = listSynced.copy(chapters = listOf(one)) + assertFalse(shouldFetchDetailChapters(withChapters, AppMode.AUDIOBOOKSHELF, ConnectionStatus.CONNECTED)) + } + + @Test + fun `scanned-local and archived books never ask the server`() { + assertFalse(shouldFetchDetailChapters(listSynced.copy(isLocal = true), AppMode.LOCAL, ConnectionStatus.CONNECTED)) + assertFalse(shouldFetchDetailChapters(listSynced, AppMode.LOCAL, ConnectionStatus.CONNECTED)) + assertFalse(shouldFetchDetailChapters(listSynced.copy(archivedAt = 1L), AppMode.AUDIOBOOKSHELF, ConnectionStatus.CONNECTED)) + } + + @Test + fun `fetched chapters fill an empty row, in order`() { + assertEquals(listOf(one, two), detailChapters(emptyList(), listOf(two, one))) + } + + @Test + fun `stored chapters win over fetched ones`() { + assertEquals(listOf(one), detailChapters(listOf(one), listOf(one, two))) + } + + @Test + fun `nothing stored and nothing fetched shows no chapters`() { + assertEquals(emptyList(), detailChapters(emptyList(), null)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/components/ThumbnailCoverUrlTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/components/ThumbnailCoverUrlTest.kt new file mode 100644 index 00000000..32868525 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/components/ThumbnailCoverUrlTest.kt @@ -0,0 +1,68 @@ +package com.ninelivesaudio.app.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * List rows ask Audiobookshelf for a smaller cover with `width=`. Only server + * item cover URLs change. Saved covers and anything unfamiliar pass through. + */ +class ThumbnailCoverUrlTest { + + @Test + fun `a server cover gets a width`() { + assertEquals( + "https://abs.example.com/api/items/li_1/cover?width=240", + thumbnailCoverUrl("https://abs.example.com/api/items/li_1/cover", 240), + ) + } + + @Test + fun `a server under a subpath still gets a width`() { + assertEquals( + "http://nas.local:8080/abs/api/items/li_1/cover?width=240", + thumbnailCoverUrl("http://nas.local:8080/abs/api/items/li_1/cover", 240), + ) + } + + @Test + fun `an existing query string keeps its values`() { + assertEquals( + "https://abs.example.com/api/items/li_1/cover?ts=123&width=240", + thumbnailCoverUrl("https://abs.example.com/api/items/li_1/cover?ts=123", 240), + ) + } + + @Test + fun `a URL that already names a width is left alone`() { + val url = "https://abs.example.com/api/items/li_1/cover?width=800" + + assertEquals(url, thumbnailCoverUrl(url, 240)) + } + + @Test + fun `saved covers on the device are untouched`() { + val file = "file:///storage/emulated/0/Android/data/x/files/Music/Book/cover.jpg" + val content = "content://com.android.externalstorage.documents/document/primary%3ABook%2Fcover.jpg" + + assertEquals(file, thumbnailCoverUrl(file, 240)) + assertEquals(content, thumbnailCoverUrl(content, 240)) + } + + @Test + fun `other images and broken URLs are untouched`() { + val other = "https://images.example.com/covers/li_1.jpg" + val broken = "https://abs example.com/api/items/li_1/cover" + + assertEquals(other, thumbnailCoverUrl(other, 240)) + assertEquals(broken, thumbnailCoverUrl(broken, 240)) + } + + @Test + fun `a dangling question mark does not double up`() { + assertEquals( + "https://abs.example.com/api/items/li_1/cover?width=240", + thumbnailCoverUrl("https://abs.example.com/api/items/li_1/cover?", 240), + ) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierBookLoadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierBookLoadTest.kt new file mode 100644 index 00000000..1655ce51 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierBookLoadTest.kt @@ -0,0 +1,142 @@ +package com.ninelivesaudio.app.ui.dossier + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.ListeningSession +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.Executors +import kotlin.time.Duration + +/** + * The Dossier used to load and decode every book in the library on the main + * thread to build stats for the few it had sessions for. At 50k books that + * froze each chip tap for seconds. It now asks for the listened books only. + */ +class DossierBookLoadTest { + + private val serverSettings = AppSettings( + appMode = AppMode.AUDIOBOOKSHELF, + selectedLibraryId = "lib", + ) + + @Test + fun `asks only for the books the sessions listened to, once each`() = runBlocking { + val requests = mutableListOf>>() + val sessions = listOf(session("a"), session("b"), session("a"), session("c")) + + val books = loadDossierBooks(sessions, serverSettings, fetchByIds = { libraryId, isLocal, ids -> + requests += Triple(libraryId, isLocal, ids) + ids.map { book(it) } + }) + + assertEquals(listOf(Triple("lib", false, listOf("a", "b", "c"))), requests) + assertEquals(setOf("a", "b", "c"), books.keys) + } + + @Test + fun `no sessions means no lookup at all`() = runBlocking { + var calls = 0 + val books = loadDossierBooks(emptyList(), serverSettings, fetchByIds = { _, _, _ -> + calls++ + emptyList() + }) + + assertEquals(0, calls) + assertTrue(books.isEmpty()) + } + + @Test + fun `no active library means no lookup and no books`() = runBlocking { + var calls = 0 + val books = loadDossierBooks( + listOf(session("a")), + AppSettings(appMode = AppMode.AUDIOBOOKSHELF), + fetchByIds = { _, _, ids -> + calls++ + ids.map { book(it) } + }, + ) + + assertEquals(0, calls) + assertTrue(books.isEmpty()) + } + + @Test + fun `local mode asks the local library for local books`() = runBlocking { + val settings = AppSettings( + appMode = AppMode.LOCAL, + selectedLibraryId = "server-lib", + selectedLocalLibraryId = "local-lib", + ) + var asked: Pair? = null + + loadDossierBooks(listOf(session("a")), settings, fetchByIds = { libraryId, isLocal, ids -> + asked = libraryId to isLocal + ids.map { book(it, libraryId = libraryId, isLocal = true) } + }) + + assertEquals("local-lib" to true, asked) + } + + @Test + fun `a book from another library or source is still dropped`() = runBlocking { + val books = loadDossierBooks( + listOf(session("good"), session("wrong-lib"), session("wrong-source")), + serverSettings, + fetchByIds = { _, _, _ -> + listOf( + book("good"), + book("wrong-lib", libraryId = "other"), + book("wrong-source", isLocal = true), + ) + }, + ) + + assertEquals(setOf("good"), books.keys) + } + + @Test + fun `the lookup and decoding run off the calling thread`() { + var mainThread: Thread? = null + val fakeMain = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "fake-main").also { mainThread = it } + } + try { + val lookupThread = runBlocking { + withContext(fakeMain.asCoroutineDispatcher()) { + var seen: Thread? = null + loadDossierBooks(listOf(session("a")), serverSettings, fetchByIds = { _, _, ids -> + seen = Thread.currentThread() + ids.map { book(it) } + }) + seen + } + } + + assertTrue(mainThread != null && lookupThread != null) + assertNotSame(mainThread, lookupThread) + } finally { + fakeMain.shutdownNow() + } + } + + private fun book(id: String, libraryId: String = "lib", isLocal: Boolean = false) = + AudioBook(id = id, libraryId = libraryId, isLocal = isLocal) + + private fun session(bookId: String) = ListeningSession( + id = "s-$bookId-${System.nanoTime()}", + libraryItemId = bookId, + currentTime = Duration.ZERO, + timeListening = Duration.ZERO, + startedAt = 0L, + updatedAt = 0L, + displayTitle = null, + ) +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierSupersededLoadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierSupersededLoadTest.kt new file mode 100644 index 00000000..a43ca429 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/dossier/DossierSupersededLoadTest.kt @@ -0,0 +1,93 @@ +package com.ninelivesaudio.app.ui.dossier + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Switching periods cancels the old load, but cancelling does not stop the + * CPU work it is already doing. The old load used to finish its totals and + * publish them over the newer report, with the newer period still selected. + */ +class DossierSupersededLoadTest { + + private val executor = Executors.newSingleThreadExecutor() + private val aggregation = executor.asCoroutineDispatcher() + + @After + fun tearDown() { + executor.shutdownNow() + } + + @Test + fun `a load superseded while aggregating never publishes`() = runBlocking { + var newestLoad = 1L + val published = Collections.synchronizedList(mutableListOf()) + val aggregating = CountDownLatch(1) + val finishAggregating = CountDownLatch(1) + + val oldLoad = launch(start = CoroutineStart.UNDISPATCHED) { + val mine = 1L + publishIfStillCurrent( + dispatcher = aggregation, + isCurrent = { mine == newestLoad }, + publish = { published += it }, + ) { + aggregating.countDown() + finishAggregating.await() + "30 day totals" + } + } + assertTrue(aggregating.await(10, TimeUnit.SECONDS)) + + // What a period switch does: a newer load takes over and the old job + // is cancelled while its aggregation is still running. + newestLoad = 2L + oldLoad.cancel() + finishAggregating.countDown() + oldLoad.join() + withContext(aggregation) {} + + assertEquals(emptyList(), published.toList()) + } + + @Test + fun `a load superseded without being cancelled still never publishes`() = runBlocking { + var newestLoad = 1L + val published = mutableListOf() + + publishIfStillCurrent( + dispatcher = aggregation, + isCurrent = { newestLoad == 1L }, + publish = { published += it }, + ) { + newestLoad = 2L + "30 day totals" + } + + assertEquals(emptyList(), published) + } + + @Test + fun `the newest load publishes its report`() = runBlocking { + val published = mutableListOf() + + publishIfStillCurrent( + dispatcher = aggregation, + isCurrent = { true }, + publish = { published += it }, + ) { "all time totals" } + + assertEquals(listOf("all time totals"), published) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt new file mode 100644 index 00000000..adf18e60 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/downloads/DownloadListsTest.kt @@ -0,0 +1,167 @@ +package com.ninelivesaudio.app.ui.downloads + +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * A failed download used to fall out of both lists, so a server timeout made the + * book vanish from Downloads with no Retry. Failed rows now show in the active + * list unless a later attempt or a finished download speaks for the book. + */ +class DownloadListsTest { + + private fun row( + id: String, + status: DownloadStatus, + bookId: String = "book-$id", + startedAt: Long = 0L, + completedAt: Long? = null, + bookIsDownloaded: Boolean = false, + ) = DownloadRow( + download = DownloadItem( + id = id, + audioBookId = bookId, + title = id, + status = status, + startedAt = startedAt, + completedAt = completedAt, + ), + coverPath = "cover-$id", + bookIsDownloaded = bookIsDownloaded, + ) + + private fun DownloadLists.activeIds() = active.map { it.download.id } + private fun DownloadLists.completedIds() = completed.map { it.download.id } + + @Test + fun `a failed download shows in the active list`() { + val lists = splitDownloadRows(listOf(row("f", DownloadStatus.Failed))) + + assertEquals(listOf("f"), lists.activeIds()) + assertEquals(emptyList(), lists.completedIds()) + } + + @Test + fun `in progress statuses stay active and completed goes to completed`() { + val lists = splitDownloadRows( + listOf( + row("q", DownloadStatus.Queued, startedAt = 4), + row("d", DownloadStatus.Downloading, startedAt = 3), + row("p", DownloadStatus.Paused, startedAt = 2), + row("prep", DownloadStatus.Preparing, startedAt = 1), + row("c", DownloadStatus.Completed, completedAt = 9), + row("x", DownloadStatus.Cancelled), + ), + ) + + assertEquals(listOf("q", "d", "p", "prep"), lists.activeIds()) + assertEquals(listOf("c"), lists.completedIds()) + } + + @Test + fun `a failure for a book that got downloaded anyway stays hidden`() { + val lists = splitDownloadRows(listOf(row("f", DownloadStatus.Failed, bookIsDownloaded = true))) + + assertEquals(emptyList(), lists.activeIds()) + } + + @Test + fun `a later attempt for the same book replaces the old failure`() { + val lists = splitDownloadRows( + listOf( + row("old", DownloadStatus.Failed, bookId = "b", startedAt = 1), + row("new", DownloadStatus.Downloading, bookId = "b", startedAt = 2), + ), + ) + + assertEquals(listOf("new"), lists.activeIds()) + } + + @Test + fun `only the newest of two failures for one book shows`() { + val lists = splitDownloadRows( + listOf( + row("old", DownloadStatus.Failed, bookId = "b", startedAt = 1), + row("new", DownloadStatus.Failed, bookId = "b", startedAt = 2), + ), + ) + + assertEquals(listOf("new"), lists.activeIds()) + } + + @Test + fun `failures for other books do not hide each other`() { + val lists = splitDownloadRows( + listOf( + row("a", DownloadStatus.Failed, bookId = "a", startedAt = 1), + row("b", DownloadStatus.Completed, bookId = "b", completedAt = 5), + ), + ) + + assertEquals(listOf("a"), lists.activeIds()) + } + + @Test + fun `lists are newest first`() { + val lists = splitDownloadRows( + listOf( + row("older", DownloadStatus.Queued, startedAt = 1), + row("newer", DownloadStatus.Failed, startedAt = 2), + row("c1", DownloadStatus.Completed, completedAt = 10), + row("c2", DownloadStatus.Completed, completedAt = 20), + ), + ) + + assertEquals(listOf("newer", "older"), lists.activeIds()) + assertEquals(listOf("c2", "c1"), lists.completedIds()) + } + + @Test + fun `the row's cover comes along`() { + val lists = splitDownloadRows(listOf(row("q", DownloadStatus.Queued))) + + assertEquals("cover-q", lists.active.single().coverPath) + } + + // Clear All used to delete the download rows and keep the files, so every + // offline book vanished from Downloads. Book rows now list them. + + private fun book(id: String, title: String = id) = + DownloadedBook(audioBookId = id, title = title, coverPath = "book-cover-$id") + + @Test + fun `a downloaded book with no download row still shows as completed`() { + val lists = splitDownloadRows(emptyList(), listOf(book("b"))) + + assertEquals(listOf("${BOOK_ONLY_ID_PREFIX}b"), lists.completedIds()) + val entry = lists.completed.single() + assertEquals("b", entry.download.audioBookId) + assertEquals("book-cover-b", entry.coverPath) + assertEquals(DownloadStatus.Completed, entry.download.status) + } + + @Test + fun `a completed row speaks for its book, so the book is listed once`() { + val lists = splitDownloadRows( + listOf(row("c", DownloadStatus.Completed, bookId = "b", completedAt = 5)), + listOf(book("b")), + ) + + assertEquals(listOf("c"), lists.completedIds()) + } + + @Test + fun `books without rows follow the completed rows, by title`() { + val lists = splitDownloadRows( + listOf(row("c", DownloadStatus.Completed, bookId = "x", completedAt = 5)), + listOf(book("z", title = "zebra"), book("a", title = "Aardvark")), + ) + + assertEquals( + listOf("c", "${BOOK_ONLY_ID_PREFIX}a", "${BOOK_ONLY_ID_PREFIX}z"), + lists.completedIds(), + ) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/home/HomeEmptyCopyTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/home/HomeEmptyCopyTest.kt new file mode 100644 index 00000000..90971cde --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/home/HomeEmptyCopyTest.kt @@ -0,0 +1,76 @@ +package com.ninelivesaudio.app.ui.home + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * An empty Home must not tell someone with an imported library to add a + * folder (#59). Unplayed books point at the Library, a truly empty library + * keeps the original copy. + */ +class HomeEmptyCopyTest { + + @Test + fun `empty home picks its kind from mode and whether books exist`() { + assertEquals(HomeEmptyKind.LOCAL_NO_BOOKS, homeEmptyKind(isLocalMode = true, libraryHasBooks = false)) + assertEquals(HomeEmptyKind.LOCAL_UNPLAYED, homeEmptyKind(isLocalMode = true, libraryHasBooks = true)) + assertEquals(HomeEmptyKind.SERVER_NO_BOOKS, homeEmptyKind(isLocalMode = false, libraryHasBooks = false)) + assertEquals(HomeEmptyKind.SERVER_UNPLAYED, homeEmptyKind(isLocalMode = false, libraryHasBooks = true)) + } + + @Test + fun `local library with books never says add a folder`() { + val copy = homeEmptyCopy(HomeEmptyKind.LOCAL_UNPLAYED) + + assertEquals(HomeEmptyAction.OPEN_LIBRARY, copy.action) + assertTrue(copy.body!!.contains("Library")) + listOf(copy.subtitle, copy.body, copy.button).forEach { line -> + assertFalse(line, line!!.contains("folder", ignoreCase = true)) + assertFalse(line, line.contains("Settings")) + } + } + + @Test + fun `truly empty local library keeps the add a folder copy and Settings button`() { + val copy = homeEmptyCopy(HomeEmptyKind.LOCAL_NO_BOOKS) + + assertEquals("No local audio yet", copy.subtitle) + assertEquals("Add a folder of audiobooks in Settings to begin", copy.body) + assertEquals("Open Settings", copy.button) + assertEquals(HomeEmptyAction.OPEN_SETTINGS, copy.action) + } + + @Test + fun `empty server library keeps its original copy and flavor slot`() { + val copy = homeEmptyCopy(HomeEmptyKind.SERVER_NO_BOOKS) + + assertEquals("The Archive stands empty", copy.subtitle) + assertNull(copy.body) + assertEquals("Enter The Archive", copy.button) + assertEquals(HomeEmptyAction.OPEN_LIBRARY, copy.action) + } + + @Test + fun `server library with books no longer claims the Archive is empty`() { + val copy = homeEmptyCopy(HomeEmptyKind.SERVER_UNPLAYED) + + assertFalse(copy.subtitle.contains("empty", ignoreCase = true)) + // A fixed body, so the empty-library Ritual and Unhinged flavors + // ("Add something to the collection") never show for a full library. + assertTrue(copy.body!!.contains("Library")) + assertEquals(HomeEmptyAction.OPEN_LIBRARY, copy.action) + } + + @Test + fun `empty home copy has no dashes or semicolons`() { + HomeEmptyKind.entries.map(::homeEmptyCopy).forEach { copy -> + listOfNotNull(copy.subtitle, copy.body, copy.button).forEach { line -> + assertFalse(line, line.contains('\u2014') || line.contains('\u2013') || line.contains(';')) + assertFalse(line, line.contains(" - ")) + } + } + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryEffectiveViewModeTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryEffectiveViewModeTest.kt new file mode 100644 index 00000000..e68c6a57 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryEffectiveViewModeTest.kt @@ -0,0 +1,48 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A grouped view whose unlock was lost (a refund, a Play account pin) used to + * show a blank shelf: the books were clamped to ALL but the screen still laid + * out the stored SERIES view, which had no groups. The shelf now carries the + * mode in effect. + */ +class LibraryEffectiveViewModeTest { + + private val books = listOf( + AudioBook(id = "1", title = "One", seriesName = "Saga"), + AudioBook(id = "2", title = "Two", seriesName = "Saga"), + AudioBook(id = "3", title = "Three"), + ) + + @Test + fun `stored series without the unlock lays out the flat shelf with every book`() = runBlocking { + val shelf = arrangeShelf(books, SortMode.TITLE_AZ, storedViewMode = ViewMode.SERIES, isUnlocked = false) + + assertEquals(ViewMode.ALL, shelf.viewMode) + assertTrue(shelf.sections.isEmpty()) + assertEquals(setOf("1", "2", "3"), shelf.books.map { it.id }.toSet()) + } + + @Test + fun `stored series with the unlock groups the shelf`() = runBlocking { + val shelf = arrangeShelf(books, SortMode.TITLE_AZ, storedViewMode = ViewMode.SERIES, isUnlocked = true) + + assertEquals(ViewMode.SERIES, shelf.viewMode) + assertEquals(listOf("Saga", "Standalone/Unknown Series"), shelf.sections.map { it.key }) + } + + @Test + fun `every locked grouped view falls back to all`() = runBlocking { + for (mode in listOf(ViewMode.SERIES, ViewMode.AUTHOR, ViewMode.GENRE)) { + val shelf = arrangeShelf(books, SortMode.RECENTLY_PLAYED, storedViewMode = mode, isUnlocked = false) + assertEquals("$mode", ViewMode.ALL, shelf.viewMode) + assertEquals("$mode", 3, shelf.books.size) + } + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerCopyTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerCopyTest.kt new file mode 100644 index 00000000..01bf340f --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryErrorBannerCopyTest.kt @@ -0,0 +1,32 @@ +package com.ninelivesaudio.app.ui.library + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Library's error banner speaks like the rest of the screen ("Library + * could not be loaded") and never shows raw exception text, which used to + * read "Failed to load audiobooks: SQLiteException ...". + */ +class LibraryErrorBannerCopyTest { + + @Test + fun `each failure has its own line in the screen voice`() { + assertEquals("Libraries could not be loaded.", libraryErrorMessage(LibraryLoadFailure.LIBRARIES)) + assertEquals("Books could not be loaded.", libraryErrorMessage(LibraryLoadFailure.BOOKS)) + assertEquals("Saved books could not be read.", libraryErrorMessage(LibraryLoadFailure.SHELF)) + } + + @Test + fun `no line carries exception detail or a dash`() { + for (failure in LibraryLoadFailure.entries) { + val line = libraryErrorMessage(failure) + assertTrue(line, line.endsWith(".")) + for (banned in listOf(":", ";", "Failed", "\u2014", "\u2013", " - ")) { + assertFalse("$failure: $line", line.contains(banned)) + } + } + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupExpansionTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupExpansionTest.kt new file mode 100644 index 00000000..72665078 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupExpansionTest.kt @@ -0,0 +1,69 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Grouped views start collapsed only past 50 groups. Small libraries open as + * before, and a group the user opened or closed stays that way. + */ +class LibraryGroupExpansionTest { + + private fun sections(count: Int) = (1..count).map { index -> + GroupedSection( + key = "group-$index", + title = "Group $index", + books = listOf(AudioBook(id = "a-$index"), AudioBook(id = "b-$index")), + ) + } + + private fun List.headers() = filterIsInstance() + private fun List.rows() = filterIsInstance() + + @Test + fun `50 groups start open, 51 start closed`() { + val fifty = flattenGroupedItems(sections(COLLAPSE_GROUPS_ABOVE), emptyMap()) + assertTrue(fifty.headers().all { it.isExpanded }) + assertEquals(100, fifty.rows().size) + + val fiftyOne = flattenGroupedItems(sections(COLLAPSE_GROUPS_ABOVE + 1), emptyMap()) + assertTrue(fiftyOne.headers().none { it.isExpanded }) + assertEquals(51, fiftyOne.size) + } + + @Test + fun `a hand-picked choice beats the default both ways`() { + val big = flattenGroupedItems(sections(200), mapOf("group-7" to true)) + assertEquals(listOf("group-7"), big.headers().filter { it.isExpanded }.map { it.groupKey }) + assertEquals(listOf("a-7", "b-7"), big.rows().map { it.book.id }) + + val small = flattenGroupedItems(sections(3), mapOf("group-2" to false)) + assertEquals(listOf(true, false, true), small.headers().map { it.isExpanded }) + assertEquals(4, small.rows().size) + } + + @Test + fun `a tap flips what the header showed`() { + // Big view: closed by default, a tap opens it, a second tap closes it. + val opened = toggledGroupChoices(emptyMap(), "group-7", groupCount = 200) + assertEquals(mapOf("group-7" to true), opened) + assertEquals(mapOf("group-7" to false), toggledGroupChoices(opened, "group-7", groupCount = 200)) + + // Small view: open by default, so the first tap closes it. + assertEquals(mapOf("group-2" to false), toggledGroupChoices(emptyMap(), "group-2", groupCount = 3)) + } + + @Test + fun `choices survive a refilter that changes the group count`() { + val choices = toggledGroupChoices(emptyMap(), "group-7", groupCount = 200) + // A search narrows to 10 groups (all open by default) and back to 200. + val narrowed = flattenGroupedItems(sections(10), choices) + assertTrue(narrowed.headers().all { it.isExpanded }) + val widened = flattenGroupedItems(sections(200), choices) + assertTrue(widened.headers().single { it.groupKey == "group-7" }.isExpanded) + assertFalse(widened.headers().single { it.groupKey == "group-8" }.isExpanded) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsTest.kt new file mode 100644 index 00000000..faeff428 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryGroupHeaderSemanticsTest.kt @@ -0,0 +1,20 @@ +package com.ninelivesaudio.app.ui.library + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** TalkBack hears whether a group is open and what a tap on it does. */ +class LibraryGroupHeaderSemanticsTest { + + @Test + fun `an open group says so and offers to collapse`() { + assertEquals("Expanded", groupHeaderStateDescription(isExpanded = true)) + assertEquals("Collapse group", groupHeaderClickLabel(isExpanded = true)) + } + + @Test + fun `a closed group says so and offers to expand`() { + assertEquals("Collapsed", groupHeaderStateDescription(isExpanded = false)) + assertEquals("Expand group", groupHeaderClickLabel(isExpanded = false)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt index 9ca85d48..4ad7897a 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReconnectReloadTest.kt @@ -1,6 +1,7 @@ package com.ninelivesaudio.app.ui.library import com.ninelivesaudio.app.domain.model.Library +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -28,6 +29,20 @@ class LibraryReconnectReloadTest { assertTrue(shouldReloadLibrariesAfterSync(books, listOf("books", "podcasts"), listOf("books"))) } + @Test + fun `a progress pull from a check that held its record back still reloads a changed library list`() { + // The check pruned "books" from the saved list but wrote no record, + // because another library's full download waits for Wi-Fi. + assertEquals( + AfterProgressPull.RELOAD_LIBRARIES, + afterProgressPull(books, shownLibraryIds = listOf("books", "podcasts"), cachedLibraryIds = listOf("podcasts")), + ) + assertEquals( + AfterProgressPull.REFILTER, + afterProgressPull(books, shownLibraryIds = listOf("books", "podcasts"), cachedLibraryIds = listOf("podcasts", "books")), + ) + } + @Test fun `same libraries with one selected only refilters`() { assertFalse(shouldReloadLibrariesAfterSync(podcasts, listOf("books", "podcasts"), listOf("books", "podcasts"))) diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReturnReloadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReturnReloadTest.kt new file mode 100644 index 00000000..c9b45684 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryReturnReloadTest.kt @@ -0,0 +1,58 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The tabs keep the Library alive while another tab is open. A server with + * thousands of books took minutes to re-download on every return, so a return + * now keeps the shelf unless what it was loaded for changed. + */ +class LibraryReturnReloadTest { + + private val shown = ShelfIdentity(AppMode.AUDIOBOOKSHELF, "https://abs", "jeff", "books") + + @Test + fun `an unchanged return keeps the shelf`() { + assertFalse(shouldReloadOnLibraryReturn(shown, shown, lastFetchFailed = false)) + } + + @Test + fun `a library picked in Settings reloads`() { + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(activeLibraryId = "podcasts"), false)) + } + + @Test + fun `a server, account, or mode switch reloads`() { + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(serverUrl = "https://other"), false)) + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(username = "guest"), false)) + assertTrue(shouldReloadOnLibraryReturn(shown, shown.copy(appMode = AppMode.LOCAL), false)) + } + + @Test + fun `an empty failed shelf retries on return`() { + assertTrue(shouldReloadOnLibraryReturn(shown, shown, lastFetchFailed = true)) + } + + @Test + fun `the first entry leaves the shelf to the load already starting`() { + assertFalse(shouldReloadOnLibraryReturn(null, shown, lastFetchFailed = true)) + } + + @Test + fun `identity follows the active mode's library`() { + val settings = AppSettings( + appMode = AppMode.LOCAL, + serverUrl = "https://abs", + username = "jeff", + selectedLibraryId = "books", + selectedLocalLibraryId = "folder", + ) + assertEquals("folder", settings.shelfIdentity().activeLibraryId) + assertEquals("books", settings.copy(appMode = AppMode.AUDIOBOOKSHELF).shelfIdentity().activeLibraryId) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySortTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySortTest.kt new file mode 100644 index 00000000..62d4d15d --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySortTest.kt @@ -0,0 +1,109 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.domain.model.AudioBook +import kotlin.random.Random +import kotlin.time.Duration.Companion.seconds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * The shelf sort works out its keys once per book and the query no longer + * sorts first. The order must match the former SQL-then-Kotlin sort exactly. + */ +class LibrarySortTest { + + @Test + fun `every sort mode matches the former order on 1,000 random books`() { + val books = randomBooks(Random(42), 1_000) + // The former query returned books ORDER BY Title, then Kotlin sorted + // stably on lowercased keys. Now the query order is anything. + val formerInput = books.sortedBy { it.title } + val newInput = books.shuffled(Random(7)) + + for (mode in SortMode.entries) { + assertEquals( + "order differs for $mode", + formerSortBooks(formerInput, mode).map { it.id }, + sortBooks(newInput, mode).map { it.id }, + ) + } + } + + @Test + fun `groups keep the shelf order without sorting again`() { + val books = randomBooks(Random(3), 400) + for (mode in SortMode.entries) { + val sorted = sortBooks(books.shuffled(Random(11)), mode) + val sections = buildGroupedSections(sorted, ViewMode.AUTHOR, mode) + sections.forEach { section -> + assertEquals( + "group ${section.key} out of order for $mode", + formerSortBooks(section.books.sortedBy { it.title }, mode).map { it.id }, + section.books.map { it.id }, + ) + } + } + } + + @Test + fun `the shelf query leaves sorting to Kotlin`() { + val sql = buildLibrarySql(tab = 0, hideFinished = true, downloadedOnly = false, hasSearch = true) + assertFalse(sql.contains("ORDER BY")) + } + + private fun randomBooks(random: Random, count: Int): List { + val words = listOf("alpha", "Beta", "gamma", "DELTA", "Echo", "zulu", "\u00e9lan", "omega") + val authors = listOf("Ann", "ann", "Bob", "", "Cy", "cy", "Dee") + val seen = mutableSetOf() + return (0 until count).map { index -> + var title: String + do { + // Case-only twins ("Echo 3", "echo 3") tie on the lowercased key. + val word = words[random.nextInt(words.size)] + val cased = if (random.nextBoolean()) word.lowercase() else word.replaceFirstChar { it.uppercase() } + title = "$cased ${random.nextInt(400)}" + } while (!seen.add(title)) + AudioBook( + id = "id-$index", + title = title, + author = authors[random.nextInt(authors.size)], + duration = (random.nextInt(4) * 3_600).seconds, + progress = listOf(0.0, 0.0, 0.25, 0.5, 1.0)[random.nextInt(5)], + addedAt = if (random.nextInt(4) == 0) null else random.nextLong(5) * 1_000L, + lastPlayedAt = if (random.nextInt(3) == 0) null else random.nextLong(5) * 1_000L, + genres = listOf("Horror"), + ) + } + } + + /** The sort as it was before keys were worked out once, kept here as the reference. */ + private fun formerSortBooks(books: List, sortMode: SortMode): List { + val sequence = books.asSequence() + return when (sortMode) { + SortMode.RECENTLY_ADDED -> sequence.sortedWith( + compareByDescending { it.addedAt ?: Long.MIN_VALUE } + .thenBy { it.title.lowercase() } + ) + SortMode.TITLE_AZ -> sequence.sortedBy { it.title.lowercase() } + SortMode.TITLE_ZA -> sequence.sortedByDescending { it.title.lowercase() } + SortMode.AUTHOR_AZ -> sequence.sortedWith(compareBy({ it.author.lowercase() }, { it.title.lowercase() })) + SortMode.AUTHOR_ZA -> sequence.sortedWith( + compareByDescending { it.author.lowercase() }.thenByDescending { it.title.lowercase() } + ) + SortMode.PROGRESS_HIGH -> sequence.sortedByDescending { it.progressPercent } + SortMode.PROGRESS_LOW -> sequence.sortedBy { it.progressPercent } + SortMode.DURATION_LONG -> sequence.sortedByDescending { it.duration.inWholeSeconds } + SortMode.DURATION_SHORT -> sequence.sortedBy { it.duration.inWholeSeconds } + SortMode.RECENTLY_PLAYED -> sequence.sortedWith( + compareByDescending { it.lastPlayedAt ?: Long.MIN_VALUE } + .thenBy { it.title.lowercase() } + ) + SortMode.UNPLAYED_FIRST -> sequence.sortedWith( + compareBy { if (it.hasProgress) 1 else 0 } + .thenBy { it.title.lowercase() } + ) + }.toList() + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt index 278c81ba..2d50134f 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySyncGateTest.kt @@ -36,4 +36,37 @@ class LibrarySyncGateTest { fun `local library never syncs when offline`() { assertFalse(shouldSyncOnLibraryLoad(isLocalLibrary = true, isOnline = false)) } + + @Test + fun `opening the Library right after the app's own check sends nothing`() { + assertTrue( + libraryLoadSkipsNetwork(explicit = false, checkDue = false, savedLibraryIsSynced = true, savedLibraryHasBooks = true), + ) + } + + @Test + fun `opening the Library checks when the app's check is two minutes old or more`() { + assertFalse( + libraryLoadSkipsNetwork(explicit = false, checkDue = true, savedLibraryIsSynced = true, savedLibraryHasBooks = true), + ) + } + + @Test + fun `pull to refresh always goes to the server`() { + assertFalse( + libraryLoadSkipsNetwork(explicit = true, checkDue = false, savedLibraryIsSynced = true, savedLibraryHasBooks = true), + ) + } + + @Test + fun `a library the app's check does not cover still loads from the server`() { + // Podcast libraries are not checked in the background. + assertFalse( + libraryLoadSkipsNetwork(explicit = false, checkDue = false, savedLibraryIsSynced = false, savedLibraryHasBooks = true), + ) + // Nothing cached yet (fresh sign-in, new server). + assertFalse( + libraryLoadSkipsNetwork(explicit = false, checkDue = false, savedLibraryIsSynced = true, savedLibraryHasBooks = false), + ) + } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/navigation/TabNavigationTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/navigation/TabNavigationTest.kt new file mode 100644 index 00000000..8118be30 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/navigation/TabNavigationTest.kt @@ -0,0 +1,37 @@ +package com.ninelivesaudio.app.ui.navigation + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The tabs pop to a route that has to be on the back stack, or Navigation + * ignores the pop and every tap pushes a new screen. On first run the graph's + * start destination is Welcome, which onboarding removes, so tab taps piled + * up (#58). The tab root is Home on every launch, and onboarding leaves Home + * under whatever screen it lands on. + */ +class TabNavigationTest { + + @Test + fun `the tab root is where a normal launch starts`() { + assertEquals(startDestinationFor(onboardingComplete = true), TAB_ROOT_ROUTE) + } + + @Test + fun `onboarding leaves Home under the screen it lands on`() { + assertEquals(listOf(Routes.HOME, Routes.SETTINGS), postOnboardingRoutes(Routes.SETTINGS)) + } + + @Test + fun `onboarding that lands on Home leaves just Home`() { + assertEquals(listOf(Routes.HOME), postOnboardingRoutes(Routes.HOME)) + } + + @Test + fun `the first-run stack never keeps Welcome`() { + listOf(Routes.HOME, Routes.LIBRARY, Routes.SETTINGS).forEach { target -> + assertEquals(false, Routes.WELCOME in postOnboardingRoutes(target)) + assertEquals(TAB_ROOT_ROUTE, postOnboardingRoutes(target).first()) + } + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt index cfa75ba9..c50dfd3d 100644 --- a/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/ui/player/BookmarkPublicationTest.kt @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.ui.player +import com.ninelivesaudio.app.data.remote.RemoteResult import com.ninelivesaudio.app.domain.model.Bookmark import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -10,6 +11,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class BookmarkPublicationTest { @@ -22,7 +25,7 @@ class BookmarkPublicationTest { val publication = BookmarkPublication() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val aStarted = CompletableDeferred() - val releaseA = CompletableDeferred>() + val releaseA = CompletableDeferred>>() var visible = emptyList() val aRequest = publication.replace("book-a") @@ -32,17 +35,17 @@ class BookmarkPublicationTest { aStarted.complete(Unit) releaseA.await() } - }) { visible = it }) + }) { visible = bookmarkListState(visible, it).bookmarks }) aStarted.await() val bRequest = publication.replace("book-b") - val bJob = requireNotNull(publication.launch(scope, bRequest, { bBookmarks }) { visible = it }) + val bJob = requireNotNull(publication.launch(scope, bRequest, { RemoteResult.Ok(bBookmarks) }) { visible = bookmarkListState(visible, it).bookmarks }) bJob.join() assertEquals(bBookmarks, visible) // This simulates a repository that catches cancellation and still // returns its already-started A response. - releaseA.complete(aBookmarks) + releaseA.complete(RemoteResult.Ok(aBookmarks)) aJob.join() assertEquals(bBookmarks, visible) scope.cancel() @@ -63,11 +66,11 @@ class BookmarkPublicationTest { releaseA.await() } error("A failed after B became current") - }) { visible = it }) + }) { visible = bookmarkListState(visible, it).bookmarks }) aStarted.await() val bRequest = publication.replace("book-b") - val bJob = requireNotNull(publication.launch(scope, bRequest, { bBookmarks }) { visible = it }) + val bJob = requireNotNull(publication.launch(scope, bRequest, { RemoteResult.Ok(bBookmarks) }) { visible = bookmarkListState(visible, it).bookmarks }) bJob.join() assertEquals(bBookmarks, visible) @@ -76,4 +79,31 @@ class BookmarkPublicationTest { assertEquals(bBookmarks, visible) scope.cancel() } + + @Test + fun `a thrown load publishes a failure, not an empty list`() = runBlocking { + val publication = BookmarkPublication() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + var published: RemoteResult>? = null + + val request = publication.replace("book-a") + requireNotNull(publication.launch(scope, request, { error("socket closed") }) { published = it }).join() + + assertTrue(published is RemoteResult.Failed) + scope.cancel() + } + + @Test + fun `a failed refresh keeps the bookmarks on screen and flags the failure`() { + val state = bookmarkListState(aBookmarks, RemoteResult.Failed("timeout")) + assertEquals(aBookmarks, state.bookmarks) + assertTrue(state.loadFailed) + } + + @Test + fun `a good load clears the failure flag`() { + val state = bookmarkListState(aBookmarks, RemoteResult.Ok(bBookmarks)) + assertEquals(bBookmarks, state.bookmarks) + assertFalse(state.loadFailed) + } } diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/ConnectFieldTrimTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/ConnectFieldTrimTest.kt new file mode 100644 index 00000000..e963d41e --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/ConnectFieldTrimTest.kt @@ -0,0 +1,56 @@ +package com.ninelivesaudio.app.ui.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The server URL and username fields used to trim on every keystroke, which ate + * the space in "john smith" as it was typed. The fields now keep the raw text + * and Connect trims the ends. + */ +class ConnectFieldTrimTest { + + @Test + fun `connect trims the ends of the server URL and username`() { + val state = SettingsViewModel.UiState( + serverUrl = " https://abs.example.com ", + username = " john smith ", + ).trimmedForConnect() + + assertEquals("https://abs.example.com", state.serverUrl) + assertEquals("john smith", state.username) + } + + @Test + fun `the space inside a username survives connect`() { + val state = SettingsViewModel.UiState(username = "john smith").trimmedForConnect() + + assertEquals("john smith", state.username) + } + + @Test + fun `connect leaves the password exactly as typed`() { + val state = SettingsViewModel.UiState(password = " pass word ").trimmedForConnect() + + assertEquals(" pass word ", state.password) + } + + @Test + fun `a whitespace only server URL counts as blank at connect`() { + val state = SettingsViewModel.UiState(serverUrl = " ").trimmedForConnect() + + assertEquals("", state.serverUrl) + } + + @Test + fun `the fingerprint host ignores spaces around the typed URL`() { + assertEquals("abs.example.com", extractServerHost(" https://ABS.example.com:13378 ")) + } + + @Test + fun `a blank or broken URL has no fingerprint host`() { + assertNull(extractServerHost(" ")) + assertNull(extractServerHost("https://bad host")) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/LocalScanUiStateTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/LocalScanUiStateTest.kt new file mode 100644 index 00000000..77d3ded3 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/LocalScanUiStateTest.kt @@ -0,0 +1,83 @@ +package com.ninelivesaudio.app.ui.settings + +import com.ninelivesaudio.app.domain.model.Library +import com.ninelivesaudio.app.service.local.LocalLibraryScanner +import com.ninelivesaudio.app.service.local.LocalScanImport +import com.ninelivesaudio.app.service.local.LocalScanRequest +import com.ninelivesaudio.app.service.local.LocalScanState +import com.ninelivesaudio.app.service.local.ScannedLocalBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The folder scan runs app-wide now (#57), and Settings only shows it. These + * pin that Settings shows the same thing it did when the scan ran inside it. + */ +class LocalScanUiStateTest { + + private val library = Library(id = "folder", name = "Audiobooks", isLocal = true, folderUri = "content://tree/a") + private val tenBooks = LocalLibraryScanner.ScanResult( + books = (1..10).map { ScannedLocalBook(id = "b$it", title = "b$it", author = "A", tracks = emptyList()) }, + skippedCount = 0, + errorMessages = emptyList(), + foldersScanned = 10, + ) + + @Test + fun `a running scan clears the last result`() { + val before = SettingsViewModel.UiState(lastScanMessage = "9 books found", errorMessage = "old") + + val shown = before.withLocalScan(LocalScanState.Running(LocalScanRequest.Rescan(library))) + + assertTrue(shown.isScanning) + assertNull(shown.lastScanMessage) + assertNull(shown.errorMessage) + } + + @Test + fun `a finished rescan shows its count`() { + val finished = LocalScanState.Finished( + id = 1, + request = LocalScanRequest.Rescan(library), + imported = LocalScanImport(library, tenBooks), + failure = null, + ) + + val shown = SettingsViewModel.UiState(isScanning = true).withLocalScan(finished) + + assertFalse(shown.isScanning) + assertEquals("10 books found", shown.lastScanMessage) + assertEquals("Rescan complete: 10 books found", shown.successMessage) + } + + @Test + fun `a finished Add Folder selects the new library`() { + val finished = LocalScanState.Finished( + id = 1, + request = LocalScanRequest.AddFolder("content://tree/a"), + imported = LocalScanImport(library, tenBooks), + failure = null, + ) + + val shown = SettingsViewModel.UiState(isScanning = true, inaccessibleLocalLibraryIds = setOf("folder")) + .withLocalScan(finished) + + assertEquals(library, shown.selectedLocalLibrary) + assertTrue(shown.inaccessibleLocalLibraryIds.isEmpty()) + assertEquals("10 books imported", shown.successMessage) + } + + @Test + fun `a failed scan says which action failed`() { + fun failed(request: LocalScanRequest) = + LocalScanState.Finished(id = 1, request = request, imported = null, failure = "No access") + + val start = SettingsViewModel.UiState(isScanning = true) + assertEquals("Rescan failed: No access", start.withLocalScan(failed(LocalScanRequest.Rescan(library))).errorMessage) + assertEquals("Scan failed: No access", start.withLocalScan(failed(LocalScanRequest.AddFolder("u"))).errorMessage) + assertFalse(start.withLocalScan(failed(LocalScanRequest.Rescan(library))).isScanning) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsSavedSelectionTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsSavedSelectionTest.kt new file mode 100644 index 00000000..1ffbd097 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsSavedSelectionTest.kt @@ -0,0 +1,57 @@ +package com.ninelivesaudio.app.ui.settings + +import com.ninelivesaudio.app.domain.model.AppMode +import com.ninelivesaudio.app.domain.model.AppSettings +import com.ninelivesaudio.app.domain.model.Library +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The tabs keep Settings alive while another tab is open. A library picked on + * the Library tab meanwhile has to show in Settings on return, or the local + * cleanup actions ("All Books", "All Deleted Books") target the old library. + */ +class SettingsSavedSelectionTest { + + private val folderA = Library(id = "a", name = "A", isLocal = true, folderUri = "content://a") + private val folderB = Library(id = "b", name = "B", isLocal = true, folderUri = "content://b") + private val books = Library(id = "books", name = "Books") + private val podcasts = Library(id = "podcasts", name = "Podcasts") + + private val shown = SettingsViewModel.UiState( + localLibraries = listOf(folderA, folderB), + selectedLocalLibrary = folderA, + libraries = listOf(books, podcasts), + selectedLibrary = books, + ) + + @Test + fun `a local folder picked on another tab shows here`() { + val settings = AppSettings(appMode = AppMode.LOCAL, selectedLocalLibraryId = "b", selectedLibraryId = "books") + + assertEquals(folderB, shown.withSavedSelections(settings).selectedLocalLibrary) + } + + @Test + fun `a server library picked on another tab shows here`() { + val settings = AppSettings(appMode = AppMode.AUDIOBOOKSHELF, selectedLibraryId = "podcasts", selectedLocalLibraryId = "a") + + assertEquals(podcasts, shown.withSavedSelections(settings).selectedLibrary) + } + + @Test + fun `an unchanged selection stays put`() { + val settings = AppSettings(selectedLocalLibraryId = "a", selectedLibraryId = "books") + + assertEquals(shown, shown.withSavedSelections(settings)) + } + + @Test + fun `an id this screen does not know yet leaves the pick alone`() { + val settings = AppSettings(selectedLocalLibraryId = "new-folder", selectedLibraryId = null) + + val after = shown.withSavedSelections(settings) + assertEquals(folderA, after.selectedLocalLibrary) + assertEquals(books, after.selectedLibrary) + } +}