diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1e37b22..2a75b059 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,12 @@ android { compose = true buildConfig = true } + + // The exported Room schemas, so MigrationTestHelper can build a database + // at any old version in instrumented migration tests. + sourceSets { + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } } ksp { @@ -282,6 +288,7 @@ dependencies { testImplementation(libs.androidx.work.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.room.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/10.json b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/10.json new file mode 100644 index 00000000..02b76f16 --- /dev/null +++ b/app/schemas/com.ninelivesaudio.app.data.local.AppDatabase/10.json @@ -0,0 +1,586 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "3b58eaef56fce5fd3a132e1a72189c37", + "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, `SearchText` TEXT NOT NULL DEFAULT '', 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" + }, + { + "fieldPath": "searchText", + "columnName": "SearchText", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "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, '3b58eaef56fce5fd3a132e1a72189c37')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/MigrationUpgradeInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/MigrationUpgradeInstrumentedTest.kt new file mode 100644 index 00000000..0fe92934 --- /dev/null +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/MigrationUpgradeInstrumentedTest.kt @@ -0,0 +1,125 @@ +package com.ninelivesaudio.app.data.local + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.ninelivesaudio.app.data.local.migration.ALL_MIGRATIONS +import com.ninelivesaudio.app.data.local.migration.MIGRATION_9_10 +import com.ninelivesaudio.app.data.local.migration.SEARCH_BACKFILL_PAGE_SIZE +import com.ninelivesaudio.app.data.repository.buildLibrarySql +import com.ninelivesaudio.app.data.repository.buildLibrarySqlArgs +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A real v9 database, built from the exported 9.json, upgraded to v10. Room + * validates the result against the v10 entities, so a column that differs + * from what the entity declares fails here instead of on a user's phone. + */ +@RunWith(AndroidJUnit4::class) +class MigrationUpgradeInstrumentedTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + ) + + @Test + fun nineToTenFillsTheSearchColumnForEveryBookAndKeepsTheData() { + helper.createDatabase(TEST_DB, 9).use { db -> + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("emile", "Émile", "Jean-Jacques ROUSSEAU", "Œuvres", "Zoë")) + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("dune", "DUNE", null, null, null)) + // Past one backfill page, so the paging is exercised on real SQLite. + for (n in 0 until SEARCH_BACKFILL_PAGE_SIZE + 5) { + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("bulk-$n", "Bulk $n", "Author $n", null, null)) + } + db.insert( + "PlaybackProgress", + SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("AudioBookId", "emile") + put("PositionSeconds", 42.5) + put("IsFinished", 0) + put("UpdatedAt", "2026-09-20T08:30:00Z") + }, + ) + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 10, true, MIGRATION_9_10) + + db.query("SELECT Id, Title, Author, SeriesName, Narrator, SearchText, Progress, GenresJson FROM AudioBooks").use { rows -> + assertEquals(SEARCH_BACKFILL_PAGE_SIZE + 7, rows.count) + while (rows.moveToNext()) { + val expected = bookSearchText(rows.getString(1), rows.getString(2), rows.getString(3), rows.getString(4)) + assertEquals(rows.getString(0), expected, rows.getString(5)) + assertEquals(0.25, rows.getDouble(6), 0.0) + assertEquals("[\"Horror\"]", rows.getString(7)) + } + } + db.query("SELECT Title, SearchText FROM AudioBooks WHERE Id = 'emile'").use { rows -> + rows.moveToFirst() + assertEquals("Émile", rows.getString(0)) + assertEquals("emile\u001Fjean-jacques rousseau\u001Foeuvres\u001Fzoe", rows.getString(1)) + } + db.query("SELECT PositionSeconds, UpdatedAt FROM PlaybackProgress WHERE AudioBookId = 'emile'").use { rows -> + rows.moveToFirst() + assertEquals(42.5, rows.getDouble(0), 0.0) + assertEquals("2026-09-20T08:30:00Z", rows.getString(1)) + } + } + + @Test + fun anUpgradedLibraryFindsAccentedAndUppercaseTitlesWithNoResync() = runBlocking { + helper.createDatabase(TEST_DB, 9).use { db -> + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("emile", "Émile", "Rousseau", null, null)) + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("dune", "DUNE", "Frank Herbert", null, null)) + db.insert("AudioBooks", SQLiteDatabase.CONFLICT_FAIL, v9Book("pct", "100% Pure", "Someone", null, null)) + } + + val database = Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + TEST_DB, + ).addMigrations(*ALL_MIGRATIONS).allowMainThreadQueries().build() + helper.closeWhenFinished(database) + + suspend fun shelfSearch(query: String) = database.audioBookDao().getFilteredBooks( + SimpleSQLiteQuery( + buildLibrarySql(tab = 0, hideFinished = false, downloadedOnly = false, hasSearch = true), + buildLibrarySqlArgs(LIBRARY, query), + ) + ).map { it.id }.toSet() + + assertEquals(setOf("emile"), shelfSearch("Emile")) + assertEquals(setOf("emile"), shelfSearch("émile")) + assertEquals(setOf("dune"), shelfSearch("dune")) + assertEquals(setOf("pct"), shelfSearch("%")) + } + + private fun v9Book(id: String, title: String, author: String?, seriesName: String?, narrator: String?) = + ContentValues().apply { + put("Id", id) + put("LibraryId", LIBRARY) + put("IsLocal", 0) + put("Title", title) + put("Author", author) + put("SeriesName", seriesName) + put("Narrator", narrator) + put("DurationSeconds", 120.0) + put("Progress", 0.25) + put("GenresJson", "[\"Horror\"]") + } + + private companion object { + const val TEST_DB = "migration-upgrade-test.db" + const val LIBRARY = "lib" + } +} 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 index 757b1ed0..59da6362 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/data/local/ShelfProjectionInstrumentedTest.kt @@ -102,6 +102,33 @@ class ShelfProjectionInstrumentedTest { assertEquals(setOf("pct", "num"), shelfSearch("100")) } + @Test + fun searchIgnoresAccentsAndCaseInEveryField() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll( + listOf( + book("emile", "Émile"), + book("dune", "DUNE"), + book("by-author", "Other").copy(author = "Jo Nesbø"), + book("by-narrator", "Another").copy(narrator = "Zoë Wanamaker"), + book("plain", "Nothing Here"), + ) + ) + + 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("emile"), shelfSearch("Emile")) + assertEquals(setOf("emile"), shelfSearch("ÉMILE")) + assertEquals(setOf("dune"), shelfSearch("dune")) + assertEquals(setOf("by-author"), shelfSearch("nesbo")) + assertEquals(setOf("by-narrator"), shelfSearch("zoe")) + } + private fun book( id: String, title: String, diff --git a/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt index e64d18cd..1b216c69 100644 --- a/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ninelivesaudio/app/service/AutoBrowseQueryInstrumentedTest.kt @@ -105,6 +105,24 @@ class AutoBrowseQueryInstrumentedTest { assertEquals(1, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("wolf")!!, 1)) } + @Test + fun searchIgnoresAccentsAndCase() = runBlocking { + val dao = database.audioBookDao() + dao.upsertAll( + listOf( + book(id = "emile", title = "Émile"), + book(id = "dune", title = "DUNE"), + book(id = "other", title = "Other"), + ), + ) + + fun ids(rows: List) = rows.map { it.id } + + assertEquals(listOf("emile"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("emile")!!, 100, 0))) + assertEquals(listOf("dune"), ids(dao.searchAutoPage(LIBRARY, 0, autoSearchPattern("Dune")!!, 100, 0))) + assertEquals(1, dao.countAutoSearch(LIBRARY, 0, autoSearchPattern("EMILE")!!, 100)) + } + private suspend fun seed() { // Mixed case and distinct titles, so NOCASE and lowercase() agree on order. val titles = (0 until 40).map { index -> 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 29758fdd..16079275 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 = 9, + version = 10, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/SearchText.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/SearchText.kt new file mode 100644 index 00000000..b8f6cf5c --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/SearchText.kt @@ -0,0 +1,28 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.domain.util.foldForSearch + +/** + * Keeps the searched fields apart in the search column. A query cannot type + * it, so "dune frank" never matches across a title and an author. + */ +private const val SEARCH_FIELD_SEPARATOR = '\u001F' + +/** + * The AudioBooks.SearchText value for a book: title, author, series and + * narrator, each folded by [foldForSearch]. The Library search and Android + * Auto search match a folded query against this one column, so accents and + * case never cause a miss (issue #61). Filled on every write by + * [com.ninelivesaudio.app.data.local.entity.AudioBookEntity], and for rows + * that predate it by the 9 to 10 migration. + */ +internal fun bookSearchText(title: String?, author: String?, seriesName: String?, narrator: String?): String = + buildString { + append(foldForSearch(title.orEmpty())) + append(SEARCH_FIELD_SEPARATOR) + append(foldForSearch(author.orEmpty())) + append(SEARCH_FIELD_SEPARATOR) + append(foldForSearch(seriesName.orEmpty())) + append(SEARCH_FIELD_SEPARATOR) + append(foldForSearch(narrator.orEmpty())) + } 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 index 1edeb291..3ed32ad1 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/SqlLike.kt @@ -1,5 +1,7 @@ package com.ninelivesaudio.app.data.local +import com.ninelivesaudio.app.domain.util.foldForSearch + /** * 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 @@ -14,3 +16,10 @@ internal fun escapeLike(text: String): String = buildString(text.length + 4) { /** A LIKE pattern that finds [text] anywhere, for a LIKE that declares ESCAPE '\'. */ internal fun containsLikePattern(text: String): String = "%${escapeLike(text)}%" + +/** + * A LIKE pattern that finds a typed [query] anywhere in AudioBooks.SearchText, + * folded the way that column is (see [bookSearchText]), for a LIKE that + * declares ESCAPE '\'. + */ +internal fun searchTextLikePattern(query: String): String = containsLikePattern(foldForSearch(query)) 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 462eeea6..ae9afdd9 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 @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RawQuery +import androidx.room.Transaction import androidx.sqlite.db.SupportSQLiteQuery import com.ninelivesaudio.app.data.local.entity.AudioBookEntity import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow @@ -71,6 +72,16 @@ interface AudioBookDao { @Query("SELECT LocalPath FROM AudioBooks WHERE Id != :excludeId AND LocalPath IS NOT NULL AND LocalPath != ''") suspend fun getLocalPathsExcept(excludeId: String): List + /** + * Ids of every other book stored at a file path (not a content URI), for + * the shared-folder check on delete. Only those rows need their track lists. + */ + @Query( + "SELECT Id FROM AudioBooks WHERE Id != :excludeId AND LocalPath IS NOT NULL AND LocalPath != '' " + + "AND LocalPath NOT LIKE 'content:%'" + ) + suspend fun getFilePathBookIdsExcept(excludeId: String): List + @Query("SELECT * FROM AudioBooks WHERE Id = :id") fun observeById(id: String): Flow @@ -208,8 +219,9 @@ interface AudioBookDao { 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). + * One page of Android Auto search hits in one library and source: the + * folded search column (title, author, series, narrator) contains + * [pattern], a folded and escaped LIKE pattern (backslash escapes). */ @Query( """ @@ -217,7 +229,7 @@ interface AudioBookDao { 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 '\') + AND SearchText LIKE :pattern ESCAPE '\' ORDER BY Title COLLATE NOCASE, Id LIMIT :limit OFFSET :offset """ @@ -230,7 +242,7 @@ interface AudioBookDao { 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 '\') + AND SearchText LIKE :pattern ESCAPE '\' LIMIT :cap ) """ @@ -335,7 +347,14 @@ interface AudioBookDao { /** * Dynamic filtered shelf query, built by AudioBookRepository.getFilteredBooks(). * Light rows only (see [ShelfBookRow]), never `ab.*`. + * + * One transaction, so the whole read is one snapshot. A big shelf spans + * several cursor windows, and each window outside a transaction is read + * afresh: a sync page saved in between (REPLACE gives a re-saved book a + * new rowid, the end of this unordered read) came back twice, with + * another book missing, and the grouped list crashed on the repeat. */ + @Transaction @RawQuery(observedEntities = [AudioBookEntity::class, PlaybackProgressEntity::class]) suspend fun getFilteredBooks(query: SupportSQLiteQuery): List diff --git a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt index c9434812..3fdaea09 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/local/entity/AudioBookEntity.kt @@ -4,6 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import com.ninelivesaudio.app.data.local.bookSearchText @Entity( tableName = "AudioBooks", @@ -79,4 +80,15 @@ data class AudioBookEntity( @ColumnInfo(name = "ChaptersJson") val chaptersJson: String? = null, -) +) { + /** + * Title, author, series and narrator folded for search (see + * [bookSearchText]). Worked out from those fields whenever a row is + * built, a copy included, so every write fills it and no caller can + * forget to. Room sets it on read only to hand back the stored value, so + * a change to how search folds text needs a migration that backfills + * every row again, the way 9 to 10 does. + */ + @ColumnInfo(name = "SearchText", defaultValue = "''") + var searchText: String = bookSearchText(title, author, seriesName, narrator) +} 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 c4317cbc..8f207d0c 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 @@ -2,6 +2,7 @@ package com.ninelivesaudio.app.data.local.migration import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.ninelivesaudio.app.data.local.bookSearchText /** * Room migration definitions. @@ -106,6 +107,56 @@ val MIGRATION_8_9 = object : Migration(8, 9) { } } +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + // Folded title, author, series and narrator, so search ignores + // accents and case (issue #61). Filled here for every book already + // cached, so an upgraded library searches right away with no resync. + db.execSQL("ALTER TABLE AudioBooks ADD COLUMN SearchText TEXT NOT NULL DEFAULT ''") + backfillSearchText(db) + } +} + +/** Rows read per backfill page, so a huge library never sits in memory whole. */ +internal const val SEARCH_BACKFILL_PAGE_SIZE = 500 + +/** + * Fills AudioBooks.SearchText for every row, a page at a time in rowid order. + * Each page is read in full and its cursor closed before any row is written, + * so the read never sees its own table change under it. SQLite has no accent + * folding of its own, so the values come from [bookSearchText], the same + * function every later write uses. Runs inside the migration's transaction. + */ +internal fun backfillSearchText(db: SupportSQLiteDatabase) { + val update = db.compileStatement("UPDATE AudioBooks SET SearchText = ? WHERE rowid = ?") + try { + var after = Long.MIN_VALUE + while (true) { + val page = ArrayList>(SEARCH_BACKFILL_PAGE_SIZE) + db.query( + "SELECT rowid, Id, Title, Author, SeriesName, Narrator FROM AudioBooks " + + "WHERE rowid > ? ORDER BY rowid LIMIT ?", + arrayOf(after, SEARCH_BACKFILL_PAGE_SIZE), + ).use { rows -> + while (rows.moveToNext()) { + page += rows.getLong(0) to + bookSearchText(rows.getString(2), rows.getString(3), rows.getString(4), rows.getString(5)) + } + } + for ((rowId, text) in page) { + update.bindString(1, text) + update.bindLong(2, rowId) + update.executeUpdateDelete() + update.clearBindings() + } + if (page.size < SEARCH_BACKFILL_PAGE_SIZE) break + after = page.last().first + } + } finally { + update.close() + } +} + /** * All migrations to register with Room, in order. * Add new migrations here as they are created. @@ -119,4 +170,5 @@ val ALL_MIGRATIONS: Array = arrayOf( MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, ) 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 3e327538..36e1aeb2 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,7 +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.searchTextLikePattern import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity import com.ninelivesaudio.app.data.local.dao.AudioBookDao @@ -1062,11 +1062,12 @@ internal fun buildLibrarySql( append(" AND ab.IsDownloaded = 1") } - // Search. The bound patterns are escaped (see buildLibrarySqlArgs), so - // "%" and "_" typed in the search box match themselves. + // Search title, author, series and narrator through the folded search + // column, so accents and case never cause a miss. The bound pattern is + // folded and escaped (see buildLibrarySqlArgs), so "%" and "_" typed in + // the search box match themselves. if (hasSearch) { - append(" AND (ab.Title LIKE ? ESCAPE '\\' OR ab.Author LIKE ? ESCAPE '\\'") - append(" OR ab.SeriesName LIKE ? ESCAPE '\\' OR ab.Narrator LIKE ? ESCAPE '\\')") + append(" AND ab.SearchText LIKE ? ESCAPE '\\'") } // No ORDER BY: the Library sorts in Kotlin (sortBooks), so sorting here @@ -1074,11 +1075,10 @@ internal fun buildLibrarySql( } /** - * The arguments for [buildLibrarySql]: the library, then one escaped pattern - * per searched column when there is a search. + * The arguments for [buildLibrarySql]: the library, then the folded, escaped + * search pattern 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) + return arrayOf(libraryId, searchTextLikePattern(searchQuery)) } diff --git a/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt b/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt index 62f000bf..fe60158f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt +++ b/app/src/main/java/com/ninelivesaudio/app/data/repository/ProgressRepository.kt @@ -175,13 +175,23 @@ internal suspend fun insertOwnedPendingProgress( } } -internal suspend fun acknowledgePendingFallbackOnSuccess( - deliver: suspend () -> Boolean, +/** + * Deliver a session save and acknowledge its queued row only once the server + * holds everything the row does. The session sync carries the position and + * listening time but no finished flag, so a finished save also pushes the + * flag itself, after the sync so the flag is the last word. If either call + * fails the row stays queued and the reconnect flush delivers it. Returns + * whether the session sync landed, which is what acknowledges listening time. + */ +internal suspend fun deliverSessionProgress( + isFinished: Boolean, + syncSession: suspend () -> Boolean, + pushFinished: suspend () -> Boolean, acknowledge: suspend () -> Unit, ): Boolean { - val delivered = deliver() - if (delivered) acknowledge() - return delivered + val synced = syncSession() + if (synced && (!isFinished || pushFinished())) acknowledge() + return synced } @Singleton @@ -365,8 +375,9 @@ class ProgressRepository @Inject constructor( duration = duration, onPersisted = onPersisted, ) - acknowledgePendingFallbackOnSuccess( - deliver = { + deliverSessionProgress( + isFinished = isFinished, + syncSession = { try { apiService.syncSessionProgress( sessionId = sessionId, @@ -380,6 +391,15 @@ class ProgressRepository @Inject constructor( false } }, + pushFinished = { + try { + apiService.updateProgress(itemId, currentTime, true, duration) + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (_: Exception) { + false + } + }, acknowledge = { acknowledgePendingProgressLocked(itemId) }, ) } 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 76976dcf..aa04e84f 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 @@ -12,6 +12,9 @@ data class AppSettings( val selectedLibraryId: String? = null, // persisted library selection val selectedLocalLibraryId: String? = null, // persisted local library selection val downloadPath: String = "", + // Book downloads wait for an unmetered network. On by default, and settings + // saved before this existed decode to the default, so upgrades get it too. + val downloadOnWifiOnly: Boolean = true, val autoDownloadCovers: Boolean = true, val playbackSpeed: Double = 1.0, val autoSyncProgress: Boolean = true, diff --git a/app/src/main/java/com/ninelivesaudio/app/domain/util/SearchFold.kt b/app/src/main/java/com/ninelivesaudio/app/domain/util/SearchFold.kt new file mode 100644 index 00000000..c8154189 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/domain/util/SearchFold.kt @@ -0,0 +1,37 @@ +package com.ninelivesaudio.app.domain.util + +import java.text.Normalizer + +/** + * [text] the way search compares it: lowercase with accents dropped, so + * "Émile" and "emile", or "DUNE" and "dune", fold to the same thing. SQLite + * LIKE folds case for ASCII only and never drops accents (issue #61), so the + * stored search column and the typed query are both folded by this one + * function. The Library's letter rail files titles with it too. + * + * Letters with no accent to drop (ø, ł, ß, æ, and friends) get their plain + * spelling, so a search for "nesbo" finds "Nesbø". Everything else, digits, + * punctuation, spaces and other alphabets, is kept as it is. + */ +fun foldForSearch(text: String): String { + if (text.all { it in ' '..'@' || it in '['..'~' }) return text + val decomposed = Normalizer.normalize(text, Normalizer.Form.NFD) + return buildString(decomposed.length) { + for (c in decomposed) { + if (Character.getType(c) == Character.NON_SPACING_MARK.toInt()) continue + when (c) { + 'Ø', 'ø' -> append('o') + 'Ł', 'ł' -> append('l') + 'Đ', 'đ' -> append('d') + 'Ħ', 'ħ' -> append('h') + 'ı' -> append('i') + 'ß', 'ẞ' -> append("ss") + 'Æ', 'æ' -> append("ae") + 'Œ', 'œ' -> append("oe") + 'Þ', 'þ' -> append("th") + 'ς' -> append('σ') + else -> append(c.lowercaseChar()) + } + } + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt index 076dd0ac..5e658c66 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementCachePrefs.kt @@ -83,9 +83,20 @@ class EntitlementCachePrefs @Inject constructor( get() = prefs.getBoolean(KEY_DOWNLOADS_PAUSED, false) set(value) = prefs.edit().putBoolean(KEY_DOWNLOADS_PAUSED, value).apply() + /** + * Download row ids the user sent over mobile data while "Download on Wi-Fi + * only" is on (#79). Kept here rather than on the row, so it needs no + * schema change, and device-local for the same reason as the pause. + */ + var meteredOverrideDownloadIds: Set + // Copied out: the set getStringSet returns must never be modified. + get() = prefs.getStringSet(KEY_METERED_OVERRIDES, null)?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(KEY_METERED_OVERRIDES, value.toSet()).apply() + companion object { const val FILE_NAME = "nine_lives_entitlement_cache" const val KEY_DOWNLOADS_PAUSED = "downloads_paused" + const val KEY_METERED_OVERRIDES = "metered_override_download_ids" const val KEY_SLOT_WINNER = "slot_winner_audiobook_id" const val KEY_PLAY_UNLOCK = "play_unlock_cached" const val KEY_FORCE_FREE = "force_free" diff --git a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt index 4c52f8d4..cc0d04a0 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/AutoBookItem.kt @@ -1,6 +1,6 @@ package com.ninelivesaudio.app.service -import com.ninelivesaudio.app.data.local.containsLikePattern +import com.ninelivesaudio.app.data.local.searchTextLikePattern import com.ninelivesaudio.app.data.local.entity.AutoBrowseRow import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.domain.model.AppSettings @@ -86,12 +86,13 @@ internal suspend fun autoBrowsePage( 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 + * [query] as a LIKE "contains" pattern for the folded search column, made by + * [searchTextLikePattern], so accents and case never cause a miss and `%`, + * `_` and `\` match themselves. Null for a blank query, which searches * nothing. */ internal fun autoSearchPattern(query: String): String? = - query.trim().takeIf { it.isNotEmpty() }?.let(::containsLikePattern) + query.trim().takeIf { it.isNotEmpty() }?.let(::searchTextLikePattern) /** * One page of Auto search hits in the active library and source, never past 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 34cadc28..a822c56a 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/DownloadManager.kt @@ -1,10 +1,7 @@ package com.ninelivesaudio.app.service import android.content.Context -import androidx.work.Constraints import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.ninelivesaudio.app.data.local.converter.toDomain import com.ninelivesaudio.app.data.local.converter.toEntity @@ -15,11 +12,10 @@ 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.DownloadOwnership +import com.ninelivesaudio.app.service.download.FolderReads import com.ninelivesaudio.app.service.download.DownloadNotifications import com.ninelivesaudio.app.service.download.DownloadQueueWorker import com.ninelivesaudio.app.service.download.estimateTotalBytes @@ -30,21 +26,34 @@ 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 com.ninelivesaudio.app.service.download.drainRequest +import com.ninelivesaudio.app.service.download.drainWaitsForUnmetered +import com.ninelivesaudio.app.service.download.drainWorkPolicy +import com.ninelivesaudio.app.service.download.liveOverrides +import com.ninelivesaudio.app.service.download.mayDownloadOnDrain +import com.ninelivesaudio.app.service.download.overrideRestartsDrain +import com.ninelivesaudio.app.service.download.wifiRuleChangeRestartsDrain +import com.ninelivesaudio.app.service.download.runEngineExclusively +import com.ninelivesaudio.app.service.download.removeCancelledPartialFiles +import com.ninelivesaudio.app.service.download.removeDownloadedBookFiles +import com.ninelivesaudio.app.service.download.deleteKeepsLocalCopy +import com.ninelivesaudio.app.service.download.DownloadOwnerRow import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob 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.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.io.File import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -65,6 +74,7 @@ class DownloadManager @Inject constructor( private val downloadItemDao: DownloadItemDao, private val audioBookDao: AudioBookDao, private val engine: DownloadEngine, + private val ownership: DownloadOwnership, private val slotStore: DownloadSlotStore, private val settingsManager: SettingsManager, private val connectivityMonitor: ConnectivityMonitor, @@ -85,15 +95,16 @@ class DownloadManager @Inject constructor( /** * 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 + * Cancel, delete and pause hold it from the row read through the engine + * stop, the write and any 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. + * Lock order: this one first, [slotMutex] or [DownloadOwnership]'s lock + * inside it, never the reverse. */ private val rowMutex = Mutex() private val workManager: WorkManager by lazy { WorkManager.getInstance(context) } @@ -144,8 +155,16 @@ class DownloadManager @Inject constructor( @Volatile private var drainRunning = false + /** + * Whether the running drain was enqueued to wait for Wi-Fi. Only + * meaningful while [drainRunning]. See [mayDownloadOnDrain]. + */ + @Volatile + private var drainUnmetered = false + /** Called by [DownloadQueueWorker] on entry to doWork(). */ - fun onDrainStarted() { + fun onDrainStarted(unmetered: Boolean) { + drainUnmetered = unmetered drainRunning = true } @@ -154,21 +173,125 @@ class DownloadManager @Inject constructor( drainRunning = false } + /** Held across every engine run, cleanup included. See [runEngineExclusively]. */ + private val engineLock = Mutex() + /** - * The book the engine is streaming right now in this process, or null. - * Cancel cleanup checks it one last time before deleting anything. + * Run [block] (one engine run) once no other engine run is still + * unwinding. Which book the engine owns is tracked by [DownloadOwnership], + * which the engine claims itself. Called by [DownloadQueueWorker]. */ - @Volatile - private var engineBookId: String? = null + suspend fun runEngine(block: suspend () -> T): T = + runEngineExclusively(lock = engineLock, onStart = {}, onStop = {}, block = block) + + // ─── Wi-Fi only (#79) ──────────────────────────────────────────────────── + + private val wifiOnly: Boolean + get() = settingsManager.currentSettings.downloadOnWifiOnly + + private val _meteredOverrides by lazy { MutableStateFlow(slotStore.meteredOverrides) } + + /** + * Download ids the user sent over mobile data while Wi-Fi only is on. + * Each one runs on any connection until it finishes, is cancelled or is + * deleted. The setting itself never changes. + */ + val meteredOverrides: StateFlow> get() = _meteredOverrides.asStateFlow() + + private fun writeOverrides(ids: Set) { + if (ids == _meteredOverrides.value) return + slotStore.meteredOverrides = ids + _meteredOverrides.value = ids + } + + private fun dropMeteredOverride(downloadId: String) { + writeOverrides(_meteredOverrides.value - downloadId) + } + + /** Overrides with a row still behind them. Finished rows let theirs go. */ + private suspend fun prunedOverrides(): Set { + val liveIds = downloadItemDao.getAll() + .filter { it.status != DownloadStatus.Completed.ordinal } + .map { it.id } + return liveOverrides(_meteredOverrides.value, liveIds).also(::writeOverrides) + } + + /** + * Start a download that is waiting for Wi-Fi over mobile data, once, + * without touching the setting. False when the row is gone or is not + * waiting to download. + */ + suspend fun startOnMobileData(downloadId: String): Boolean = finishInOwnerScope(scope) { + rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) ?: return@withLock false + val status = entity.toDomain().status + if (status != DownloadStatus.Queued && status != DownloadStatus.Downloading) return@withLock false + writeOverrides(_meteredOverrides.value + downloadId) + if (overrideRestartsDrain(drainRunning, drainUnmetered)) restartDrainHoldingRowLock() + true + } + } - /** Called by [DownloadQueueWorker] right before it runs the engine on a book. */ - fun onEngineStarted(audioBookId: String) { - engineBookId = audioBookId + /** The books this drain may start right now. See [mayDownloadOnDrain]. */ + fun runnableOnDrain(items: List, drainUnmetered: Boolean): List { + val wifiOnly = wifiOnly + val overrides = _meteredOverrides.value + return items.filter { mayDownloadOnDrain(wifiOnly, drainUnmetered, it.id in overrides) } } - /** Called by [DownloadQueueWorker] from a finally once the engine returns or throws. */ - fun onEngineStopped() { - engineBookId = null + /** + * Called by the drain when it has nothing it may start. True when + * something runnable showed up after all, so the drain goes round again. + * + * Otherwise whatever is left is waiting for Wi-Fi, and a follow-up drain + * that waits for an unmetered network is appended behind this one. Under + * [rowMutex], so a mobile data override cannot slip in between this + * decision and the drain exiting: once this returns false the drain counts + * as stopped, and an override replaces it rather than joining it. + */ + suspend fun finishDrainOrContinue(drainUnmetered: Boolean): Boolean = rowMutex.withLock { + if (downloadsPaused) return@withLock false + val left = filterToSlotWinner(downloadItemDao.getDownloadable().map { it.toDomain() }) + if (left.isEmpty()) return@withLock false + if (selectNextDownload(runnableOnDrain(left, drainUnmetered)) != null) return@withLock true + + // The engine is off for good in this drain, so it can stop counting + // as running before doWork() actually returns. + drainRunning = false + android.util.Log.d("DownloadManager", "drain idle: ${left.size} waiting for Wi-Fi") + workManager.enqueueUniqueWork( + DOWNLOAD_WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + drainRequest(waitForUnmetered = true), + ) + false + } + + /** + * Apply a flip of the Wi-Fi only setting to work already queued. See + * [wifiRuleChangeRestartsDrain] for what restarts. Respects a paused + * queue, and does nothing with nothing to download. + */ + suspend fun onWifiOnlyChanged(): Unit = finishInOwnerScope(scope) { + rowMutex.withLock { + if (downloadsPaused) return@withLock + if (downloadItemDao.getDownloadable().isEmpty()) return@withLock + val engineRowOverridden = ownership.engineDownloadId?.let { it in _meteredOverrides.value } ?: false + if (wifiRuleChangeRestartsDrain(wifiOnly, drainRunning, drainUnmetered, engineRowOverridden)) { + restartDrainHoldingRowLock() + } + } + } + + /** + * Stop any running drain, confirmed, and start a fresh one under the + * current rule. The stop comes first so two engines never share a book's + * `.part` file. Caller holds [rowMutex]. + */ + private suspend fun restartDrainHoldingRowLock() { + if (downloadsPaused) return + if (drainRunning) stopDrainAndAwait() + enqueueDrainHoldingRowLock(replace = true) } /** @@ -217,7 +340,11 @@ class DownloadManager @Inject constructor( * "Queued..." forever with nothing to correct it. */ sealed interface QueueResult { - data class Queued(val item: DownloadItem) : QueueResult + /** + * [waitingForWifi] is true when Wi-Fi only holds it back right now, so + * the caller can say so instead of showing a download that never moves. + */ + data class Queued(val item: DownloadItem, val waitingForWifi: Boolean = false) : QueueResult /** * The free tier already holds its one offline book. @@ -359,7 +486,10 @@ class DownloadManager @Inject constructor( // Respect a paused queue: the book waits until the user resumes. if (!downloadsPaused) enqueueDrain(replace = false) - return QueueResult.Queued(downloadItem) + return QueueResult.Queued( + item = downloadItem, + waitingForWifi = wifiOnly && connectivityMonitor.isMetered.value, + ) } finally { // NonCancellable is load-bearing. This whole function runs in the @@ -511,19 +641,24 @@ class DownloadManager @Inject constructor( /** Pause a download: mark it Paused; restart the drain if it was the active one. */ suspend fun pauseDownload(downloadId: String): Unit = finishInOwnerScope(scope) { - val entity = downloadItemDao.getById(downloadId) ?: return@finishInOwnerScope - val wasDownloading = entity.status == DownloadStatus.Downloading.ordinal - writeAfterEngineStops(wasDownloading, { stopDrainAndAwait() }) { - // Re-read after the stop: the engine's last tick moved the bytes on, - // and it may have finished the book in the meantime. A Completed row - // only counts once the book itself is marked downloaded, since the - // stop can land between those two writes. - val latest = downloadItemDao.getById(downloadId) ?: return@writeAfterEngineStops - val book = audioBookDao.getById(latest.audioBookId) - if (pauseKeepsCompletedRow(latest.status, book?.isDownloaded, book?.localPath)) { - return@writeAfterEngineStops + // Held like cancel, so no drain starts between the stop and the write. + val wasDownloading = rowMutex.withLock { + val entity = downloadItemDao.getById(downloadId) ?: return@withLock false + ownership.actOnceEngineIsOff( + audioBookId = entity.audioBookId, + rowIsDownloading = { isDownloadingRow(downloadId) }, + stopEngine = { stopDrainAndAwait() }, + ) { stop -> + // Re-read after any stop: the engine's last tick moved the bytes on, + // and it may have finished the book in the meantime. A Completed row + // that predates the book's saved local copy still gets Paused. + val latest = downloadItemDao.getById(downloadId) ?: return@actOnceEngineIsOff stop.wasDownloading + val book = audioBookDao.getById(latest.audioBookId) + if (!pauseKeepsCompletedRow(latest.status, book?.isDownloaded, book?.localPath)) { + downloadItemDao.upsert(latest.copy(status = DownloadStatus.Paused.ordinal)) + } + stop.wasDownloading } - downloadItemDao.upsert(latest.copy(status = DownloadStatus.Paused.ordinal)) } if (wasDownloading) { // The engine was stopped above; let the drain continue with the rest. @@ -583,19 +718,26 @@ class DownloadManager @Inject constructor( suspend fun cancelDownload(downloadId: String): Unit = finishInOwnerScope(scope) { // 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. + // back or start a drain 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() }) { + val audioBookId = downloadItemDao.getById(downloadId)?.audioBookId + if (audioBookId == null) { + dropMeteredOverride(downloadId) + return@withLock false + } + ownership.actOnceEngineIsOff( + audioBookId = audioBookId, + rowIsDownloading = { isDownloadingRow(downloadId) }, + stopEngine = { stopDrainAndAwait() }, + ) { stop -> + val entity = downloadItemDao.getById(downloadId) 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) + dropMeteredOverride(downloadId) + if (entity != null && cancelMayTouchFiles(entity.status, stop.wasDownloading, stop.stopConfirmed)) { + removeCancelledPartialFiles(entity.audioBookId, startedWriting = entity.downloadedBytes > 0) } + stop.wasDownloading } - downloading } if (downloadItemDao.getDownloadable().isEmpty()) { @@ -609,49 +751,23 @@ class DownloadManager @Inject constructor( } } + private suspend fun isDownloadingRow(downloadId: String): Boolean = + downloadItemDao.getById(downloadId)?.status == DownloadStatus.Downloading.ordinal + /** - * 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. + * Delete the partial folder a cancelled download left behind (#51). Runs + * inside [DownloadOwnership.actOnceEngineIsOff], after this book's row is + * gone. See [removeCancelledPartialFiles]. Best effort: a failure here + * never fails the cancel. */ - private suspend fun removeCancelledPartialFiles(audioBookId: String) { + private suspend fun removeCancelledPartialFiles(audioBookId: String, startedWriting: Boolean) { 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) { + when (val decision = removeCancelledPartialFiles(folderReads, ownership, audioBookId, startedWriting)) { + null -> Unit 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") - } + is CancelCleanupDecision.Delete -> + android.util.Log.d("DownloadManager", "cancel cleanup removed ${decision.files.size} files") } } catch (cancellation: kotlinx.coroutines.CancellationException) { throw cancellation @@ -660,41 +776,95 @@ class DownloadManager @Inject constructor( } } - /** Delete a download's files and DB record. */ - suspend fun deleteDownload(audioBookId: String): Unit = finishInOwnerScope(scope) { - val downloadEntity = downloadItemDao.getByAudioBookId(audioBookId) - val wasDownloading = downloadEntity?.status == DownloadStatus.Downloading.ordinal - writeAfterEngineStops(wasDownloading, { stopDrainAndAwait() }) { - deleteDownloadedFilesAndRows(audioBookId, downloadEntity?.id) + /** + * Delete a download's files and DB record. False when its files could not + * be proven this book's, so they stayed and the book stays downloaded. + */ + suspend fun deleteDownload(audioBookId: String): Boolean = finishInOwnerScope(scope) { + // Held through the file deletion like cancel, so no resume or drain + // start lands on this book while its files are being judged. + var removed = true + val wasDownloading = rowMutex.withLock { + ownership.actOnceEngineIsOff( + audioBookId = audioBookId, + rowIsDownloading = { + downloadItemDao.getByAudioBookId(audioBookId)?.status == DownloadStatus.Downloading.ordinal + }, + stopEngine = { stopDrainAndAwait() }, + ) { stop -> + removed = deleteDownloadedFilesAndRows(audioBookId, downloadItemDao.getByAudioBookId(audioBookId)?.id) + stop.wasDownloading + } } if (wasDownloading) { // Restart the drain for whatever else is queued. enqueueDrain(replace = true) } + removed } - private suspend fun deleteDownloadedFilesAndRows(audioBookId: String, downloadId: String?) { + private suspend fun deleteDownloadedFilesAndRows(audioBookId: String, downloadId: String?): Boolean { // Read the book only now, after any engine on it has stopped, so a // completion that landed during the stop is the state being undone. // Use the actual localPath stored on the audiobook — this matches the path - // set by the engine (basePath/Author - Title), not basePath/audioBookId. + // set by the engine (an "Author - Title [id]" folder, or "Author - Title" for older downloads). val bookEntity = audioBookDao.getById(audioBookId) - withContext(Dispatchers.IO) { - val localPath = bookEntity?.localPath - if (!localPath.isNullOrEmpty()) { - File(localPath).deleteRecursively() - } + val localPath = bookEntity?.localPath + if (!localPath.isNullOrEmpty() && !removeDownloadedFiles(bookEntity.toDomain(), localPath)) { + // The files stayed, so the record that reaches them stays too. + return false } if (bookEntity != null) { - // The cover.jpg lived inside localPath and was removed by deleteRecursively - // above, so drop its reference too. + // The cover.jpg either went with the folder or belongs to another + // edition now, so this book stops pointing at it either way. audioBookDao.upsert(bookEntity.copy(isDownloaded = 0, localPath = null, localCoverPath = null)) } if (downloadId != null) { downloadItemDao.deleteById(downloadId) + dropMeteredOverride(downloadId) + } + return true + } + + /** + * Delete [book]'s downloaded files from [localPath] and nothing else (#49). + * Runs inside [DownloadOwnership.actOnceEngineIsOff]. See + * [removeDownloadedBookFiles]. False when files stayed because they could + * not be proven this book's (see [deleteKeepsLocalCopy]), or the check failed. + */ + private suspend fun removeDownloadedFiles(book: AudioBook, localPath: String): Boolean = + try { + val decision = removeDownloadedBookFiles(folderReads, ownership, book, localPath) + when (decision) { + null -> Unit + is CancelCleanupDecision.Keep -> + android.util.Log.d("DownloadManager", "delete kept files: ${decision.reason}") + is CancelCleanupDecision.Delete -> + android.util.Log.d("DownloadManager", "delete removed ${decision.files.size} files") + } + !deleteKeepsLocalCopy(decision) + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (e: Exception) { + android.util.Log.w("DownloadManager", "delete left files: ${e.message}") + false } + + /** Room and the engine's path rules, as delete and cancel read them. */ + private val folderReads = object : FolderReads { + override suspend fun book(id: String) = audioBookDao.getById(id)?.toDomain() + override suspend fun books(ids: List) = + lookUpInChunks(ids) { audioBookDao.getByIds(it) }.map { it.toDomain() } + override suspend fun filePathBookIdsExcept(audioBookId: String) = + audioBookDao.getFilePathBookIdsExcept(audioBookId) + override suspend fun localPathsExcept(audioBookId: String) = audioBookDao.getLocalPathsExcept(audioBookId) + override suspend fun downloadRows() = + downloadItemDao.getAll().map { DownloadOwnerRow(it.audioBookId, it.status, it.downloadedBytes) } + override fun plannedLocation(book: AudioBook, startedWriting: Boolean) = + engine.downloadLocationFor(book, startedWriting) + override fun storedLocation(localPath: String) = engine.storedDownloadLocation(localPath) } /** Check if a book is downloaded. */ @@ -732,9 +902,11 @@ class DownloadManager @Inject constructor( /** * Ensure the single download-queue worker is running. * - * [replace] = false (KEEP): used when adding work. If a drain worker is - * already running it keeps going and picks up the new Queued row on its next - * loop; otherwise a fresh one starts. + * [replace] = false: used when adding work. If a drain worker is already + * running in this process it keeps going (KEEP) and picks up the new Queued + * row on its next loop. Otherwise any idle drain still waiting on its + * network constraint is swapped (REPLACE) for one under the current Wi-Fi + * only rule. See [drainWorkPolicy]. * * [replace] = true (REPLACE): used when pausing/cancelling the active book. * It cancels the running drain (stopping the engine on the current book) and @@ -747,7 +919,7 @@ class DownloadManager @Inject constructor( } /** [enqueueDrain] for a caller that already holds [rowMutex]. */ - private fun enqueueDrainHoldingRowLock(replace: Boolean) { + private suspend 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 @@ -755,15 +927,16 @@ class DownloadManager @Inject constructor( // deliberate path is unaffected. if (downloadsPaused) return - val request = OneTimeWorkRequestBuilder() - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .build() - val policy = if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP - android.util.Log.d("DownloadManager", "enqueueDrain policy=${if (replace) "REPLACE" else "KEEP"}") - workManager.enqueueUniqueWork(DOWNLOAD_WORK_NAME, policy, request) + // Every entry point lands here (Book Detail, Downloads, the paused + // notification's Resume, entitlement changes), so all of them get the + // Wi-Fi only rule. A queued book sent over mobile data lets the drain + // run on any connection, and that drain takes only such books. + val overrides = prunedOverrides() + val overrideQueued = overrides.isNotEmpty() && + downloadItemDao.getDownloadable().any { it.id in overrides } + val waitForUnmetered = drainWaitsForUnmetered(wifiOnly, overrideQueued) + val policy = drainWorkPolicy(replace, drainRunning) + android.util.Log.d("DownloadManager", "enqueueDrain policy=$policy unmetered=$waitForUnmetered") + workManager.enqueueUniqueWork(DOWNLOAD_WORK_NAME, policy, drainRequest(waitForUnmetered)) } } 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 index bc124848..e9c449f4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/CancelCleanup.kt @@ -1,5 +1,6 @@ package com.ninelivesaudio.app.service.download +import com.ninelivesaudio.app.domain.model.AudioBook import com.ninelivesaudio.app.domain.model.DownloadStatus import java.io.File import java.io.IOException @@ -47,6 +48,24 @@ internal fun resolveDownloadRoot(configuredPath: String, defaultRoot: File): Pai return defaultRoot to true } +/** + * The folder [book]'s download writes to under [root] (#49). + * + * New downloads get their own "Author - Title [id]" folder. A download that + * started before that naming ([startedWriting]: its row has bytes on record) + * and has not written into its own folder keeps going in its old shared + * "Author - Title" folder, so its finished tracks are not stranded. The engine + * creates the folder it picks before writing a byte, so once a download has + * started here its own folder exists and wins from then on. Delete and cancel + * use this same rule to find the folder another download may be writing. + */ +internal fun bookDownloadFolder(root: File, book: AudioBook, startedWriting: Boolean): File { + val own = File(root, itemDownloadFolderName(book.author, book.title, book.id)) + if (!startedWriting || own.exists()) return own + val legacy = File(root, downloadFolderName(book.author, book.title, book.id)) + return if (legacy.isDirectory) legacy else own +} + /** System dirs a configured download path may never point into. */ internal val FORBIDDEN_DOWNLOAD_ROOTS = listOf("/system", "/data/data", "/data/user", "/proc", "/dev") @@ -93,6 +112,18 @@ internal enum class CancelKeepReason { /** A path could not be resolved, so overlap cannot be ruled out. */ UNRESOLVABLE, + + /** + * A delete in a user-picked root has no track list to name this book's + * files by, so none of them can be told from the user's own. + */ + NO_TRACK_LIST, + + /** + * A delete was allowed but one of this book's files would not go (storage + * turned read only, a permission changed), so the record stays with them. + */ + REMOVE_FAILED, } internal sealed interface CancelCleanupDecision { @@ -139,19 +170,9 @@ internal fun decideCancelCleanup( 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) + val folder = when (val contained = containedFolder(location)) { + is Containment.Inside -> contained.folder + is Containment.Refused -> return CancelCleanupDecision.Keep(contained.reason) } for (raw in otherBookPaths) { @@ -168,14 +189,8 @@ internal fun decideCancelCleanup( 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) - } + val children = listChildren(folder) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) if (children.any { !Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) }) { return CancelCleanupDecision.Keep(CancelKeepReason.UNEXPECTED_CONTENTS) } @@ -183,26 +198,6 @@ internal fun decideCancelCleanup( 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. @@ -272,3 +267,211 @@ private fun pathsOverlap(a: File, b: File): Boolean { val pb = b.toPath() return pa == pb || pa.startsWith(pb) || pb.startsWith(pa) } + +// ─── Deleting a finished download (#49) ─────────────────────────────────── + +/** Another book whose stored localPath may be this folder, and the files it owns there. */ +internal data class FolderSharer( + val path: String, + /** The leaf names its track list resolves to, or null when it has no track list. */ + val fileNames: List?, +) + +/** + * Where a downloaded book's stored localPath sits, for [deleteDownloadFiles]. + * + * The stored path wins over today's folder name, since the title can change + * after the download. A folder straight inside the app's own storage counts as + * app owned even when another root is configured now, so a book downloaded + * before a path change can still be deleted. Anything else is judged against + * the configured root, the same way the engine picks it. Null for a blank + * path or a content URI, which the engine never writes. + */ +internal fun deleteLocationFor(storedPath: String, configuredPath: String, defaultRoot: File): DownloadLocation? { + val folder = bookPathAsFile(storedPath) ?: return null + val parent = canonicalOrNull(folder)?.parentFile + if (parent != null && parent == canonicalOrNull(defaultRoot)) { + return DownloadLocation(root = defaultRoot, folder = folder, rootIsAppOwned = true) + } + val (root, appOwned) = resolveDownloadRoot(configuredPath, defaultRoot) + return DownloadLocation(root = root, folder = folder, rootIsAppOwned = appOwned) +} + +/** + * Decide what deleting a downloaded book may remove from [location], then + * remove it. Uses the same rules as cancel cleanup and never recurses. + * + * Kept whole when: + * - 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, + * - a [sharers] path sits above or inside the folder, or will not resolve, + * - a [sharers] entry is this exact folder but has no track list, so its + * files cannot be told apart from this book's, + * - an [otherDownloadFolders] entry (where a live download row writes) is the + * folder. + * + * When another downloaded book shares this exact folder (two editions with the + * same author and title), only [ownFileNames] that no sharer also claims go, + * with their `.part` leftovers. The cover stays for the other book. When + * nobody shares it and it holds only plain files, every file goes and then the + * folder. A subfolder or link inside is never followed, so with one present + * only this book's own files and its cover go. + * + * A user-picked root may hold the user's own files, so there only this book's + * own named files, their `.part` leftovers and its cover go, never anything + * else, and the folder only once that leaves it empty. The root itself is + * never touched. + */ +internal fun deleteDownloadFiles( + location: DownloadLocation, + ownFileNames: List, + sharers: List, + otherDownloadFolders: List, +): CancelCleanupDecision { + val decision = decideDownloadDelete(location, ownFileNames, sharers, otherDownloadFolders) + if (decision !is CancelCleanupDecision.Delete) return decision + applyCancelCleanup(decision) + // A file that is still there after the delete keeps the book's record, so + // its audio never sits on disk with nothing pointing at it. + val left = decision.files.any { Files.exists(it.toPath(), LinkOption.NOFOLLOW_LINKS) } + return if (left) CancelCleanupDecision.Keep(CancelKeepReason.REMOVE_FAILED) else decision +} + +/** + * Whether a delete has to leave the book marked downloaded: its files stayed + * because they could not be proven this book's, and dropping the record would + * strand them out of reach. A missing folder, or a path the engine never wrote + * (null), leaves nothing behind. + */ +internal fun deleteKeepsLocalCopy(decision: CancelCleanupDecision?): Boolean = + decision is CancelCleanupDecision.Keep && decision.reason != CancelKeepReason.NO_FOLDER + +private fun decideDownloadDelete( + location: DownloadLocation, + ownFileNames: List, + sharers: List, + otherDownloadFolders: List, +): CancelCleanupDecision { + val folder = when (val contained = containedFolder(location)) { + is Containment.Inside -> contained.folder + is Containment.Refused -> return CancelCleanupDecision.Keep(contained.reason) + } + + // Other books stored at exactly this folder. One above or inside it means + // the layout is not ours to judge, so everything stays. + val sameFolder = mutableListOf() + for (sharer in sharers) { + val other = bookPathAsFile(sharer.path) ?: continue + val otherCanonical = canonicalOrNull(other) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + if (otherCanonical == folder) { + sameFolder += sharer + } else 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) + } + if (sameFolder.any { it.fileNames == null }) { + return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + } + + val children = listChildren(folder) + ?: return CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE) + val plainFiles = children.filter { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) }.map(Path::toFile) + + if (location.rootIsAppOwned && sameFolder.isEmpty() && plainFiles.size == children.size) { + return CancelCleanupDecision.Delete(folder, plainFiles) + } + // In a user-picked root only named files go, so with no names nothing is ours to take. + if (!location.rootIsAppOwned && ownFileNames.isEmpty()) { + return CancelCleanupDecision.Keep(CancelKeepReason.NO_TRACK_LIST) + } + + val claimedByOthers = sameFolder.flatMapTo(HashSet()) { it.fileNames.orEmpty() } + val own = ownFileNames.filterNot { it in claimedByOthers } + val targets = buildSet { + own.forEach { add(it); add("$it.part") } + if (sameFolder.isEmpty()) add(COVER_FILE_NAME) + } + return CancelCleanupDecision.Delete(folder, plainFiles.filter { it.name in targets }) +} + +/** One download row, as the ownership check needs it. */ +internal data class DownloadOwnerRow( + val audioBookId: String, + val status: Int, + /** Bytes on record. Above zero, the download has started writing (see [bookDownloadFolder]). */ + val downloadedBytes: Long = 0, +) + +/** + * Books other than [excludeBookId] that may still be writing to their folder, + * so a delete must treat that folder as taken (see `otherDownloadFolders` on + * [deleteDownloadFiles]). + * + * Any row short of Completed counts. So does a Completed row whose book is not + * in [booksWithLocalCopy] (marked downloaded with a stored localPath). The + * engine writes both in one step now, but a row left that way by an older + * build or a process death between the two writes still has audio on disk and + * no stored path that would make it a sharer. Any doubt keeps the files. The + * folder the engine is writing right now is protected separately, see + * [DownloadOwnership]. + */ +internal fun unfinishedDownloadOwners( + rows: List, + booksWithLocalCopy: Set, + excludeBookId: String, +): List = + rows + .filter { row -> + row.status != DownloadStatus.Completed.ordinal || row.audioBookId !in booksWithLocalCopy + } + .map { it.audioBookId } + .filter { it != excludeBookId } + .distinct() + +/** The cover the engine saves next to the audio. */ +private const val COVER_FILE_NAME = "cover.jpg" + +private sealed interface Containment { + data class Inside(val folder: File) : Containment + data class Refused(val reason: CancelKeepReason) : Containment +} + +/** + * The canonical book folder when it is a real directory sitting directly in + * the canonical root. A symlink, the root itself, `..`, a deeper folder, or + * anything that resolves elsewhere is refused. Shared by cancel and delete so + * both stay inside the same fence. + */ +private fun containedFolder(location: DownloadLocation): Containment { + val root = canonicalOrNull(location.root) + ?: return Containment.Refused(CancelKeepReason.UNRESOLVABLE) + if (Files.isSymbolicLink(location.folder.toPath())) { + return Containment.Refused(CancelKeepReason.OUTSIDE_ROOT) + } + val folder = canonicalOrNull(location.folder) + ?: return Containment.Refused(CancelKeepReason.UNRESOLVABLE) + if (folder == root || folder.parentFile != root) { + return Containment.Refused(CancelKeepReason.OUTSIDE_ROOT) + } + if (!Files.isDirectory(folder.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return Containment.Refused(CancelKeepReason.NO_FOLDER) + } + return Containment.Inside(folder) +} + +/** Everything directly inside [folder], or null when it cannot be listed. */ +private fun listChildren(folder: File): List? = try { + // DirectoryStream, not Files.list(...).toList(): Stream.toList needs API 34. + Files.newDirectoryStream(folder.toPath()).use { stream -> stream.toList() } +} catch (_: IOException) { + null +} catch (_: SecurityException) { + null +} 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 df9d413a..d4bbdbe2 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 @@ -44,6 +44,7 @@ class DownloadEngine @Inject constructor( private val audioBookDao: AudioBookDao, private val api: AudiobookshelfApi, private val settingsManager: SettingsManager, + private val ownership: DownloadOwnership, ) { companion object { private const val TAG = "DownloadEngine" @@ -55,17 +56,43 @@ class DownloadEngine @Inject constructor( * progress to Room throughout and invokes [onProgress] for UI liveliness. * Returns the terminal [DownloadItem] (Completed / Failed / Paused); the * caller owns any completion/failure event emission. + * + * Returns null without touching anything when the row is gone or no longer + * Queued or Downloading by the time the engine claims it. The claim and the + * completion go through [DownloadOwnership], see the rule there. */ suspend fun download( item: DownloadItem, audioBook: AudioBook, onProgress: suspend (downloadId: String, downloaded: Long, total: Long) -> Unit, + ): DownloadItem? { + // The row as it is now, not as the drain read it before waiting its turn. + val bytesOnRecord = downloadItemDao.getById(item.id)?.downloadedBytes ?: item.downloadedBytes + val downloadDir = getDownloadPath(audioBook, startedWriting = bytesOnRecord > 0) + val claimed = ownership.claim( + downloadId = item.id, + audioBookId = audioBook.id, + folder = downloadDir, + readRow = { downloadItemDao.getById(item.id)?.toDomain() }, + writeRow = { downloadItemDao.upsert(it.toEntity()) }, + ) ?: return null + return try { + downloadInto(downloadDir, claimed, audioBook, onProgress) + } finally { + ownership.release() + } + } + + private suspend fun downloadInto( + downloadDir: File, + item: DownloadItem, + audioBook: AudioBook, + onProgress: suspend (downloadId: String, downloaded: Long, total: Long) -> Unit, ): DownloadItem { - var download = item.copy(status = DownloadStatus.Downloading) - downloadItemDao.upsert(download.toEntity()) + // Already written as Downloading by the claim. + var download = item // Create download directory - val downloadDir = getDownloadPath(audioBook) downloadDir.mkdirs() // Get full book details if audio files are missing @@ -95,7 +122,7 @@ class DownloadEngine @Inject constructor( // Track order comes from the server-reported index, not response order, // and leaf names are resolved once so sanitization collisions stay distinct. val orderedFiles = book.audioFiles.sortedBy { it.index } - val fileNames = resolveDownloadFileNames(orderedFiles) + val fileNames = downloadedFileNames(book.audioFiles) // Download each audio file for (i in orderedFiles.indices) { @@ -218,34 +245,41 @@ class DownloadEngine @Inject constructor( } } + // Persist the cover next to the audio so it renders offline. Best-effort: + // a cover failure must never fail an otherwise-complete download. A + // network call, so it runs before the completion commit, not inside it. + val localCoverUri = persistCover(book, downloadDir) + // All files downloaded successfully download = download.copy( status = DownloadStatus.Completed, downloadedBytes = maxOf(downloadedBytes, totalBytes), completedAt = System.currentTimeMillis(), ) - downloadItemDao.upsert(download.toEntity()) - - // Persist the cover next to the audio so it renders offline. Best-effort: - // a cover failure must never fail an otherwise-complete download. - val localCoverUri = persistCover(book, downloadDir) - - // 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( - completedDownloadBook( - row = bookEntity.toDomain(), - downloaded = book, - localPath = downloadDir.absolutePath, - localCoverUri = localCoverUri, - ).toEntity() - ) + val completed = download + // The row turns Completed and the book gets its local copy in one step, + // so a delete never sees one without the other. 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 committed = ownership.commitCompletion( + rowExists = { downloadItemDao.getById(completed.id) != null }, + ) { + downloadItemDao.upsert(completed.toEntity()) + val bookEntity = audioBookDao.getById(audioBook.id) + if (bookEntity != null) { + audioBookDao.upsert( + completedDownloadBook( + row = bookEntity.toDomain(), + downloaded = book, + localPath = downloadDir.absolutePath, + localCoverUri = localCoverUri, + ).toEntity() + ) + } } - - return download + // Cancelled or deleted while finishing: nothing was written back. + return if (committed) completed else completed.copy(status = DownloadStatus.Cancelled) } /** @@ -297,27 +331,35 @@ class DownloadEngine @Inject constructor( // ─── File Paths ────────────────────────────────────────────────────────── - /** Get download directory for an audiobook. */ - private fun getDownloadPath(audioBook: AudioBook): File { - val basePath = getBasePath() - val folderName = downloadFolderName(audioBook.author, audioBook.title, audioBook.id) - return File(basePath, folderName) - } + /** Get download directory for an audiobook. See [bookDownloadFolder]. */ + private fun getDownloadPath(audioBook: AudioBook, startedWriting: Boolean): File = + bookDownloadFolder(getBasePath(), audioBook, startedWriting) /** * Where [audioBook]'s download folder is under the current settings, for - * cancel cleanup. Resolves exactly like [getDownloadPath] but creates + * delete and cancel. Resolves exactly like [getDownloadPath] but creates * nothing. */ - internal fun downloadLocationFor(audioBook: AudioBook): DownloadLocation { + internal fun downloadLocationFor(audioBook: AudioBook, startedWriting: Boolean): 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) + val folder = bookDownloadFolder(root, audioBook, startedWriting) + return DownloadLocation(root = root, folder = folder, rootIsAppOwned = appOwned) } + /** + * Where a finished download's stored [localPath] sits, for delete. Null when + * the path is not one the engine could have written. See [deleteLocationFor]. + */ + internal fun storedDownloadLocation(localPath: String): DownloadLocation? = + deleteLocationFor( + storedPath = localPath, + configuredPath = settingsManager.currentSettings.downloadPath, + defaultRoot = defaultBasePath(), + ) + /** Base storage directory for all downloads. */ private fun getBasePath(): File { // Respect user-configured path when possible, with path traversal validation. diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicy.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicy.kt new file mode 100644 index 00000000..0c07f4c2 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicy.kt @@ -0,0 +1,119 @@ +package com.ninelivesaudio.app.service.download + +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.workDataOf +import com.ninelivesaudio.app.domain.model.DownloadStatus + +// ─── "Download on Wi-Fi only" (#79, decisions are unit-testable) ────────── +// +// One drain worker runs the whole queue, so the network rule lives on that +// worker. With the setting on it waits for an unmetered network. A book the +// user sends over mobile data runs the drain on any connection instead, and +// that drain takes only the books sent that way. Whatever is left behind +// gets a follow-up drain that waits for Wi-Fi. + +/** Input key: true when the drain was enqueued to wait for an unmetered network. */ +internal const val KEY_DRAIN_UNMETERED = "drain_unmetered" + +/** Copy for the Wi-Fi only setting and the downloads waiting on it. */ +object WifiOnlyCopy { + const val SETTING_TITLE = "Download on Wi-Fi only" + const val SETTING_SUBTITLE = + "Books wait for Wi-Fi before they download. Tap Use mobile data in Downloads to skip the wait." + const val WAITING_STATUS = "Waiting for Wi-Fi" + const val USE_MOBILE_DATA = "Use mobile data" + const val QUEUED_NOTICE = "Waiting for Wi-Fi. Tap Use mobile data in Downloads to start it now." +} + +/** + * Whether the next drain should wait for an unmetered network: only with the + * setting on and no book queued that the user sent over mobile data. + */ +internal fun drainWaitsForUnmetered(wifiOnly: Boolean, overrideQueued: Boolean): Boolean = + wifiOnly && !overrideQueued + +/** + * The drain worker request. [waitForUnmetered] picks the network constraint + * and rides along as input, so the worker knows which kind of drain it is. + * A request from before this setting carries no input and reads as a drain + * on any connection, which then takes only overridden books. + */ +internal fun drainRequest(waitForUnmetered: Boolean): OneTimeWorkRequest = + OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(if (waitForUnmetered) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + ) + .setInputData(workDataOf(KEY_DRAIN_UNMETERED to waitForUnmetered)) + .build() + +/** + * How to enqueue the drain. A replace stays a replace. Adding work joins a + * drain that is running in this process (it picks new rows up on its next + * loop), but replaces an idle one, which may be sitting on the old network + * rule waiting for Wi-Fi that a mobile data override no longer needs. + */ +internal fun drainWorkPolicy(replace: Boolean, drainRunning: Boolean): ExistingWorkPolicy = + if (replace || !drainRunning) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP + +/** + * Whether a drain may start this book. A drain on Wi-Fi takes everything, and + * so does any drain with the setting off. A drain on any connection with the + * setting on takes only books the user sent over mobile data. + */ +internal fun mayDownloadOnDrain(wifiOnly: Boolean, drainUnmetered: Boolean, overridden: Boolean): Boolean = + !wifiOnly || drainUnmetered || overridden + +/** + * Whether flipping the setting has to restart the drain. + * + * With no drain running, always: the queue gets re-enqueued under the new + * rule (a drain waiting for Wi-Fi starts at once once the setting is off). + * Turning the setting on stops a drain running on any connection, so a book + * already streaming over mobile data stops and waits, unless it is a book the + * user sent over mobile data on purpose. Turning it off swaps a drain bound to + * Wi-Fi for one on any connection (the book in flight restarts its current + * file), or losing Wi-Fi later would still park the queue. A drain already + * on any connection carries on, since it now takes every book. + */ +internal fun wifiRuleChangeRestartsDrain( + wifiOnly: Boolean, + drainRunning: Boolean, + drainUnmetered: Boolean, + engineRowOverridden: Boolean, +): Boolean = when { + !drainRunning -> true + !wifiOnly -> drainUnmetered + drainUnmetered -> false + else -> !engineRowOverridden +} + +/** + * Whether sending a book over mobile data has to restart the drain. A drain + * running on any connection picks it up on its next loop. Anything else (no + * drain, or one bound to Wi-Fi that is about to be stopped for losing it) + * would leave the book waiting, so the drain is replaced. + */ +internal fun overrideRestartsDrain(drainRunning: Boolean, drainUnmetered: Boolean): Boolean = + !drainRunning || drainUnmetered + +/** The overrides whose download row still exists, so the stored set cannot grow forever. */ +internal fun liveOverrides(overrides: Set, liveRowIds: Collection): Set { + val live = liveRowIds.toHashSet() + return overrides.filterTo(HashSet()) { it in live } +} + +/** + * Whether a download is held back by the setting right now, so the screen + * says "Waiting for Wi-Fi" instead of a Queued row or a frozen progress bar. + * A Downloading row counts too: it is what a book interrupted by losing Wi-Fi + * looks like until the drain picks it up again. + */ +internal fun isWaitingForWifi(status: DownloadStatus, wifiOnly: Boolean, metered: Boolean, overridden: Boolean): Boolean = + wifiOnly && metered && !overridden && + (status == DownloadStatus.Queued || status == DownloadStatus.Downloading) diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt new file mode 100644 index 00000000..b2d3de98 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadOwnership.kt @@ -0,0 +1,222 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** The book the engine is downloading right now, and the folder it writes to. */ +internal data class EngineClaim(val audioBookId: String, val downloadId: String, val folder: File) + +/** How [DownloadOwnership.actOnceEngineIsOff] dealt with the engine. */ +internal data class EngineStop( + /** The engine was (or may have been) on the book, so it was stopped first. */ + val wasDownloading: Boolean, + /** The stop was confirmed. False when it timed out or was not needed. */ + val stopConfirmed: Boolean, +) + +/** + * Who owns a download folder, and the one rule that keeps delete and cancel + * from taking another book's files (#49). + * + * The rule: every change to what the engine owns, and every delete or cancel + * decision together with its file removal, runs under [lock]. The engine's + * changes are claiming a row (only while it still exists and is still Queued + * or Downloading) along with the folder it will write, committing completion + * (row Completed and the book's local copy in one step, only while the row + * still exists), and letting the folder go. Delete, cancel and pause act + * through [actOnceEngineIsOff]: with the engine on the book they stop it + * first, outside the lock, then act under it. The folder the engine is + * writing is never deleted, whichever book it belongs to. No network I/O + * ever runs under the lock, and any doubt keeps the files. + * + * Lock order: DownloadManager's row lock and the engine lock come first, this + * one innermost. + */ +@Singleton +class DownloadOwnership @Inject constructor() { + private val lock = Mutex() + + @Volatile + private var current: EngineClaim? = null + + /** What the engine holds right now. Only stable under the lock. */ + internal val engineClaim: EngineClaim? get() = current + + /** The download row the engine is on, for the Wi-Fi only rule. A plain read, no lock. */ + val engineDownloadId: String? get() = current?.downloadId + + /** + * The engine claims [downloadId] and [folder]: under the lock it reads the + * row fresh and, only while it is still Queued or Downloading, writes it + * back as Downloading. Returns that row, or null when the row is gone or + * moved on, in which case nothing is written and nothing is claimed. A + * cancelled engine never claims. + */ + internal suspend fun claim( + downloadId: String, + audioBookId: String, + folder: File, + readRow: suspend () -> DownloadItem?, + writeRow: suspend (DownloadItem) -> Unit, + ): DownloadItem? = lock.withLock { + currentCoroutineContext().ensureActive() + val fresh = readRow() + ?.takeIf { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Downloading } + ?: return@withLock null + val downloading = fresh.copy(status = DownloadStatus.Downloading) + writeRow(downloading) + current = EngineClaim(audioBookId, downloadId, folder) + downloading + } + + /** + * The engine commits a finished download: [commit] (the Completed row and + * the book's local copy) runs under the lock, and only while [rowExists]. + * False when the download was cancelled or deleted meanwhile. + */ + internal suspend fun commitCompletion(rowExists: suspend () -> Boolean, commit: suspend () -> Unit): Boolean = + lock.withLock { + if (!rowExists()) return@withLock false + commit() + true + } + + /** The engine lets its folder go. Runs even when the engine was cancelled. */ + internal suspend fun release() { + withContext(NonCancellable) { lock.withLock { current = null } } + } + + /** + * Run [act] (a delete, cancel or pause of [audioBookId]) under the lock, + * once the engine is off the book. + * + * When the engine is not on the book and its row is not Downloading, [act] + * runs in the same lock section as that check, so the engine cannot claim + * the book halfway through. Otherwise [stopEngine] runs first, outside the + * lock (the engine needs the lock to let go), and [act] after it. Nothing + * can claim the book again in between: the stopped drain is cancelled, a + * cancelled engine never claims, and callers hold the row lock that every + * drain start needs. + */ + internal suspend fun actOnceEngineIsOff( + audioBookId: String, + rowIsDownloading: suspend () -> Boolean, + stopEngine: suspend () -> Boolean, + act: suspend (EngineStop) -> T, + ): T { + lock.withLock { + if (current?.audioBookId != audioBookId && !rowIsDownloading()) { + return act(EngineStop(wasDownloading = false, stopConfirmed = false)) + } + } + val confirmed = stopEngine() + return lock.withLock { act(EngineStop(wasDownloading = true, stopConfirmed = confirmed)) } + } +} + +/** + * What delete and cancel read to judge a folder. DownloadManager backs it with + * Room and the engine's path rules. + */ +internal interface FolderReads { + suspend fun book(id: String): AudioBook? + /** Books by id, any order, missing ones left out. */ + suspend fun books(ids: List): List + /** Ids of other books stored at a file path. See `AudioBookDao.getFilePathBookIdsExcept`. */ + suspend fun filePathBookIdsExcept(audioBookId: String): List + /** Every other book's stored localPath. */ + suspend fun localPathsExcept(audioBookId: String): List + suspend fun downloadRows(): List + /** + * Where [book] downloads to under the current settings. [startedWriting] + * is whether its row has bytes on record, see [bookDownloadFolder]. + */ + fun plannedLocation(book: AudioBook, startedWriting: Boolean): DownloadLocation + /** Where a stored localPath sits, or null when the engine never wrote it. */ + fun storedLocation(localPath: String): DownloadLocation? +} + +/** + * Delete [book]'s downloaded files from [localPath] and nothing else (#49). + * Call it inside [DownloadOwnership.actOnceEngineIsOff], so what it reads + * cannot change before the files go. + * + * Two editions with the same author and title shared one folder before each + * download got its own (and those folders are still around), so the other + * books stored there come along with their track lists, and only this book's + * own files go. A folder another download may still be writing keeps + * everything: any unfinished row's folder (see [unfinishedDownloadOwners]) and + * the folder the engine is really writing, even when a title change moved + * where its row says it goes. The rules live in [deleteDownloadFiles]. + */ +internal suspend fun removeDownloadedBookFiles( + reads: FolderReads, + ownership: DownloadOwnership, + book: AudioBook, + localPath: String, +): CancelCleanupDecision? { + val location = reads.storedLocation(localPath) ?: return null + val sharers = reads.books(reads.filePathBookIdsExcept(book.id)).map { other -> + FolderSharer( + path = other.localPath.orEmpty(), + fileNames = other.audioFiles.takeIf { it.isNotEmpty() }?.let(::downloadedFileNames), + ) + } + val claim = ownership.engineClaim + val rows = reads.downloadRows() + val candidates = reads.books(rows.map { it.audioBookId }.filter { it != book.id }.distinct()) + .associateBy { it.id } + val withLocalCopy = candidates.values + .filter { it.isDownloaded && !it.localPath.isNullOrEmpty() } + .mapTo(HashSet()) { it.id } + val started = rows.filter { it.downloadedBytes > 0 }.mapTo(HashSet()) { it.audioBookId } + val otherFolders = unfinishedDownloadOwners(rows, withLocalCopy, book.id) + // No book row means nothing the engine could be writing for it. + .mapNotNull { candidates[it] } + .map { reads.plannedLocation(it, it.id in started).folder } + + listOfNotNull(claim?.folder) + return deleteDownloadFiles(location, downloadedFileNames(book.audioFiles), sharers, otherFolders) +} + +/** + * Delete the partial folder a cancelled download of [audioBookId] left behind + * (#51), after its row is gone. [startedWriting] is whether that row had bytes + * on record, which picks the folder (see [bookDownloadFolder]). Call it inside + * [DownloadOwnership.actOnceEngineIsOff], so what it reads cannot change before + * the files go. The rules live in [decideCancelCleanup] and keep the files on + * any doubt, including when another book or download points at the same + * folder, or the engine is really writing there (whatever its row now says). + */ +internal suspend fun removeCancelledPartialFiles( + reads: FolderReads, + ownership: DownloadOwnership, + audioBookId: String, + startedWriting: Boolean, +): CancelCleanupDecision? { + val claim = ownership.engineClaim + // The engine still on this book means its stop never confirmed. + if (claim?.audioBookId == audioBookId) return CancelCleanupDecision.Keep(CancelKeepReason.SHARED) + val book = reads.book(audioBookId) ?: return null + val location = reads.plannedLocation(book, startedWriting) + val otherBookPaths = reads.localPathsExcept(audioBookId) + // Every download row still around, mapped to the folder it would write to. + val rows = reads.downloadRows() + val started = rows.filter { it.downloadedBytes > 0 }.mapTo(HashSet()) { it.audioBookId } + val otherFolders = reads.books(rows.map { it.audioBookId }.distinct()) + .map { reads.plannedLocation(it, it.id in started).folder } + listOfNotNull(claim?.folder) + val bookIsDownloaded = book.isDownloaded || !book.localPath.isNullOrEmpty() + + val decision = decideCancelCleanup(location, bookIsDownloaded, otherBookPaths, otherFolders) + if (decision is CancelCleanupDecision.Delete) applyCancelCleanup(decision) + return decision +} 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 452fc115..68604cbb 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 @@ -30,16 +30,75 @@ internal fun sanitizeDownloadFileName(name: String): String = .take(MAX_FILE_NAME_LENGTH) /** - * Folder name for a downloaded book: "Author - Title" when a real author is - * known, otherwise just the title, falling back to the book id when both are - * blank. Sanitized for the filesystem. + * The folder name downloads used before #49: "Author - Title" when a real + * author is known, otherwise just the title, falling back to the book id when + * both are blank. Sanitized for the filesystem. Two editions with the same + * author and title got the same folder, so only a download that started under + * this name still uses it (see [bookDownloadFolder]). */ -internal fun downloadFolderName(author: String, title: String, fallbackId: String): String { +internal fun downloadFolderName(author: String, title: String, fallbackId: String): String = + sanitizeDownloadFileName(authorTitle(author, title)).ifBlank { fallbackId } + +private fun authorTitle(author: String, title: String): String { val realAuthor = author.takeIf { it.isNotBlank() && it != "Unknown Author" } - val raw = if (realAuthor != null) "$realAuthor - $title" else title - return sanitizeDownloadFileName(raw).ifBlank { fallbackId } + return if (realAuthor != null) "$realAuthor - $title" else title } +/** Characters of the item id a download folder's name ends with. */ +private const val SHORT_ITEM_ID_LENGTH = 8 + +/** UTF-8 bytes a download folder's name may use. Android filesystems cap a name at 255. */ +internal const val MAX_FOLDER_NAME_BYTES = 200 + +/** + * The tail of an ABS item id, letters and digits only. ABS ids end in random + * characters (a UUID, or `li_` plus random letters), so eight of them tell two + * editions of one title apart. An id with no letters or digits uses its hash. + */ +internal fun shortItemId(itemId: String): String = + itemId.filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' } + .takeLast(SHORT_ITEM_ID_LENGTH) + .ifEmpty { Integer.toHexString(itemId.hashCode()) } + +/** + * Folder name for a new download: "Author - Title [id]". The title comes + * first so other apps show it readably, and the item id keeps two editions out + * of each other's folder (#49). The title is cut so the whole name fits + * [MAX_FOLDER_NAME_BYTES], and the id always survives. + */ +internal fun itemDownloadFolderName(author: String, title: String, itemId: String): String { + val suffix = "[${shortItemId(itemId)}]" + val room = MAX_FOLDER_NAME_BYTES - suffix.length - 1 + val base = takeUtf8Bytes(sanitizeDownloadFileName(authorTitle(author, title)), room).trimEnd() + return if (base.isEmpty()) suffix else "$base $suffix" +} + +/** The longest start of [text] that fits [maxBytes] of UTF-8, never splitting a character. */ +private fun takeUtf8Bytes(text: String, maxBytes: Int): String { + var bytes = 0 + var end = 0 + while (end < text.length) { + val codePoint = text.codePointAt(end) + val size = when { + codePoint < 0x80 -> 1 + codePoint < 0x800 -> 2 + codePoint < 0x10000 -> 3 + else -> 4 + } + if (bytes + size > maxBytes) break + bytes += size + end += Character.charCount(codePoint) + } + return text.substring(0, end) +} + +/** + * The leaf names a book's tracks get on disk, in the engine's order. Delete + * uses the same list to tell this book's files from another edition's. + */ +internal fun downloadedFileNames(files: List): List = + resolveDownloadFileNames(files.sortedBy { it.index }) + /** Stable on-disk leaf names, disambiguating only sanitization collisions. */ internal fun resolveDownloadFileNames(files: List): List { val baseNames = files.mapIndexed { index, file -> 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 64897a71..71f7d031 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 @@ -50,8 +50,16 @@ class DownloadQueueWorker( EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) } + /** + * Whether this drain was enqueued to wait for Wi-Fi. A request from before + * the Wi-Fi only setting carries no input and reads false, so with the + * setting on it only takes books sent over mobile data. + */ + private val unmetered: Boolean + get() = inputData.getBoolean(KEY_DRAIN_UNMETERED, false) + override suspend fun doWork(): Result = try { - deps.downloadManager().onDrainStarted() + deps.downloadManager().onDrainStarted(unmetered) drain() } finally { // Cleared on EVERY exit path, including cancellation. Entitlement-drop @@ -87,7 +95,14 @@ class DownloadQueueWorker( // orders by age, so an unfiltered list would meet a PRESERVED loser // first and start downloading a book the free tier cannot keep. val active = manager.filterToSlotWinner(dao.getDownloadable().map { it.toDomain() }) - val item = selectNextDownload(active) ?: break + // With Wi-Fi only on, a drain running on any connection takes only + // books the user sent over mobile data. When nothing it may start + // is left, the manager hands the rest to a drain that waits for Wi-Fi. + val item = selectNextDownload(manager.runnableOnDrain(active, unmetered)) + if (item == null) { + if (manager.finishDrainOrContinue(unmetered)) continue + break + } android.util.Log.d(TAG, "next id=${item.id} status=${item.status} title=${item.title}") val bookEntity = books.getById(item.audioBookId) @@ -115,9 +130,11 @@ class DownloadQueueWorker( // and Android rate-limits notification posts, so unthrottled updates // get dropped and the bar appears frozen / far behind. var lastNotifiedPercent = -1 - // Cancel cleanup checks this last thing before deleting files. - manager.onEngineStarted(item.audioBookId) - val result = try { + // One engine at a time across drains: a replacement waits here until + // a cancelled drain's engine has really exited. The wait can outlast + // an old engine or a user action, so the engine claims the row only + // if it is still downloadable, and returns null otherwise. + val result = manager.runEngine { withContext(Dispatchers.IO) { engine.download(item, book) { id, downloaded, total -> manager.publishProgress(id, downloaded, total) @@ -132,14 +149,12 @@ class DownloadQueueWorker( } } } - } finally { - manager.onEngineStopped() - } + } ?: continue manager.notifyTerminal(result) android.util.Log.d(TAG, "done id=${item.id} result=${result.status}") } - android.util.Log.d(TAG, "drain END (queue empty)") + android.util.Log.d(TAG, "drain END (nothing left this drain may start)") // Best-effort: drop the ongoing notification when there is nothing left. // (Skipped when paused, where the standalone paused notification stands in.) if (!manager.isDownloadsPaused()) { 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 c984d7f1..cb51c533 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 @@ -48,6 +48,13 @@ class DownloadSlotStore @Inject constructor( cache.downloadsPaused = value } + /** Downloads sent over mobile data. See [EntitlementCachePrefs.meteredOverrideDownloadIds]. */ + var meteredOverrides: Set + get() = cache.meteredOverrideDownloadIds + set(value) { + cache.meteredOverrideDownloadIds = value + } + var persistedWinner: String? get() = cache.slotWinnerAudioBookId set(value) { diff --git a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt index cca14123..7450a9e7 100644 --- a/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt +++ b/app/src/main/java/com/ninelivesaudio/app/service/download/DownloadWork.kt @@ -4,6 +4,8 @@ import com.ninelivesaudio.app.domain.model.DownloadItem import com.ninelivesaudio.app.domain.model.DownloadStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock // ─── WorkManager identifier + queue selection ───────────────────────────── // @@ -30,21 +32,29 @@ fun selectNextDownload(items: List): DownloadItem? = ) /** - * Run a user's pause, cancel or delete write so the engine cannot undo it. + * Run the engine with [lock] held, bracketed by [onStart] and [onStop]. * - * DownloadEngine upserts a stale Downloading snapshot on every progress tick - * and marks the book downloaded when it finishes. A write that lands while it - * is still streaming this book gets clobbered straight back. So when the - * engine is on this book, [stopEngine] (cancel plus confirmed termination) - * runs first, and only then [write]. + * WorkManager marks a cancelled drain finished as soon as the cancel is + * recorded, and the stop wait gives up after ten seconds, but a read stuck on + * a stalled server only unwinds at the 60 second read timeout. Its cleanup + * then deletes the book's `.part` file, the one a replacement drain may be + * writing by then. Holding one process-wide lock across the whole run, cleanup + * included, means a replacement engine cannot start until the old one has + * actually exited. [onStart] runs only once the lock is held, so whatever it + * records names the engine that is really running. */ -internal suspend fun writeAfterEngineStops( - engineActive: Boolean, - stopEngine: suspend () -> Unit, - write: suspend () -> T, -): T { - if (engineActive) stopEngine() - return write() +internal suspend fun runEngineExclusively( + lock: Mutex, + onStart: () -> Unit, + onStop: () -> Unit, + block: suspend () -> T, +): T = lock.withLock { + onStart() + try { + block() + } finally { + onStop() + } } /** 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 066e25e2..aeea25ba 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 @@ -22,7 +22,9 @@ import com.ninelivesaudio.app.service.PlaybackManager import com.ninelivesaudio.app.service.RemoteMediaAccessDecision import com.ninelivesaudio.app.service.remoteMediaAccessDecision import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.service.download.WifiOnlyCopy import com.ninelivesaudio.app.service.local.LocalFolderAccess +import com.ninelivesaudio.app.ui.downloads.DELETE_REFUSED_NOTICE import com.ninelivesaudio.app.service.local.reconcileLocalBookAccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* @@ -412,8 +414,14 @@ class BookDetailViewModel @Inject constructor( // Clear any previous notice. Leaving a stale "free keeps one // book" warning up after a retry that WORKED, or after the user // unlocked and came back, would be its own small lie. - is DownloadManager.QueueResult.Queued -> - _uiState.update { it.copy(downloadNotice = null, downloadNoticeOffersUnlock = false) } + // A book held for Wi-Fi says so, or the Queued button just + // sits there on mobile data looking broken. + is DownloadManager.QueueResult.Queued -> _uiState.update { + it.copy( + downloadNotice = if (result.waitingForWifi) WifiOnlyCopy.QUEUED_NOTICE else null, + downloadNoticeOffersUnlock = false, + ) + } is DownloadManager.QueueResult.BlockedByFreeSlot -> { // The free tier holds one offline book. Say so, out loud, @@ -466,7 +474,10 @@ class BookDetailViewModel @Inject constructor( fun deleteDownload() { viewModelScope.launch { - downloadManager.deleteDownload(bookId) + if (!downloadManager.deleteDownload(bookId)) { + _uiState.update { it.copy(downloadNotice = DELETE_REFUSED_NOTICE) } + return@launch + } _uiState.update { it.copy( isDownloaded = false, 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 89cbf65e..6a421c06 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 @@ -1,19 +1,24 @@ package com.ninelivesaudio.app.ui.components +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import coil.compose.SubcomposeAsyncImage -import coil.compose.SubcomposeAsyncImageContent +import coil.compose.AsyncImage import java.net.URI /** * Displays an audiobook cover image, falling back to a vintage worn-book * placeholder when no cover URL is available OR when the image fails to load. * - * Uses SubcomposeAsyncImage so that broken/missing server URLs also - * show the vintage placeholder instead of a blank rectangle. + * Uses AsyncImage with the placeholder composed underneath it, so the + * placeholder shows while the cover loads and stays if the server URL is + * broken, and a long list of these does not subcompose a slot per row. * * [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 @@ -34,30 +39,26 @@ fun BookCoverImage( val seed = bookId?.hashCode() ?: title?.hashCode() ?: 0 if (!coverUrl.isNullOrEmpty()) { - SubcomposeAsyncImage( - model = imageModel - ?: thumbnailWidthPx?.let { thumbnailCoverUrl(coverUrl, it) } - ?: coverUrl, - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale, - alignment = alignment, - loading = { - VintageBookPlaceholder( - title = title, - seed = seed, - ) - }, - error = { + // Keyed on the URL, so a recycled row that now shows another book + // starts from its placeholder again. + var loaded by remember(coverUrl, thumbnailWidthPx) { mutableStateOf(false) } + Box(modifier = modifier, propagateMinConstraints = true) { + if (!loaded) { VintageBookPlaceholder( title = title, seed = seed, ) - }, - success = { - SubcomposeAsyncImageContent() - }, - ) + } + AsyncImage( + model = imageModel + ?: thumbnailWidthPx?.let { thumbnailCoverUrl(coverUrl, it) } + ?: coverUrl, + contentDescription = contentDescription, + contentScale = contentScale, + alignment = alignment, + onSuccess = { loaded = true }, + ) + } } else { VintageBookPlaceholder( modifier = modifier, diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt b/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt index 914677c8..6b0494f8 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/components/FluorescentSquareProgress.kt @@ -2,6 +2,7 @@ package com.ninelivesaudio.app.ui.components import android.graphics.BlurMaskFilter import android.graphics.DashPathEffect +import android.graphics.Paint import android.graphics.PathMeasure import androidx.compose.animation.core.EaseInOutSine import androidx.compose.animation.core.RepeatMode @@ -11,26 +12,63 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.ninelivesaudio.app.ui.components.unhinged.LocalUnhingedSettings import com.ninelivesaudio.app.ui.theme.NineLivesTheme +import kotlin.math.roundToInt + +/** How the glow is drawn right now. */ +internal enum class GlowMode { + /** Nothing to light, so nothing is drawn and no animation runs. */ + Off, + + /** Lit and held at a middle brightness, for people who asked for less motion. */ + Still, + + /** Lit and breathing slowly. */ + Breathing, +} + +/** + * At zero progress there is no arc to light, and a list of unstarted books + * would otherwise run an animation per row for nothing. With reduced motion + * the arc is drawn once and left alone. + */ +internal fun glowMode(progress: Float, reduceMotion: Boolean): GlowMode = when { + !(progress > 0f) -> GlowMode.Off + reduceMotion -> GlowMode.Still + else -> GlowMode.Breathing +} + +/** Breath position (0 dim, 1 bright) the glow holds at under reduced motion. */ +private const val STILL_BREATH = 0.5f + +private const val BREATH_ALPHA_DIM = 0.55f +private const val BREATH_ALPHA_BRIGHT = 1.00f +private const val BREATH_BLOOM_DIM_DP = 20f +private const val BREATH_BLOOM_BRIGHT_DP = 38f /** * Fluorescent rounded-square progress indicator with a multi-layer glow effect - * and continuous breathing animation. + * and a slow breathing animation. * * Draws a stroked rounded-rectangle path clipped to [progress] (0.0–1.0) with * four stacked layers — outer bleed, mid corona, core tube, and hot filament — - * all modulated by a slow breathing animation. + * all modulated by a slow breathing animation. At zero progress nothing is + * drawn and nothing animates. With reduced motion (the system animation scale + * at zero, or the in-app toggle) the glow is drawn once and held still. * * The progress arc starts and ends at the top-center of the rounded square. * @@ -49,177 +87,254 @@ fun FluorescentSquareProgress( strokeScale: Float = 1.0f, ) { val clampedProgress = progress.coerceIn(0f, 1f) - val density = LocalDensity.current - val glowColor = color - - // ── Breathing animation (continuous, regardless of progress) ────────── - val infiniteTransition = rememberInfiniteTransition(label = "fluorescent_breath") - - val breathAlpha by infiniteTransition.animateFloat( - initialValue = 0.55f, - targetValue = 1.00f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 3000, easing = EaseInOutSine), - repeatMode = RepeatMode.Reverse, - ), - label = "breath_alpha", - ) - - val breathBloom by infiniteTransition.animateFloat( - initialValue = 20f, - targetValue = 38f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 3000, easing = EaseInOutSine), - repeatMode = RepeatMode.Reverse, - ), - label = "breath_bloom", - ) + val reduceMotion = LocalUnhingedSettings.current.reduceMotionRequested + val mode = glowMode(clampedProgress, reduceMotion) + if (mode == GlowMode.Off) return + + val glowArgb = color.toArgb() + + // ── Breathing animation ────────────────────────────────────────────── + // One 0..1 position drives both the brightness and the bloom, since the + // two always moved on the same clock. Under reduced motion there is no + // transition at all, only a held value. + val breath: State = if (mode == GlowMode.Breathing) { + rememberInfiniteTransition(label = "fluorescent_breath").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3000, easing = EaseInOutSine), + repeatMode = RepeatMode.Reverse, + ), + label = "breath", + ) + } else { + remember { mutableFloatStateOf(STILL_BREATH) } + } + + // The paints, paths and path effects live here and are rebuilt only when + // the size, colour or progress change. A frame of the breathing animation + // only changes alphas (and, a pixel at a time, the outer blur). + val cache = remember { GlowDrawCache() } Canvas(modifier = modifier) { - val paddingPx = with(density) { padding.toPx() } - val cornerRadiusPx = with(density) { cornerRadius.toPx() } + val ready = cache.prepare( + width = size.width, + height = size.height, + paddingPx = padding.toPx(), + cornerRadiusPx = cornerRadius.toPx(), + strokeScale = strokeScale, + density = density, + argb = glowArgb, + progress = clampedProgress, + ) + if (!ready) return@Canvas + + drawIntoCanvas { canvas -> cache.draw(canvas.nativeCanvas, breath.value) } + } +} + +/** + * Everything [FluorescentSquareProgress] draws with, kept between frames. + * [prepare] rebuilds only what the inputs changed, and [draw] touches nothing + * but the per-frame alphas, so a frame allocates next to nothing. + */ +private class GlowDrawCache { + private val roundRect = Path() + private val bleedRect = Path() + private val nativeRoundRect = roundRect.asAndroidPath() + private val nativeBleedRect = bleedRect.asAndroidPath() + private val measure = PathMeasure() + + private val bleedPaint = strokePaint() + private val coronaPaint = strokePaint() + private val corePaint = strokePaint() + private val filamentPaint = strokePaint() + + private var lastWidth = Float.NaN + private var lastHeight = Float.NaN + private var lastPaddingPx = Float.NaN + private var lastCornerRadiusPx = Float.NaN + private var lastStrokeScale = Float.NaN + private var lastDensity = Float.NaN + private var lastArgb = 0 + private var lastProgress = Float.NaN + private var usable = false + + private var scatterPx = 0f + private var bloomScale = 0f + private var lastBloomPx = -1f + /** Rebuilds what changed. False when there is nothing to draw at this size. */ + fun prepare( + width: Float, + height: Float, + paddingPx: Float, + cornerRadiusPx: Float, + strokeScale: Float, + density: Float, + argb: Int, + progress: Float, + ): Boolean { + if (width == lastWidth && height == lastHeight && paddingPx == lastPaddingPx && + cornerRadiusPx == lastCornerRadiusPx && strokeScale == lastStrokeScale && + density == lastDensity && argb == lastArgb && progress == lastProgress + ) { + return usable + } + lastWidth = width + lastHeight = height + lastPaddingPx = paddingPx + lastCornerRadiusPx = cornerRadiusPx + lastStrokeScale = strokeScale + lastDensity = density + lastArgb = argb + lastProgress = progress + usable = rebuild(width, height, paddingPx, cornerRadiusPx, strokeScale, density, argb, progress) + return usable + } + + private fun rebuild( + width: Float, + height: Float, + paddingPx: Float, + cornerRadiusPx: Float, + strokeScale: Float, + density: Float, + argb: Int, + progress: Float, + ): Boolean { val left = paddingPx val top = paddingPx - val right = size.width - paddingPx - val bottom = size.height - paddingPx + val right = width - paddingPx + val bottom = height - paddingPx val rectWidth = right - left val rectHeight = bottom - top + if (rectWidth <= 0f || rectHeight <= 0f) return false - if (rectWidth <= 0f || rectHeight <= 0f) return@Canvas - - // ── Build the rounded-rect path ────────────────────────────────── - val roundRectPath = Path().apply { - addRoundRect( - androidx.compose.ui.geometry.RoundRect( - left = left, - top = top, - right = right, - bottom = bottom, - radiusX = cornerRadiusPx, - radiusY = cornerRadiusPx, - ), - ) - close() - } + // ── The rounded-rect path ──────────────────────────────────────── + roundRect.reset() + roundRect.addRoundRect( + RoundRect( + left = left, + top = top, + right = right, + bottom = bottom, + radiusX = cornerRadiusPx, + radiusY = cornerRadiusPx, + ), + ) + roundRect.close() // ── Measure path and compute top-center phase offset ───────────── - val nativePath = roundRectPath.asAndroidPath() - val pathMeasure = PathMeasure(nativePath, true) - val totalLength = pathMeasure.length - if (totalLength <= 0f) return@Canvas + measure.setPath(nativeRoundRect, true) + val totalLength = measure.length + if (totalLength <= 0f) return false // addRoundRect starts at top-left corner. The distance from top-left // corner to top-center is: cornerRadius + (rectWidth / 2 - cornerRadius) // = rectWidth / 2. So the phase offset is rectWidth / 2. val phaseOffset = rectWidth / 2f - - val drawLength = clampedProgress * totalLength + val drawLength = progress * totalLength // ── Outset path for the outer bleed layer ──────────────────────── - val outsetPx = with(density) { (8.dp * strokeScale).toPx() } - val bleedPath = Path().apply { - addRoundRect( - androidx.compose.ui.geometry.RoundRect( - left = left - outsetPx, - top = top - outsetPx, - right = right + outsetPx, - bottom = bottom + outsetPx, - radiusX = cornerRadiusPx + outsetPx, - radiusY = cornerRadiusPx + outsetPx, - ), - ) - close() - } - val bleedNativePath = bleedPath.asAndroidPath() - val bleedMeasure = PathMeasure(bleedNativePath, true) - val bleedTotalLength = bleedMeasure.length - val bleedDrawLength = clampedProgress * bleedTotalLength + val outsetPx = 8f * strokeScale * density + bleedRect.reset() + bleedRect.addRoundRect( + RoundRect( + left = left - outsetPx, + top = top - outsetPx, + right = right + outsetPx, + bottom = bottom + outsetPx, + radiusX = cornerRadiusPx + outsetPx, + radiusY = cornerRadiusPx + outsetPx, + ), + ) + bleedRect.close() + measure.setPath(nativeBleedRect, true) + val bleedTotalLength = measure.length + val bleedDrawLength = progress * bleedTotalLength val bleedPhaseOffset = (rectWidth + outsetPx * 2) / 2f - // ── Helper to create dash effect (segment + gap covering rest) ─── - fun dashEffect(segmentLen: Float, total: Float, phase: Float): DashPathEffect { - val seg = segmentLen.coerceAtLeast(0.01f) - val gap = (total - seg).coerceAtLeast(0.01f) - return DashPathEffect(floatArrayOf(seg, gap), total - phase) + // ── Strokes ───────────────────────────────────────────────────── + val rgb = argb or OPAQUE + + bleedPaint.color = rgb + bleedPaint.strokeWidth = 6f * strokeScale * density + bleedPaint.pathEffect = dashEffect(bleedDrawLength, bleedTotalLength, bleedPhaseOffset) + // The bloom radius follows the breath, so draw() sets the filter. + bleedPaint.maskFilter = null + lastBloomPx = -1f + bloomScale = strokeScale * density + scatterPx = 1.5f * strokeScale * density + + coronaPaint.color = rgb + coronaPaint.strokeWidth = 4f * strokeScale * density + coronaPaint.maskFilter = BlurMaskFilter(12f * strokeScale * density, BlurMaskFilter.Blur.NORMAL) + coronaPaint.pathEffect = dashEffect(drawLength, totalLength, phaseOffset) + + corePaint.color = rgb + corePaint.strokeWidth = 3f * strokeScale * density + corePaint.maskFilter = BlurMaskFilter(4f * strokeScale * density, BlurMaskFilter.Blur.NORMAL) + corePaint.pathEffect = dashEffect(drawLength, totalLength, phaseOffset) + + filamentPaint.color = FILAMENT_RGB + filamentPaint.strokeWidth = 1.5f * strokeScale * density + filamentPaint.pathEffect = dashEffect(drawLength, totalLength, phaseOffset) + return true + } + + /** One frame at [breath] (0 dim, 1 bright). */ + fun draw(canvas: android.graphics.Canvas, breath: Float) { + val breathAlpha = lerp(BREATH_ALPHA_DIM, BREATH_ALPHA_BRIGHT, breath) + val bloomPx = lerp(BREATH_BLOOM_DIM_DP, BREATH_BLOOM_BRIGHT_DP, breath) * bloomScale + // A blur filter's radius is fixed when it is made, so this is the one + // thing that cannot be reused every frame. Whole pixels are finer than + // the eye can follow on a 3 second breath, and cost far fewer filters. + val bloomWhole = bloomPx.roundToInt().coerceAtLeast(1).toFloat() + if (bloomWhole != lastBloomPx) { + bleedPaint.maskFilter = BlurMaskFilter(bloomWhole, BlurMaskFilter.Blur.NORMAL) + lastBloomPx = bloomWhole } - val bloomPx = with(density) { (breathBloom * strokeScale).dp.toPx() } - - drawIntoCanvas { canvas -> - val nativeCanvas = canvas.nativeCanvas - - // ── Layer 1: Outer bleed (drawn twice with offset for scatter) ── - val bleedPaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (6.dp * strokeScale).toPx() } - setColor(Color( - red = glowColor.red, - green = glowColor.green, - blue = glowColor.blue, - alpha = 0.22f * breathAlpha, - ).toArgb()) - maskFilter = BlurMaskFilter(bloomPx, BlurMaskFilter.Blur.NORMAL) - pathEffect = dashEffect(bleedDrawLength, bleedTotalLength, bleedPhaseOffset) - } - nativeCanvas.drawPath(bleedNativePath, bleedPaint) - - // Second bleed draw with slight offset for light scatter - nativeCanvas.save() - val scatterPx = with(density) { (1.5.dp * strokeScale).toPx() } - nativeCanvas.translate(scatterPx, -scatterPx) - nativeCanvas.drawPath(bleedNativePath, bleedPaint) - nativeCanvas.restore() - - // ── Layer 2: Mid corona ───────────────────────────────────────── - val coronaPaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (4.dp * strokeScale).toPx() } - setColor(Color( - red = glowColor.red, - green = glowColor.green, - blue = glowColor.blue, - alpha = 0.45f * breathAlpha, - ).toArgb()) - maskFilter = BlurMaskFilter( - with(density) { (12.dp * strokeScale).toPx() }, - BlurMaskFilter.Blur.NORMAL, - ) - pathEffect = dashEffect(drawLength, totalLength, phaseOffset) - } - nativeCanvas.drawPath(nativePath, coronaPaint) - - // ── Layer 3: Core tube ────────────────────────────────────────── - val corePaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (3.dp * strokeScale).toPx() } - setColor(Color( - red = glowColor.red, - green = glowColor.green, - blue = glowColor.blue, - alpha = 0.90f * breathAlpha, - ).toArgb()) - maskFilter = BlurMaskFilter( - with(density) { (4.dp * strokeScale).toPx() }, - BlurMaskFilter.Blur.NORMAL, - ) - pathEffect = dashEffect(drawLength, totalLength, phaseOffset) - } - nativeCanvas.drawPath(nativePath, corePaint) - - // ── Layer 4: Hot filament ─────────────────────────────────────── - val filamentColor = Color(0xFFFFF0C0) // Warm near-white - val filamentPaint = android.graphics.Paint().apply { - isAntiAlias = true - style = android.graphics.Paint.Style.STROKE - strokeWidth = with(density) { (1.5.dp * strokeScale).toPx() } - setColor(filamentColor.copy(alpha = 0.75f * breathAlpha).toArgb()) - pathEffect = dashEffect(drawLength, totalLength, phaseOffset) - } - nativeCanvas.drawPath(nativePath, filamentPaint) + bleedPaint.alpha = alphaOf(0.22f * breathAlpha) + coronaPaint.alpha = alphaOf(0.45f * breathAlpha) + corePaint.alpha = alphaOf(0.90f * breathAlpha) + filamentPaint.alpha = alphaOf(0.75f * breathAlpha) + + // ── Layer 1: Outer bleed (drawn twice with offset for scatter) ── + canvas.drawPath(nativeBleedRect, bleedPaint) + + // Second bleed draw with slight offset for light scatter + canvas.save() + canvas.translate(scatterPx, -scatterPx) + canvas.drawPath(nativeBleedRect, bleedPaint) + canvas.restore() + + // ── Layers 2 to 4: Mid corona, core tube, hot filament ────────── + canvas.drawPath(nativeRoundRect, coronaPaint) + canvas.drawPath(nativeRoundRect, corePaint) + canvas.drawPath(nativeRoundRect, filamentPaint) + } + + private fun dashEffect(segmentLen: Float, total: Float, phase: Float): DashPathEffect { + val seg = segmentLen.coerceAtLeast(0.01f) + val gap = (total - seg).coerceAtLeast(0.01f) + return DashPathEffect(floatArrayOf(seg, gap), total - phase) + } + + private fun alphaOf(fraction: Float): Int = (fraction * 255f).roundToInt().coerceIn(0, 255) + + private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t + + private companion object { + const val OPAQUE = 0xFF000000.toInt() + + /** Warm near-white for the hot filament. */ + const val FILAMENT_RGB = 0xFFFFF0C0.toInt() + + fun strokePaint() = Paint().apply { + isAntiAlias = true + style = Paint.Style.STROKE } } } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt index 7be05422..0356e9b4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/dossier/NightwatchDossierScreen.kt @@ -14,9 +14,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Lock @@ -199,104 +201,137 @@ private fun DossierContent( isUnlocked: Boolean, onNavigateToUnlock: () -> Unit, ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), + var booksExpanded by remember { mutableStateOf(false) } + + // Lazy, so Show all on a long history composes only the cards on screen. + // Several composables in one lazy item stack on top of each other, so each + // section item wraps its children in a spaced Column like the old one. + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // ─── Period Selector ───────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DossierPeriod.entries.forEach { period -> - // Greyed, never hidden, matching every other gate in the app. A - // free user should be able to see that longer windows exist and - // what they would be buying, not wonder why the Dossier only - // has one chip. - val locked = !FreeTier.allowsDossierPeriod(period, isUnlocked) - FilterChip( - selected = state.selectedPeriod == period, - onClick = { - if (locked) onNavigateToUnlock() else viewModel.onPeriodChanged(period) - }, - label = { - Text( - text = period.label, - style = MaterialTheme.typography.labelMedium, - modifier = if (locked) Modifier.alpha(0.5f) else Modifier, + item(key = "overview") { + DossierSection { + // ─── Period Selector ───────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DossierPeriod.entries.forEach { period -> + // Greyed, never hidden, matching every other gate in the app. A + // free user should be able to see that longer windows exist and + // what they would be buying, not wonder why the Dossier only + // has one chip. + val locked = !FreeTier.allowsDossierPeriod(period, isUnlocked) + FilterChip( + selected = state.selectedPeriod == period, + onClick = { + if (locked) onNavigateToUnlock() else viewModel.onPeriodChanged(period) + }, + label = { + Text( + text = period.label, + style = MaterialTheme.typography.labelMedium, + modifier = if (locked) Modifier.alpha(0.5f) else Modifier, + ) + }, + // Same lock sigil GatedControl uses, so a gated chip reads + // as part of the existing vocabulary rather than a new badge. + trailingIcon = if (!locked) null else { + { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = "${period.label} requires unlock", + modifier = Modifier + .size(14.dp) + .alpha(0.5f), + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = NineLivesTheme.colors.archiveVoidSurface, + selectedContainerColor = NineLivesTheme.colors.goldFilamentFaint, + labelColor = NineLivesTheme.colors.archiveTextSecondary, + selectedLabelColor = NineLivesTheme.colors.goldFilament, + ), + border = FilterChipDefaults.filterChipBorder( + borderColor = NineLivesTheme.colors.archiveOutline, + selectedBorderColor = NineLivesTheme.colors.goldFilament.copy(alpha = 0.4f), + enabled = true, + selected = state.selectedPeriod == period, + ), + shape = RoundedCornerShape(20.dp), ) - }, - // Same lock sigil GatedControl uses, so a gated chip reads - // as part of the existing vocabulary rather than a new badge. - trailingIcon = if (!locked) null else { - { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = "${period.label} requires unlock", - modifier = Modifier - .size(14.dp) - .alpha(0.5f), - ) - } - }, - colors = FilterChipDefaults.filterChipColors( - containerColor = NineLivesTheme.colors.archiveVoidSurface, - selectedContainerColor = NineLivesTheme.colors.goldFilamentFaint, - labelColor = NineLivesTheme.colors.archiveTextSecondary, - selectedLabelColor = NineLivesTheme.colors.goldFilament, - ), - border = FilterChipDefaults.filterChipBorder( - borderColor = NineLivesTheme.colors.archiveOutline, - selectedBorderColor = NineLivesTheme.colors.goldFilament.copy(alpha = 0.4f), - enabled = true, - selected = state.selectedPeriod == period, - ), - shape = RoundedCornerShape(20.dp), - ) + } + } + + // ─── Overview ────────────────────────────────────────────── + OverviewSection(state, viewModel) } } - // ─── Overview ────────────────────────────────────────────── - OverviewSection(state, viewModel) - // ─── Book Stats ──────────────────────────────────────────── if (state.bookStats.isNotEmpty()) { - BookStatsSection(state.bookStats, viewModel) + bookStatsItems( + bookStats = state.bookStats, + expanded = booksExpanded, + onToggleExpanded = { booksExpanded = !booksExpanded }, + viewModel = viewModel, + ) } // ─── Narrators ───────────────────────────────────────────── if (state.narratorStats.isNotEmpty()) { - NarratorSection(state.narratorStats, state.narratorWhisper, viewModel) + item(key = "narrators") { + DossierSection { NarratorSection(state.narratorStats, state.narratorWhisper, viewModel) } + } } // ─── Authors ─────────────────────────────────────────────── if (state.authorStats.isNotEmpty()) { - AuthorSection(state.authorStats, state.authorWhisper, viewModel) + item(key = "authors") { + DossierSection { AuthorSection(state.authorStats, state.authorWhisper, viewModel) } + } } // ─── Genres ──────────────────────────────────────────────── if (state.genreStats.isNotEmpty()) { - GenreSection(state.genreStats, state.genreWhisper, viewModel) + item(key = "genres") { + DossierSection { GenreSection(state.genreStats, state.genreWhisper, viewModel) } + } } // ─── Temporal ────────────────────────────────────────────── if (state.hourlyDistribution.isNotEmpty()) { - TemporalSection(state, viewModel) + item(key = "temporal") { + DossierSection { TemporalSection(state, viewModel) } + } } // ─── Shareable Summary Card ──────────────────────────────── - DossierShareSection(state, viewModel) + item(key = "share") { + DossierSection { DossierShareSection(state, viewModel) } + } // Bottom padding - Spacer(modifier = Modifier.height(80.dp)) + item(key = "bottom-padding") { + Spacer(modifier = Modifier.height(80.dp)) + } } } +@Composable +private fun DossierSection(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) +} + // ═══════════════════════════════════════════════════════════════ // Overview Section // ═══════════════════════════════════════════════════════════════ @@ -392,134 +427,147 @@ private fun StatChip(value: String, label: String, highlight: Boolean = false) { // Book Stats Section // ═══════════════════════════════════════════════════════════════ -@Composable -private fun BookStatsSection( +/** + * The expanded list is one lazy item per book, so tapping Show all on a long + * listening history composes only the cards on screen. + */ +private fun LazyListScope.bookStatsItems( bookStats: List, + expanded: Boolean, + onToggleExpanded: () -> Unit, viewModel: NightwatchDossierViewModel, ) { - SectionHeader("Books") + item(key = "books-header") { SectionHeader("Books") } - var expanded by remember { mutableStateOf(false) } val visibleBooks = if (expanded || bookStats.size <= 5) bookStats else bookStats.take(5) + items(visibleBooks, key = { it.bookId }) { book -> + BookStatCard(book, viewModel) + } - visibleBooks.forEach { book -> - DossierCard { - Row( + if (bookStats.size > 5) { + item(key = "books-toggle") { + TextButton( + onClick = onToggleExpanded, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - // Cover thumbnail - Box( - modifier = Modifier - .size(56.dp) - .clip(RoundedCornerShape(8.dp)) - .background(NineLivesTheme.colors.archiveVoidElevated), - ) { - BookCoverImage( - coverUrl = book.coverUrl, - contentDescription = book.title, - modifier = Modifier.fillMaxSize(), - title = book.title, - bookId = book.bookId, - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = book.title, - style = MaterialTheme.typography.bodyMedium, - color = NineLivesTheme.colors.archiveTextPrimary, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = book.author, - style = MaterialTheme.typography.bodySmall, - color = NineLivesTheme.colors.archiveTextSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - book.narrator?.let { narrator -> - Text( - text = "Read by $narrator", - style = MaterialTheme.typography.labelSmall, - color = NineLivesTheme.colors.archiveTextMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - // Time badge - Column(horizontalAlignment = Alignment.End) { - Text( - text = viewModel.formatDuration(book.listeningTime), - style = MaterialTheme.typography.titleMedium, - color = NineLivesTheme.colors.goldFilament, - fontWeight = FontWeight.Bold, - ) - Text( - text = "${book.sessionCount} sessions", - style = MaterialTheme.typography.labelSmall, - color = NineLivesTheme.colors.archiveTextMuted, - ) - } + Text( + text = if (expanded) "Show less" else "Show all ${bookStats.size} books", + color = NineLivesTheme.colors.goldFilamentDim, + style = MaterialTheme.typography.labelMedium, + ) } + } + } +} - Spacer(modifier = Modifier.height(8.dp)) - - // Progress bar - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), +@Composable +private fun BookStatCard( + book: BookStat, + viewModel: NightwatchDossierViewModel, +) { + DossierCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Cover thumbnail + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(NineLivesTheme.colors.archiveVoidElevated), ) { - LinearProgressIndicator( - progress = { (book.progress / 100.0).toFloat().coerceIn(0f, 1f) }, - modifier = Modifier - .weight(1f) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)), - color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.goldFilament, - trackColor = NineLivesTheme.colors.archiveVoidElevated, + BookCoverImage( + coverUrl = book.coverUrl, + contentDescription = book.title, + modifier = Modifier.fillMaxSize(), + title = book.title, + bookId = book.bookId, ) + } - val progressLabel = when { - book.isFinished -> "Complete" - book.chaptersTotal > 0 && book.currentChapter > 0 -> - "Ch ${book.currentChapter}/${book.chaptersTotal}" - book.progress > 0 -> "${book.progress.toInt()}%" - else -> "" - } - if (progressLabel.isNotEmpty()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = book.title, + style = MaterialTheme.typography.bodyMedium, + color = NineLivesTheme.colors.archiveTextPrimary, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = book.author, + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + book.narrator?.let { narrator -> Text( - text = progressLabel, + text = "Read by $narrator", style = MaterialTheme.typography.labelSmall, - color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.archiveTextMuted, - fontSize = 10.sp, + color = NineLivesTheme.colors.archiveTextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } - // Book whisper - book.whisper?.let { whisper -> - Spacer(modifier = Modifier.height(4.dp)) - WhisperText(whisper) + // Time badge + Column(horizontalAlignment = Alignment.End) { + Text( + text = viewModel.formatDuration(book.listeningTime), + style = MaterialTheme.typography.titleMedium, + color = NineLivesTheme.colors.goldFilament, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${book.sessionCount} sessions", + style = MaterialTheme.typography.labelSmall, + color = NineLivesTheme.colors.archiveTextMuted, + ) } } - } - if (bookStats.size > 5) { - TextButton( - onClick = { expanded = !expanded }, + Spacer(modifier = Modifier.height(8.dp)) + + // Progress bar + Row( modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = if (expanded) "Show less" else "Show all ${bookStats.size} books", - color = NineLivesTheme.colors.goldFilamentDim, - style = MaterialTheme.typography.labelMedium, + LinearProgressIndicator( + progress = { (book.progress / 100.0).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.goldFilament, + trackColor = NineLivesTheme.colors.archiveVoidElevated, ) + + val progressLabel = when { + book.isFinished -> "Complete" + book.chaptersTotal > 0 && book.currentChapter > 0 -> + "Ch ${book.currentChapter}/${book.chaptersTotal}" + book.progress > 0 -> "${book.progress.toInt()}%" + else -> "" + } + if (progressLabel.isNotEmpty()) { + Text( + text = progressLabel, + style = MaterialTheme.typography.labelSmall, + color = if (book.isFinished) NineLivesTheme.colors.archiveSuccess else NineLivesTheme.colors.archiveTextMuted, + fontSize = 10.sp, + ) + } + } + + // Book whisper + book.whisper?.let { whisper -> + Spacer(modifier = Modifier.height(4.dp)) + WhisperText(whisper) } } } 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 72d1113c..3f511a45 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 @@ -25,6 +25,7 @@ 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 +import com.ninelivesaudio.app.service.download.WifiOnlyCopy import com.ninelivesaudio.app.ui.components.ArchiveScreenHeader import com.ninelivesaudio.app.ui.copy.unhinged.CopyEngine @@ -88,6 +89,8 @@ fun DownloadsScreen( ) { item -> ActiveDownloadCard( item = item, + waitingForWifi = uiState.waitingForWifi(item.download), + onUseMobileData = { viewModel.startOnMobileData(item.download.id) }, onPause = { viewModel.pauseDownload(item.download.id) }, onResume = { viewModel.resumeDownload(item.download.id) }, onCancel = { viewModel.cancelDownload(item.download.id) }, @@ -146,6 +149,8 @@ private fun SectionHeader(title: String) { @Composable private fun ActiveDownloadCard( item: DownloadsViewModel.DownloadUiItem, + waitingForWifi: Boolean, + onUseMobileData: () -> Unit, onPause: () -> Unit, onResume: () -> Unit, onCancel: () -> Unit, @@ -199,12 +204,15 @@ private fun ActiveDownloadCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Status badge - val (statusText, statusColor) = when (download.status) { - DownloadStatus.Queued -> "Queued" to NineLivesTheme.colors.archiveTextMuted - DownloadStatus.Downloading -> "Downloading" to NineLivesTheme.colors.goldFilament - DownloadStatus.Paused -> "Paused" to NineLivesTheme.colors.archiveWarning - DownloadStatus.Failed -> "Failed" to NineLivesTheme.colors.archiveError + // Status badge. Waiting for Wi-Fi reads as its own state: + // without it a book interrupted by losing Wi-Fi sat on + // "Downloading" with a bar that never moved. + val (statusText, statusColor) = when { + waitingForWifi -> WifiOnlyCopy.WAITING_STATUS to NineLivesTheme.colors.archiveWarning + download.status == DownloadStatus.Queued -> "Queued" to NineLivesTheme.colors.archiveTextMuted + download.status == DownloadStatus.Downloading -> "Downloading" to NineLivesTheme.colors.goldFilament + download.status == DownloadStatus.Paused -> "Paused" to NineLivesTheme.colors.archiveWarning + download.status == DownloadStatus.Failed -> "Failed" to NineLivesTheme.colors.archiveError else -> "" to NineLivesTheme.colors.archiveTextMuted } Text( @@ -314,6 +322,28 @@ private fun ActiveDownloadCard( ) } } + + // One book over mobile data, now. The setting stays as it is. + if (waitingForWifi) { + TextButton( + onClick = onUseMobileData, + modifier = Modifier.align(Alignment.End), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + ) { + Icon( + Icons.Outlined.SignalCellularAlt, + contentDescription = null, + tint = NineLivesTheme.colors.goldFilament, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = WifiOnlyCopy.USE_MOBILE_DATA, + style = MaterialTheme.typography.labelMedium, + color = NineLivesTheme.colors.goldFilament, + ) + } + } } } } 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 134fb610..424c9cc0 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 @@ -12,6 +12,7 @@ import com.ninelivesaudio.app.service.ConnectivityMonitor import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.DownloadManager import com.ninelivesaudio.app.service.SettingsManager +import com.ninelivesaudio.app.service.download.isWaitingForWifi import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -40,7 +41,21 @@ class DownloadsViewModel @Inject constructor( val isLocalMode: Boolean = false, /** Why the last Resume or Retry did not start, or null. */ val notice: String? = null, - ) + /** The "Download on Wi-Fi only" setting. */ + val wifiOnly: Boolean = true, + /** Whether the current network may cost money. Unknown counts as metered. */ + val onMeteredNetwork: Boolean = true, + /** Downloads the user already sent over mobile data. */ + val meteredOverrides: Set = emptySet(), + ) { + /** True when Wi-Fi only is what holds [download] back right now. */ + fun waitingForWifi(download: DownloadItem): Boolean = isWaitingForWifi( + status = download.status, + wifiOnly = wifiOnly, + metered = onMeteredNetwork, + overridden = download.id in meteredOverrides, + ) + } private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -140,7 +155,24 @@ class DownloadsViewModel @Inject constructor( // are unavailable in LOCAL mode. viewModelScope.launch { settingsManager.settings.collect { settings -> - _uiState.update { it.copy(isLocalMode = settings.appMode == AppMode.LOCAL) } + _uiState.update { + it.copy( + isLocalMode = settings.appMode == AppMode.LOCAL, + wifiOnly = settings.downloadOnWifiOnly, + ) + } + } + } + + // What decides whether a download is waiting for Wi-Fi rather than stuck. + viewModelScope.launch { + connectivityMonitor.isMetered.collect { metered -> + _uiState.update { it.copy(onMeteredNetwork = metered) } + } + } + viewModelScope.launch { + downloadManager.meteredOverrides.collect { ids -> + _uiState.update { it.copy(meteredOverrides = ids) } } } } @@ -160,6 +192,13 @@ class DownloadsViewModel @Inject constructor( } } + /** Start a download waiting for Wi-Fi over mobile data, without changing the setting. */ + fun startOnMobileData(downloadId: String) { + viewModelScope.launch { + downloadManager.startOnMobileData(downloadId) + } + } + fun dismissNotice() { _uiState.update { it.copy(notice = null) } } @@ -172,7 +211,9 @@ class DownloadsViewModel @Inject constructor( fun deleteDownload(audioBookId: String) { viewModelScope.launch { - downloadManager.deleteDownload(audioBookId) + if (!downloadManager.deleteDownload(audioBookId)) { + _uiState.update { it.copy(notice = DELETE_REFUSED_NOTICE) } + } } } @@ -184,6 +225,11 @@ class DownloadsViewModel @Inject constructor( } } +/** What a delete says when it kept the files because they might not be only this book's. */ +internal const val DELETE_REFUSED_NOTICE = + "Couldn't safely remove this book's files, so it's still downloaded. " + + "Another download may share its folder, or the download folder changed since." + /** * A Retry the free slot refuses has to say so. It used to requeue anyway and * sit in Queued forever with no word. diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LetterIndexRail.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LetterIndexRail.kt new file mode 100644 index 00000000..f2e78a49 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LetterIndexRail.kt @@ -0,0 +1,224 @@ +package com.ninelivesaudio.app.ui.library + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ninelivesaudio.app.ui.theme.NineLivesTheme +import kotlin.math.roundToInt + +/** What TalkBack calls the letter rail. */ +internal const val LETTER_RAIL_LABEL = "Jump to letter" + +private val RAIL_WIDTH = 28.dp +private val MIN_LETTER_SPACING = 14.dp + +/** + * A strip of letters down the right edge of the Library list. Touch or drag + * on it jumps the list to the first row under that letter, with a big letter + * shown while a finger is down. Fill the list's box with it: it only draws + * and takes touches on the strip itself. + * + * A drag costs a lookup in [index] and a scroll request, so nothing here + * grows with the number of books. TalkBack gets the strip as one adjustable + * control, where swiping up or down steps through the letters. + * + * @param leadingItemCount list items before the first row [index] counts, such + * as a sync warning banner + */ +@Composable +internal fun LetterIndexRail( + index: LetterIndex, + listState: LazyListState, + leadingItemCount: Int, + modifier: Modifier = Modifier, + bottomInset: Dp = 0.dp, +) { + if (index.isEmpty) return + + val haptics = LocalHapticFeedback.current + val minSpacingPx = with(LocalDensity.current) { MIN_LETTER_SPACING.toPx() } + val currentIndex by rememberUpdatedState(index) + val currentLeading by rememberUpdatedState(leadingItemCount) + + // The letter under a finger, or -1 with none down. The one the list is + // sitting on is worked out from its scroll position, a binary search that + // only re-runs the rail when the answer changes. + var touchedLabel by remember { mutableIntStateOf(-1) } + // The last letter picked, since a short list cannot always scroll as far + // as the last few letters and would otherwise never report reaching them. + var pickedLabel by remember { mutableIntStateOf(-1) } + var railHeightPx by remember { mutableIntStateOf(0) } + + val scrolledLabel by remember(index, leadingItemCount) { + derivedStateOf { + val fromScroll = index.labelIndexAt(listState.firstVisibleItemIndex - leadingItemCount) + if (!listState.canScrollForward && pickedLabel > fromScroll) { + pickedLabel.coerceAtMost(index.labels.lastIndex) + } else { + fromScroll + } + } + } + + val jumpTo = remember(listState) { + { labelIdx: Int -> + val live = currentIndex + if (labelIdx in live.labels.indices) { + pickedLabel = labelIdx + listState.requestScrollToItem(live.offsets[labelIdx] + currentLeading) + } + } + } + + val shownLabel = if (touchedLabel >= 0) touchedLabel else scrolledLabel + val stride = letterRailStride(index.labels.size, railHeightPx.toFloat(), minSpacingPx) + + Box(modifier = modifier.fillMaxSize()) { + if (touchedLabel in index.labels.indices) { + LetterBubble( + label = index.labels[touchedLabel], + modifier = Modifier.align(Alignment.Center), + ) + } + + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = 8.dp, bottom = bottomInset + 8.dp) + .fillMaxHeight() + .width(RAIL_WIDTH) + .onSizeChanged { railHeightPx = it.height } + // The strip sits on the screen edge, where the back swipe lives. + .systemGestureExclusion() + .clip(RoundedCornerShape(14.dp)) + .background( + if (touchedLabel >= 0) NineLivesTheme.colors.archiveVoidSurface.copy(alpha = 0.85f) + else Color.Transparent, + ) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + var last = -1 + fun pickAt(y: Float) { + val count = currentIndex.labels.size + val picked = letterIndexAtTouch(y, size.height.toFloat(), count) + if (picked != last) { + last = picked + touchedLabel = picked + jumpTo(picked) + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + pickAt(down.position.y) + while (true) { + val change = awaitPointerEvent().changes + .firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + pickAt(change.position.y) + change.consume() + } + touchedLabel = -1 + } + } + .clearAndSetSemantics { + contentDescription = LETTER_RAIL_LABEL + stateDescription = letterRailSpokenLabel(index.labels[shownLabel.coerceIn(0, index.labels.lastIndex)]) + progressBarRangeInfo = ProgressBarRangeInfo( + current = shownLabel.toFloat(), + range = 0f..index.labels.lastIndex.toFloat(), + steps = (index.labels.size - 2).coerceAtLeast(0), + ) + setProgress { target -> + jumpTo(target.roundToInt().coerceIn(0, index.labels.lastIndex)) + true + } + }, + ) { + index.labels.forEachIndexed { position, label -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + if (position % stride == 0) { + val active = position == shownLabel + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = if (active) NineLivesTheme.colors.goldFilament + else NineLivesTheme.colors.archiveTextSecondary, + fontWeight = if (active) FontWeight.Bold else FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 10.sp, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + ) + } + } + } + } + } +} + +/** The big letter shown in the middle of the list while the rail is held. */ +@Composable +private fun LetterBubble(label: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = NineLivesTheme.colors.archiveVoidElevated, + border = BorderStroke(1.dp, NineLivesTheme.colors.goldFilament.copy(alpha = 0.6f)), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 14.dp), + style = MaterialTheme.typography.headlineLarge, + color = NineLivesTheme.colors.goldFilament, + fontWeight = FontWeight.Bold, + ) + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt new file mode 100644 index 00000000..07492f4a --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndex.kt @@ -0,0 +1,156 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.util.foldForSearch +import kotlin.math.ceil + +/** Where digits, symbols and anything outside A to Z are filed. */ +internal const val NUMBER_SIGN_LABEL = "#" + +/** Under this many rows the list is short enough to scroll, so no rail. */ +internal const val MIN_ROWS_FOR_LETTER_INDEX = 40 + +/** A to Z and the number sign. */ +private const val MAX_LETTER_LABELS = 27 + +/** + * The letters a sorted Library list can be jumped to. [labels] are in list + * order (reversed for a Z to A sort) and [offsets] holds, for each, the + * position of the first row under it in the list of rows (headers and books + * for a grouped list). Built once per list change by [flatLetterIndex] or + * [groupedLetterIndex], never during scrolling. + */ +internal class LetterIndex(val labels: List, val offsets: IntArray) { + val isEmpty: Boolean get() = labels.isEmpty() + + /** + * Which label covers the row at [position]: the last one whose first row + * is at or before it. A position before the first label gets the first. + */ + fun labelIndexAt(position: Int): Int { + var low = 0 + var high = offsets.size - 1 + var found = 0 + while (low <= high) { + val mid = (low + high) ushr 1 + if (offsets[mid] <= position) { + found = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + return found + } + + companion object { + val Empty = LetterIndex(emptyList(), IntArray(0)) + } +} + +/** + * Whether the shelf is in alphabetical order under [sortMode], by title or + * by author. Every other sort has no meaningful letters to jump to. + */ +internal fun sortsAlphabetically(sortMode: SortMode): Boolean = when (sortMode) { + SortMode.TITLE_AZ, SortMode.TITLE_ZA, SortMode.AUTHOR_AZ, SortMode.AUTHOR_ZA -> true + else -> false +} + +private val ASCII_LABELS = Array(26) { ('A' + it).toString() } + +/** + * The letter a title or name is filed under, matching how the shelf sorts it + * (on its first character, so "The Hobbit" is under T). Accents are dropped + * by [foldForSearch], so "Émile" is under E and "Øystein" under O. Digits, + * symbols, other alphabets and blanks share the number sign. + */ +internal fun letterLabelFor(text: String): String { + var i = 0 + while (i < text.length && text[i].isWhitespace()) i++ + if (i == text.length) return NUMBER_SIGN_LABEL + val c = text[i] + return when { + c in 'A'..'Z' -> ASCII_LABELS[c - 'A'] + c in 'a'..'z' -> ASCII_LABELS[c - 'a'] + c.isLetter() -> { + // Filed the way search folds it, so the rail and search agree. + val base = foldForSearch(c.toString()).firstOrNull() + if (base != null && base in 'a'..'z') ASCII_LABELS[base - 'a'] else NUMBER_SIGN_LABEL + } + else -> NUMBER_SIGN_LABEL + } +} + +/** + * One pass over [rowCount] rows, keeping the first row of each label that + * [labelAt] names (null skips a row). Stops as soon as every letter has been + * seen, and returns [LetterIndex.Empty] for a short list or one with a single + * label, where a rail would be clutter. + */ +internal fun buildLetterIndex(rowCount: Int, labelAt: (Int) -> String?): LetterIndex { + if (rowCount < MIN_ROWS_FOR_LETTER_INDEX) return LetterIndex.Empty + val seen = HashSet(MAX_LETTER_LABELS * 2) + val labels = ArrayList(MAX_LETTER_LABELS) + val offsets = IntArray(MAX_LETTER_LABELS) + for (row in 0 until rowCount) { + val label = labelAt(row) ?: continue + if (seen.add(label)) { + offsets[labels.size] = row + labels.add(label) + if (labels.size == MAX_LETTER_LABELS) break + } + } + if (labels.size < 2) return LetterIndex.Empty + return LetterIndex(labels, offsets.copyOf(labels.size)) +} + +/** The rail for the flat shelf, filed by title or author to match [sortMode]. */ +internal fun flatLetterIndex(books: List, sortMode: SortMode): LetterIndex { + if (!sortsAlphabetically(sortMode)) return LetterIndex.Empty + val byAuthor = sortMode == SortMode.AUTHOR_AZ || sortMode == SortMode.AUTHOR_ZA + return buildLetterIndex(books.size) { row -> + val book = books[row] + letterLabelFor(if (byAuthor) book.author else book.title) + } +} + +/** + * The rail for a grouped shelf. Only the group headers carry letters, and the + * offsets count every header and expanded book row before them. + */ +internal fun groupedLetterIndex(items: List, sortMode: SortMode): LetterIndex { + if (!sortsAlphabetically(sortMode)) return LetterIndex.Empty + return buildLetterIndex(items.size) { row -> + (items[row] as? LibraryListItem.GroupHeader)?.let { letterLabelFor(it.title) } + } +} + +/** + * A remember key that is [value] by reference. The list of books is a key for + * the index, and comparing two lists of 8,000 books by content on the main + * thread is exactly the cost the index exists to avoid. + */ +internal class IdentityKey(private val value: Any) { + override fun equals(other: Any?): Boolean = other is IdentityKey && other.value === value + override fun hashCode(): Int = System.identityHashCode(value) +} + +/** The letter under a finger [y] pixels down a rail [heightPx] tall. */ +internal fun letterIndexAtTouch(y: Float, heightPx: Float, labelCount: Int): Int { + if (labelCount <= 0 || heightPx <= 0f) return 0 + return ((y / heightPx) * labelCount).toInt().coerceIn(0, labelCount - 1) +} + +/** + * Show every [letterRailStride]th letter, so no two shown letters sit closer + * than [minSlotPx] on a rail [heightPx] tall. Touch still maps to every letter. + */ +internal fun letterRailStride(labelCount: Int, heightPx: Float, minSlotPx: Float): Int { + if (labelCount <= 0 || heightPx <= 0f) return 1 + return ceil(minSlotPx * labelCount / heightPx - 0.0001f).toInt().coerceAtLeast(1) +} + +/** A rail letter as TalkBack should say it. */ +internal fun letterRailSpokenLabel(label: String): String = + if (label == NUMBER_SIGN_LABEL) "numbers and symbols" else label 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 8c65fcbb..4fd467a2 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.shape.RoundedCornerShape @@ -34,7 +35,10 @@ 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.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.onClick as onClickAction import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight @@ -55,6 +59,8 @@ import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.ui.components.ContainmentFrame import com.ninelivesaudio.app.ui.components.CornerSigils import com.ninelivesaudio.app.ui.components.FluorescentSquareProgress +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.ninelivesaudio.app.ui.animation.unhinged.anomalies.AnomalyHost import com.ninelivesaudio.app.ui.animation.unhinged.anomalies.AnomalyTriggerContext @@ -67,6 +73,9 @@ import com.ninelivesaudio.app.ui.theme.NineLivesTheme import com.ninelivesaudio.app.ui.unlock.UnlockViewModel import com.ninelivesaudio.app.ui.theme.unhinged.* +/** Room under the last row for the mini player, which the letter rail also stops short of. */ +private val LIBRARY_LIST_BOTTOM_PADDING = 100.dp + @OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryScreen( @@ -245,19 +254,39 @@ fun LibraryScreen( } } else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 18.dp, - end = 18.dp, - top = 0.dp, - bottom = 100.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - (shelfDecision as? LibraryShelfDecision.ShowShelf) - ?.warning - ?.let { warning -> + val listState = rememberLazyListState() + val syncWarning = (shelfDecision as? LibraryShelfDecision.ShowShelf)?.warning + val flatShelf = uiState.effectiveViewMode == ViewMode.ALL + + // Where each letter starts, worked out off the main thread + // once per list change. Scrolling and dragging the rail only + // look this up, so a shelf of 8,000 books costs nothing per frame. + val flatBooks = uiState.filteredBooks + val groupedItems = uiState.groupedListItems + val sortMode = uiState.sortMode + // Keyed on the list itself, not its contents, so a new list + // is one reference check here and never a compare of 8,000 books. + val shelfKey = IdentityKey(if (flatShelf) flatBooks else groupedItems) + val letterIndex by produceState(LetterIndex.Empty, shelfKey, flatShelf, sortMode) { + value = withContext(Dispatchers.Default) { + if (flatShelf) flatLetterIndex(flatBooks, sortMode) + else groupedLetterIndex(groupedItems, sortMode) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 18.dp, + end = 18.dp, + top = 0.dp, + bottom = LIBRARY_LIST_BOTTOM_PADDING, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + syncWarning?.let { warning -> item(key = "sync-warning") { SyncWarningBanner( result = warning, @@ -265,48 +294,52 @@ fun LibraryScreen( ) } } - // 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, - key = { _, book -> book.id }, - ) { index, book -> - ArchiveBookListItem( - book = book, - index = index, - whisperEpoch = whisperEpoch, - onClick = { onNavigateToBookDetail(book.id) }, - ) - } - } else { - // Grouped expandable list in SERIES / AUTHOR / GENRE modes - itemsIndexed( - items = uiState.groupedListItems, - key = { _, item -> - when (item) { - is LibraryListItem.GroupHeader -> "header-${item.groupKey}" - is LibraryListItem.BookRow -> "book-${item.groupKey}-${item.book.id}" - } - }, - ) { index, item -> - when (item) { - is LibraryListItem.GroupHeader -> GroupHeaderRow( - title = item.title, - count = item.count, - isExpanded = item.isExpanded, - onClick = { viewModel.onGroupExpansionToggled(item.groupKey) }, - ) - is LibraryListItem.BookRow -> ArchiveBookListItem( - book = item.book, + // 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, + key = { _, book -> book.id }, + ) { index, book -> + ArchiveBookListItem( + book = book, index = index, whisperEpoch = whisperEpoch, - onClick = { onNavigateToBookDetail(item.book.id) }, + onClick = { onNavigateToBookDetail(book.id) }, ) } + } else { + // Grouped expandable list in SERIES / AUTHOR / GENRE modes + itemsIndexed( + items = uiState.groupedListItems, + key = { _, item -> item.listKey }, + ) { index, item -> + when (item) { + is LibraryListItem.GroupHeader -> GroupHeaderRow( + title = item.title, + count = item.count, + isExpanded = item.isExpanded, + onClick = { viewModel.onGroupExpansionToggled(item.groupKey) }, + ) + is LibraryListItem.BookRow -> ArchiveBookListItem( + book = item.book, + index = index, + whisperEpoch = whisperEpoch, + onClick = { onNavigateToBookDetail(item.book.id) }, + ) + } + } } } + + LetterIndexRail( + index = letterIndex, + listState = listState, + // The banner is the one list item ahead of the shelf rows. + leadingItemCount = if (syncWarning != null) 1 else 0, + bottomInset = LIBRARY_LIST_BOTTOM_PADDING, + ) } } } @@ -342,6 +375,9 @@ internal fun RefreshableEmptyLibraryContent( // ─── Relic Search Bar ──────────────────────────────────────────────────── +/** What TalkBack calls the search box. */ +internal const val SEARCH_FIELD_LABEL = "Search the library" + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RelicSearchBar( @@ -358,6 +394,9 @@ private fun RelicSearchBar( modifier = modifier .fillMaxWidth() .height(48.dp) + // The hint inside the box changes with the copy mode, so give + // TalkBack one steady name for the field. + .semantics { contentDescription = SEARCH_FIELD_LABEL } .shadow( elevation = if (isFocused) 6.dp else 2.dp, shape = RoundedCornerShape(12.dp), @@ -772,6 +811,28 @@ internal fun GroupHeaderRow( // ─── Archive Book List Item ────────────────────────────────────────────── +/** + * What TalkBack reads for a Library row, as one sentence: the title once, then + * the author, whether it is archived, how far along it is in words, and whether + * it is on the device. The row's pieces (cover, ring, dots, percent text) are + * hidden from TalkBack so none of them repeat it. + */ +internal fun bookRowDescription(book: AudioBook): String = buildList { + add(book.title) + if (book.author.isNotBlank()) add("by ${book.author}") + if (book.isArchived) add("archived") + if (book.isFinished) { + add("finished") + } else if (book.hasProgress) { + add("${book.progressPercent.toInt()} percent listened") + if (book.chapters.isNotEmpty()) { + val chapterIdx = book.getCurrentChapterIndex() + if (chapterIdx >= 0) add("chapter ${chapterIdx + 1} of ${book.chapters.size}") + } + } + if (book.isDownloaded) add("downloaded") +}.joinToString(", ") + @Composable private fun ArchiveBookListItem( book: AudioBook, @@ -793,10 +854,16 @@ private fun ArchiveBookListItem( if (showWhisper) BookWhisperCatalog.getWhisper(book, whisperEpoch) else null } + val description = remember(book) { bookRowDescription(book) } + Column( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .clearAndSetSemantics { + contentDescription = description + onClickAction { onClick(); true } + }, ) { Row( modifier = Modifier @@ -822,7 +889,8 @@ private fun ArchiveBookListItem( ) { BookCoverImage( coverUrl = book.effectiveCoverPath, - contentDescription = book.title, + // The row's description already names the book. + contentDescription = null, modifier = Modifier.fillMaxSize(), title = book.title, bookId = book.id, 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 185cd6c1..fa8ab174 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 @@ -79,6 +79,17 @@ sealed class LibraryListItem { ) : LibraryListItem() } +/** + * The grouped list's LazyColumn key for this row. A row's key carries the + * group key's length, so a group "Smith-Jones" with book "x" and a group + * "Smith" with book "Jones-x" can never run together into one key. + */ +internal val LibraryListItem.listKey: String + get() = when (this) { + is LibraryListItem.GroupHeader -> "header-$groupKey" + is LibraryListItem.BookRow -> "book-${groupKey.length}-$groupKey-${book.id}" + } + data class GroupedSection( val key: String, val title: String, @@ -117,13 +128,26 @@ internal fun shouldRequeryShelfAfterSync( * Membership is compared as a set: the shown list puts retained libraries * after fetched ones while the cache sorts by display order, and treating * that order difference as a change reloaded on every sync, forever. + * A rename keeps the id, so [librariesRenamed] reports it separately. */ internal fun shouldReloadLibrariesAfterSync( selectedLibrary: Library?, shownLibraryIds: List, cachedLibraryIds: List, + librariesRenamed: Boolean = false, ): Boolean = (selectedLibrary == null && cachedLibraryIds.isNotEmpty()) || - cachedLibraryIds.toSet() != shownLibraryIds.toSet() + cachedLibraryIds.toSet() != shownLibraryIds.toSet() || + librariesRenamed + +/** + * Whether a library shown under one name is saved under another. Only ids + * present in both lists count, since added and removed libraries are the id + * comparison's job, and order never matters. + */ +internal fun librariesRenamed(shown: List, cached: List): Boolean { + val cachedNames = cached.associate { it.id to it.name } + return shown.any { library -> cachedNames[library.id]?.let { it != library.name } == true } +} internal enum class AfterProgressPull { REFILTER, RELOAD_LIBRARIES } @@ -139,8 +163,9 @@ internal fun afterProgressPull( selectedLibrary: Library?, shownLibraryIds: List, cachedLibraryIds: List, + librariesRenamed: Boolean = false, ): AfterProgressPull = - if (shouldReloadLibrariesAfterSync(selectedLibrary, shownLibraryIds, cachedLibraryIds)) { + if (shouldReloadLibrariesAfterSync(selectedLibrary, shownLibraryIds, cachedLibraryIds, librariesRenamed)) { AfterProgressPull.RELOAD_LIBRARIES } else { AfterProgressPull.REFILTER @@ -493,6 +518,7 @@ class LibraryViewModel @Inject constructor( selectedLibrary = state.selectedLibrary, shownLibraryIds = state.libraries.map { it.id }, cachedLibraryIds = cached.map { it.id }, + librariesRenamed = librariesRenamed(state.libraries, cached), ) ) { // This can replace a manual refresh in the lane, and the @@ -519,7 +545,13 @@ class LibraryViewModel @Inject constructor( settings = settingsManager.currentSettings, cached = libraryRepository.getAudiobookshelf(), ) - when (afterProgressPull(state.selectedLibrary, state.libraries.map { it.id }, cached.map { it.id })) { + val pull = afterProgressPull( + selectedLibrary = state.selectedLibrary, + shownLibraryIds = state.libraries.map { it.id }, + cachedLibraryIds = cached.map { it.id }, + librariesRenamed = librariesRenamed(state.libraries, cached), + ) + when (pull) { AfterProgressPull.RELOAD_LIBRARIES -> libraryLoadLaunch.launch(viewModelScope) { loadLibrariesOwningRefresh() } AfterProgressPull.REFILTER -> applyFilter() @@ -1374,7 +1406,9 @@ internal suspend fun arrangeShelf( val sort = FreeTier.effectiveSort(storedSort, isUnlocked) val viewMode = FreeTier.effectiveViewMode(storedViewMode, isUnlocked) currentCoroutineContext().ensureActive() - val sorted = sortBooks(books, sort) + // One row per book, whatever the read returned. A book twice is two list + // rows with one key, and the list crashes on the second. + val sorted = sortBooks(withoutRepeatedIds(books), sort) currentCoroutineContext().ensureActive() return ArrangedShelf( books = sorted, @@ -1386,7 +1420,8 @@ internal suspend fun arrangeShelf( /** * 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. + * not sorted again. The one exception is a series in Series view, which reads + * in [seriesReadingOrder]. */ internal fun buildGroupedSections( books: List, @@ -1396,16 +1431,67 @@ internal fun buildGroupedSections( if (viewMode == ViewMode.ALL) return emptyList() // Genre view uses multi-placement: a book appears in every genre group it belongs to. + // A group's key is its name with runs of spaces made one ("John Smith" is + // "John Smith"). Capitals stay distinct, so "Saga" and "SAGA" remain two + // series, and the key never depends on which book sorts first. A book is in + // each group at most once. val grouped = linkedMapOf>() + val bookKeys = LinkedHashSet() books.forEach { book -> - val keys = groupingKeysForBook(book, viewMode) - keys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } + bookKeys.clear() + groupingKeysForBook(book, viewMode).forEach { name -> + val spelling = collapseSpaces(name) + if (spelling.isNotEmpty()) bookKeys += spelling + } + bookKeys.forEach { key -> grouped.getOrPut(key) { mutableListOf() }.add(book) } } - return grouped.entries + val sections = grouped.entries .map { (key, values) -> SectionSortEntry(GroupedSection(key = key, title = key, books = values)) } .sortedWith(groupedSectionComparator(sortMode)) .map { it.section } + if (viewMode != ViewMode.SERIES) return sections + + // Only now, after the groups are ordered by their books in shelf order, + // does each series switch to reading order. A Recently Added shelf still + // lists the series with the newest book first. Books with no series are + // not a series, so they keep the shelf order. + return sections.map { section -> + if (section.key == UNKNOWN_SERIES_GROUP) section + else section.copy(books = seriesReadingOrder(section.books)) + } +} + +/** + * A series' books in reading order (issue #63): by sequence, compared as + * numbers, so 2 comes before 10 and 1.5 sits between 1 and 2. A book with no + * sequence, or one that is not a plain number, goes last. Ties go by title, + * and books that still tie keep the order they came in. + */ +internal fun seriesReadingOrder(books: List): List { + if (books.size < 2) return books + return books + .map { SeriesOrderEntry(it) } + .sortedWith(compareBy(nullsLast()) { it.sequence }.thenBy { it.titleKey }) + .map { it.book } +} + +private val PLAIN_SEQUENCE_NUMBER = Regex("""\d+(?:[.,]\d+)?""") + +/** + * A series sequence as a number: "10", "1.5", or "1,5" as some tags write + * it. Null for anything else ("Book 3", "1-3", blank), which sorts last. + */ +internal fun seriesSequenceNumber(sequence: String?): Double? { + val text = sequence?.trim() ?: return null + if (!PLAIN_SEQUENCE_NUMBER.matches(text)) return null + return text.replace(',', '.').toDoubleOrNull() +} + +/** A book with its reading order keys worked out once, not on every compare. */ +private class SeriesOrderEntry(val book: AudioBook) { + val sequence: Double? = seriesSequenceNumber(book.seriesSequence) + val titleKey: String = book.title.lowercase() } /** Grouped views start collapsed when they have more groups than this. */ @@ -1433,15 +1519,22 @@ internal fun toggledGroupChoices( /** * Headers and rows for a grouped view. A group the user expanded or collapsed * by hand ([choices]) stays that way, any other follows [groupsStartExpanded]. + * + * Every row has its own [listKey]: a repeat (which the groups should never + * hold) is dropped and the first kept, so a stray one cannot crash the list. */ internal fun flattenGroupedItems( groupedSections: List, choices: Map, ): List = buildList { val startExpanded = groupsStartExpanded(groupedSections.size) + val usedKeys = HashSet() + fun addOnce(item: LibraryListItem) { + if (usedKeys.add(item.listKey)) add(item) + } groupedSections.forEach { section -> val expanded = choices[section.key] ?: startExpanded - add( + addOnce( LibraryListItem.GroupHeader( groupKey = section.key, title = section.title, @@ -1450,14 +1543,34 @@ internal fun flattenGroupedItems( ) ) if (expanded) { - section.books.forEach { add(LibraryListItem.BookRow(groupKey = section.key, book = it)) } + section.books.forEach { addOnce(LibraryListItem.BookRow(groupKey = section.key, book = it)) } + } + } +} + +/** [books] with each id once, the first kept, in order. */ +internal fun withoutRepeatedIds(books: List): List { + val seen = HashSet(books.size * 2) + return books.filter { seen.add(it.id) } +} + +/** [name] trimmed, with each run of spaces inside it made one space. */ +private fun collapseSpaces(name: String): String { + val trimmed = name.trim() + return buildString(trimmed.length) { + var lastWasSpace = false + for (c in trimmed) { + val isSpace = c.isWhitespace() + if (!isSpace) append(c) else if (!lastWasSpace) append(' ') + lastWasSpace = isSpace } } } private fun groupingKeysForBook(book: AudioBook, viewMode: ViewMode): List = when (viewMode) { ViewMode.SERIES -> listOf(book.seriesName?.takeIf { it.isNotBlank() } ?: UNKNOWN_SERIES_GROUP) - ViewMode.AUTHOR -> listOf(book.author.takeIf { it.isNotBlank() } ?: UNKNOWN_AUTHOR_GROUP) + // Multi-placement too: a co-authored book appears under each author. + ViewMode.AUTHOR -> authorGroupNames(book).ifEmpty { listOf(UNKNOWN_AUTHOR_GROUP) } ViewMode.GENRE -> book.genres .asSequence() .map { it.trim() } @@ -1468,6 +1581,42 @@ private fun groupingKeysForBook(book: AudioBook, viewMode: ViewMode): List emptyList() } +/** + * The authors a book is listed under in Author view (issue #64). The book + * itself keeps its full author line for display. Audiobookshelf's library + * list sends a co-authored book's authors as one string joined with ", ", so + * a server book is split there ([splitServerAuthorNames]). A local book's + * author comes from its tags or folder, where "Tolkien, J.R.R." is one + * person written last name first, so it stays whole. Empty for no author. + */ +internal fun authorGroupNames(book: AudioBook): List { + val author = book.author.trim() + if (author.isEmpty()) return emptyList() + return if (book.isLocal) listOf(author) else splitServerAuthorNames(author) +} + +/** + * The names in an Audiobookshelf author line, which joins its authors with + * ", ". The library list sends nothing more structured, so a piece that is + * only a name suffix ("Jr.", "III", "PhD") is put back on the name before it: + * "Martin Luther King, Jr., Coretta Scott King" is two people, not three. + */ +internal fun splitServerAuthorNames(authorName: String): List { + val names = ArrayList() + for (piece in authorName.split(", ")) { + val name = piece.trim() + if (name.isEmpty()) continue + if (names.isNotEmpty() && isNameSuffix(name)) names[names.lastIndex] = "${names.last()}, $name" + else names += name + } + return names.distinct() +} + +/** Name suffixes that follow a comma inside one author's name. */ +private val NAME_SUFFIXES = setOf("jr", "sr", "ii", "iii", "iv", "phd", "ph.d", "md", "m.d", "esq", "inc", "llc", "ltd") + +private fun isNameSuffix(piece: String): Boolean = piece.lowercase().removeSuffix(".") in NAME_SUFFIXES + /** 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() 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 c49b3a2f..cedac4fc 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 @@ -12,6 +12,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.graphics.graphicsLayer @@ -559,25 +561,72 @@ private fun ChapterSelectorButton( ) } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - containerColor = NineLivesTheme.colors.archiveVoidSurface, + } + + // Composed only while open, so a book with hundreds of chapters builds + // nothing until asked and then only the rows on screen. + if (expanded) { + ChapterSheet( + chapters = chapters, + currentChapterIndex = currentChapterIndex, + onChapterSelected = { + onChapterSelected(it) + expanded = false + }, + onDismiss = { expanded = false }, + ) + } +} + +/** The row the chapter list opens scrolled to: the chapter that is playing. */ +internal fun chapterListInitialIndex(currentChapterIndex: Int, chapterCount: Int): Int = + currentChapterIndex.coerceIn(0, (chapterCount - 1).coerceAtLeast(0)) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChapterSheet( + chapters: List, + currentChapterIndex: Int, + onChapterSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = chapterListInitialIndex(currentChapterIndex, chapters.size), + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = NineLivesTheme.colors.archiveVoidSurface, + contentColor = NineLivesTheme.colors.archiveTextPrimary, + ) { + Text( + text = "Chapters", + style = MaterialTheme.typography.titleMedium, + color = NineLivesTheme.colors.goldFilament, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + state = listState, + modifier = Modifier.weight(1f, fill = false), + contentPadding = PaddingValues(bottom = 32.dp), ) { - chapters.forEachIndexed { index, chapter -> - DropdownMenuItem( - text = { - Text( - text = "${index + 1}. ${chapter.title}", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (index == currentChapterIndex) NineLivesTheme.colors.goldFilament else NineLivesTheme.colors.archiveTextPrimary, - ) - }, - onClick = { - onChapterSelected(index) - expanded = false - }, + itemsIndexed(chapters) { index, chapter -> + Text( + text = "${index + 1}. ${chapter.title}", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (index == currentChapterIndex) NineLivesTheme.colors.goldFilament else NineLivesTheme.colors.archiveTextPrimary, + modifier = Modifier + .fillMaxWidth() + .clickable { onChapterSelected(index) } + .padding(horizontal = 20.dp, vertical = 14.dp), ) } } diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt index c82afae5..bbeecb78 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt @@ -52,6 +52,7 @@ import com.ninelivesaudio.app.domain.model.AppMode import com.ninelivesaudio.app.entitlement.FreeTier import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.ThemeMode +import com.ninelivesaudio.app.service.download.WifiOnlyCopy import com.ninelivesaudio.app.ui.components.ArchiveScreenHeader import com.ninelivesaudio.app.ui.components.GatedControl import com.ninelivesaudio.app.ui.components.LabeledSwitchRow @@ -686,6 +687,15 @@ fun SettingsScreen( // ═════════════════════════════════════════════════════════════ if (uiState.appMode == AppMode.AUDIOBOOKSHELF) { SettingsGroup(title = "Data") { + ArchivePreferenceRow( + title = WifiOnlyCopy.SETTING_TITLE, + subtitle = WifiOnlyCopy.SETTING_SUBTITLE, + checked = uiState.downloadOnWifiOnly, + onCheckedChange = viewModel::setDownloadOnWifiOnly, + ) + + HorizontalDivider(color = NineLivesTheme.colors.archiveVoidElevated, thickness = 1.dp) + Button( onClick = viewModel::syncNow, modifier = Modifier.fillMaxWidth(), 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 fc05c69b..af12f324 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 @@ -29,6 +29,7 @@ import com.ninelivesaudio.app.domain.model.Library import com.ninelivesaudio.app.domain.model.SyncResult import com.ninelivesaudio.app.domain.model.ThemeMode import com.ninelivesaudio.app.service.ConnectivityMonitor +import com.ninelivesaudio.app.service.DownloadManager import com.ninelivesaudio.app.service.ConnectivityMonitor.ConnectionStatus import com.ninelivesaudio.app.service.PlaybackManager import com.ninelivesaudio.app.service.SettingsManager @@ -188,6 +189,7 @@ class SettingsViewModel @Inject constructor( private val settingsManager: SettingsManager, private val apiService: ApiService, private val connectivityMonitor: ConnectivityMonitor, + private val downloadManager: DownloadManager, private val audioBookDao: AudioBookDao, private val libraryDao: LibraryDao, private val libraryRepository: LibraryRepository, @@ -272,6 +274,9 @@ class SettingsViewModel @Inject constructor( val sleepTimerRewindSeconds: Int = 15, val includeArchivedInStats: Boolean = true, + // Downloads + val downloadOnWifiOnly: Boolean = true, + // Feedback Report val reportType: ReportType = ReportType.BUG, val includeLogsInReport: Boolean = false, @@ -299,6 +304,10 @@ class SettingsViewModel @Inject constructor( data class PendingSweep(val type: SweepType, val count: Int) private val _uiState = MutableStateFlow(UiState()) + + /** The server libraries Room last emitted, for names only (#81). */ + @Volatile + private var latestCachedLibraries: List = emptyList() val uiState: StateFlow = _uiState.asStateFlow() private val authUiGeneration = AuthUiGenerationCoordinator() private val authUiOperationMutex = Mutex() @@ -352,6 +361,28 @@ class SettingsViewModel @Inject constructor( } } + // A server library renamed on the next sync shows its new name here + // without a restart (#81). Only names of libraries already shown are + // refreshed: which libraries appear and which is selected stay with + // loadLibraries(). + viewModelScope.launch { + libraryRepository.observeAudiobookshelf().collect { cached -> + // Set before the update so a loadLibraries() that publishes + // after this point still carries these names. + latestCachedLibraries = cached + _uiState.update { state -> + val libraries = withCachedLibraryNames(state.libraries, cached) + if (libraries == state.libraries) return@update state + state.copy( + libraries = libraries, + selectedLibrary = state.selectedLibrary?.let { selected -> + libraries.firstOrNull { it.id == selected.id } ?: selected + }, + ) + } + } + } + // Observe local libraries viewModelScope.launch { libraryRepository.observeLocalLibraries().collect { locals -> @@ -425,6 +456,7 @@ class SettingsViewModel @Inject constructor( sleepTimerShakeResetEnabled = settings.sleepTimerShakeResetEnabled, sleepTimerRewindSeconds = settings.sleepTimerRewindSeconds, includeArchivedInStats = settings.includeArchivedInStats, + downloadOnWifiOnly = settings.downloadOnWifiOnly, themeMode = settings.themeMode, ) } @@ -849,6 +881,19 @@ class SettingsViewModel @Inject constructor( } } + /** + * Saves the setting, then applies it to downloads already queued: turning + * it off starts a queue that was waiting for Wi-Fi, turning it on stops a + * book downloading over mobile data unless the user sent it that way. + */ + fun setDownloadOnWifiOnly(enabled: Boolean) { + _uiState.update { it.copy(downloadOnWifiOnly = enabled) } + viewModelScope.launch { + settingsManager.updateSettings { it.copy(downloadOnWifiOnly = enabled) } + downloadManager.onWifiOnlyChanged() + } + } + fun toggleIncludeArchivedInStats() { val enabled = !_uiState.value.includeArchivedInStats _uiState.update { it.copy(includeArchivedInStats = enabled) } @@ -1494,9 +1539,12 @@ class SettingsViewModel @Inject constructor( } _uiState.update { + // A rename the collector saw while this load ran wins over the + // older snapshot this load started from (#81). + val named = withCachedLibraryNames(libs, latestCachedLibraries) it.copy( - libraries = libs, - selectedLibrary = selected, + libraries = named, + selectedLibrary = selected?.let { s -> named.firstOrNull { l -> l.id == s.id } ?: s }, ) } } catch (_: Exception) { @@ -1530,6 +1578,16 @@ class SettingsViewModel @Inject constructor( * selector is still useful and requires no network). INVALID means the token was * rejected and the user was logged out, so there is nothing to load. */ +/** [shown] with each library's name taken from [cached] where the ids match. */ +internal fun withCachedLibraryNames(shown: List, cached: List): List { + if (shown.isEmpty() || cached.isEmpty()) return shown + val names = cached.associate { it.id to it.name } + return shown.map { library -> + val name = names[library.id] + if (name == null || name == library.name) library else library.copy(name = name) + } +} + internal fun shouldLoadCachedLibrariesAfterValidation(result: TokenValidationResult): Boolean = result != TokenValidationResult.INVALID diff --git a/app/src/test/java/com/ninelivesaudio/app/data/local/SearchTextTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/local/SearchTextTest.kt new file mode 100644 index 00000000..b83974b3 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/SearchTextTest.kt @@ -0,0 +1,51 @@ +package com.ninelivesaudio.app.data.local + +import com.ninelivesaudio.app.data.local.converter.toEntity +import com.ninelivesaudio.app.data.local.entity.AudioBookEntity +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** Every book row carries one folded copy of the fields the search reads. */ +class SearchTextTest { + + @Test + fun `the searched fields are folded and kept apart`() { + assertEquals( + "l'etranger\u001Falbert camus\u001Fclassiques\u001Fjerome", + bookSearchText("L'Étranger", "Albert Camus", "Classiques", "Jérôme"), + ) + assertEquals("dune\u001F\u001F\u001F", bookSearchText("DUNE", null, null, null)) + } + + @Test + fun `a search cannot match across two fields`() { + // "dune frank" would match "Dune Frank Herbert" if the fields ran together. + val text = bookSearchText("Dune", "Frank Herbert", null, null) + assertFalse(text.contains("dune frank")) + assertFalse(text.contains("dune ")) + } + + @Test + fun `a row is filled on write`() { + val entity = AudioBook( + id = "b1", + title = "Émile", + author = "Jean-Jacques ROUSSEAU", + narrator = "Zoë", + seriesName = "Œuvres", + ).toEntity() + + assertEquals(bookSearchText("Émile", "Jean-Jacques ROUSSEAU", "Œuvres", "Zoë"), entity.searchText) + } + + @Test + fun `a copied row follows its new fields`() { + val entity = AudioBookEntity(id = "b1", title = "Dune", author = "Author") + + val renamed = entity.copy(title = "Élan", author = "Wolfgang") + + assertEquals(bookSearchText("Élan", "Wolfgang", null, null), renamed.searchText) + } +} 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 index 152b7f81..bd7c1737 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/local/SqlLikeTest.kt @@ -19,21 +19,27 @@ class SqlLikeTest { } @Test - fun `every searched column declares the escape character`() { + fun `the search reads the folded search column and 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) + val escaped = Regex("ab\\.SearchText LIKE \\? ESCAPE '\\\\'").findAll(sql).count() + assertEquals(1, likes) assertEquals(likes, escaped) } @Test - fun `the search binds escaped patterns, one per column`() { - val pattern = "%50\\% off\\_sale%" + fun `the search binds one escaped pattern`() { assertArrayEquals( - arrayOf("lib", pattern, pattern, pattern, pattern), + arrayOf("lib", "%50\\% off\\_sale%"), buildLibrarySqlArgs("lib", "50% off_sale"), ) assertArrayEquals(arrayOf("lib"), buildLibrarySqlArgs("lib", " ")) } + + @Test + fun `the search pattern is folded the way the column is`() { + assertArrayEquals(arrayOf("lib", "%emile%"), buildLibrarySqlArgs("lib", "Émile")) + assertArrayEquals(arrayOf("lib", "%dune%"), buildLibrarySqlArgs("lib", "DUNE")) + assertArrayEquals(arrayOf("lib", "%nesbo\\_%"), buildLibrarySqlArgs("lib", "Nesbø_")) + } } 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 index c3141a69..21b193d7 100644 --- 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 @@ -1,6 +1,9 @@ package com.ninelivesaudio.app.data.local.migration +import android.database.Cursor import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteStatement +import com.ninelivesaudio.app.data.local.bookSearchText import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -10,7 +13,8 @@ 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. + * a real install upgrades, so these pin the chain and the SQL here. The + * instrumented MigrationUpgradeInstrumentedTest runs 9 to 10 on real SQLite. */ class MigrationsTest { @@ -23,7 +27,7 @@ class MigrationsTest { .mapNotNull { it.name.removeSuffix(".json").toIntOrNull() } .maxOrNull() - assertEquals((1 until 9).map { it to it + 1 }, steps) + assertEquals((1 until 10).map { it to it + 1 }, steps) assertEquals(newestSchema, steps.last().second) } @@ -41,6 +45,149 @@ class MigrationsTest { assertEquals(listOf(expected), executed) } + @Test + fun `9 to 10 adds the search column the way the exported v10 schema describes it`() { + val executed = mutableListOf() + MIGRATION_9_10.migrate(backfillDatabase(executed, rows = emptyList(), updates = mutableListOf())) + + val field = Regex("\\{[^{}]*\"columnName\":\\s*\"SearchText\"[^{}]*\\}") + .find(File(schemaDir, "10.json").readText()) + ?.value + assertTrue("SearchText missing from the exported v10 schema", field != null) + assertTrue(field, Regex("\"affinity\":\\s*\"TEXT\"").containsMatchIn(field!!)) + assertTrue(field, Regex("\"notNull\":\\s*true").containsMatchIn(field)) + assertTrue(field, Regex("\"defaultValue\":\\s*\"''\"").containsMatchIn(field)) + assertEquals(listOf("ALTER TABLE AudioBooks ADD COLUMN SearchText TEXT NOT NULL DEFAULT ''"), executed) + } + + @Test + fun `9 to 10 fills the search column for every existing book`() { + // More rows than one backfill page, so the paging is exercised too. + val rows = (1..(SEARCH_BACKFILL_PAGE_SIZE + 3)).map { n -> + BookRow( + rowId = n * 2L, + id = "book-$n", + title = if (n == 1) "Émile" else "Title $n", + author = if (n == 2) null else "AUTHOR $n", + seriesName = if (n == 3) "Œuvres" else null, + narrator = if (n == 4) "Zoë" else null, + ) + } + val updates = mutableListOf>() + + MIGRATION_9_10.migrate(backfillDatabase(mutableListOf(), rows, updates)) + + assertEquals(rows.map { it.id }, updates.map { it.first }) + rows.zip(updates).forEach { (row, update) -> + assertEquals(bookSearchText(row.title, row.author, row.seriesName, row.narrator), update.second) + } + assertEquals("emile", updates.first().second.substringBefore('\u001F')) + } + + private data class BookRow( + val rowId: Long, + val id: String, + val title: String, + val author: String?, + val seriesName: String?, + val narrator: String?, + ) + + /** + * A database holding [rows] in the AudioBooks table: it records execSQL, + * answers the backfill's paged read, and records each UPDATE as + * (Id, SearchText) in [updates]. + */ + private fun backfillDatabase( + executed: MutableList, + rows: List, + updates: MutableList>, + ): SupportSQLiteDatabase = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + when (method.name) { + "execSQL" -> { + executed += args!![0] as String + null + } + "query" -> { + val sql = args!![0] as String + assertTrue(sql, sql.startsWith("SELECT rowid, Id, Title, Author, SeriesName, Narrator FROM AudioBooks")) + val bind = args[1] as Array<*> + val after = (bind[0] as Number).toLong() + val limit = (bind[1] as Number).toInt() + cursorOf(rows.filter { it.rowId > after }.sortedBy { it.rowId }.take(limit)) + } + "compileStatement" -> { + assertEquals("UPDATE AudioBooks SET SearchText = ? WHERE rowid = ?", args!![0]) + recordingUpdate(rows, updates) + } + "toString" -> "BackfillDatabase" + else -> error("unexpected call ${method.name}") + } + } as SupportSQLiteDatabase + + private fun cursorOf(page: List): Cursor { + var position = -1 + return Proxy.newProxyInstance(Cursor::class.java.classLoader, arrayOf(Cursor::class.java)) { _, method, args -> + when (method.name) { + "moveToNext" -> ++position < page.size + "getLong" -> { + assertEquals(0, args!![0]) + page[position].rowId + } + "getString" -> page[position].let { row -> + when (args!![0] as Int) { + 1 -> row.id + 2 -> row.title + 3 -> row.author + 4 -> row.seriesName + 5 -> row.narrator + else -> error("no column ${args[0]}") + } + } + "close" -> null + "toString" -> "PageCursor" + else -> error("unexpected cursor call ${method.name}") + } + } as Cursor + } + + private fun recordingUpdate(rows: List, updates: MutableList>): SupportSQLiteStatement { + var text: String? = null + var rowId: Long? = null + return Proxy.newProxyInstance( + SupportSQLiteStatement::class.java.classLoader, + arrayOf(SupportSQLiteStatement::class.java), + ) { _, method, args -> + when (method.name) { + "bindString" -> { + assertEquals(1, args!![0]) + text = args[1] as String + null + } + "bindLong" -> { + assertEquals(2, args!![0]) + rowId = args[1] as Long + null + } + "executeUpdateDelete" -> { + updates += rows.single { it.rowId == rowId }.id to text!! + 1 + } + "clearBindings" -> { + text = null + rowId = null + null + } + "close" -> null + "toString" -> "RecordingUpdate" + else -> error("unexpected statement call ${method.name}") + } + } as SupportSQLiteStatement + } + /** A database that records execSQL and fails on anything else. */ private fun recordingDatabase(executed: MutableList): SupportSQLiteDatabase = Proxy.newProxyInstance( diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt index 06fb2767..00f3e200 100644 --- a/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/PendingProgressQueueOwnerTest.kt @@ -211,8 +211,10 @@ class PendingProgressQueueOwnerTest { fun `failed session sync retains its pending fallback`() = runBlocking { var acknowledged = false - val delivered = acknowledgePendingFallbackOnSuccess( - deliver = { false }, + val delivered = deliverSessionProgress( + isFinished = false, + syncSession = { false }, + pushFinished = { error("an unfinished save never pushes the finished flag") }, acknowledge = { acknowledged = true }, ) @@ -224,8 +226,10 @@ class PendingProgressQueueOwnerTest { fun `successful session sync acknowledges its pending fallback`() = runBlocking { var acknowledged = false - val delivered = acknowledgePendingFallbackOnSuccess( - deliver = { true }, + val delivered = deliverSessionProgress( + isFinished = false, + syncSession = { true }, + pushFinished = { error("an unfinished save never pushes the finished flag") }, acknowledge = { acknowledged = true }, ) diff --git a/app/src/test/java/com/ninelivesaudio/app/data/repository/SessionProgressDeliveryTest.kt b/app/src/test/java/com/ninelivesaudio/app/data/repository/SessionProgressDeliveryTest.kt new file mode 100644 index 00000000..d9930dab --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/data/repository/SessionProgressDeliveryTest.kt @@ -0,0 +1,108 @@ +package com.ninelivesaudio.app.data.repository + +import com.ninelivesaudio.app.service.positionCountsAsFinished +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A pause on a server book saves through its listening session. The session + * sync carries the position but no finished flag, so a finished save must not + * count as delivered until the finished flag itself reaches the server. + */ +class SessionProgressDeliveryTest { + + /** The queued fallback rows for one book and what the server holds. */ + private class FakeServerBook { + val queuedFinishedFlags = mutableListOf() + var serverFinished = false + var finishedPushes = 0 + + suspend fun save( + isFinished: Boolean, + sessionAnswers: Boolean = true, + finishedPushAnswers: Boolean = true, + ): Boolean { + // The repository queues the row before trying the server. + queuedFinishedFlags += isFinished + return deliverSessionProgress( + isFinished = isFinished, + // Like the real session sync: position only, never the flag. + syncSession = { sessionAnswers }, + pushFinished = { + finishedPushes++ + if (finishedPushAnswers) serverFinished = true + finishedPushAnswers + }, + acknowledge = { queuedFinishedFlags.clear() }, + ) + } + } + + @Test + fun `pause inside the last second marks the book finished on the server`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = true) + + assertTrue(delivered) + assertTrue(book.serverFinished) + assertTrue(book.queuedFinishedFlags.isEmpty()) + } + + @Test + fun `finished push that fails keeps the queued finished row`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = true, finishedPushAnswers = false) + + // The listening time still went through the session. + assertTrue(delivered) + assertFalse(book.serverFinished) + assertEquals(listOf(true), book.queuedFinishedFlags) + } + + @Test + fun `finished save whose session sync fails keeps its row for the reconnect flush`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = true, sessionAnswers = false) + + assertFalse(delivered) + assertEquals(0, book.finishedPushes) + assertEquals(listOf(true), book.queuedFinishedFlags) + } + + @Test + fun `unfinished pause is delivered by the session sync alone`() = runBlocking { + val book = FakeServerBook() + + val delivered = book.save(isFinished = false) + + assertTrue(delivered) + assertEquals(0, book.finishedPushes) + assertTrue(book.queuedFinishedFlags.isEmpty()) + } + + @Test + fun `seeking back from a queued finish lets the newer unfinished pause win`() = runBlocking { + val book = FakeServerBook() + // The end was reached while the server was away. + book.save(isFinished = true, sessionAnswers = false) + + // The listener sought back and paused well before the end. + val delivered = book.save(isFinished = false) + + assertTrue(delivered) + assertFalse(book.serverFinished) + assertTrue(book.queuedFinishedFlags.isEmpty()) + } + + @Test + fun `pause thirty seconds before the end is not a finished save`() { + assertFalse(positionCountsAsFinished(currentTimeSec = 3570.0, durationSec = 3600.0)) + assertTrue(positionCountsAsFinished(currentTimeSec = 3599.5, durationSec = 3600.0)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt b/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt index 155b9fa3..1fcc5699 100644 --- a/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/domain/model/AppSettingsTest.kt @@ -153,6 +153,25 @@ class AppSettingsTest { assertFalse(AudioBook(libraryId = "local-library", isLocal = true).isInActiveLibrary(serverSettings)) } + @Test + fun `downloads wait for Wi-Fi by default`() { + assertTrue(AppSettings().downloadOnWifiOnly) + } + + @Test + fun `an upgrade from settings saved before Wi-Fi only gets it on`() { + val legacy = """{"appMode":"AUDIOBOOKSHELF","serverUrl":"https://example.com","downloadPath":"/x"}""" + val decoded = json.decodeFromString(legacy) + + assertTrue(decoded.downloadOnWifiOnly) + } + + @Test + fun `turning Wi-Fi only off survives a round trip`() { + val decoded = json.decodeFromString(json.encodeToString(AppSettings(downloadOnWifiOnly = false))) + assertFalse(decoded.downloadOnWifiOnly) + } + @Test fun `orphan book is rejected when no active library is selected`() { val settings = AppSettings( diff --git a/app/src/test/java/com/ninelivesaudio/app/domain/util/SearchFoldTest.kt b/app/src/test/java/com/ninelivesaudio/app/domain/util/SearchFoldTest.kt new file mode 100644 index 00000000..f84578df --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/domain/util/SearchFoldTest.kt @@ -0,0 +1,44 @@ +package com.ninelivesaudio.app.domain.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * SQLite LIKE folds case for ASCII only and never drops accents, so search + * compares folded text on both sides instead (issue #61). + */ +class SearchFoldTest { + + @Test + fun `case folds, ASCII and beyond`() { + assertEquals("dune", foldForSearch("DUNE")) + assertEquals("привет", foldForSearch("ПРИВЕТ")) + } + + @Test + fun `accents are dropped`() { + assertEquals("emile", foldForSearch("Émile")) + assertEquals("elan", foldForSearch("élan")) + assertEquals("osten", foldForSearch("Östen")) + assertEquals("nandu", foldForSearch("ñandú")) + // Already decomposed: E plus a combining acute. + assertEquals("emile", foldForSearch("Émile")) + } + + @Test + fun `Nordic and other letters with no decomposition fold to their plain spelling`() { + assertEquals("jo nesbo", foldForSearch("Jo Nesbø")) + assertEquals("lodz", foldForSearch("Łódź")) + assertEquals("strasse", foldForSearch("Straße")) + assertEquals("aesir", foldForSearch("Æsir")) + assertEquals("oeuvre", foldForSearch("Œuvre")) + } + + @Test + fun `everything else is left as typed`() { + assertEquals("100% pure_a\\b", foldForSearch("100% Pure_A\\b")) + assertEquals(" two spaces ", foldForSearch(" Two Spaces ")) + assertEquals("日本語", foldForSearch("日本語")) + assertEquals("", foldForSearch("")) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt index ceb22688..9484bc76 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/AutoSearchTest.kt @@ -35,6 +35,12 @@ class AutoSearchTest { assertEquals("%c:\\\\x%", autoSearchPattern("c:\\x")) } + @Test + fun `the pattern is folded the way the search column is`() { + assertEquals("%emile%", autoSearchPattern("Émile")) + assertEquals("%dune%", autoSearchPattern("DUNE")) + } + @Test fun `a blank query searches nothing`() = runBlocking { assertNull(autoSearchPattern("")) 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 index 06b81919..e4e255f5 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/CancelCleanupTest.kt @@ -272,46 +272,6 @@ class CancelCleanupTest { 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 diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt new file mode 100644 index 00000000..b7834b55 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadDeleteTest.kt @@ -0,0 +1,421 @@ +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.assertNull +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 + +/** + * Deleting a download removes this book's files and nothing else (#49). Two + * editions can share one "Author - Title" folder, a user-picked root may hold + * the user's own audio, and a stored path can point anywhere. Every doubt + * keeps files, just like cancel cleanup. + */ +class DownloadDeleteTest { + + @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 delete( + root: File, + folder: File, + ownFileNames: List, + appOwned: Boolean = true, + sharers: List = emptyList(), + otherDownloadFolders: List = emptyList(), + ) = deleteDownloadFiles( + location = DownloadLocation(root, folder, appOwned), + ownFileNames = ownFileNames, + sharers = sharers, + otherDownloadFolders = otherDownloadFolders, + ) + + // ─── Shared folders (#49) ──────────────────────────────────────────────── + + @Test + fun `deleting one of two editions that share every file keeps the other's files`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "cover.jpg") + val other = FolderSharer(folder.absolutePath, listOf("01.mp3", "02.mp3")) + + delete(root, folder, ownFileNames = listOf("01.mp3", "02.mp3"), sharers = listOf(other)) + + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + assertTrue(File(folder, "cover.jpg").exists()) + } + + @Test + fun `deleting one edition takes only its own files from a shared folder`() { + val root = root() + val folder = bookFolder(root, "Part 1.mp3", "Part 1.mp3.part", "Track 01.m4b", "cover.jpg") + val other = FolderSharer(folder.absolutePath, listOf("Track 01.m4b")) + + val decision = delete(root, folder, ownFileNames = listOf("Part 1.mp3"), sharers = listOf(other)) + + assertTrue(decision is CancelCleanupDecision.Delete) + assertFalse(File(folder, "Part 1.mp3").exists()) + assertFalse(File(folder, "Part 1.mp3.part").exists()) + // The other edition's audio and the cover its row points at both stay. + assertTrue(File(folder, "Track 01.m4b").exists()) + assertTrue(File(folder, "cover.jpg").exists()) + assertTrue(folder.exists()) + } + + @Test + fun `a file that will not delete keeps the book's record`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3") + // A read-only folder: its files cannot be unlinked. + folder.setWritable(false) + try { + val decision = delete(root, folder, ownFileNames = listOf("01.mp3", "02.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.REMOVE_FAILED), decision) + assertTrue(deleteKeepsLocalCopy(decision)) + assertTrue(File(folder, "01.mp3").exists()) + } finally { + folder.setWritable(true) + } + } + + @Test + fun `a sharer stored as a file uri with a trailing slash still counts`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + val other = FolderSharer(folder.toURI().toString(), listOf("01.mp3")) + + delete(root, folder, ownFileNames = listOf("01.mp3"), sharers = listOf(other)) + + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a sharer whose track list is unknown keeps everything`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3") + val other = FolderSharer(folder.absolutePath, fileNames = null) + + val decision = delete(root, folder, ownFileNames = listOf("01.mp3"), sharers = listOf(other)) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), decision) + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + } + + @Test + fun `a book path above or inside the folder keeps everything`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + val above = delete(root, folder, listOf("01.mp3"), sharers = listOf(FolderSharer(root.absolutePath, listOf("x.mp3")))) + val inside = delete( + root, folder, listOf("01.mp3"), + sharers = listOf(FolderSharer(File(folder, "Disc 1").absolutePath, listOf("x.mp3"))), + ) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), above) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), inside) + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a download still writing to the folder keeps everything`() { + val root = root() + val folder = bookFolder(root, "01.mp3") + + val decision = delete(root, folder, listOf("01.mp3"), otherDownloadFolders = listOf(File(folder.path))) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), decision) + assertTrue(File(folder, "01.mp3").exists()) + } + + @Test + fun `a sibling book's folder is never touched`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "cover.jpg") + val sibling = File(root, "Other Author - Other Book").apply { mkdirs() } + File(sibling, "01.mp3").writeBytes(byteArrayOf(9)) + + delete(root, folder, listOf("01.mp3"), sharers = listOf(FolderSharer(sibling.absolutePath, listOf("01.mp3")))) + + assertFalse(folder.exists()) + assertTrue(File(sibling, "01.mp3").exists()) + } + + // ─── A folder only this book uses ──────────────────────────────────────── + + @Test + fun `a folder only this book uses goes whole`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "cover.jpg") + + delete(root, folder, ownFileNames = listOf("01.mp3", "02.mp3")) + + assertFalse(folder.exists()) + assertTrue(root.exists()) + } + + @Test + fun `a subfolder inside the book folder is never recursed into`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "cover.jpg") + val nested = File(folder, "extras").apply { mkdirs() } + File(nested, "bonus.mp3").writeBytes(byteArrayOf(4)) + + delete(root, folder, ownFileNames = listOf("01.mp3")) + + assertFalse(File(folder, "01.mp3").exists()) + assertTrue(File(nested, "bonus.mp3").exists()) + assertTrue(folder.exists()) + } + + // ─── User-picked folder ────────────────────────────────────────────────── + + @Test + fun `a user-picked root loses this book's own files and its emptied folder`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "02.mp3.part", "cover.jpg") + + delete(root, folder, listOf("01.mp3", "02.mp3"), appOwned = false) + + assertFalse("the book's files stayed in a user-picked root", folder.exists()) + assertTrue(root.exists()) + } + + @Test + fun `a user-picked root keeps anything the book does not name`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "my notes.txt", "bonus.mp3") + File(root, "loose.mp3").writeBytes(byteArrayOf(9)) + + delete(root, folder, listOf("01.mp3"), appOwned = false) + + assertFalse(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "my notes.txt").exists()) + assertTrue(File(folder, "bonus.mp3").exists()) + assertTrue(File(root, "loose.mp3").exists()) + } + + @Test + fun `a user-picked root keeps the files another edition shares`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3", "cover.jpg") + val other = FolderSharer(folder.absolutePath, listOf("02.mp3")) + + delete(root, folder, listOf("01.mp3", "02.mp3"), appOwned = false, sharers = listOf(other)) + + assertFalse(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + assertTrue(File(folder, "cover.jpg").exists()) + } + + @Test + fun `a user-picked root with no track list keeps everything`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "cover.jpg") + + val decision = delete(root, folder, emptyList(), appOwned = false) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.NO_TRACK_LIST), decision) + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(deleteKeepsLocalCopy(decision)) + } + + @Test + fun `a user-picked root itself is never deleted`() { + val root = root() + File(root, "01.mp3").writeBytes(byteArrayOf(6)) + + val decision = delete(root, root, listOf("01.mp3"), appOwned = false) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(root, "01.mp3").exists()) + } + + // ─── What the record does after a delete ───────────────────────────────── + + @Test + fun `files kept for any doubt keep the book marked downloaded`() { + assertTrue(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.SHARED))) + assertTrue(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT))) + assertTrue(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.UNRESOLVABLE))) + } + + @Test + fun `a delete that went through or found nothing clears the record`() { + assertFalse(deleteKeepsLocalCopy(CancelCleanupDecision.Delete(File("x"), emptyList()))) + assertFalse(deleteKeepsLocalCopy(CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER))) + assertFalse(deleteKeepsLocalCopy(null)) + } + + // ─── Root containment ──────────────────────────────────────────────────── + + @Test + fun `a stored path that escapes the root is refused`() { + val root = root() + val outside = tempFolder.newFolder("Someone Else's Music").apply { + File(this, "01.mp3").writeBytes(byteArrayOf(5)) + } + val escaping = File(root, "../Someone Else's Music") + + val decision = delete(root, escaping, listOf("01.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(outside, "01.mp3").exists()) + } + + @Test + fun `the root itself is refused`() { + val root = root() + bookFolder(root, "01.mp3") + File(root, "loose.mp3").writeBytes(byteArrayOf(6)) + + val decision = delete(root, root, listOf("loose.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(root, "loose.mp3").exists()) + assertTrue(File(root, "Herman Melville - Moby-Dick/01.mp3").exists()) + } + + @Test + fun `a folder nested deeper than one level is refused`() { + val root = root() + val deep = File(bookFolder(root), "Disc 1").apply { mkdirs() } + File(deep, "01.mp3").writeBytes(byteArrayOf(7)) + + val decision = delete(root, deep, listOf("01.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(deep, "01.mp3").exists()) + } + + @Test + fun `a symlinked book folder is never followed`() { + val root = root() + val target = tempFolder.newFolder("elsewhere").apply { File(this, "01.mp3").writeBytes(byteArrayOf(8)) } + val link = File(root, "Herman Melville - Moby-Dick") + Files.createSymbolicLink(link.toPath(), target.toPath()) + + val decision = delete(root, link, listOf("01.mp3")) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.OUTSIDE_ROOT), decision) + assertTrue(File(target, "01.mp3").exists()) + } + + @Test + fun `a missing folder is a no-op`() { + val root = root() + val decision = delete(root, File(root, "Gone - Gone"), listOf("01.mp3")) + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.NO_FOLDER), decision) + } + + // ─── Downloads that still own a folder ────────────────────────────────── + + private fun row(bookId: String, status: DownloadStatus) = DownloadOwnerRow(bookId, status.ordinal) + + @Test + fun `an edition whose row says Completed before its local copy is saved still owns the folder`() { + // An older build wrote Completed before the localPath, and a process + // death between the two leaves it that way. Deleting the other + // edition must not count this one as finished. + val owners = unfinishedDownloadOwners( + rows = listOf(row("A", DownloadStatus.Completed), row("B", DownloadStatus.Completed)), + booksWithLocalCopy = setOf("A"), + excludeBookId = "A", + ) + assertEquals(listOf("B"), owners) + } + + @Test + fun `unfinished rows own their folder and finished ones with a local copy do not`() { + val owners = unfinishedDownloadOwners( + rows = listOf( + row("A", DownloadStatus.Queued), + row("B", DownloadStatus.Paused), + row("C", DownloadStatus.Failed), + row("D", DownloadStatus.Completed), + row("E", DownloadStatus.Downloading), + ), + booksWithLocalCopy = setOf("D"), + excludeBookId = "A", + ) + assertEquals(listOf("B", "C", "E"), owners) + } + + @Test + fun `deleting one edition while the other is finishing keeps the other's audio`() { + val root = root() + val folder = bookFolder(root, "01.mp3", "02.mp3") + val owners = unfinishedDownloadOwners( + rows = listOf(row("B", DownloadStatus.Completed)), + booksWithLocalCopy = emptySet(), + excludeBookId = "A", + ) + // B has no localPath yet, so it is not a sharer. Only its ownership protects it. + val ownerFolders = owners.map { folder } + + val decision = delete(root, folder, listOf("01.mp3", "02.mp3"), otherDownloadFolders = ownerFolders) + + assertEquals(CancelCleanupDecision.Keep(CancelKeepReason.SHARED), decision) + assertTrue(File(folder, "01.mp3").exists()) + assertTrue(File(folder, "02.mp3").exists()) + } + + // ─── Which root a stored path belongs to ───────────────────────────────── + + @Test + fun `a stored path in app storage is app owned even with another root configured`() { + val appRoot = root() + val userRoot = tempFolder.newFolder("MyBooks") + val folder = bookFolder(appRoot, "01.mp3") + + val location = deleteLocationFor(folder.absolutePath, userRoot.absolutePath, appRoot)!! + + assertTrue(location.rootIsAppOwned) + assertEquals(appRoot.canonicalFile, location.root.canonicalFile) + } + + @Test + fun `a stored path in a user-picked root is not app owned`() { + val appRoot = root() + val userRoot = tempFolder.newFolder("MyBooks") + val folder = File(userRoot, "Author - Title").apply { mkdirs() } + + val location = deleteLocationFor(folder.absolutePath, userRoot.absolutePath, appRoot)!! + + assertFalse(location.rootIsAppOwned) + } + + @Test + fun `a content uri or blank path has nothing to delete`() { + val appRoot = root() + assertNull(deleteLocationFor("content://com.android.externalstorage/tree/x", "", appRoot)) + assertNull(deleteLocationFor(" ", "", appRoot)) + } + + @Test + fun `a stored file uri resolves to its folder`() { + val appRoot = root() + val folder = bookFolder(appRoot, "01.mp3") + + val location = deleteLocationFor(folder.toURI().toString(), "", appRoot)!! + + assertEquals(folder.canonicalFile, location.folder.canonicalFile) + assertTrue(location.rootIsAppOwned) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicyTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicyTest.kt new file mode 100644 index 00000000..08740401 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadNetworkPolicyTest.kt @@ -0,0 +1,170 @@ +package com.ninelivesaudio.app.service.download + +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +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.Test + +/** + * "Download on Wi-Fi only" (#79). The single drain worker waits for an + * unmetered network while the setting is on, a per-download override runs it + * on any connection without touching the setting, and a waiting download says + * it is waiting instead of looking stuck. + */ +class DownloadNetworkPolicyTest { + + private fun OneTimeWorkRequest.networkType(): NetworkType = workSpec.constraints.requiredNetworkType + private fun OneTimeWorkRequest.waitsForUnmetered(): Boolean = workSpec.input.getBoolean(KEY_DRAIN_UNMETERED, false) + + private fun requestFor(wifiOnly: Boolean, overrideQueued: Boolean) = + drainRequest(drainWaitsForUnmetered(wifiOnly = wifiOnly, overrideQueued = overrideQueued)) + + // ─── The worker request ────────────────────────────────────────────────── + + @Test + fun `with Wi-Fi only on the drain waits for an unmetered network`() { + val request = requestFor(wifiOnly = true, overrideQueued = false) + + assertEquals(NetworkType.UNMETERED, request.networkType()) + assertTrue(request.waitsForUnmetered()) + } + + @Test + fun `with Wi-Fi only off the drain runs on any connection`() { + val request = requestFor(wifiOnly = false, overrideQueued = false) + + assertEquals(NetworkType.CONNECTED, request.networkType()) + assertFalse(request.waitsForUnmetered()) + } + + @Test + fun `a mobile data override runs the drain on any connection`() { + val request = requestFor(wifiOnly = true, overrideQueued = true) + + assertEquals(NetworkType.CONNECTED, request.networkType()) + assertFalse(request.waitsForUnmetered()) + } + + // ─── What a drain may download ─────────────────────────────────────────── + + @Test + fun `a drain on any connection only takes overridden books while Wi-Fi only is on`() { + assertTrue(mayDownloadOnDrain(wifiOnly = true, drainUnmetered = false, overridden = true)) + assertFalse(mayDownloadOnDrain(wifiOnly = true, drainUnmetered = false, overridden = false)) + } + + @Test + fun `a drain on Wi-Fi takes every book`() { + assertTrue(mayDownloadOnDrain(wifiOnly = true, drainUnmetered = true, overridden = false)) + } + + @Test + fun `with Wi-Fi only off every book goes, even on an old request`() { + assertTrue(mayDownloadOnDrain(wifiOnly = false, drainUnmetered = false, overridden = false)) + } + + // ─── Already queued work when the setting flips ────────────────────────── + + @Test + fun `turning Wi-Fi only on stops a book downloading on mobile data`() { + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = true, drainUnmetered = false, engineRowOverridden = false) + ) + } + + @Test + fun `turning Wi-Fi only on leaves a book the user sent over mobile data running`() { + assertFalse( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = true, drainUnmetered = false, engineRowOverridden = true) + ) + } + + @Test + fun `turning Wi-Fi only on leaves a drain already on Wi-Fi running`() { + assertFalse( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = true, drainUnmetered = true, engineRowOverridden = false) + ) + } + + @Test + fun `turning Wi-Fi only off lets a drain on any connection carry on`() { + assertFalse( + wifiRuleChangeRestartsDrain(wifiOnly = false, drainRunning = true, drainUnmetered = false, engineRowOverridden = false) + ) + } + + @Test + fun `turning Wi-Fi only off frees a drain still bound to Wi-Fi`() { + // Left alone, losing Wi-Fi later would park the queue with the setting off. + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = false, drainRunning = true, drainUnmetered = true, engineRowOverridden = false) + ) + } + + @Test + fun `with no drain running a flip re-enqueues the queue under the new rule`() { + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = false, drainRunning = false, drainUnmetered = false, engineRowOverridden = false) + ) + assertTrue( + wifiRuleChangeRestartsDrain(wifiOnly = true, drainRunning = false, drainUnmetered = false, engineRowOverridden = false) + ) + } + + // ─── The override ──────────────────────────────────────────────────────── + + @Test + fun `an override restarts a drain that is waiting or stuck on Wi-Fi rules`() { + assertTrue(overrideRestartsDrain(drainRunning = false, drainUnmetered = false)) + assertTrue(overrideRestartsDrain(drainRunning = true, drainUnmetered = true)) + } + + @Test + fun `an override joins a drain already running on any connection`() { + assertFalse(overrideRestartsDrain(drainRunning = true, drainUnmetered = false)) + } + + @Test + fun `overrides for rows that are gone are dropped`() { + assertEquals(setOf("a"), liveOverrides(setOf("a", "gone"), liveRowIds = listOf("a", "b"))) + } + + // ─── Starting the drain ────────────────────────────────────────────────── + + @Test + fun `adding work joins a running drain and replaces an idle one`() { + assertEquals(ExistingWorkPolicy.KEEP, drainWorkPolicy(replace = false, drainRunning = true)) + // An idle drain may be sitting on the old network rule, so it is swapped. + assertEquals(ExistingWorkPolicy.REPLACE, drainWorkPolicy(replace = false, drainRunning = false)) + assertEquals(ExistingWorkPolicy.REPLACE, drainWorkPolicy(replace = true, drainRunning = true)) + } + + // ─── Saying it is waiting ──────────────────────────────────────────────── + + @Test + fun `a queued book on mobile data with Wi-Fi only on is waiting for Wi-Fi`() { + assertTrue(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = true, metered = true, overridden = false)) + } + + @Test + fun `a book interrupted when Wi-Fi dropped is waiting, not stuck`() { + assertTrue(isWaitingForWifi(DownloadStatus.Downloading, wifiOnly = true, metered = true, overridden = false)) + } + + @Test + fun `nothing waits on Wi-Fi, with the setting off, or once overridden`() { + assertFalse(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = true, metered = false, overridden = false)) + assertFalse(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = false, metered = true, overridden = false)) + assertFalse(isWaitingForWifi(DownloadStatus.Queued, wifiOnly = true, metered = true, overridden = true)) + } + + @Test + fun `paused and failed books are not waiting for Wi-Fi`() { + assertFalse(isWaitingForWifi(DownloadStatus.Paused, wifiOnly = true, metered = true, overridden = false)) + assertFalse(isWaitingForWifi(DownloadStatus.Failed, wifiOnly = true, metered = true, overridden = false)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt new file mode 100644 index 00000000..ae6ea457 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadOwnershipTest.kt @@ -0,0 +1,499 @@ +package com.ninelivesaudio.app.service.download + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.AudioFile +import com.ninelivesaudio.app.domain.model.DownloadItem +import com.ninelivesaudio.app.domain.model.DownloadStatus +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Who owns a download folder changes only under one lock, and delete and + * cancel judge and remove files under that same lock (#49). Each race here is + * driven step by step: a hook inside one side's reads starts the other side + * and lets it run as far as it can before the first side carries on. All of + * it runs on one thread, so the interleaving is the same on every run. + */ +class DownloadOwnershipTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun track(name: String, index: Int = 0) = AudioFile(id = name, ino = name, index = index, filename = name) + + /** + * Room and the path rules, in memory, with the engine's real folder rule. + * The races here play out in shared "Author - Title" folders left by + * downloads that started before each book got its own folder (#49). + */ + private class FakeReads(val root: File) : FolderReads { + val books = mutableMapOf() + val rows = mutableMapOf() + var afterSharerIdsRead: (suspend () -> Unit)? = null + + override suspend fun book(id: String) = books[id] + override suspend fun books(ids: List) = ids.mapNotNull { books[it] } + override suspend fun filePathBookIdsExcept(audioBookId: String): List { + val ids = books.values + .filter { it.id != audioBookId && !it.localPath.isNullOrEmpty() && !it.localPath.startsWith("content:") } + .map { it.id } + afterSharerIdsRead?.invoke() + return ids + } + override suspend fun localPathsExcept(audioBookId: String) = + books.values.filter { it.id != audioBookId }.mapNotNull { it.localPath?.takeIf(String::isNotEmpty) } + override suspend fun downloadRows() = + rows.values.map { DownloadOwnerRow(it.audioBookId, it.status.ordinal, it.downloadedBytes) } + override fun plannedLocation(book: AudioBook, startedWriting: Boolean) = + DownloadLocation(root, bookDownloadFolder(root, book, startedWriting), true) + override fun storedLocation(localPath: String) = deleteLocationFor(localPath, "", root) + } + + private fun folder(root: File, name: String, vararg files: String) = File(root, name).apply { + mkdirs() + files.forEach { File(this, it).writeBytes(byteArrayOf(1, 2, 3)) } + } + + private suspend fun DownloadOwnership.claimFor(reads: FakeReads, downloadId: String, folder: File) = + claim( + downloadId = downloadId, + audioBookId = reads.rows[downloadId]?.audioBookId ?: downloadId.removePrefix("d"), + folder = folder, + readRow = { reads.rows[downloadId] }, + writeRow = { reads.rows[it.id] = it }, + ) + + /** The engine's completion from its in-memory [row], as DownloadEngine writes it. */ + private suspend fun DownloadOwnership.finish(reads: FakeReads, row: DownloadItem, folder: File): Boolean { + val committed = commitCompletion(rowExists = { row.id in reads.rows }) { + reads.rows[row.id] = row.copy(status = DownloadStatus.Completed) + val book = reads.books.getValue(row.audioBookId) + reads.books[book.id] = book.copy(isDownloaded = true, localPath = folder.absolutePath) + } + release() + return committed + } + + private suspend fun DownloadOwnership.noEngineOn(audioBookId: String, act: suspend () -> Unit) = + actOnceEngineIsOff(audioBookId, rowIsDownloading = { false }, stopEngine = { error("engine was not on $audioBookId") }) { + act() + } + + // ─── Reported races ────────────────────────────────────────────────────── + + @Test + fun `deleting one edition while the other commits its finish keeps the other's audio`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val shared = folder(root, "Herman Melville - Moby-Dick", "a1.mp3", "b1.mp3", "cover.jpg") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", + isDownloaded = true, localPath = shared.absolutePath, audioFiles = listOf(track("a1.mp3")), + ) + reads.books["A"] = a + reads.books["B"] = a.copy(id = "B", isDownloaded = false, localPath = null, audioFiles = listOf(track("b1.mp3"))) + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = "B", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + val engineRow = ownership.claimFor(reads, "dB", shared)!! + + // B finishes right after delete has looked for books stored there + // (B has no localPath yet, so it is not one of them). + var finish: Job? = null + reads.afterSharerIdsRead = { + reads.afterSharerIdsRead = null + finish = launch { ownership.finish(reads, engineRow, shared) } + yield() + } + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, shared.absolutePath) } + finish?.join() + + assertTrue("B's audio went with A's delete", File(shared, "b1.mp3").exists()) + } + + @Test + fun `cancelling a paused book never deletes the folder the engine is really writing`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + // A paused here with a partial track. B started into the same folder, + // then a sync renamed B, so its row now names another folder while its + // engine keeps writing to this one. + val shared = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "b-01.mp3.part") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.books["B"] = AudioBook(id = "B", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Paused) + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = "B", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dB", shared)) + reads.books["B"] = reads.books.getValue("B").copy(title = "Moby-Dick (Unabridged)") + + ownership.noEngineOn("A") { + reads.rows.remove("dA") + removeCancelledPartialFiles(reads, ownership, "A", startedWriting = true) + } + + assertTrue("B's in-progress track went with A's cancel", File(shared, "b-01.mp3.part").exists()) + } + + @Test + fun `deleting an edition never deletes the folder the engine is really writing`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val shared = folder(root, "Herman Melville - Moby-Dick", "a1.mp3", "b1.mp3.part") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", + isDownloaded = true, localPath = shared.absolutePath, audioFiles = listOf(track("a1.mp3")), + ) + reads.books["A"] = a + reads.books["B"] = AudioBook(id = "B", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = "B", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dB", shared)) + // A sync renamed B while its engine keeps writing here. + reads.books["B"] = reads.books.getValue("B").copy(title = "Moby-Dick (Unabridged)") + + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, shared.absolutePath) } + + assertTrue("B's in-progress track went with A's delete", File(shared, "b1.mp3.part").exists()) + } + + @Test + fun `a queued download cancelled as the engine picks it never comes back`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + reads.rows["dC"] = DownloadItem(id = "dC", audioBookId = "C", status = DownloadStatus.Queued, startedAt = 2) + val ownership = DownloadOwnership() + var stopRequested = false + + // The user cancels A right as the engine reads its row. C keeps the + // drain alive, so nothing else stops it. + var cancel: Job? = null + val claimed = ownership.claim( + downloadId = "dA", + audioBookId = "A", + folder = File(root, "A"), + readRow = { + val row = reads.rows["dA"] + cancel = launch { + ownership.actOnceEngineIsOff( + audioBookId = "A", + rowIsDownloading = { reads.rows["dA"]?.status == DownloadStatus.Downloading }, + stopEngine = { stopRequested = true; true }, + ) { reads.rows.remove("dA") } + } + yield() + row + }, + writeRow = { reads.rows[it.id] = it }, + ) + cancel?.join() + + assertNull("the cancelled row came back", reads.rows["dA"]) + assertTrue("the engine kept downloading a cancelled book", claimed == null || stopRequested) + } + + @Test + fun `a queued download cancelled before the engine claims it never starts`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + + // The drain picked A from an older read, and the cancel got in first. + ownership.noEngineOn("A") { reads.rows.remove("dA") } + val claimed = ownership.claimFor(reads, "dA", File(root, "A")) + + assertNull(claimed) + assertNull(reads.rows["dA"]) + assertNull(ownership.engineClaim) + } + + @Test + fun `a download deleted while it finishes is not marked downloaded`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + val engineRow = ownership.claimFor(reads, "dA", dir)!! + + // The stop timed out, the row went anyway, and then the engine finished. + reads.rows.remove("dA") + val committed = ownership.finish(reads, engineRow, dir) + + assertFalse(committed) + assertNull(reads.rows["dA"]) + assertNull(reads.books.getValue("A").localPath) + } + + @Test + fun `cancel keeps the files when the stop never confirmed`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "02.mp3.part") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dA", dir)) + + // A read stuck on a stalled server: the stop gives up, the engine stays. + ownership.actOnceEngineIsOff("A", rowIsDownloading = { true }, stopEngine = { false }) { + reads.rows.remove("dA") + removeCancelledPartialFiles(reads, ownership, "A", startedWriting = true) + } + + assertTrue(File(dir, "02.mp3.part").exists()) + } + + // ─── Everyday paths still work ─────────────────────────────────────────── + + @Test + fun `deleting a book nobody shares removes its folder`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "02.mp3", "cover.jpg") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", isDownloaded = true, + localPath = dir.absolutePath, audioFiles = listOf(track("01.mp3", 0), track("02.mp3", 1)), + ) + reads.books["A"] = a + val ownership = DownloadOwnership() + + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, dir.absolutePath) } + + assertFalse(dir.exists()) + } + + @Test + fun `cancelling a paused book nobody shares removes its partial folder`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3", "02.mp3.part") + val reads = FakeReads(root) + reads.books["A"] = AudioBook(id = "A", title = "Moby-Dick", author = "Herman Melville") + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Paused) + val ownership = DownloadOwnership() + + ownership.noEngineOn("A") { + reads.rows.remove("dA") + removeCancelledPartialFiles(reads, ownership, "A", startedWriting = true) + } + + assertFalse(dir.exists()) + } + + @Test + fun `deleting a book while an unrelated one downloads goes ahead at once`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val dir = folder(root, "Herman Melville - Moby-Dick", "01.mp3") + val other = folder(root, "Jane Austen - Emma", "01.mp3.part") + val reads = FakeReads(root) + val a = AudioBook( + id = "A", title = "Moby-Dick", author = "Herman Melville", isDownloaded = true, + localPath = dir.absolutePath, audioFiles = listOf(track("01.mp3")), + ) + reads.books["A"] = a + reads.books["E"] = AudioBook(id = "E", title = "Emma", author = "Jane Austen") + reads.rows["dE"] = DownloadItem(id = "dE", audioBookId = "E", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dE", other)) + + // The engine stays on E the whole time and never lets go. + withTimeout(2_000) { + ownership.noEngineOn("A") { removeDownloadedBookFiles(reads, ownership, a, dir.absolutePath) } + } + + assertFalse(dir.exists()) + assertTrue(File(other, "01.mp3.part").exists()) + } + + @Test + fun `the engine on the book is stopped outside the lock so it can let go`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dA"] = DownloadItem(id = "dA", audioBookId = "A", status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + assertNotNull(ownership.claimFor(reads, "dA", File(root, "A"))) + + // The stop waits for the engine to exit, and exiting releases the claim. + val stop = withTimeout(2_000) { + ownership.actOnceEngineIsOff( + audioBookId = "A", + rowIsDownloading = { false }, + stopEngine = { ownership.release(); true }, + ) { it } + } + + assertEquals(EngineStop(wasDownloading = true, stopConfirmed = true), stop) + assertNull(ownership.engineClaim) + } + + @Test + fun `a paused or finished row is never claimed`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + reads.rows["dP"] = DownloadItem(id = "dP", audioBookId = "P", status = DownloadStatus.Paused) + reads.rows["dD"] = DownloadItem(id = "dD", audioBookId = "D", status = DownloadStatus.Completed) + val ownership = DownloadOwnership() + + assertNull(ownership.claimFor(reads, "dP", File(root, "P"))) + assertNull(ownership.claimFor(reads, "dD", File(root, "D"))) + assertEquals(DownloadStatus.Paused, reads.rows.getValue("dP").status) + assertNull(ownership.engineClaim) + } + + // ─── Each download in its own folder (#49) ─────────────────────────────── + + private val editionA = AudioBook(id = "li_editionaaa1", title = "Moby-Dick", author = "Herman Melville") + private val editionB = AudioBook(id = "li_editionbbb2", title = "Moby-Dick", author = "Herman Melville") + private val legacyName = "Herman Melville - Moby-Dick" + + @Test + fun `a new download never lands in another edition's cancelled partial folder`() { + val root = tempFolder.newFolder("Audiobookshelf") + // A cancelled partial of edition A, left in the old shared folder. + folder(root, legacyName, "01.mp3", "02.mp3.part") + + val target = bookDownloadFolder(root, editionB, startedWriting = false) + + assertNotEquals(File(root, legacyName), target) + assertFalse("B would reuse A's partial track", File(target, "01.mp3").exists()) + } + + @Test + fun `two editions download side by side into separate folders`() { + val root = tempFolder.newFolder("Audiobookshelf") + val a = bookDownloadFolder(root, editionA, startedWriting = false) + val b = bookDownloadFolder(root, editionB, startedWriting = false) + + assertNotEquals(a, b) + assertEquals(root, a.parentFile) + assertEquals(root, b.parentFile) + } + + @Test + fun `a download started before its own folder existed resumes in its old folder`() { + val root = tempFolder.newFolder("Audiobookshelf") + val old = folder(root, legacyName, "01.mp3") + + assertEquals(old, bookDownloadFolder(root, editionA, startedWriting = true)) + } + + @Test + fun `a started download that already wrote into its own folder stays there`() { + val root = tempFolder.newFolder("Audiobookshelf") + folder(root, legacyName, "01.mp3") + val own = folder(root, itemDownloadFolderName(editionA.author, editionA.title, editionA.id), "01.mp3") + + assertEquals(own, bookDownloadFolder(root, editionA, startedWriting = true)) + } + + @Test + fun `a started download with no old folder gets its own`() { + val root = tempFolder.newFolder("Audiobookshelf") + val target = bookDownloadFolder(root, editionA, startedWriting = true) + assertEquals(itemDownloadFolderName(editionA.author, editionA.title, editionA.id), target.name) + } + + @Test + fun `deleting one edition while the other downloads removes all of the first one's files`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val aDir = bookDownloadFolder(root, editionA, startedWriting = false) + folder(root, aDir.name, "01.mp3", "cover.jpg") + val a = editionA.copy(isDownloaded = true, localPath = aDir.absolutePath, audioFiles = listOf(track("01.mp3"))) + reads.books[a.id] = a + reads.books[editionB.id] = editionB + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = editionB.id, status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + val bDir = bookDownloadFolder(root, editionB, startedWriting = false) + assertNotNull(ownership.claimFor(reads, "dB", bDir)) + folder(root, bDir.name, "01.mp3.part") + + ownership.noEngineOn(a.id) { removeDownloadedBookFiles(reads, ownership, a, aDir.absolutePath) } + + assertFalse("A's files were left behind", aDir.exists()) + assertTrue(File(bDir, "01.mp3.part").exists()) + } + + @Test + fun `deleting an old shared-folder download while a new edition is queued removes it`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val old = folder(root, legacyName, "01.mp3", "cover.jpg") + val a = editionA.copy(isDownloaded = true, localPath = old.absolutePath, audioFiles = listOf(track("01.mp3"))) + reads.books[a.id] = a + reads.books[editionB.id] = editionB + reads.rows["dB"] = DownloadItem(id = "dB", audioBookId = editionB.id, status = DownloadStatus.Queued) + val ownership = DownloadOwnership() + + ownership.noEngineOn(a.id) { removeDownloadedBookFiles(reads, ownership, a, old.absolutePath) } + + assertFalse("A's old folder was kept for a download that will never write there", old.exists()) + } + + @Test + fun `an old paused download still keeps its shared folder safe from the other edition's delete`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val old = folder(root, legacyName, "a1.mp3", "b1.mp3", "b2.mp3.part") + val a = editionA.copy(isDownloaded = true, localPath = old.absolutePath, audioFiles = listOf(track("a1.mp3"))) + reads.books[a.id] = a + reads.books[editionB.id] = editionB + reads.rows["dB"] = DownloadItem( + id = "dB", audioBookId = editionB.id, status = DownloadStatus.Paused, downloadedBytes = 3, + ) + val ownership = DownloadOwnership() + + ownership.noEngineOn(a.id) { removeDownloadedBookFiles(reads, ownership, a, old.absolutePath) } + + assertTrue("B's finished track went with A's delete", File(old, "b1.mp3").exists()) + assertTrue(File(old, "b2.mp3.part").exists()) + } + + @Test + fun `cancelling a new download cleans its own folder and leaves the old shared one alone`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + // An old cancelled partial nobody tracks any more, from before the upgrade. + val old = folder(root, legacyName, "01.mp3") + val own = folder(root, itemDownloadFolderName(editionB.author, editionB.title, editionB.id), "01.mp3", "02.mp3.part") + reads.books[editionB.id] = editionB + val ownership = DownloadOwnership() + + ownership.noEngineOn(editionB.id) { + removeCancelledPartialFiles(reads, ownership, editionB.id, startedWriting = true) + } + + assertFalse(own.exists()) + assertTrue(File(old, "01.mp3").exists()) + } + + @Test + fun `cancelling an old paused download cleans its old folder`() = runBlocking { + val root = tempFolder.newFolder("Audiobookshelf") + val reads = FakeReads(root) + val old = folder(root, legacyName, "01.mp3", "02.mp3.part") + reads.books[editionA.id] = editionA + val ownership = DownloadOwnership() + + ownership.noEngineOn(editionA.id) { + removeCancelledPartialFiles(reads, ownership, editionA.id, startedWriting = true) + } + + assertFalse(old.exists()) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt index 8c26f700..1350d330 100644 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/DownloadPoliciesTest.kt @@ -60,6 +60,71 @@ class DownloadPoliciesTest { assertEquals("A_B - C_D", downloadFolderName("A/B", "C:D", "id1")) } + // ─── itemDownloadFolderName (#49) ───────────────────────────────────── + + @Test + fun `new folder name puts the title first and the item id in brackets at the end`() { + assertEquals( + "Jane Doe - My Book [b7c1d2e3]", + itemDownloadFolderName("Jane Doe", "My Book", "5f0c2a9e-3b1d-4c8e-9a7f-1e2db7c1d2e3"), + ) + } + + @Test + fun `two editions of one title get different folders`() { + val first = itemDownloadFolderName("Herman Melville", "Moby-Dick", "li_8gch9ve09orgn4fdz8") + val second = itemDownloadFolderName("Herman Melville", "Moby-Dick", "li_2kq0x7mbn1aa3plw4c") + assertTrue(first != second) + assertTrue(first != downloadFolderName("Herman Melville", "Moby-Dick", "li_8gch9ve09orgn4fdz8")) + } + + @Test + fun `new folder name is stable for the same item`() { + assertEquals( + itemDownloadFolderName("A", "B", "li_8gch9ve09orgn4fdz8"), + itemDownloadFolderName("A", "B", "li_8gch9ve09orgn4fdz8"), + ) + } + + @Test + fun `short id keeps only letters and digits from the id`() { + assertEquals("abc12345", shortItemId("x/y:z*abc-123?45")) + assertEquals("ab", shortItemId("a/b")) + } + + @Test + fun `short id falls back to a hash when the id has no letters or digits`() { + assertEquals(Integer.toHexString("--".hashCode()), shortItemId("--")) + assertEquals("[${shortItemId("")}]", itemDownloadFolderName("", "", "")) + } + + @Test + fun `new folder name sanitizes odd characters`() { + val name = itemDownloadFolderName("A/B", "C:D*E?\"F|H\\I", "li_8gch9ve09orgn4fdz8") + assertEquals("A_B - C_D_E__F_G__H_I [rgn4fdz8]", name) + } + + @Test + fun `new folder name without author or title is just the id`() { + assertEquals("[rgn4fdz8]", itemDownloadFolderName("Unknown Author", " ", "li_8gch9ve09orgn4fdz8")) + } + + @Test + fun `a long title is cut to fit and the id survives`() { + val name = itemDownloadFolderName("Author", "x".repeat(400), "li_8gch9ve09orgn4fdz8") + assertTrue(name.endsWith(" [rgn4fdz8]")) + assertTrue(name.toByteArray(Charsets.UTF_8).size <= MAX_FOLDER_NAME_BYTES) + } + + @Test + fun `a long multibyte title fits the byte limit without splitting a character`() { + // Three bytes each in UTF-8, plus one emoji (a surrogate pair, four bytes) at every step. + val name = itemDownloadFolderName("作者", "長い題名📚".repeat(60), "li_8gch9ve09orgn4fdz8") + assertTrue(name.endsWith(" [rgn4fdz8]")) + assertTrue(name.toByteArray(Charsets.UTF_8).size <= MAX_FOLDER_NAME_BYTES) + assertEquals(name, String(name.toByteArray(Charsets.UTF_8), Charsets.UTF_8)) + } + // ─── estimateTotalBytes ─────────────────────────────────────────────── @Test diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/EngineExclusiveRunTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/EngineExclusiveRunTest.kt new file mode 100644 index 00000000..2e23712b --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/service/download/EngineExclusiveRunTest.kt @@ -0,0 +1,73 @@ +package com.ninelivesaudio.app.service.download + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicBoolean + +/** + * WorkManager reports a cancelled drain as stopped long before its engine has + * unwound. A read stuck on a stalled server can hold on for the 60 second read + * timeout, and its cleanup then deletes the `.part` file a replacement drain is + * writing. Engine runs are serialized, so a replacement only starts once the + * old run has fully exited, cleanup included. + */ +class EngineExclusiveRunTest { + + @Test + fun `a replacement run waits for the cancelled run's cleanup`() = runBlocking { + val lock = Mutex() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val oldStarted = CompletableDeferred() + val oldCleanedUp = AtomicBoolean(false) + + val old = scope.launch { + runEngineExclusively(lock, onStart = {}, onStop = {}) { + oldStarted.complete(Unit) + try { + awaitCancellation() + } finally { + // The stalled read finally gives up and deletes its .part. + withContext(NonCancellable) { delay(200) } + oldCleanedUp.set(true) + } + } + } + oldStarted.await() + // Cancellation is recorded at once, the old run is still unwinding. + old.cancel() + + val sawCleanupFirst = withTimeout(5_000) { + scope.async { + runEngineExclusively(lock, onStart = {}, onStop = {}) { oldCleanedUp.get() } + }.await() + } + + assertTrue("the replacement started while the old run was still cleaning up", sawCleanupFirst) + } + + @Test + fun `start and stop bracket the run, even when it throws`() = runBlocking { + val events = mutableListOf() + runCatching { + runEngineExclusively(Mutex(), onStart = { events += "start" }, onStop = { events += "stop" }) { + events += "run" + error("boom") + } + } + assertEquals(listOf("start", "run", "stop"), events) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/service/download/UserDownloadWriteOrderTest.kt b/app/src/test/java/com/ninelivesaudio/app/service/download/UserDownloadWriteOrderTest.kt deleted file mode 100644 index 21579d42..00000000 --- a/app/src/test/java/com/ninelivesaudio/app/service/download/UserDownloadWriteOrderTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.ninelivesaudio.app.service.download - -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Pause, cancel and delete on the book the engine is streaming must stop the - * engine BEFORE writing. DownloadEngine upserts a stale Downloading snapshot - * about ten times a second, so a write that lands first gets clobbered by the - * next progress tick (a cancelled row comes back, a paused one keeps going). - */ -class UserDownloadWriteOrderTest { - - @Test - fun `an active download is stopped before the user's write lands`() = runBlocking { - val events = mutableListOf() - writeAfterEngineStops( - engineActive = true, - stopEngine = { events += "stop" }, - write = { events += "write" }, - ) - assertEquals(listOf("stop", "write"), events) - } - - @Test - fun `an idle row is written without stopping the drain`() = runBlocking { - val events = mutableListOf() - writeAfterEngineStops( - engineActive = false, - stopEngine = { events += "stop" }, - write = { events += "write" }, - ) - assertEquals(listOf("write"), events) - } -} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/components/FluorescentGlowModeTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/components/FluorescentGlowModeTest.kt new file mode 100644 index 00000000..df97b898 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/components/FluorescentGlowModeTest.kt @@ -0,0 +1,43 @@ +package com.ninelivesaudio.app.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The glow around a cover is skipped at zero progress, holds still when the + * user wants less motion, and breathes the rest of the time. + */ +class FluorescentGlowModeTest { + + @Test + fun `no progress draws nothing`() { + assertEquals(GlowMode.Off, glowMode(progress = 0f, reduceMotion = false)) + } + + @Test + fun `no progress draws nothing even when motion is reduced`() { + assertEquals(GlowMode.Off, glowMode(progress = 0f, reduceMotion = true)) + } + + @Test + fun `a progress that is not a number draws nothing`() { + assertEquals(GlowMode.Off, glowMode(progress = Float.NaN, reduceMotion = false)) + assertEquals(GlowMode.Off, glowMode(progress = -0.2f, reduceMotion = false)) + } + + @Test + fun `progress breathes by default`() { + assertEquals(GlowMode.Breathing, glowMode(progress = 0.4f, reduceMotion = false)) + assertEquals(GlowMode.Breathing, glowMode(progress = 1f, reduceMotion = false)) + } + + @Test + fun `progress holds still when motion is reduced`() { + assertEquals(GlowMode.Still, glowMode(progress = 0.4f, reduceMotion = true)) + } + + @Test + fun `the tiniest progress still counts`() { + assertEquals(GlowMode.Breathing, glowMode(progress = 0.0001f, reduceMotion = false)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryAuthorGroupingTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryAuthorGroupingTest.kt new file mode 100644 index 00000000..3eb3964a --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryAuthorGroupingTest.kt @@ -0,0 +1,75 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Audiobookshelf's library list sends a co-authored book's authors as one + * string, "Terry Pratchett, Neil Gaiman". Author view filed that under a + * group of its own. It now lists the book under each author (issue #64). + */ +class LibraryAuthorGroupingTest { + + @Test + fun `a co-authored server book is listed under each author`() { + val books = listOf( + book("good-omens", "Terry Pratchett, Neil Gaiman"), + book("mort", "Terry Pratchett"), + book("coraline", "Neil Gaiman"), + ) + + val sections = buildGroupedSections(sortBooks(books, SortMode.TITLE_AZ), ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(listOf("Neil Gaiman", "Terry Pratchett"), sections.map { it.key }) + assertEquals(listOf("coraline", "good-omens"), sections[0].books.map { it.id }) + assertEquals(listOf("good-omens", "mort"), sections[1].books.map { it.id }) + } + + @Test + fun `the book keeps its full author line for display`() { + val sections = buildGroupedSections( + listOf(book("good-omens", "Terry Pratchett, Neil Gaiman")), + ViewMode.AUTHOR, + SortMode.TITLE_AZ, + ) + + sections.forEach { section -> + assertEquals("Terry Pratchett, Neil Gaiman", section.books.single().author) + } + } + + @Test + fun `a name suffix stays with the name before it`() { + assertEquals( + listOf("Martin Luther King, Jr.", "Coretta Scott King"), + splitServerAuthorNames("Martin Luther King, Jr., Coretta Scott King"), + ) + assertEquals(listOf("John Smith, III"), splitServerAuthorNames("John Smith, III")) + assertEquals(listOf("Jane Doe, PhD", "Ann Lee, M.D."), splitServerAuthorNames("Jane Doe, PhD, Ann Lee, M.D.")) + } + + @Test + fun `blank pieces and repeats are dropped`() { + assertEquals(listOf("Ann", "Bob"), splitServerAuthorNames("Ann, , Bob, Ann")) + assertEquals(listOf("Solo"), splitServerAuthorNames("Solo")) + } + + @Test + fun `a local book's author tag is one name, commas and all`() { + // Tags often put the last name first. + val local = book("hobbit", "Tolkien, J.R.R.", isLocal = true) + + assertEquals(listOf("Tolkien, J.R.R."), authorGroupNames(local)) + } + + @Test + fun `a book with no author is under Unknown Author`() { + val sections = buildGroupedSections(listOf(book("x", " ")), ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(listOf("Unknown Author"), sections.map { it.key }) + } + + private fun book(id: String, author: String, isLocal: Boolean = false) = + AudioBook(id = id, title = id, author = author, isLocal = isLocal) +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt new file mode 100644 index 00000000..95e7b531 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryDuplicateRowTest.kt @@ -0,0 +1,161 @@ +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.Test + +/** + * The grouped Library crashed with "Key book-Caleb Ibarra-li_mock_00105 was + * already used" while a big library synced. The shelf read spans several + * cursor windows, and a sync page saved between two of them moved re-saved + * books to the end of the read, so the read returned them twice. One book + * twice in a group is two list rows with one key, and the list throws. + */ +class LibraryDuplicateRowTest { + + @Test + fun `a book the shelf read returned twice shows once`() = runBlocking { + // What the read returns when a sync page lands between cursor + // windows: the re-saved book again at the end, identical. + val caleb = book("li_mock_00105", "Mockbook 0105 Ashen Tide Hollow", "Caleb Ibarra") + val stored = listOf( + caleb, + book("li_mock_00106", "Mockbook 0106 Iron Saint", "Mara Vance"), + book("li_mock_00412", "Mockbook 0412 Glass River", "Caleb Ibarra"), + caleb, + ) + + val shelf = arrangeShelf(stored, SortMode.TITLE_AZ, ViewMode.AUTHOR, isUnlocked = true) + val items = flattenGroupedItems(shelf.sections, mapOf("Caleb Ibarra" to true, "Mara Vance" to true)) + + assertEquals(listOf("li_mock_00105", "li_mock_00106", "li_mock_00412"), shelf.books.map { it.id }) + assertNoRepeatedRows(items) + val calebHeader = items.filterIsInstance().single { it.groupKey == "Caleb Ibarra" } + assertEquals(2, calebHeader.count) + } + + @Test + fun `an author line that repeats a name lists the book once`() { + val books = listOf( + book("a", "A", "John Smith, John Smith"), + book("b", "B", "John Smith, John Smith"), + book("c", "C", " John Smith , JOHN SMITH"), + ) + + val sections = buildGroupedSections(books, ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(setOf("John Smith", "JOHN SMITH"), sections.map { it.key }.toSet()) + assertEquals(listOf("a", "b", "c"), sections.single { it.key == "John Smith" }.books.map { it.id }) + assertEquals(listOf("c"), sections.single { it.key == "JOHN SMITH" }.books.map { it.id }) + assertNoRepeatedRows(flattenGroupedItems(sections, mapOf("John Smith" to true, "JOHN SMITH" to true))) + } + + @Test + fun `spacing variants are one group and capitals stay apart`() { + val books = listOf( + book("a", "A", "Ursula K. Le Guin"), + book("b", "B", "Ursula K. Le Guin, Neil Gaiman"), + book("c", "C", "Saga Author", series = "Saga"), + book("d", "D", "Saga Author", series = "SAGA"), + ) + + val authors = buildGroupedSections(books, ViewMode.AUTHOR, SortMode.TITLE_AZ) + assertEquals(listOf("a", "b"), authors.single { it.key == "Ursula K. Le Guin" }.books.map { it.id }) + assertEquals(listOf("b"), authors.single { it.key == "Neil Gaiman" }.books.map { it.id }) + + // Two series whose names differ only in capitals are two series, so + // their reading orders never interleave. + val series = buildGroupedSections(books, ViewMode.SERIES, SortMode.TITLE_AZ) + assertEquals(listOf("c"), series.single { it.key == "Saga" }.books.map { it.id }) + assertEquals(listOf("d"), series.single { it.key == "SAGA" }.books.map { it.id }) + } + + @Test + fun `genre and series variants never make two groups with one key`() { + val books = listOf( + book("a", "A", "X", genres = listOf("Fantasy", "fantasy ", "Sci Fi")), + book("b", "B", "X", genres = listOf(" FANTASY"), series = "The Expanse"), + book("c", "C", "X", series = "the expanse"), + ) + + for (mode in listOf(ViewMode.GENRE, ViewMode.SERIES, ViewMode.AUTHOR)) { + val sections = buildGroupedSections(books, mode, SortMode.TITLE_AZ) + assertEquals("$mode keys", sections.map { it.key }.distinct(), sections.map { it.key }) + sections.forEach { section -> + assertEquals("$mode ${section.key}", section.books.map { it.id }.distinct(), section.books.map { it.id }) + } + } + assertEquals( + setOf("Fantasy", "fantasy", "FANTASY", "Sci Fi", "Uncategorized Genre"), + buildGroupedSections(books, ViewMode.GENRE, SortMode.TITLE_AZ).map { it.key }.toSet(), + ) + } + + @Test + fun `a stray repeat in the groups never repeats a list row`() { + val one = AudioBook(id = "one", title = "One") + val two = AudioBook(id = "two", title = "Two") + val sections = listOf( + GroupedSection(key = "Ann", title = "Ann", books = listOf(one, one, two)), + GroupedSection(key = "Ann", title = "Ann", books = listOf(two)), + GroupedSection(key = "Bob", title = "Bob", books = listOf(one)), + ) + + val items = flattenGroupedItems(sections, emptyMap()) + + assertNoRepeatedRows(items) + // The first of each is kept, in order. + assertEquals( + listOf("header-Ann", "Ann/one", "Ann/two", "header-Bob", "Bob/one"), + items.map { + when (it) { + is LibraryListItem.GroupHeader -> "header-${it.groupKey}" + is LibraryListItem.BookRow -> "${it.groupKey}/${it.book.id}" + } + }, + ) + } + + @Test + fun `list keys cannot run together`() { + val a = LibraryListItem.BookRow(groupKey = "Smith-Jones", book = AudioBook(id = "x")) + val b = LibraryListItem.BookRow(groupKey = "Smith", book = AudioBook(id = "Jones-x")) + val header = LibraryListItem.GroupHeader(groupKey = "Smith", title = "Smith", count = 1, isExpanded = true) + + assertEquals(3, setOf(a.listKey, b.listKey, header.listKey).size) + } + + @Test + fun `an eight thousand book library has one row per book per group`() = runBlocking { + val firsts = listOf("Mara", "Jonas", "Ilse", "Theo", "Ruth", "Caleb", "Wren", "Otto", "Nadia", "Silas", "Petra", "Hugo") + val lasts = listOf("Vance", "Okafor", "Lindqvist", "Marsh", "Delacroix", "Hale", "Ibarra", "Kowal", "Strand", "Achebe") + val books = (1..8000).map { i -> + val author = "${firsts[i % firsts.size]} ${lasts[(i / firsts.size) % lasts.size]}" + book("li_mock_%05d".format(i), "Mockbook %04d".format(i), author) + } + // Two pages saved mid-read: their books came back again at the end. + val stored = books + books.subList(99, 299) + + val shelf = arrangeShelf(stored, SortMode.TITLE_AZ, ViewMode.AUTHOR, isUnlocked = true) + val everyGroupOpen = shelf.sections.associate { it.key to true } + val items = flattenGroupedItems(shelf.sections, everyGroupOpen) + + assertEquals(8000, shelf.books.size) + assertEquals(8000, items.count { it is LibraryListItem.BookRow }) + assertNoRepeatedRows(items) + } + + private fun assertNoRepeatedRows(items: List) { + val keys = items.map { it.listKey } + assertEquals("repeated list keys", emptyList(), keys.groupBy { it }.filterValues { it.size > 1 }.keys.toList()) + } + + private fun book( + id: String, + title: String, + author: String, + genres: List = emptyList(), + series: String? = null, + ) = AudioBook(id = id, title = title, author = author, genres = genres, seriesName = series) +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt new file mode 100644 index 00000000..092f8a77 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryLetterIndexTest.kt @@ -0,0 +1,310 @@ +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 + +/** + * The letter rail on the Library list. Its offsets are worked out once per + * list change, so these pin what they say and that a long shelf is cheap. + */ +class LibraryLetterIndexTest { + + private fun book(id: Int, title: String, author: String = "") = + AudioBook(id = "b$id", title = title, author = author) + + /** [perLetter] books for each letter in [letters], titled like "A title 3". */ + private fun shelf(letters: List, perLetter: Int): List = + letters.flatMapIndexed { li, letter -> + (0 until perLetter).map { n -> book(li * 1000 + n, "$letter title $n") } + } + + private fun LetterIndex.asPairs() = labels.zip(offsets.toList()) + + @Test + fun `a title's letter is its first letter in capitals`() { + assertEquals("D", letterLabelFor("dune")) + assertEquals("D", letterLabelFor("Dune")) + assertEquals("T", letterLabelFor("The Hobbit")) + } + + @Test + fun `digits and symbols share the number sign`() { + assertEquals("#", letterLabelFor("1984")) + assertEquals("#", letterLabelFor("'Salem's Lot")) + assertEquals("#", letterLabelFor("[REC]")) + } + + @Test + fun `a blank title falls under the number sign`() { + assertEquals("#", letterLabelFor("")) + assertEquals("#", letterLabelFor(" ")) + } + + @Test + fun `leading spaces are skipped`() { + assertEquals("M", letterLabelFor(" Mort")) + } + + @Test + fun `an accented first letter files under its plain letter`() { + assertEquals("E", letterLabelFor("Émile")) + assertEquals("O", letterLabelFor("Östen")) + assertEquals("N", letterLabelFor("ñandú")) + } + + @Test + fun `the rail files a letter the way search folds it`() { + assertEquals("O", letterLabelFor("Øystein")) + assertEquals("L", letterLabelFor("Łódź")) + assertEquals("A", letterLabelFor("Æsir")) + } + + @Test + fun `a letter outside the Latin alphabet falls under the number sign`() { + assertEquals("#", letterLabelFor("日本語")) + assertEquals("#", letterLabelFor("Привет")) + } + + @Test + fun `a flat title shelf gets the first row of each letter`() { + val books = shelf(listOf('A', 'B', 'D'), perLetter = 20) + + val index = flatLetterIndex(books, SortMode.TITLE_AZ) + + assertEquals(listOf("A" to 0, "B" to 20, "D" to 40), index.asPairs()) + } + + @Test + fun `numbers come first on an ascending shelf and last on a descending one`() { + val asc = listOf(book(1, "1984")) + shelf(listOf('A', 'B'), 25) + val desc = shelf(listOf('B', 'A'), 25) + listOf(book(9, "1984")) + + assertEquals(listOf("#" to 0, "A" to 1, "B" to 26), flatLetterIndex(asc, SortMode.TITLE_AZ).asPairs()) + assertEquals(listOf("B" to 0, "A" to 25, "#" to 50), flatLetterIndex(desc, SortMode.TITLE_ZA).asPairs()) + } + + @Test + fun `a letter with no books is left off`() { + val books = shelf(listOf('A', 'C'), 25) + + assertEquals(listOf("A", "C"), flatLetterIndex(books, SortMode.TITLE_AZ).labels) + } + + @Test + fun `an accented title that sorts at the end does not disturb the offsets`() { + // The shelf sorts on the lowercased title, so "Émile" lands after "z". + // The rail keeps the first run of each letter and ignores the stray. + val books = shelf(listOf('E', 'Z'), 25) + book(99, "Émile") + + val index = flatLetterIndex(books, SortMode.TITLE_AZ) + + assertEquals(listOf("E" to 0, "Z" to 25), index.asPairs()) + } + + @Test + fun `an author sort files books under the author's letter`() { + val books = (0 until 50).map { book(it, title = "Zzz $it", author = if (it < 25) "Austen" else "Brontë") } + + val index = flatLetterIndex(books, SortMode.AUTHOR_AZ) + + assertEquals(listOf("A" to 0, "B" to 25), index.asPairs()) + } + + @Test + fun `a sort that is not alphabetical gets no index`() { + val books = shelf(listOf('A', 'B', 'C'), 20) + val alphabetical = setOf( + SortMode.TITLE_AZ, SortMode.TITLE_ZA, SortMode.AUTHOR_AZ, SortMode.AUTHOR_ZA, + ) + + for (mode in SortMode.entries - alphabetical) { + assertTrue("index for $mode", flatLetterIndex(books, mode).isEmpty) + assertFalse("alphabetical $mode", sortsAlphabetically(mode)) + } + for (mode in alphabetical) { + assertTrue("alphabetical $mode", sortsAlphabetically(mode)) + } + } + + @Test + fun `a short shelf gets no index`() { + val books = shelf(listOf('A', 'B', 'C'), perLetter = 5) + + assertTrue(books.size < MIN_ROWS_FOR_LETTER_INDEX) + assertTrue(flatLetterIndex(books, SortMode.TITLE_AZ).isEmpty) + } + + @Test + fun `a shelf under a single letter gets no index`() { + val books = shelf(listOf('A'), perLetter = 60) + + assertTrue(flatLetterIndex(books, SortMode.TITLE_AZ).isEmpty) + } + + @Test + fun `an empty shelf gets no index`() { + assertTrue(flatLetterIndex(emptyList(), SortMode.TITLE_AZ).isEmpty) + assertTrue(groupedLetterIndex(emptyList(), SortMode.TITLE_AZ).isEmpty) + } + + @Test + fun `a grouped list indexes its headers and counts the rows between them`() { + // Expanded groups put book rows between headers. The offset is the + // header's position in the flat list, not its position among groups. + val sections = listOf("Apple" to 10, "Avocado" to 12, "Banana" to 20, "Cherry" to 30) + .map { (name, count) -> + GroupedSection(name, name, (0 until count).map { book(it, "$name book $it") }) + } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to true }) + + val index = groupedLetterIndex(items, SortMode.TITLE_AZ) + + assertEquals(listOf("A" to 0, "B" to 24, "C" to 45), index.asPairs()) + assertTrue(items[0] is LibraryListItem.GroupHeader) + assertTrue(items[24] is LibraryListItem.GroupHeader) + assertTrue(items[45] is LibraryListItem.GroupHeader) + } + + @Test + fun `book rows never add letters to a grouped index`() { + val sections = listOf("Alpha", "Beta", "Gamma").map { name -> + GroupedSection(name, name, (0 until 30).map { book(it, "Zebra $it") }) + } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to true }) + + assertEquals(listOf("A", "B", "G"), groupedLetterIndex(items, SortMode.TITLE_AZ).labels) + } + + @Test + fun `collapsed groups index just their headers`() { + val names = ('A'..'Z').flatMap { listOf("$it one", "$it two") } + val sections = names.map { GroupedSection(it, it, listOf(book(1, "x"))) } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to false }) + + val index = groupedLetterIndex(items, SortMode.TITLE_AZ) + + assertEquals(('A'..'Z').map { "$it" }, index.labels) + assertEquals((0 until 26).map { it * 2 }, index.offsets.toList()) + } + + @Test + fun `a grouped list that is not in alphabetical order gets no index`() { + val sections = listOf("Alpha", "Beta", "Gamma").map { name -> + GroupedSection(name, name, (0 until 30).map { book(it, "x $it") }) + } + val items = flattenGroupedItems(sections, choices = sections.associate { it.key to true }) + + assertTrue(groupedLetterIndex(items, SortMode.RECENTLY_PLAYED).isEmpty) + } + + @Test + fun `the work stops once every letter has been found`() { + var calls = 0 + val index = buildLetterIndex(rowCount = 50_000) { i -> + calls++ + // 26 letters then the number sign, then 49,973 more of the same. + if (i < 26) ('A' + i).toString() else if (i == 26) "#" else "Z" + } + + assertEquals(27, index.labels.size) + assertEquals(27, calls) + } + + @Test + fun `a 50,000 book shelf is indexed in one pass`() { + var calls = 0 + val index = buildLetterIndex(rowCount = 50_000) { i -> + calls++ + // Only ever two letters, so the pass cannot stop early. + if (i < 25_000) "A" else "B" + } + + assertEquals(listOf("A" to 0, "B" to 25_000), index.asPairs()) + assertEquals(50_000, calls) + } + + @Test + fun `the label for a scroll position is the last one that has started`() { + val index = LetterIndex(listOf("A", "B", "D"), intArrayOf(0, 20, 40)) + + assertEquals(0, index.labelIndexAt(0)) + assertEquals(0, index.labelIndexAt(19)) + assertEquals(1, index.labelIndexAt(20)) + assertEquals(1, index.labelIndexAt(39)) + assertEquals(2, index.labelIndexAt(40)) + assertEquals(2, index.labelIndexAt(10_000)) + } + + @Test + fun `a position before the first label counts as the first label`() { + val index = LetterIndex(listOf("A", "B"), intArrayOf(3, 20)) + + assertEquals(0, index.labelIndexAt(-1)) + assertEquals(0, index.labelIndexAt(0)) + } + + @Test + fun `a touch on the rail picks the letter under the finger`() { + // 27 letters in 540 px is 20 px each. + assertEquals(0, letterIndexAtTouch(y = 0f, heightPx = 540f, labelCount = 27)) + assertEquals(0, letterIndexAtTouch(y = 19.9f, heightPx = 540f, labelCount = 27)) + assertEquals(1, letterIndexAtTouch(y = 20f, heightPx = 540f, labelCount = 27)) + assertEquals(26, letterIndexAtTouch(y = 539.9f, heightPx = 540f, labelCount = 27)) + } + + @Test + fun `a drag past either end of the rail sticks to the first or last letter`() { + assertEquals(0, letterIndexAtTouch(y = -300f, heightPx = 540f, labelCount = 27)) + assertEquals(26, letterIndexAtTouch(y = 9_000f, heightPx = 540f, labelCount = 27)) + } + + @Test + fun `a rail with no height or no letters picks nothing unsafe`() { + assertEquals(0, letterIndexAtTouch(y = 10f, heightPx = 0f, labelCount = 27)) + assertEquals(0, letterIndexAtTouch(y = 10f, heightPx = 540f, labelCount = 0)) + } + + @Test + fun `every letter shows when each has room`() { + assertEquals(1, letterRailStride(labelCount = 27, heightPx = 540f, minSlotPx = 20f)) + } + + @Test + fun `a short rail shows every other letter or fewer`() { + // 27 letters at 20 px need 540 px. 270 px fits every second one. + assertEquals(2, letterRailStride(labelCount = 27, heightPx = 270f, minSlotPx = 20f)) + assertEquals(3, letterRailStride(labelCount = 27, heightPx = 180f, minSlotPx = 20f)) + } + + @Test + fun `a rail that is nearly tall enough still thins its letters`() { + // 540 px are needed and 400 px are there, so every letter would be + // crowded. Rounding the stride down to 1 would leave them all in. + assertEquals(2, letterRailStride(labelCount = 27, heightPx = 400f, minSlotPx = 20f)) + } + + @Test + fun `a rail with no height still has a stride`() { + assertEquals(1, letterRailStride(labelCount = 27, heightPx = 0f, minSlotPx = 20f)) + assertEquals(1, letterRailStride(labelCount = 0, heightPx = 100f, minSlotPx = 20f)) + } + + @Test + fun `an identity key matches the same list and never a copy`() { + val books = shelf(listOf('A', 'B'), 25) + val copy = books.toList() + + assertEquals(IdentityKey(books), IdentityKey(books)) + assertEquals(IdentityKey(books).hashCode(), IdentityKey(books).hashCode()) + assertFalse(IdentityKey(books) == IdentityKey(copy)) + } + + @Test + fun `TalkBack hears the number sign as words`() { + assertEquals("numbers and symbols", letterRailSpokenLabel("#")) + assertEquals("M", letterRailSpokenLabel("M")) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRenameReloadTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRenameReloadTest.kt new file mode 100644 index 00000000..651c6d14 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRenameReloadTest.kt @@ -0,0 +1,80 @@ +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 + +/** + * A library renamed on the server keeps its id, so the id comparison saw an + * unchanged list and the selector kept showing the old name until the list + * itself changed. A name that differs for the same id now counts as a change. + */ +class LibraryRenameReloadTest { + + private val books = Library(id = "books", name = "Books") + private val podcasts = Library(id = "podcasts", name = "Podcasts") + + @Test + fun `a renamed library is noticed`() { + assertTrue(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(books, podcasts.copy(name = "Shows")))) + } + + @Test + fun `an unchanged list is not a rename, whatever its order`() { + assertFalse(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(books, podcasts))) + assertFalse(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(podcasts, books))) + } + + @Test + fun `added and removed libraries are not renames`() { + // Membership changes are the id comparison's job. + assertFalse(librariesRenamed(shown = listOf(books), cached = listOf(books, podcasts))) + assertFalse(librariesRenamed(shown = listOf(books, podcasts), cached = listOf(podcasts))) + } + + @Test + fun `a rename reloads the list even though the ids match`() { + val renamed = librariesRenamed(shown = listOf(books, podcasts), cached = listOf(books.copy(name = "Audiobooks"), podcasts)) + assertTrue( + shouldReloadLibrariesAfterSync( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("books", "podcasts"), + librariesRenamed = renamed, + ), + ) + assertEquals( + AfterProgressPull.RELOAD_LIBRARIES, + afterProgressPull( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("books", "podcasts"), + librariesRenamed = renamed, + ), + ) + } + + @Test + fun `an unchanged list still only refilters`() { + val renamed = librariesRenamed(shown = listOf(books, podcasts), cached = listOf(podcasts, books)) + assertFalse( + shouldReloadLibrariesAfterSync( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("podcasts", "books"), + librariesRenamed = renamed, + ), + ) + assertEquals( + AfterProgressPull.REFILTER, + afterProgressPull( + selectedLibrary = books, + shownLibraryIds = listOf("books", "podcasts"), + cachedLibraryIds = listOf("podcasts", "books"), + librariesRenamed = renamed, + ), + ) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRowSemanticsTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRowSemanticsTest.kt new file mode 100644 index 00000000..c9dd5c23 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibraryRowSemanticsTest.kt @@ -0,0 +1,118 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import com.ninelivesaudio.app.domain.model.Chapter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +/** What TalkBack reads for a Library row, and for the search box above it. */ +class LibraryRowSemanticsTest { + + private fun chapters(count: Int) = + (0 until count).map { Chapter(id = it, start = it * 100.0, end = (it + 1) * 100.0, title = "Ch $it") } + + @Test + fun `a new book reads its title and author once`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert") + + assertEquals("Dune, by Frank Herbert", bookRowDescription(book)) + } + + @Test + fun `the title is said once even when it is long`() { + val book = AudioBook(id = "1", title = "The Long Way", author = "Becky Chambers") + + val spoken = bookRowDescription(book) + + assertEquals(1, Regex("The Long Way").findAll(spoken).count()) + } + + @Test + fun `a book with no author reads just the title`() { + assertEquals("Dune", bookRowDescription(AudioBook(id = "1", title = "Dune", author = " "))) + } + + @Test + fun `progress is said in words`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", progress = 0.42) + + assertEquals("Dune, by Frank Herbert, 42 percent listened", bookRowDescription(book)) + } + + @Test + fun `progress that came from the server as a whole percent reads the same`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", progress = 42.0) + + assertEquals("Dune, by Frank Herbert, 42 percent listened", bookRowDescription(book)) + } + + @Test + fun `the chapter is said in words after the progress`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 0.42, + chapters = chapters(10), currentTime = 250.seconds, + ) + + assertEquals( + "Dune, by Frank Herbert, 42 percent listened, chapter 3 of 10", + bookRowDescription(book), + ) + } + + @Test + fun `a downloaded book says so`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", isDownloaded = true) + + assertEquals("Dune, by Frank Herbert, downloaded", bookRowDescription(book)) + } + + @Test + fun `a finished book says finished instead of a percent`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 1.0, isFinished = true, + ) + + assertEquals("Dune, by Frank Herbert, finished", bookRowDescription(book)) + } + + @Test + fun `an archived book says so`() { + val book = AudioBook(id = "1", title = "Dune", author = "Frank Herbert", archivedAt = 5L) + + assertEquals("Dune, by Frank Herbert, archived", bookRowDescription(book)) + } + + @Test + fun `everything together reads in a steady order`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 0.5, + chapters = chapters(4), currentTime = 150.seconds, + isDownloaded = true, archivedAt = 5L, + ) + + assertEquals( + "Dune, by Frank Herbert, archived, 50 percent listened, chapter 2 of 4, downloaded", + bookRowDescription(book), + ) + } + + @Test + fun `no row description uses a percent sign or a bullet`() { + val book = AudioBook( + id = "1", title = "Dune", author = "Frank Herbert", progress = 0.5, + chapters = chapters(4), currentTime = 150.seconds, isDownloaded = true, + ) + + val spoken = bookRowDescription(book) + + assertFalse(spoken.contains('%')) + assertFalse(spoken.contains('•')) + } + + @Test + fun `the search box has a label that does not depend on the flavor text`() { + assertEquals("Search the library", SEARCH_FIELD_LABEL) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySeriesOrderTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySeriesOrderTest.kt new file mode 100644 index 00000000..836471fc --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/library/LibrarySeriesOrderTest.kt @@ -0,0 +1,136 @@ +package com.ninelivesaudio.app.ui.library + +import com.ninelivesaudio.app.domain.model.AudioBook +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Series view lists each series in reading order, by sequence, whatever the + * shelf sort (issue #63). It used to reuse the shelf sort, so a series read + * in title or date order. + */ +class LibrarySeriesOrderTest { + + @Test + fun `sequences compare as numbers, so 2 comes before 10`() { + val books = listOf(book("10"), book("2"), book("1"), book("11"), book("3")) + + assertEquals(listOf("1", "2", "3", "10", "11"), seriesReadingOrder(books).map { it.seriesSequence }) + } + + @Test + fun `a half step sits between its neighbours`() { + val books = listOf(book("2"), book("1.5"), book("1"), book("0.5"), book("1,25")) + + assertEquals(listOf("0.5", "1", "1,25", "1.5", "2"), seriesReadingOrder(books).map { it.seriesSequence }) + } + + @Test + fun `missing and non-numeric sequences go last, by title`() { + val books = listOf( + book(null, title = "Zeta"), + book("Prequel", title = "Alpha"), + book("2"), + book("", title = "Beta"), + book("1"), + ) + + assertEquals( + listOf("1", "2", "Alpha", "Beta", "Zeta"), + seriesReadingOrder(books).map { if (it.title == "Book") it.seriesSequence else it.title }, + ) + } + + @Test + fun `equal sequences go by title, and equal titles keep the order they came in`() { + val books = listOf( + book("1", title = "beta", id = "b"), + book("1", title = "Alpha", id = "a2"), + book("1", title = "alpha", id = "a1"), + ) + + assertEquals(listOf("a2", "a1", "b"), seriesReadingOrder(books).map { it.id }) + } + + @Test + fun `only a plain number is a number`() { + assertEquals(10.0, seriesSequenceNumber(" 10 ")!!, 0.0) + assertEquals(1.5, seriesSequenceNumber("1.5")!!, 0.0) + assertEquals(1.5, seriesSequenceNumber("1,5")!!, 0.0) + assertNull(seriesSequenceNumber(null)) + assertNull(seriesSequenceNumber("")) + assertNull(seriesSequenceNumber("Book 3")) + assertNull(seriesSequenceNumber("1-3")) + assertNull(seriesSequenceNumber("NaN")) + assertNull(seriesSequenceNumber("1e3")) + } + + @Test + fun `Series view uses reading order whatever the shelf sort`() { + val books = listOf( + book("3", title = "Ashes", id = "s3", addedAt = 3), + book("1", title = "Cinders", id = "s1", addedAt = 1), + book("2", title = "Blaze", id = "s2", addedAt = 2), + ) + for (mode in SortMode.entries) { + val sections = buildGroupedSections(sortBooks(books, mode), ViewMode.SERIES, mode) + + assertEquals("for $mode", listOf("s1", "s2", "s3"), sections.single().books.map { it.id }) + } + } + + @Test + fun `series are still ordered by the shelf sort, newest book first for Recently Added`() { + val books = listOf( + // Old series whose book 3 was added last of everything. + book("1", series = "Old", id = "old1", addedAt = 1), + book("3", series = "Old", id = "old3", addedAt = 30), + // New series, every book added after Old's first. + book("1", series = "New", id = "new1", addedAt = 10), + book("2", series = "New", id = "new2", addedAt = 20), + ) + + val sections = buildGroupedSections(sortBooks(books, SortMode.RECENTLY_ADDED), ViewMode.SERIES, SortMode.RECENTLY_ADDED) + + assertEquals(listOf("Old", "New"), sections.map { it.key }) + assertEquals(listOf("old1", "old3"), sections[0].books.map { it.id }) + assertEquals(listOf("new1", "new2"), sections[1].books.map { it.id }) + } + + @Test + fun `books with no series keep the shelf order`() { + val books = listOf( + book(null, series = null, title = "Zulu", id = "z"), + book(null, series = null, title = "Alpha", id = "a"), + ) + + val sections = buildGroupedSections(sortBooks(books, SortMode.TITLE_ZA), ViewMode.SERIES, SortMode.TITLE_ZA) + + assertEquals(listOf("z", "a"), sections.single().books.map { it.id }) + } + + @Test + fun `other views keep the shelf order inside each group`() { + val books = listOf(book("2", title = "Alpha", id = "a"), book("1", title = "Beta", id = "b")) + + val sections = buildGroupedSections(sortBooks(books, SortMode.TITLE_AZ), ViewMode.AUTHOR, SortMode.TITLE_AZ) + + assertEquals(listOf("a", "b"), sections.single().books.map { it.id }) + } + + private fun book( + sequence: String?, + title: String = "Book", + id: String = "id-$sequence-$title", + series: String? = "Saga", + addedAt: Long? = null, + ) = AudioBook( + id = id, + title = title, + author = "Author", + seriesName = series, + seriesSequence = sequence, + addedAt = addedAt, + ) +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/player/ChapterListScrollTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/player/ChapterListScrollTest.kt new file mode 100644 index 00000000..36d7cc59 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/player/ChapterListScrollTest.kt @@ -0,0 +1,33 @@ +package com.ninelivesaudio.app.ui.player + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The chapter list opens already scrolled to the chapter that is playing, so + * a book with hundreds of chapters does not start at chapter 1. + */ +class ChapterListScrollTest { + + @Test + fun `opens at the current chapter`() { + assertEquals(0, chapterListInitialIndex(currentChapterIndex = 0, chapterCount = 300)) + assertEquals(137, chapterListInitialIndex(currentChapterIndex = 137, chapterCount = 300)) + assertEquals(299, chapterListInitialIndex(currentChapterIndex = 299, chapterCount = 300)) + } + + @Test + fun `no current chapter opens at the top`() { + assertEquals(0, chapterListInitialIndex(currentChapterIndex = -1, chapterCount = 300)) + } + + @Test + fun `a stale index past the end opens at the last chapter`() { + assertEquals(299, chapterListInitialIndex(currentChapterIndex = 450, chapterCount = 300)) + } + + @Test + fun `an empty list opens at the top`() { + assertEquals(0, chapterListInitialIndex(currentChapterIndex = 3, chapterCount = 0)) + } +} diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsLibraryRenameTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsLibraryRenameTest.kt new file mode 100644 index 00000000..9c4b0254 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/ui/settings/SettingsLibraryRenameTest.kt @@ -0,0 +1,33 @@ +package com.ninelivesaudio.app.ui.settings + +import com.ninelivesaudio.app.domain.model.Library +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class SettingsLibraryRenameTest { + + @Test + fun `a library renamed in the cache shows its new name`() { + val shown = listOf(Library(id = "a", name = "Mock Shelf"), Library(id = "b", name = "Podcasts")) + val cached = listOf(Library(id = "a", name = "Renamed Shelf"), Library(id = "b", name = "Podcasts")) + + assertEquals(listOf("Renamed Shelf", "Podcasts"), withCachedLibraryNames(shown, cached).map { it.name }) + } + + @Test + fun `only names change, never which libraries are shown`() { + val shown = listOf(Library(id = "a", name = "A")) + val cached = listOf(Library(id = "a", name = "A2"), Library(id = "new", name = "New")) + + assertEquals(listOf("a"), withCachedLibraryNames(shown, cached).map { it.id }) + assertEquals(shown, withCachedLibraryNames(shown, emptyList())) + } + + @Test + fun `an unchanged cache hands back the same list`() { + val shown = listOf(Library(id = "a", name = "A")) + + assertSame(shown.single(), withCachedLibraryNames(shown, listOf(Library(id = "a", name = "A"))).single()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf08d6b6..bf9d9f5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,7 @@ hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-com room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } # Retrofit + OkHttp (Network) retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }