From d421bc7cd83ebd7c331ffb4c65d77fdeabec562b Mon Sep 17 00:00:00 2001 From: Max Grakov Date: Sun, 19 Jul 2026 17:01:08 +0300 Subject: [PATCH] chore: automated stabilization pass (5 review rounds, 51 fixes) (#468) --- .../21.json | 437 ++++++++++++++++++ .../persistent/api/CachedBookGroupingTest.kt | 8 +- .../LissenDataSourceFactoryIntegrationTest.kt | 46 +- .../playback/LocalFallbackDataSourceTest.kt | 4 +- .../lissen/playback/MediaLibraryTreeTest.kt | 6 +- .../common/AudiobookshelfChannel.kt | 19 + .../common/api/AudiobookshelfAuthService.kt | 54 ++- .../AudiobookshelfOAuthCallbackActivity.kt | 9 + .../library/LibraryAudiobookshelfChannel.kt | 30 +- .../podcast/PodcastAudiobookshelfChannel.kt | 23 +- .../lissen/content/LissenMediaProvider.kt | 56 +-- .../lissen/content/cache/common/FileKey.kt | 9 + .../persistent/CachingSessionRegistry.kt | 10 +- .../persistent/ContentAutoCachingService.kt | 9 +- .../cache/persistent/ContentCachingManager.kt | 8 +- .../cache/persistent/ContentCachingService.kt | 8 +- .../cache/persistent/LocalCacheModule.kt | 1 + .../cache/persistent/LocalCacheRepository.kt | 62 ++- .../cache/persistent/LocalCacheStorage.kt | 2 +- .../content/cache/persistent/Migrations.kt | 8 + .../OfflineBookStorageProperties.kt | 16 +- .../persistent/api/CachedBookRepository.kt | 208 ++++++--- .../persistent/api/CachedLibraryRepository.kt | 3 + .../persistent/api/FetchRequestBuilder.kt | 71 +-- .../cache/persistent/api/HideCompletedSql.kt | 17 + .../cache/persistent/api/OrderingSql.kt | 13 + .../persistent/api/SearchRequestBuilder.kt | 23 +- .../converter/CachedBookEntityConverter.kt | 12 +- .../CachedBookEntityDetailedConverter.kt | 21 +- .../cache/persistent/dao/CachedBookDao.kt | 35 +- .../cache/persistent/entity/AuthorEntry.kt | 9 + .../persistent/entity/CachedBookEntity.kt | 5 +- .../cache/temporary/SeriesCoverProvider.kt | 7 +- .../ShortTermCacheStorageProperties.kt | 5 +- .../grakovne/lissen/domain/DownloadOption.kt | 2 +- .../preferences/AppearancePreferences.kt | 2 +- .../preferences/ConnectionPreferences.kt | 9 +- .../preferences/DownloadPreferences.kt | 2 +- .../preferences/LibraryPreferences.kt | 2 +- .../playback/MediaLibrarySessionCallback.kt | 11 +- .../lissen/playback/MediaLibraryTree.kt | 33 +- .../lissen/playback/MediaRepository.kt | 53 ++- .../playback/PlaybackEnhancerService.kt | 8 +- .../service/LissenDataSourceFactory.kt | 2 +- .../service/LissenMediaSchemeConverter.kt | 4 +- .../service/LissenMediaSourceFactory.kt | 2 +- .../service/PlaybackNavigationService.kt | 82 ++-- .../playback/service/PlaybackService.kt | 5 +- .../service/PlaybackSynchronizationService.kt | 15 +- .../ui/screens/library/LibraryScreen.kt | 4 +- .../composables/MiniPlayerComposable.kt | 13 +- .../composables/QuickSettingsComposable.kt | 10 - .../composable/NavigationBarComposable.kt | 2 +- .../composable/PlayingQueueComposable.kt | 22 +- .../ui/screens/settings/SettingsScreen.kt | 22 +- .../advanced/AdvancedSettingsComposable.kt | 22 +- .../advanced/AppearancePreferencesScreen.kt | 22 +- .../ClientCertificateSettingsScreen.kt | 20 +- .../advanced/ConfigBackupSettingsScreen.kt | 22 +- .../advanced/ConnectionSettingsScreen.kt | 20 +- .../advanced/CustomHeadersSettingsScreen.kt | 24 +- .../advanced/LocalUrlSettingsScreen.kt | 24 +- .../advanced/PlaybackPreferencesScreen.kt | 22 +- .../settings/advanced/SeekSettingsScreen.kt | 24 +- .../advanced/cache/CacheSettingsScreen.kt | 22 +- .../cache/CachedItemsSettingsScreen.kt | 22 +- .../composable/ServerInfoComposable.kt | 2 - .../settings/composable/SettingsTopAppBar.kt | 45 ++ .../lissen/viewmodel/CachingModelView.kt | 2 + .../lissen/viewmodel/LibraryViewModel.kt | 22 +- .../lissen/viewmodel/LoginViewModel.kt | 9 +- .../lissen/viewmodel/SettingsViewModel.kt | 8 +- .../lissen/widget/WidgetPlaybackAction.kt | 27 ++ .../lissen/widget/cover/PlayerCoverWidget.kt | 19 +- .../lissen/widget/state/PlayerStateWidget.kt | 25 +- .../lissen/content/LissenMediaProviderTest.kt | 4 +- .../persistent/CachingSessionRegistryTest.kt | 19 +- .../api/CachedBookRepositoryTest.kt | 140 ++++-- .../cache/persistent/api/OrderingSqlTest.kt | 27 ++ .../lissen/domain/DownloadOptionTest.kt | 6 + .../service/LissenDataSourceFactoryTest.kt | 6 +- .../service/LissenMediaSchemeConverterTest.kt | 22 +- .../lissen/viewmodel/LibraryViewModelTest.kt | 14 +- .../lissen/viewmodel/SettingsViewModelTest.kt | 16 +- 84 files changed, 1410 insertions(+), 806 deletions(-) create mode 100644 app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/21.json create mode 100644 app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FileKey.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/HideCompletedSql.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSql.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/AuthorEntry.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/SettingsTopAppBar.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/widget/WidgetPlaybackAction.kt create mode 100644 app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSqlTest.kt diff --git a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/21.json b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/21.json new file mode 100644 index 00000000..4306797b --- /dev/null +++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/21.json @@ -0,0 +1,437 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "b5bacfcab06282fe9d1d6af28cb7e9ca", + "entities": [ + { + "tableName": "detailed_books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `author` TEXT, `narrator` TEXT, `year` TEXT, `abstract` TEXT, `publisher` TEXT, `duration` INTEGER NOT NULL, `libraryId` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `seriesId` TEXT, `authorsJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT" + }, + { + "fieldPath": "abstract", + "columnName": "abstract", + "affinity": "TEXT" + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "libraryId", + "columnName": "libraryId", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesJson", + "columnName": "seriesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesNames", + "columnName": "seriesNames", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT" + }, + { + "fieldPath": "authorsJson", + "columnName": "authorsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_detailed_books_libraryId", + "unique": false, + "columnNames": [ + "libraryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_detailed_books_libraryId` ON `${TABLE_NAME}` (`libraryId`)" + }, + { + "name": "index_detailed_books_seriesId", + "unique": false, + "columnNames": [ + "seriesId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_detailed_books_seriesId` ON `${TABLE_NAME}` (`seriesId`)" + } + ] + }, + { + "tableName": "book_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookFileId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `duration` REAL NOT NULL, `mimeType` TEXT NOT NULL, `bookId` TEXT NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookFileId", + "columnName": "bookFileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_book_files_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_files_bookId` ON `${TABLE_NAME}` (`bookId`)" + } + ], + "foreignKeys": [ + { + "table": "detailed_books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "book_chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookChapterId` TEXT NOT NULL, `duration` REAL NOT NULL, `start` REAL NOT NULL, `end` REAL NOT NULL, `title` TEXT NOT NULL, `bookId` TEXT NOT NULL, `isCached` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookChapterId", + "columnName": "bookChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCached", + "columnName": "isCached", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_book_chapters_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_chapters_bookId` ON `${TABLE_NAME}` (`bookId`)" + } + ], + "foreignKeys": [ + { + "table": "detailed_books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "media_progress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `currentTime` REAL NOT NULL, `isFinished` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`bookId`))", + "fields": [ + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentTime", + "columnName": "currentTime", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isFinished", + "columnName": "isFinished", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "lastUpdate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookId" + ] + }, + "indices": [ + { + "name": "index_media_progress_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_media_progress_bookId` ON `${TABLE_NAME}` (`bookId`)" + } + ] + }, + { + "tableName": "libraries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "cached_bookmark", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `libraryItemId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `totalPosition` INTEGER NOT NULL, `syncState` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryItemId", + "columnName": "libraryItemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPosition", + "columnName": "totalPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_cached_bookmark_libraryItemId", + "unique": false, + "columnNames": [ + "libraryItemId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_bookmark_libraryItemId` ON `${TABLE_NAME}` (`libraryItemId`)" + }, + { + "name": "index_cached_bookmark_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_bookmark_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + } + ], + "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, 'b5bacfcab06282fe9d1d6af28cb7e9ca')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookGroupingTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookGroupingTest.kt index 9ac16e7b..d87186f2 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookGroupingTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookGroupingTest.kt @@ -94,7 +94,7 @@ class CachedBookGroupingTest { insert("o1", "Other", "ser-other", """[{"title":"Other","sequence":"1","id":"ser-other"}]""") insert("z1", "Zulu") - val all = repository.fetchLibraryGrouped(LIBRARY, pageSize = 20, pageNumber = 0) + val all = repository.fetchLibraryGrouped(LIBRARY, pageSize = 20, pageNumber = 0, libraryType = null) assertEquals(4, all.totalItems) assertEquals(listOf("a1", "ser-dune", "ser-other", "z1"), all.items.map { it.keyOf() }) @@ -103,10 +103,10 @@ class CachedBookGroupingTest { assertEquals("Dune", dune.title) assertEquals(listOf("d1", "d2", "d3"), dune.coverItemIds) - val page0 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 0) + val page0 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 0, libraryType = null) assertEquals(listOf("a1", "ser-dune"), page0.items.map { it.keyOf() }) - val page1 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 1) + val page1 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 1, libraryType = null) assertEquals(listOf("ser-other", "z1"), page1.items.map { it.keyOf() }) } @@ -117,7 +117,7 @@ class CachedBookGroupingTest { insert("d2", "Dune B", "ser-dune") insert("z1", "Zulu") - val books = repository.fetchSeriesItems(LIBRARY, "ser-dune") + val books = repository.fetchSeriesItems(LIBRARY, "ser-dune", libraryType = null) assertEquals(setOf("d1", "d2"), books.map { it.id }.toSet()) } diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenDataSourceFactoryIntegrationTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenDataSourceFactoryIntegrationTest.kt index a60c1a0b..f740db84 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenDataSourceFactoryIntegrationTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenDataSourceFactoryIntegrationTest.kt @@ -9,8 +9,8 @@ import io.mockk.mockk import org.grakovne.lissen.channel.common.OperationError import org.grakovne.lissen.channel.common.OperationResult import org.grakovne.lissen.content.LissenMediaProvider -import org.grakovne.lissen.playback.service.apply -import org.grakovne.lissen.playback.service.unapply +import org.grakovne.lissen.playback.service.parseLissenUri +import org.grakovne.lissen.playback.service.toLissenUri import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -24,7 +24,7 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun apply_creates_valid_lissen_uri() { - val uri = apply("book-123", "file-456") + val uri = toLissenUri("book-123", "file-456") assertEquals("lissen", uri.scheme) assertEquals(2, uri.pathSegments.size) assertEquals("book-123", uri.pathSegments[0]) @@ -33,8 +33,8 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun unapply_parses_valid_lissen_uri() { - val uri = apply("book-abc", "file-xyz") - val result = unapply(uri) + val uri = toLissenUri("book-abc", "file-xyz") + val result = parseLissenUri(uri) assertNotNull(result) assertEquals("book-abc", result!!.first) assertEquals("file-xyz", result.second) @@ -44,8 +44,8 @@ class LissenDataSourceFactoryIntegrationTest { fun apply_unapply_roundtrip() { val bookId = "li_abcdef123456" val fileId = "ino_ghijkl789012" - val uri = apply(bookId, fileId) - val (parsedBookId, parsedFileId) = unapply(uri)!! + val uri = toLissenUri(bookId, fileId) + val (parsedBookId, parsedFileId) = parseLissenUri(uri)!! assertEquals(bookId, parsedBookId) assertEquals(fileId, parsedFileId) } @@ -53,38 +53,38 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun unapply_returns_null_for_http_uri() { val uri = Uri.parse("https://server/api/items/book1/file/file1") - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun unapply_returns_null_for_file_uri() { val uri = Uri.parse("file:///data/media_cache/book1/file1") - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun unapply_returns_null_for_lissen_uri_with_single_segment() { val uri = Uri.parse("lissen://book1") - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun unapply_returns_null_for_lissen_uri_with_three_segments() { val uri = Uri.parse("lissen://host/book1/file1/extra") - val result = unapply(uri) + val result = parseLissenUri(uri) // 3 segments → null assertNull(result) } @Test fun uri_resolution_returns_local_file_uri_when_cached() { - val lissenUri = apply("book-1", "file-1") + val lissenUri = toLissenUri("book-1", "file-1") val localUri = Uri.parse("file:///data/media_cache/book-1/file-1") every { mediaProvider.provideFileUri("book-1", "file-1") } returns OperationResult.Success(localUri) - val (bookId, fileId) = unapply(lissenUri)!! + val (bookId, fileId) = parseLissenUri(lissenUri)!! val resolved = mediaProvider.provideFileUri(bookId, fileId).fold( onSuccess = { it }, @@ -97,13 +97,13 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun uri_resolution_returns_remote_uri_when_not_cached() { - val lissenUri = apply("book-1", "file-1") + val lissenUri = toLissenUri("book-1", "file-1") val remoteUri = Uri.parse("https://server:8080/api/items/book-1/file/file-1") every { mediaProvider.provideFileUri("book-1", "file-1") } returns OperationResult.Success(remoteUri) - val (bookId, fileId) = unapply(lissenUri)!! + val (bookId, fileId) = parseLissenUri(lissenUri)!! val resolved = mediaProvider.provideFileUri(bookId, fileId).fold( onSuccess = { it }, @@ -116,12 +116,12 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun uri_resolution_falls_back_to_lissen_uri_on_error() { - val lissenUri = apply("book-1", "file-1") + val lissenUri = toLissenUri("book-1", "file-1") every { mediaProvider.provideFileUri("book-1", "file-1") } returns OperationResult.Error(OperationError.InternalError) - val (bookId, fileId) = unapply(lissenUri)!! + val (bookId, fileId) = parseLissenUri(lissenUri)!! val resolved = mediaProvider.provideFileUri(bookId, fileId).fold( onSuccess = { it }, @@ -133,7 +133,7 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun dataspec_preserves_position_after_uri_replacement() { - val lissenUri = apply("book-1", "file-1") + val lissenUri = toLissenUri("book-1", "file-1") val localUri = Uri.parse("file:///data/media_cache/book-1/file-1") val original = @@ -155,7 +155,7 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun dataspec_preserves_length_after_uri_replacement() { - val lissenUri = apply("book-1", "file-1") + val lissenUri = toLissenUri("book-1", "file-1") val remoteUri = Uri.parse("https://server/api/items/book-1/file/file-1") val original = @@ -181,8 +181,8 @@ class LissenDataSourceFactoryIntegrationTest { fun apply_handles_ids_with_underscores_and_dashes() { val bookId = "li_abc-123_def" val fileId = "ino_xyz-789_ghi" - val uri = apply(bookId, fileId) - val result = unapply(uri) + val uri = toLissenUri(bookId, fileId) + val result = parseLissenUri(uri) assertNotNull(result) assertEquals(bookId, result!!.first) assertEquals(fileId, result.second) @@ -190,8 +190,8 @@ class LissenDataSourceFactoryIntegrationTest { @Test fun apply_handles_numeric_ids() { - val uri = apply("123456", "789012") - val result = unapply(uri) + val uri = toLissenUri("123456", "789012") + val result = parseLissenUri(uri) assertNotNull(result) assertEquals("123456", result!!.first) assertEquals("789012", result.second) diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LocalFallbackDataSourceTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LocalFallbackDataSourceTest.kt index 944de972..d91686e6 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LocalFallbackDataSourceTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LocalFallbackDataSourceTest.kt @@ -13,7 +13,7 @@ import io.mockk.mockk import org.grakovne.lissen.channel.common.OperationResult import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.playback.service.LocalFallbackDataSource -import org.grakovne.lissen.playback.service.apply +import org.grakovne.lissen.playback.service.toLissenUri import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows @@ -29,7 +29,7 @@ class LocalFallbackDataSourceTest { private val mediaProvider = mockk() private val payload = ByteArray(1000) { (it % 251).toByte() } - private val lissenUri = apply("book-1", "file-1") + private val lissenUri = toLissenUri("book-1", "file-1") private val remoteUri = Uri.parse("https://server:8080/api/items/book-1/file/file-1") @Test diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/MediaLibraryTreeTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/MediaLibraryTreeTest.kt index f157bd48..49d008c8 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/MediaLibraryTreeTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/MediaLibraryTreeTest.kt @@ -58,7 +58,7 @@ class MediaLibraryTreeTest { OperationResult.Error(OperationError.InternalError) coEvery { lissenMediaProvider.fetchBooks(any(), any(), any()) } returns OperationResult.Error(OperationError.InternalError) - coEvery { localCacheRepository.fetchDetailedItems() } returns + coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns OperationResult.Error(OperationError.InternalError) tree = MediaLibraryTree(context, playbackPreferences, libraryPreferences, localCacheRepository, lissenMediaProvider) @@ -231,7 +231,7 @@ class MediaLibraryTreeTest { @Test fun getChildren_downloads_returnsEmptyWhenRepositoryFails() = runBlocking { - coEvery { localCacheRepository.fetchDetailedItems() } returns + coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns OperationResult.Error(OperationError.InternalError) val result = tree.getChildren("root/downloads", 0, 100).get() assertEquals(SessionResult.RESULT_SUCCESS, result.resultCode) @@ -241,7 +241,7 @@ class MediaLibraryTreeTest { @Test fun getChildren_downloads_returnsDownloadedBooks() = runBlocking { - coEvery { localCacheRepository.fetchDetailedItems() } returns + coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns OperationResult.Success( PagedItems( items = diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt index 7798c361..041bc9df 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt @@ -15,6 +15,8 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.ConnectionInf import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter +import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo +import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest import org.grakovne.lissen.channel.common.ConnectionInfo import org.grakovne.lissen.channel.common.MediaChannel import org.grakovne.lissen.channel.common.OperationError @@ -132,4 +134,21 @@ abstract class AudiobookshelfChannel( .map { connectionInfoResponseConverter.apply(it) } protected fun getClientName() = "Lissen App ${BuildConfig.VERSION_NAME}" + + protected fun buildPlaybackStartRequest( + supportedMimeTypes: List, + deviceId: String, + ): PlaybackStartRequest = + PlaybackStartRequest( + supportedMimeTypes = supportedMimeTypes, + deviceInfo = + DeviceInfo( + clientName = getClientName(), + deviceId = deviceId, + deviceName = getClientName(), + ), + forceTranscode = false, + forceDirectPlay = false, + mediaPlayer = getClientName(), + ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt index a074edf1..0abb6bef 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt @@ -7,6 +7,7 @@ import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.Call @@ -52,6 +53,8 @@ class AudiobookshelfAuthService private val contextCache: OAuthContextCache, private val authMethodResponseConverter: AuthMethodResponseConverter, ) : ChannelAuthService(session) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override suspend fun authorize( host: String, username: String, @@ -196,30 +199,38 @@ class AudiobookshelfAuthService call: Call, response: Response, ) { - Timber.d("OAuth redirect received from ABS: status=${response.code}") + response.use { + Timber.d("OAuth redirect received from ABS: status=${response.code}") - if (response.code != 302) { - onFailure(examineError(response.body.string())) - return - } + if (response.code != 302) { + val body = + try { + response.body.string() + } catch (ex: IOException) { + ex.message ?: "" + } + onFailure(examineError(body)) + return + } - val location = - response - .header("Location") - ?: kotlin.run { - onFailure(examineError("invalid_redirect")) - return - } + val location = + response + .header("Location") + ?: kotlin.run { + onFailure(examineError("invalid_redirect")) + return + } - try { - val cookieHeaders: List = response.headers("Set-Cookie") - contextCache.storeCookies(cookieHeaders) + try { + val cookieHeaders: List = response.headers("Set-Cookie") + contextCache.storeCookies(cookieHeaders) - onSuccess() - forwardAuthRequest(location) - } catch (ex: Exception) { - Timber.e("Unable to process OAuth redirect due to: ${ex.message}") - onFailure(examineError(ex.message ?: "")) + onSuccess() + forwardAuthRequest(location) + } catch (ex: Exception) { + Timber.e("Unable to process OAuth redirect due to: ${ex.message}") + onFailure(examineError(ex.message ?: "")) + } } } }, @@ -293,6 +304,7 @@ class AudiobookshelfAuthService ?.let { loginResponseConverter.apply(it) } ?: run { Timber.e("OAuth token exchange returned an empty or unparseable user payload for $host (status=${response.code})") + onFailure("empty_user") return } } catch (ex: Exception) { @@ -301,7 +313,7 @@ class AudiobookshelfAuthService return } - CoroutineScope(Dispatchers.IO).launch { onSuccess(user) } + scope.launch { onSuccess(user) } } }, ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt index f330fe2d..f4acc98a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt @@ -49,6 +49,15 @@ class AudiobookshelfOAuthCallbackActivity : ComponentActivity() { val code = data.getQueryParameter("code") ?: "" Timber.d("Got Exchange code from ABS") + val returnedState = data.getQueryParameter("state").orEmpty() + val expectedState = contextCache.readPkce().state + + if (expectedState.isEmpty() || returnedState != expectedState) { + Timber.e("OAuth state validation failed: returned state does not match the stored state") + onLoginFailed("invalid_state") + return + } + lifecycleScope.launch { authService.exchangeToken( host = diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt index f396880a..2688e2df 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt @@ -18,8 +18,6 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryPageRe import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter -import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo -import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest import org.grakovne.lissen.channel.audiobookshelf.library.converter.BookResponseConverter import org.grakovne.lissen.channel.audiobookshelf.library.converter.LibraryFilteringRequestConverter import org.grakovne.lissen.channel.audiobookshelf.library.converter.LibraryOrderingRequestConverter @@ -183,8 +181,11 @@ class LibraryAudiobookshelfChannel searchResult .map { it.authors } .map { authors -> authors.map { it.id } } - .map { ids -> ids.map { id -> async { dataRepository.fetchAuthorItems(id) } } } - .map { it.awaitAll() } + .map { ids -> + ids.map { id -> + async { concurrentFetchSemaphore.withPermit { dataRepository.fetchAuthorItems(id) } } + } + }.map { it.awaitAll() } .map { result -> result .flatMap { authorResponse -> @@ -264,27 +265,12 @@ class LibraryAudiobookshelfChannel episodeId: String, supportedMimeTypes: List, deviceId: String, - ): OperationResult { - val request = - PlaybackStartRequest( - supportedMimeTypes = supportedMimeTypes, - deviceInfo = - DeviceInfo( - clientName = getClientName(), - deviceId = deviceId, - deviceName = getClientName(), - ), - forceTranscode = false, - forceDirectPlay = false, - mediaPlayer = getClientName(), - ) - - return dataRepository + ): OperationResult = + dataRepository .startPlayback( itemId = bookId, - request = request, + request = buildPlaybackStartRequest(supportedMimeTypes, deviceId), ).map { sessionResponseConverter.apply(it) } - } override suspend fun fetchBook(bookId: String): OperationResult = coroutineScope { diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt index 4f43f5aa..83aef4fd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt @@ -12,8 +12,6 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.ConnectionInf import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter -import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo -import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastOrderingRequestConverter import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastPageResponseConverter import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastResponseConverter @@ -100,28 +98,13 @@ class PodcastAudiobookshelfChannel episodeId: String, supportedMimeTypes: List, deviceId: String, - ): OperationResult { - val request = - PlaybackStartRequest( - supportedMimeTypes = supportedMimeTypes, - deviceInfo = - DeviceInfo( - clientName = getClientName(), - deviceId = deviceId, - deviceName = getClientName(), - ), - forceTranscode = false, - forceDirectPlay = false, - mediaPlayer = getClientName(), - ) - - return dataRepository + ): OperationResult = + dataRepository .startPodcastPlayback( itemId = bookId, episodeId = episodeId, - request = request, + request = buildPlaybackStartRequest(supportedMimeTypes, deviceId), ).map { sessionResponseConverter.apply(it) } - } override suspend fun fetchBook(bookId: String): OperationResult = coroutineScope { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt index 15b4aeb6..fa3779fe 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt @@ -21,7 +21,6 @@ import org.grakovne.lissen.domain.PlaybackProgress import org.grakovne.lissen.domain.PlaybackSession import org.grakovne.lissen.domain.RecentBook import org.grakovne.lissen.domain.UserAccount -import org.grakovne.lissen.domain.isSame import org.grakovne.lissen.persistence.preferences.LibraryPreferences import timber.log.Timber import java.io.File @@ -60,14 +59,16 @@ class LissenMediaProvider suspend fun provideBookmarks(playingItemId: String): List = cachedBookmarkProvider .provideBookmarks(playingItemId) - .sortedByDescending { it.createdAt } - .fold(emptyList()) { acc, item -> if (acc.any { it.isSame(item) }) acc else acc + item } + .sortedDeduplicated() suspend fun updateAndProvideBookmarks(playingItemId: String): List = cachedBookmarkProvider .fetchBookmarks(playingItemId) - .sortedByDescending { it.createdAt } - .fold(emptyList()) { acc, b -> if (acc.any { it.isSame(b) }) acc else acc + b } + .sortedDeduplicated() + + private fun List.sortedDeduplicated(): List = + sortedByDescending { it.createdAt } + .distinctBy { it.libraryItemId to it.totalPosition } fun provideFileUri( libraryItemId: String, @@ -75,21 +76,20 @@ class LissenMediaProvider ): OperationResult { Timber.d("Resolving file URI: bookId=$libraryItemId, chapterId=$chapterId") - return when (preferences.isForceCache()) { + val cached = + localCacheRepository + .provideFileUri(libraryItemId, chapterId) + ?.let { OperationResult.Success(it) } + + return cached ?: when (preferences.isForceCache()) { true -> { - localCacheRepository - .provideFileUri(libraryItemId, chapterId) - ?.let { OperationResult.Success(it) } - ?: OperationResult.Error(OperationError.InternalError) + OperationResult.Error(OperationError.InternalError) } false -> { - localCacheRepository + providePreferredChannel() .provideFileUri(libraryItemId, chapterId) - ?.let { OperationResult.Success(it) } - ?: providePreferredChannel() - .provideFileUri(libraryItemId, chapterId) - .let { OperationResult.Success(it) } + .let { OperationResult.Success(it) } } } } @@ -156,7 +156,7 @@ class LissenMediaProvider return when (preferences.isForceCache()) { true -> { - localCacheRepository.searchBooks(libraryId = libraryId, query = query) + localCacheRepository.searchBooks(libraryId = libraryId, query = query, limit = limit) } false -> { @@ -292,7 +292,7 @@ class LissenMediaProvider false -> { providePreferredChannel() .fetchRecentListenedBooks(libraryId) - .map { items -> syncFromLocalProgress(libraryId = libraryId, detailedItems = items) } + .map { items -> mergeLocalRecentProgress(libraryId = libraryId, recentBooks = items) } } } } @@ -311,7 +311,7 @@ class LissenMediaProvider false -> { providePreferredChannel() .fetchBook(bookId) - .map { syncFromLocalProgress(it) } + .map { mergeLocalItemProgress(it) } .map { trimProgress(it) } } } @@ -388,21 +388,21 @@ class LissenMediaProvider ) } - private suspend fun syncFromLocalProgress( + private suspend fun mergeLocalRecentProgress( libraryId: String, - detailedItems: List, + recentBooks: List, ): List { - val localRecentlyBooks = + val localRecentBooks = localCacheRepository .fetchRecentListenedBooks(libraryId) .fold( onSuccess = { it }, - onFailure = { return@fold detailedItems }, + onFailure = { return@fold recentBooks }, ) - val syncedRecentlyBooks = - detailedItems - .mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } } + val syncedRecentBooks = + recentBooks + .mapNotNull { item -> localRecentBooks.find { it.id == item.id }?.let { item to it } } .map { (remote, local) -> val localTimestamp = local.listenedLastUpdate ?: return@map remote val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote @@ -413,9 +413,9 @@ class LissenMediaProvider } } - return detailedItems + return recentBooks .map { item -> - syncedRecentlyBooks + syncedRecentBooks .find { item.id == it.id } ?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) } ?: item @@ -433,7 +433,7 @@ class LissenMediaProvider } } - private suspend fun syncFromLocalProgress(detailedItem: DetailedItem): DetailedItem { + private suspend fun mergeLocalItemProgress(detailedItem: DetailedItem): DetailedItem { val cachedProgress = localCacheRepository.fetchPlayingItemProgress(detailedItem.id) val channelProgress = detailedItem.progress diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FileKey.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FileKey.kt new file mode 100644 index 00000000..37dd6920 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FileKey.kt @@ -0,0 +1,9 @@ +package org.grakovne.lissen.content.cache.common + +import java.security.MessageDigest + +fun String.toFileKey(): String = + MessageDigest + .getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistry.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistry.kt index f0dc8401..f6f32d0a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistry.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistry.kt @@ -27,8 +27,14 @@ class CachingSessionRegistry { return true } - fun settle(itemId: String) { - pending.remove(itemId) + fun settle( + itemId: String, + job: Job, + ) { + if (jobs[itemId] === job) { + pending.remove(itemId) + jobs.remove(itemId) + } } fun updateStatus( diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentAutoCachingService.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentAutoCachingService.kt index 8c423ee1..9fbc71ae 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentAutoCachingService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentAutoCachingService.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.grakovne.lissen.common.NetworkService import org.grakovne.lissen.common.NetworkTypeAutoCache @@ -56,9 +57,11 @@ class ContentAutoCachingService mediaRepository.playingBook, mediaRepository.isPlaying, mediaRepository.currentChapterIndex, - ) { playingItem: DetailedItem?, isPlaying: Boolean, _: Int -> - playingItem to isPlaying - }.collectLatest { (playingItem, isPlaying) -> + ) { playingItem: DetailedItem?, isPlaying: Boolean, chapterIndex: Int -> + Triple(playingItem, isPlaying, chapterIndex) + }.distinctUntilChanged { old, new -> + old.first?.id == new.first?.id && old.second == new.second && old.third == new.third + }.collectLatest { (playingItem, isPlaying, _) -> delayedJob?.cancel() delayedJob = updatePlaybackCache(playingItem, isPlaying) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt index 9ff5472f..17e3222a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt @@ -137,6 +137,8 @@ class ContentCachingManager chapterId: String, ) = bookRepository.provideCacheState(mediaItemId, chapterId) + fun provideCachedChapterIds(mediaItemId: String) = bookRepository.provideCachedChapterIds(mediaItemId) + private suspend fun cacheBookMedia( bookId: String, files: List, @@ -168,6 +170,7 @@ class ContentCachingManager if (!response.isSuccessful) { Timber.e("Unable to cache media content: $response") + response.close() return@withContext CacheState(CacheStatus.Error) } @@ -221,14 +224,13 @@ class ContentCachingManager .writeToFile(file) } catch (ex: Exception) { Timber.e("Unable to cache cover for ${book.id} due to: ${ex.message}") - return@fold CacheState(CacheStatus.Error) } + CacheState(CacheStatus.Completed) }, onFailure = { + CacheState(CacheStatus.Completed) }, ) - - CacheState(CacheStatus.Completed) } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt index f1aaa9c3..18aca49f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt @@ -6,8 +6,10 @@ import android.os.Build import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.job import kotlinx.coroutines.launch import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.content.cache.persistent.ContentCachingNotificationService.Companion.NOTIFICATION_ID @@ -106,7 +108,7 @@ class ContentCachingService : LifecycleService() { onSuccess = { item -> cacheFetchedItem(item, task) }, onFailure = { Timber.e("Unable to fetch book ${task.itemId} for caching: ${it.code}") - registry.settle(task.itemId) + registry.settle(task.itemId, currentCoroutineContext().job) cacheProgressBus.emit(task.itemId, CacheState(CacheStatus.Error)) if (registry.inProgress().not()) { @@ -137,7 +139,7 @@ class ContentCachingService : LifecycleService() { Timber.e(error, "Caching failed for ${item.id}, emitting error state") emit(CacheState(CacheStatus.Error)) }.onCompletion { - registry.settle(item.id) + registry.settle(item.id, currentCoroutineContext().job) if (registry.inProgress().not()) { finish() @@ -176,7 +178,7 @@ class ContentCachingService : LifecycleService() { } companion object { - const val CACHE_ITEM_ACTION = "CACHING_TASK_EXTRA" + const val CACHE_ITEM_ACTION = "org.grakovne.lissen.CACHE_ITEM_ACTION" const val STOP_CACHING_ACTION = "STOP_CACHING_ACTION" const val CACHING_TASK_EXTRA = "CACHING_TASK_EXTRA" diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt index 27ebfcf7..fe899825 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt @@ -49,6 +49,7 @@ object LocalCacheModule { .addMigrations(MIGRATION_17_18) .addMigrations(MIGRATION_18_19) .addMigrations(MIGRATION_19_20) + .addMigrations(MIGRATION_20_21) .build() } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt index 5739be25..cbf74669 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt @@ -72,24 +72,12 @@ class LocalCacheRepository suspend fun searchBooks( libraryId: String, query: String, + limit: Int, ): OperationResult> = cachedBookRepository - .searchBooks(libraryId = libraryId, query = query) + .searchBooks(libraryId = libraryId, query = query, limit = limit) .let { OperationResult.Success(it) } - suspend fun fetchDetailedItems(): OperationResult> { - val items = cachedBookRepository.fetchCachedItems() - - return OperationResult - .Success( - PagedItems( - items = items, - currentPage = 0, - totalItems = cachedBookRepository.countCachedItems(), - ), - ) - } - suspend fun fetchDetailedItems( pageSize: Int, pageNumber: Int, @@ -113,16 +101,18 @@ class LocalCacheRepository pageSize: Int, pageNumber: Int, ): OperationResult> { + val libraryType = cachedLibraryRepository.fetchLibraryType(libraryId) + val books = cachedBookRepository - .fetchBooks(pageNumber = pageNumber, pageSize = pageSize, libraryId = libraryId) + .fetchBooks(pageNumber = pageNumber, pageSize = pageSize, libraryId = libraryId, libraryType = libraryType) return OperationResult .Success( PagedItems( items = books, currentPage = pageNumber, - totalItems = cachedBookRepository.countBooks(libraryId), + totalItems = cachedBookRepository.countBooks(libraryId, libraryType), ), ) } @@ -141,22 +131,22 @@ class LocalCacheRepository LibraryGrouping.SERIES -> { cachedBookRepository - .fetchLibraryGrouped(libraryId = libraryId, pageSize = pageSize, pageNumber = pageNumber) - .let { OperationResult.Success(it) } + .fetchLibraryGrouped( + libraryId = libraryId, + pageSize = pageSize, + pageNumber = pageNumber, + libraryType = cachedLibraryRepository.fetchLibraryType(libraryId), + ).let { OperationResult.Success(it) } } LibraryGrouping.AUTHOR -> { - val entries = cachedBookRepository.fetchAuthorsGrouped(libraryId) - val fromIndex = (pageNumber * pageSize).coerceIn(0, entries.size) - val toIndex = (fromIndex + pageSize).coerceIn(0, entries.size) - - OperationResult.Success( - PagedItems( - items = entries.subList(fromIndex, toIndex), - currentPage = pageNumber, - totalItems = entries.size, - ), - ) + cachedBookRepository + .fetchAuthorsGrouped( + libraryId = libraryId, + pageSize = pageSize, + pageNumber = pageNumber, + libraryType = cachedLibraryRepository.fetchLibraryType(libraryId), + ).let { OperationResult.Success(it) } } } @@ -165,16 +155,22 @@ class LocalCacheRepository seriesId: String, ): OperationResult> = cachedBookRepository - .fetchSeriesItems(libraryId = libraryId, seriesId = seriesId) - .let { OperationResult.Success(it) } + .fetchSeriesItems( + libraryId = libraryId, + seriesId = seriesId, + libraryType = cachedLibraryRepository.fetchLibraryType(libraryId), + ).let { OperationResult.Success(it) } suspend fun fetchAuthorItems( libraryId: String, authorId: String, ): OperationResult> = cachedBookRepository - .fetchAuthorItems(libraryId = libraryId, authorId = authorId) - .let { OperationResult.Success(it) } + .fetchAuthorItems( + libraryId = libraryId, + authorId = authorId, + libraryType = cachedLibraryRepository.fetchLibraryType(libraryId), + ).let { OperationResult.Success(it) } suspend fun fetchLibraries(): OperationResult> = cachedLibraryRepository diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheStorage.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheStorage.kt index 95e7eef5..dbae8bf9 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheStorage.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheStorage.kt @@ -21,7 +21,7 @@ import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity CachedLibraryEntity::class, CachedBookmarkEntity::class, ], - version = 20, + version = 21, exportSchema = true, ) abstract class LocalCacheStorage : RoomDatabase() { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt index c70b19a1..a2f00f24 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt @@ -371,3 +371,11 @@ val MIGRATION_19_20 = } } } + +val MIGRATION_20_21 = + object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE INDEX IF NOT EXISTS index_detailed_books_libraryId ON detailed_books(libraryId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_detailed_books_seriesId ON detailed_books(seriesId)") + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt index 2514af1e..9eed56bc 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt @@ -2,8 +2,8 @@ package org.grakovne.lissen.content.cache.persistent import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import org.grakovne.lissen.content.cache.common.toFileKey import java.io.File -import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton @@ -29,19 +29,19 @@ class OfflineBookStorageProperties } } - fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId) + fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId.toFileKey()) fun provideMediaCachePatch( bookId: String, fileId: String, ): File = baseFolder() - .resolve(bookId) - .resolve(fileId) + .resolve(bookId.toFileKey()) + .resolve(fileId.toFileKey()) fun provideBookCoverPath(bookId: String): File = baseFolder() - .resolve(bookId) + .resolve(bookId.toFileKey()) .resolve("cover.img") fun provideAuthorImagePath(authorName: String): File = @@ -49,12 +49,6 @@ class OfflineBookStorageProperties .resolve(AUTHORS_FOLDER) .resolve("${authorName.toFileKey()}.img") - private fun String.toFileKey(): String = - MessageDigest - .getInstance("SHA-256") - .digest(toByteArray(Charsets.UTF_8)) - .joinToString("") { "%02x".format(it) } - companion object { const val MEDIA_CACHE_FOLDER = "media_cache" private const val AUTHORS_FOLDER = "authors" diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt index 05cab85c..0a26067d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt @@ -4,11 +4,9 @@ import android.net.Uri import androidx.core.net.toUri import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery -import com.squareup.moshi.Types import org.grakovne.lissen.common.LibraryOrderingDirection import org.grakovne.lissen.common.LibraryOrderingOption import org.grakovne.lissen.common.mergeAuthorNames -import org.grakovne.lissen.common.moshi import org.grakovne.lissen.content.cache.persistent.OfflineBookStorageProperties import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityConverter import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityDetailedConverter @@ -16,11 +14,11 @@ import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityRe import org.grakovne.lissen.content.cache.persistent.converter.MediaProgressEntityConverter import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao import org.grakovne.lissen.content.cache.persistent.entity.BookEntity -import org.grakovne.lissen.content.cache.persistent.entity.BookSeriesDto import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity import org.grakovne.lissen.domain.Book import org.grakovne.lissen.domain.DetailedItem import org.grakovne.lissen.domain.LibraryEntry +import org.grakovne.lissen.domain.LibraryType import org.grakovne.lissen.domain.PagedItems import org.grakovne.lissen.domain.PlaybackProgress import org.grakovne.lissen.domain.PlayingChapter @@ -31,6 +29,10 @@ import java.time.Instant import javax.inject.Inject import javax.inject.Singleton +private const val FINISHED_POSITION_EPSILON = 1.0 + +private const val AUTHOR_KEY = "TRIM(CASE WHEN instr(author, ',') > 0 THEN substr(author, 1, instr(author, ',') - 1) ELSE author END)" + @Singleton class CachedBookRepository @Inject @@ -79,6 +81,8 @@ class CachedBookRepository chapterId: String, ) = bookDao.isBookChapterCached(bookId, chapterId) + fun provideCachedChapterIds(bookId: String) = bookDao.cachedChapterIds(bookId) + suspend fun fetchCachedItems() = bookDao .fetchCachedItems() @@ -99,17 +103,16 @@ class CachedBookRepository libraryId: String, pageNumber: Int, pageSize: Int, + libraryType: LibraryType?, ): List { val (option, direction) = buildOrdering() val request = - FetchRequestBuilder() - .libraryId(libraryId) + fetchRequest(libraryId, libraryType) .pageNumber(pageNumber) .pageSize(pageSize) .orderField(option) .orderDirection(direction) - .hideCompleted(preferences.getHideCompleted()) .build() return bookDao @@ -117,20 +120,36 @@ class CachedBookRepository .map { cachedBookEntityConverter.apply(it) } } - suspend fun countBooks(libraryId: String): Int = bookDao.countCachedBooks(libraryId = libraryId) + suspend fun countBooks( + libraryId: String, + libraryType: LibraryType?, + ): Int = bookDao.countRaw(fetchRequest(libraryId, libraryType).buildCount()) + + private fun fetchRequest( + libraryId: String, + libraryType: LibraryType?, + ): FetchRequestBuilder = + FetchRequestBuilder() + .libraryId(libraryId) + .libraryType(libraryType) + .hideCompleted(preferences.getHideCompleted()) + + private fun hideCompletedClauses(libraryType: LibraryType?): Pair = + hideCompletedSql(hideCompletedApplies(preferences.getHideCompleted(), libraryType), "id") suspend fun fetchLibraryGrouped( libraryId: String, pageSize: Int, pageNumber: Int, + libraryType: LibraryType?, ): PagedItems { - val total = bookDao.countGroupedEntries(libraryId) + val total = bookDao.countRaw(buildGroupedCountQuery(libraryId, libraryType)) if (total == 0) { return PagedItems(items = emptyList(), currentPage = pageNumber, totalItems = 0) } - val headers = bookDao.fetchGroupedEntries(buildGroupedPageQuery(libraryId, pageSize, pageNumber)) + val headers = bookDao.fetchGroupedEntries(buildGroupedPageQuery(libraryId, pageSize, pageNumber, libraryType)) val standaloneBooks = headers @@ -175,34 +194,42 @@ class CachedBookRepository suspend fun fetchSeriesItems( libraryId: String, seriesId: String, - ): List = - bookDao - .fetchBooksBySeriesIds(listOf(seriesId)) + libraryType: LibraryType?, + ): List { + val (join, filter) = hideCompletedClauses(libraryType) + + val sql = + """ + SELECT detailed_books.* FROM detailed_books + $join + WHERE seriesId = ? $filter + """.trimIndent() + + return bookDao + .fetchCachedBooks(SimpleSQLiteQuery(sql, arrayOf(seriesId))) .map { cachedBookEntityConverter.apply(it) } + } private fun buildGroupedPageQuery( libraryId: String, pageSize: Int, pageNumber: Int, + libraryType: LibraryType?, ): SupportSQLiteQuery { val (option, direction) = buildOrdering() - val field = - when (option) { - "author" -> "author" - "duration" -> "duration" - else -> "title" - } - - val descending = direction.equals("desc", ignoreCase = true) + val field = resolveOrderField(option) + val descending = resolveOrderDirection(direction) == "DESC" val aggregate = if (descending) "MAX" else "MIN" val sortDirection = if (descending) "DESC" else "ASC" + val (join, filter) = hideCompletedClauses(libraryType) val sql = """ SELECT COALESCE(seriesId, id) AS groupKey, seriesId AS seriesId, COUNT(*) AS bookCount FROM detailed_books - WHERE libraryId = ? + $join + WHERE libraryId = ? $filter GROUP BY COALESCE(seriesId, id) ORDER BY $aggregate($field) $sortDirection LIMIT ? OFFSET ? @@ -211,60 +238,116 @@ class CachedBookRepository return SimpleSQLiteQuery(sql, arrayOf(libraryId, pageSize, pageNumber * pageSize)) } - suspend fun fetchAuthorsGrouped(libraryId: String): List = - fetchAllEntities(libraryId) - .groupingBy { it.primaryAuthor() } - .eachCount() - .mapNotNull { (author, count) -> - author?.let { LibraryEntry.AuthorEntry(id = it, name = it, bookCount = count) } - }.sortedBy { it.name.lowercase() } + private fun buildGroupedCountQuery( + libraryId: String, + libraryType: LibraryType?, + ): SupportSQLiteQuery { + val (join, filter) = hideCompletedClauses(libraryType) + + val sql = + """ + SELECT COUNT(DISTINCT COALESCE(seriesId, id)) + FROM detailed_books + $join + WHERE libraryId = ? $filter + """.trimIndent() + + return SimpleSQLiteQuery(sql, arrayOf(libraryId)) + } + + suspend fun fetchAuthorsGrouped( + libraryId: String, + pageSize: Int, + pageNumber: Int, + libraryType: LibraryType?, + ): PagedItems { + val total = bookDao.countRaw(buildAuthorCountQuery(libraryId, libraryType)) + + if (total == 0) { + return PagedItems(items = emptyList(), currentPage = pageNumber, totalItems = 0) + } + + val items = + bookDao + .fetchAuthorEntries(buildAuthorPageQuery(libraryId, pageSize, pageNumber, libraryType)) + .map { LibraryEntry.AuthorEntry(id = it.author, name = it.author, bookCount = it.bookCount) } + + return PagedItems(items = items, currentPage = pageNumber, totalItems = total) + } suspend fun fetchAuthorItems( libraryId: String, authorId: String, - ): List = - fetchAllEntities(libraryId) - .filter { it.primaryAuthor() == authorId } - .map { cachedBookEntityConverter.apply(it) } - - private fun BookEntity.primaryAuthor(): String? = - author - ?.substringBefore(",") - ?.trim() - ?.takeIf { it.isNotEmpty() } - - private suspend fun fetchAllEntities(libraryId: String): List { + libraryType: LibraryType?, + ): List { val (option, direction) = buildOrdering() - val total = countBooks(libraryId) - if (total == 0) { - return emptyList() - } + val field = resolveOrderField(option) + val sortDirection = resolveOrderDirection(direction) + val (join, filter) = hideCompletedClauses(libraryType) - val request = - FetchRequestBuilder() - .libraryId(libraryId) - .pageNumber(0) - .pageSize(total) - .orderField(option) - .orderDirection(direction) - .hideCompleted(preferences.getHideCompleted()) - .build() + val sql = + """ + SELECT detailed_books.* FROM detailed_books + $join + WHERE libraryId = ? AND $AUTHOR_KEY = ? $filter + ORDER BY $field $sortDirection + """.trimIndent() - return bookDao.fetchCachedBooks(request) + return bookDao + .fetchCachedBooks(SimpleSQLiteQuery(sql, arrayOf(libraryId, authorId))) + .map { cachedBookEntityConverter.apply(it) } + } + + private fun buildAuthorPageQuery( + libraryId: String, + pageSize: Int, + pageNumber: Int, + libraryType: LibraryType?, + ): SupportSQLiteQuery { + val (join, filter) = hideCompletedClauses(libraryType) + + val sql = + """ + SELECT $AUTHOR_KEY AS author, COUNT(*) AS bookCount + FROM detailed_books + $join + WHERE libraryId = ? AND $AUTHOR_KEY IS NOT NULL AND $AUTHOR_KEY != '' $filter + GROUP BY $AUTHOR_KEY + ORDER BY LOWER($AUTHOR_KEY) ASC + LIMIT ? OFFSET ? + """.trimIndent() + + return SimpleSQLiteQuery(sql, arrayOf(libraryId, pageSize, pageNumber * pageSize)) + } + + private fun buildAuthorCountQuery( + libraryId: String, + libraryType: LibraryType?, + ): SupportSQLiteQuery { + val (join, filter) = hideCompletedClauses(libraryType) + + val sql = + """ + SELECT COUNT(DISTINCT $AUTHOR_KEY) + FROM detailed_books + $join + WHERE libraryId = ? AND $AUTHOR_KEY IS NOT NULL AND $AUTHOR_KEY != '' $filter + """.trimIndent() + + return SimpleSQLiteQuery(sql, arrayOf(libraryId)) } private fun BookEntity.primarySeriesName(): String? = seriesJson - ?.let { - val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) - moshi.adapter>(type).fromJson(it) - }?.firstOrNull() + ?.let { CachedBookDao.adapter.fromJson(it) } + ?.firstOrNull() ?.title suspend fun searchBooks( libraryId: String, query: String, + limit: Int, ): List { val (option, direction) = buildOrdering() @@ -274,6 +357,7 @@ class CachedBookRepository .libraryId(libraryId) .orderField(option) .orderDirection(direction) + .limit(limit) .build() return bookDao @@ -288,9 +372,8 @@ class CachedBookRepository ) val progress = - recentBooks - .map { it.id } - .mapNotNull { bookDao.fetchMediaProgress(it) } + bookDao + .fetchMediaProgress(recentBooks.map { it.id }) .associate { it.bookId to (it.lastUpdate to it.currentTime) } return recentBooks @@ -311,11 +394,12 @@ class CachedBookRepository playingItem: DetailedItem, progress: PlaybackProgress, ) { + val totalDuration = playingItem.chapters.sumOf { it.duration } val entity = MediaProgressEntity( bookId = playingItem.id, currentTime = progress.currentTotalTime, - isFinished = progress.currentTotalTime == playingItem.chapters.sumOf { it.duration }, + isFinished = progress.currentTotalTime >= totalDuration - FINISHED_POSITION_EPSILON, lastUpdate = Instant.now().toEpochMilli(), ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedLibraryRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedLibraryRepository.kt index 62c52a66..0840f890 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedLibraryRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedLibraryRepository.kt @@ -3,6 +3,7 @@ package org.grakovne.lissen.content.cache.persistent.api import org.grakovne.lissen.content.cache.persistent.converter.CachedLibraryEntityConverter import org.grakovne.lissen.content.cache.persistent.dao.CachedLibraryDao import org.grakovne.lissen.domain.Library +import org.grakovne.lissen.domain.LibraryType import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +16,8 @@ class CachedLibraryRepository ) { suspend fun cacheLibraries(libraries: List) = dao.updateLibraries(libraries) + suspend fun fetchLibraryType(libraryId: String): LibraryType? = dao.fetchLibrary(libraryId)?.type + suspend fun fetchLibraries() = dao .fetchLibraries() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt index 8bde3c36..a30d4b01 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt @@ -29,7 +29,43 @@ class FetchRequestBuilder { fun build(): SupportSQLiteQuery { val args = mutableListOf() + val (join, whereClause) = clauses(args) + val field = "b.${resolveOrderField(orderField)}" + val direction = resolveOrderDirection(orderDirection) + + args.add(pageSize) + args.add(pageNumber * pageSize) + + val sql = + """ + SELECT b.* + FROM detailed_books b + $join + WHERE $whereClause + ORDER BY $field $direction + LIMIT ? OFFSET ? + """.trimIndent() + + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } + + fun buildCount(): SupportSQLiteQuery { + val args = mutableListOf() + val (join, whereClause) = clauses(args) + + val sql = + """ + SELECT COUNT(*) + FROM detailed_books b + $join + WHERE $whereClause + """.trimIndent() + + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } + + private fun clauses(args: MutableList): Pair { val libraryWhereClause = when (val id = libraryId) { null -> { @@ -42,39 +78,8 @@ class FetchRequestBuilder { } } - val hideCompletedWhereClause = - when { - hideCompleted && libraryType == LibraryType.LIBRARY -> "AND (mp.isFinished = 0 OR mp.isFinished IS NULL)" - else -> "" - } + val (join, filter) = hideCompletedSql(hideCompletedApplies(hideCompleted, libraryType), "b.id") - val field = - when (orderField) { - "title" -> "b.title" - "author" -> "b.author" - "duration" -> "b.duration" - else -> "b.title" - } - - val direction = - when (orderDirection.uppercase()) { - "ASC", "DESC" -> orderDirection.uppercase() - else -> "ASC" - } - - args.add(pageSize) - args.add(pageNumber * pageSize) - - val sql = - """ - SELECT b.* - FROM detailed_books b - LEFT JOIN media_progress mp ON mp.bookId = b.id - WHERE $libraryWhereClause $hideCompletedWhereClause - ORDER BY $field $direction - LIMIT ? OFFSET ? - """.trimIndent() - - return SimpleSQLiteQuery(sql, args.toTypedArray()) + return join to "$libraryWhereClause $filter" } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/HideCompletedSql.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/HideCompletedSql.kt new file mode 100644 index 00000000..387bcdf2 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/HideCompletedSql.kt @@ -0,0 +1,17 @@ +package org.grakovne.lissen.content.cache.persistent.api + +import org.grakovne.lissen.domain.LibraryType + +fun hideCompletedApplies( + hideCompleted: Boolean, + libraryType: LibraryType?, +): Boolean = hideCompleted && libraryType == LibraryType.LIBRARY + +fun hideCompletedSql( + apply: Boolean, + idColumn: String, +): Pair = + when (apply) { + true -> "LEFT JOIN media_progress mp ON mp.bookId = $idColumn" to "AND (mp.isFinished = 0 OR mp.isFinished IS NULL)" + false -> "" to "" + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSql.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSql.kt new file mode 100644 index 00000000..cad2f520 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSql.kt @@ -0,0 +1,13 @@ +package org.grakovne.lissen.content.cache.persistent.api + +fun resolveOrderField(field: String): String = + when (field) { + "author", "createdAt", "updatedAt" -> field + else -> "title" + } + +fun resolveOrderDirection(direction: String): String = + when (direction.uppercase()) { + "ASC", "DESC" -> direction.uppercase() + else -> "ASC" + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt index 4b86ef7a..b898cc0a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt @@ -8,6 +8,7 @@ class SearchRequestBuilder { private var searchQuery: String = "" private var orderField: String = "title" private var orderDirection: String = "ASC" + private var limit: Int? = null fun libraryId(id: String) = apply { this.libraryId = id } @@ -17,6 +18,8 @@ class SearchRequestBuilder { fun orderDirection(direction: String) = apply { this.orderDirection = direction } + fun limit(value: Int) = apply { this.limit = value } + fun build(): SupportSQLiteQuery { val args = mutableListOf() @@ -32,23 +35,23 @@ class SearchRequestBuilder { args.add(pattern) } - val field = - when (orderField) { - "title", "author", "duration" -> orderField - else -> "title" - } + val field = resolveOrderField(orderField) + val direction = resolveOrderDirection(orderDirection) - val direction = - when (orderDirection.uppercase()) { - "ASC", "DESC" -> orderDirection.uppercase() - else -> "ASC" - } + val limitClause = + limit + ?.let { + args.add(it) + "LIMIT ?" + } + ?: "" val sql = """ SELECT * FROM detailed_books WHERE $whereClause ORDER BY $field $direction + $limitClause """.trimIndent() return SimpleSQLiteQuery(sql, args.toTypedArray()) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt index a9a1ac6f..aa13576a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt @@ -21,11 +21,8 @@ class CachedBookEntityConverter series = entity .seriesJson - ?.let { - val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) - val adapter = moshi.adapter>(type) - adapter.fromJson(it) - }?.joinToString(", ") { series -> + ?.let { seriesAdapter.fromJson(it) } + ?.joinToString(", ") { series -> buildString { append(series.title) series.sequence @@ -34,4 +31,9 @@ class CachedBookEntityConverter } }, ) + + private companion object { + val seriesType = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) + val seriesAdapter = moshi.adapter>(seriesType) + } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityDetailedConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityDetailedConverter.kt index 7d8f8b01..c85bcbb5 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityDetailedConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityDetailedConverter.kt @@ -29,11 +29,8 @@ class CachedBookEntityDetailedConverter entity .detailedBook .authorsJson - ?.let { - val type = Types.newParameterizedType(List::class.java, BookAuthorDto::class.java) - val adapter = moshi.adapter>(type) - adapter.fromJson(it) - }?.map { BookAuthor(id = it.id, name = it.name) } + ?.let { authorsAdapter.fromJson(it) } + ?.map { BookAuthor(id = it.id, name = it.name) } ?: emptyList(), narrator = entity.detailedBook.narrator, libraryId = entity.detailedBook.libraryId, @@ -69,11 +66,8 @@ class CachedBookEntityDetailedConverter entity .detailedBook .seriesJson - ?.let { - val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) - val adapter = moshi.adapter>(type) - adapter.fromJson(it) - }?.map { + ?.let { seriesAdapter.fromJson(it) } + ?.map { BookSeries( id = it.id, name = it.title, @@ -82,4 +76,11 @@ class CachedBookEntityDetailedConverter } ?: emptyList(), progress = entity.progress?.let { mediaProgressEntityConverter.apply(it) }, ) + + private companion object { + val authorsType = Types.newParameterizedType(List::class.java, BookAuthorDto::class.java) + val authorsAdapter = moshi.adapter>(authorsType) + val seriesType = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) + val seriesAdapter = moshi.adapter>(seriesType) + } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt index d8851cdd..37016f22 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt @@ -12,6 +12,7 @@ import androidx.sqlite.db.SupportSQLiteQuery import com.squareup.moshi.Types import kotlinx.coroutines.flow.Flow import org.grakovne.lissen.common.moshi +import org.grakovne.lissen.content.cache.persistent.entity.AuthorEntry import org.grakovne.lissen.content.cache.persistent.entity.BookAuthorDto import org.grakovne.lissen.content.cache.persistent.entity.BookChapterEntity import org.grakovne.lissen.content.cache.persistent.entity.BookEntity @@ -139,13 +140,11 @@ interface CachedBookDao { @RawQuery suspend fun fetchGroupedEntries(query: SupportSQLiteQuery): List - @Query( - """ - SELECT COUNT(DISTINCT COALESCE(seriesId, id)) FROM detailed_books - WHERE libraryId = :libraryId - """, - ) - suspend fun countGroupedEntries(libraryId: String): Int + @RawQuery + suspend fun fetchAuthorEntries(query: SupportSQLiteQuery): List + + @RawQuery + suspend fun countRaw(query: SupportSQLiteQuery): Int @Query("SELECT * FROM detailed_books WHERE id IN (:ids)") suspend fun fetchBooksByIds(ids: List): List @@ -153,14 +152,6 @@ interface CachedBookDao { @Query("SELECT * FROM detailed_books WHERE seriesId IN (:seriesIds)") suspend fun fetchBooksBySeriesIds(seriesIds: List): List - @Query( - """ - SELECT COUNT(*) FROM detailed_books - WHERE (libraryId = :libraryId) - """, - ) - suspend fun countCachedBooks(libraryId: String?): Int - @Transaction @RawQuery suspend fun searchBooks(query: SupportSQLiteQuery): List @@ -224,6 +215,16 @@ interface CachedBookDao { chapterId: String, ): Flow + @Query( + """ + SELECT bookChapterId + FROM book_chapters + WHERE bookId = :bookId + AND isCached = 1 + """, + ) + fun cachedChapterIds(bookId: String): Flow> + @Query( """ SELECT MAX(mp.lastUpdate) @@ -254,6 +255,10 @@ interface CachedBookDao { @Query("SELECT * FROM media_progress WHERE bookId = :bookId") suspend fun fetchMediaProgress(bookId: String): MediaProgressEntity? + @Transaction + @Query("SELECT * FROM media_progress WHERE bookId IN (:bookIds)") + suspend fun fetchMediaProgress(bookIds: List): List + @Delete suspend fun deleteBook(book: BookEntity) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/AuthorEntry.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/AuthorEntry.kt new file mode 100644 index 00000000..48879d90 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/AuthorEntry.kt @@ -0,0 +1,9 @@ +package org.grakovne.lissen.content.cache.persistent.entity + +import androidx.annotation.Keep + +@Keep +data class AuthorEntry( + val author: String, + val bookCount: Int, +) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt index 64579907..72b778a4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt @@ -32,7 +32,10 @@ data class CachedBookEntity( ) @Keep -@Entity(tableName = "detailed_books") +@Entity( + tableName = "detailed_books", + indices = [Index(value = ["libraryId"]), Index(value = ["seriesId"])], +) @JsonClass(generateAdapter = true) data class BookEntity( @PrimaryKey val id: String, diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/SeriesCoverProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/SeriesCoverProvider.kt index a0514d0e..a5e75970 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/SeriesCoverProvider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/SeriesCoverProvider.kt @@ -14,7 +14,6 @@ import org.grakovne.lissen.content.cache.common.SeriesCoverComposer import org.grakovne.lissen.content.cache.common.writeToFile import timber.log.Timber import java.io.File -import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -97,9 +96,5 @@ class SeriesCoverProvider private fun cacheKey( seriesId: String, coverItemIds: List, - ): String = - MessageDigest - .getInstance("SHA-256") - .digest((seriesId + coverItemIds.joinToString(",")).toByteArray()) - .joinToString("") { "%02x".format(it) } + ): String = seriesId + coverItemIds.joinToString(",") } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/ShortTermCacheStorageProperties.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/ShortTermCacheStorageProperties.kt index f30c8de7..c02ba970 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/ShortTermCacheStorageProperties.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/temporary/ShortTermCacheStorageProperties.kt @@ -2,6 +2,7 @@ package org.grakovne.lissen.content.cache.temporary import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import org.grakovne.lissen.content.cache.common.toFileKey import java.io.File import javax.inject.Inject import javax.inject.Singleton @@ -14,11 +15,11 @@ class ShortTermCacheStorageProperties ) { fun provideCoverCacheFolder(): File = coverCacheFolder() - fun provideCoverPath(itemId: String): File = coverCacheFolder().resolve(itemId) + fun provideCoverPath(itemId: String): File = coverCacheFolder().resolve(itemId.toFileKey()) fun provideSeriesCoverCacheFolder(): File = seriesCoverCacheFolder() - fun provideSeriesCoverPath(key: String): File = seriesCoverCacheFolder().resolve(key) + fun provideSeriesCoverPath(key: String): File = seriesCoverCacheFolder().resolve(key.toFileKey()) private fun coverCacheFolder(): File = baseFolder() diff --git a/app/src/main/kotlin/org/grakovne/lissen/domain/DownloadOption.kt b/app/src/main/kotlin/org/grakovne/lissen/domain/DownloadOption.kt index 2696c5a3..00961e65 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/domain/DownloadOption.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/domain/DownloadOption.kt @@ -31,6 +31,6 @@ fun String?.makeDownloadOption(): DownloadOption? = this == "all_items" -> AllItemsDownloadOption this == "current_item" -> CurrentItemDownloadOption this == "remaining_items" -> RemainingItemsDownloadOption - startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt()) + startsWith("number_items_") -> substringAfter("number_items_").toIntOrNull()?.let { NumberItemDownloadOption(it) } else -> null } diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/AppearancePreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/AppearancePreferences.kt index a7d707c3..ca88efd1 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/AppearancePreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/AppearancePreferences.kt @@ -17,7 +17,7 @@ class AppearancePreferences fun getColorScheme(): ColorScheme = store .getString(KEY_PREFERRED_COLOR_SCHEME, ColorScheme.FOLLOW_SYSTEM.name) - ?.let { ColorScheme.valueOf(it) } + ?.let { runCatching { ColorScheme.valueOf(it) }.getOrNull() } ?: ColorScheme.FOLLOW_SYSTEM fun saveColorScheme(colorScheme: ColorScheme) = store.putString(KEY_PREFERRED_COLOR_SCHEME, colorScheme.name) diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/ConnectionPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/ConnectionPreferences.kt index a08a54cb..4d4cdc67 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/ConnectionPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/ConnectionPreferences.kt @@ -33,15 +33,13 @@ class ConnectionPreferences fun clearClientCertAlias() = store.remove(KEY_CLIENT_CERT_ALIAS) fun saveCustomHeaders(headers: List) { - val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java) - val adapter = moshi.adapter>(type) + val adapter = moshi.adapter>(customHeadersType) store.putString(KEY_CUSTOM_HEADERS, adapter.toJson(headers)) } fun getCustomHeaders(): List { val json = store.getString(KEY_CUSTOM_HEADERS) ?: return emptyList() - val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java) - val adapter = moshi.adapter>(type) + val adapter = moshi.adapter>(customHeadersType) return adapter.fromJson(json) ?: emptyList() } @@ -77,6 +75,9 @@ class ConnectionPreferences } companion object { + private val customHeadersType = + Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java) + private const val KEY_CUSTOM_HEADERS = "custom_headers" private const val KEY_BYPASS_SSL = "bypass_ssl" private const val KEY_LOCAL_URLS = "local_urls" diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/DownloadPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/DownloadPreferences.kt index a2a84b37..66d80fee 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/DownloadPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/DownloadPreferences.kt @@ -23,7 +23,7 @@ class DownloadPreferences fun getAutoDownloadNetworkType(): NetworkTypeAutoCache = store .getString(KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE, NetworkTypeAutoCache.WIFI_ONLY.name) - ?.let { NetworkTypeAutoCache.valueOf(it) } + ?.let { runCatching { NetworkTypeAutoCache.valueOf(it) }.getOrNull() } ?: NetworkTypeAutoCache.WIFI_ONLY fun saveAutoDownloadNetworkType(networkTypeAutoCache: NetworkTypeAutoCache) = diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LibraryPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LibraryPreferences.kt index 6a8d9034..5f8619a3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LibraryPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LibraryPreferences.kt @@ -85,7 +85,7 @@ class LibraryPreferences private fun getPreferredLibraryType(): LibraryType = store .getString(KEY_PREFERRED_LIBRARY_TYPE) - ?.let { LibraryType.valueOf(it) } + ?.let { runCatching { LibraryType.valueOf(it) }.getOrNull() } ?: LibraryType.LIBRARY companion object { diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionCallback.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionCallback.kt index b60c4aed..55139ea7 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionCallback.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionCallback.kt @@ -27,7 +27,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.persistence.preferences.PlaybackPreferences import org.grakovne.lissen.playback.service.PlaybackService @@ -206,10 +205,8 @@ class MediaLibrarySessionCallback .fetchBook(bookId) .foldAsync( onSuccess = { - async { - preferences.savePlayingItem(it) - playbackSynchronizationService.startPlaybackSynchronization(it) - } + preferences.savePlayingItem(it) + playbackSynchronizationService.startPlaybackSynchronization(it) mediaRepository.registerPlayingBook(it) PlaybackService.bookToChapterMediaItems(it) }, @@ -256,7 +253,9 @@ class MediaLibrarySessionCallback pageSize: Int, params: MediaLibraryService.LibraryParams?, ): ListenableFuture>> { - val searchFuture = searchCache.get(query) + val searchFuture = + searchCache.get(query) + ?: return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params)) return Futures.transform( searchFuture, { items -> diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt index 171d04cd..6e3fd914 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt @@ -109,6 +109,14 @@ class MediaLibraryTree private val scope = CoroutineScope(Dispatchers.Default) + private suspend fun libraries(): List = + lissenMediaProvider + .fetchLibraries() + .fold( + onSuccess = { it }, + onFailure = { emptyList() }, + ) + private val root: MediaTreeNode by lazy { buildTree() } @OptIn(UnstableApi::class) @@ -134,7 +142,7 @@ class MediaLibraryTree } +mediaTreeNode(folderItem("$ROOT/$DOWNLOADS", context.getString(R.string.tree_node_downloads))) { - pagedChildren { _, _ -> downloadedBooksItems() } + pagedChildren { page, pageSize -> downloadedBooksItems(page, pageSize) } } } @@ -262,21 +270,9 @@ class MediaLibraryTree ) } ?: emptyList() - private suspend fun libraryItems(): List = - lissenMediaProvider - .fetchLibraries() - .fold( - onSuccess = { libs -> libs.map { libraryFolderItem("$ROOT/$LIBRARY/${it.id}", it) } }, - onFailure = { emptyList() }, - ) + private suspend fun libraryItems(): List = libraries().map { libraryFolderItem("$ROOT/$LIBRARY/${it.id}", it) } - private suspend fun resolveLibrary(libId: String): Library? = - lissenMediaProvider - .fetchLibraries() - .fold( - onSuccess = { libs -> libs.find { it.id == libId } }, - onFailure = { null }, - ) + private suspend fun resolveLibrary(libId: String): Library? = libraries().find { it.id == libId } private suspend fun booksFromLibraryItems( libraryId: String, @@ -290,9 +286,12 @@ class MediaLibraryTree onFailure = { emptyList() }, ) - private suspend fun downloadedBooksItems(): List = + private suspend fun downloadedBooksItems( + page: Int, + pageSize: Int, + ): List = localCacheRepository - .fetchDetailedItems() + .fetchDetailedItems(pageSize = pageSize, pageNumber = page) .fold( onSuccess = { paged -> paged.items.map { bookItem(it) } }, onFailure = { emptyList() }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt index 3fc77697..dcf41dab 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -14,10 +14,8 @@ import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.MoreExecutors import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,7 +35,6 @@ import org.grakovne.lissen.playback.service.DefaultTimerActivator import org.grakovne.lissen.playback.service.PlaybackService import org.grakovne.lissen.playback.service.calculateChapterIndex import org.grakovne.lissen.playback.service.calculateChapterIndexAndPosition -import org.grakovne.lissen.playback.service.calculateChapterPosition import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -126,7 +123,7 @@ class MediaRepository is PlaybackEvent.PlaybackReady -> { val book = preferences.getPlayingItem() book?.let { - updateProgress(book).await() + updateProgress(book) if (mediaController.isPlaying) { progressPoller.start() @@ -438,19 +435,21 @@ class MediaRepository preferences.savePlayingItem(book) eventBus.send(PlaybackCommand.PreparePlayback) + } else { + _isPlaybackReady.value = true } } - private fun updateProgress(detailedItem: DetailedItem): Deferred = - scope.async { - val currentIndex = mediaController.currentMediaItemIndex - val accumulated = detailedItem.chapters.take(currentIndex).sumOf { it.duration } - val currentFilePosition = mediaController.currentPosition / 1000.0 + private fun updateProgress(detailedItem: DetailedItem) { + val currentIndex = mediaController.currentMediaItemIndex + val chapters = detailedItem.chapters + val accumulated = chapters.take(currentIndex.coerceIn(0, chapters.size)).sumOf { it.duration } + val currentFilePosition = mediaController.currentPosition / 1000.0 - val newPosition = accumulated + currentFilePosition - _totalPosition.value = newPosition - updateCurrentTrackData() - } + val newPosition = accumulated + currentFilePosition + _totalPosition.value = newPosition + updateCurrentTrackData() + } private fun play() { withMain { @@ -497,18 +496,18 @@ class MediaRepository var safePosition = minOf(overallDuration, maxOf(0.0, position)) - while (book.chapters[calculateChapterIndex(book, safePosition)].available.not()) { - val chapterIndex = + val startIndex = calculateChapterIndex(book, safePosition) + if (startIndex in book.chapters.indices && book.chapters[startIndex].available.not()) { + val forward = (startIndex..book.chapters.lastIndex).firstOrNull { book.chapters[it].available } + val backward = (startIndex downTo 0).firstOrNull { book.chapters[it].available } + + val target = when (direction) { - ScrollingDirection.FORWARD -> calculateChapterIndex(book, safePosition) + 1 - ScrollingDirection.BACKWARD -> calculateChapterIndex(book, safePosition) - 1 + ScrollingDirection.FORWARD -> forward ?: backward + ScrollingDirection.BACKWARD -> backward ?: forward } - safePosition = - when { - chapterIndex in 0..book.chapters.lastIndex -> book.chapters[chapterIndex].start - else -> break - } + target?.let { safePosition = book.chapters[it].start } } val (chapterIndex, chapterPosition) = calculateChapterIndexAndPosition(book, safePosition) @@ -542,8 +541,7 @@ class MediaRepository val book = playingBook.value ?: return val totalPosition = totalPosition.value - val trackIndex = calculateChapterIndex(book, totalPosition) - val trackPosition = calculateChapterPosition(book, totalPosition) + val (trackIndex, trackPosition) = calculateChapterIndexAndPosition(book, totalPosition) _currentChapterIndex.value = trackIndex _currentChapterPosition.value = trackPosition @@ -560,7 +558,12 @@ class MediaRepository val playingBook = _playingBook.value ?: return val totalPosition = _totalPosition.value - val currentChapter = playingBook.chapters[calculateChapterIndex(playingBook, totalPosition)].title + val chapterIndex = calculateChapterIndex(playingBook, totalPosition) + if (chapterIndex !in playingBook.chapters.indices) { + Timber.w("Unable to create bookmark: chapter index $chapterIndex out of bounds") + return + } + val currentChapter = playingBook.chapters[chapterIndex].title val chapterPosition = _currentChapterPosition.value val bookmarkTitle = diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/PlaybackEnhancerService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/PlaybackEnhancerService.kt index 60a04d0e..b76ef621 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/PlaybackEnhancerService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/PlaybackEnhancerService.kt @@ -51,11 +51,15 @@ class PlaybackEnhancerService attachEqualizer(player.audioSessionId, sharedPreferences.getEqualizer()) scope.launch { - sharedPreferences.playbackVolumeBoostFlow.collectLatest { updateGain(it) } + sharedPreferences.playbackVolumeBoostFlow.collectLatest { + withContext(Dispatchers.Main) { updateGain(it) } + } } scope.launch { - sharedPreferences.equalizerFlow.collectLatest { applyEqualizer(it) } + sharedPreferences.equalizerFlow.collectLatest { + withContext(Dispatchers.Main) { applyEqualizer(it) } + } } scope.launch { diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt index 73b5ffe1..47fee4a4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactory.kt @@ -83,7 +83,7 @@ internal class LocalFallbackDataSource( private var usingLocal: Boolean = false override fun open(dataSpec: DataSpec): Long { - val (itemId, fileId) = unapply(dataSpec.uri) ?: return openPassthrough(dataSpec) + val (itemId, fileId) = parseLissenUri(dataSpec.uri) ?: return openPassthrough(dataSpec) val resolvedUri = mediaProvider diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverter.kt index b08b5ad3..490e3069 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverter.kt @@ -2,7 +2,7 @@ package org.grakovne.lissen.playback.service import android.net.Uri -fun apply( +fun toLissenUri( mediaItemId: String, fileId: String, ): Uri = @@ -13,7 +13,7 @@ fun apply( .appendPath(fileId) .build() -fun unapply(uri: Uri): Pair? { +fun parseLissenUri(uri: Uri): Pair? { if (uri.scheme != "lissen") return null val segments = uri.pathSegments diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt index fbfd486e..86f29312 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt @@ -68,7 +68,7 @@ class LissenMediaSourceFactory( .createMediaSource( MediaItem .Builder() - .setUri(apply(bookId, fileId)) + .setUri(toLissenUri(bookId, fileId)) .apply { metadata?.let { setMediaMetadata(it) } } .build(), ).let { diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt index cf234d5c..dfc82baf 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt @@ -1,6 +1,7 @@ package org.grakovne.lissen.playback.service import androidx.annotation.OptIn +import androidx.core.os.BundleCompat import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer @@ -52,7 +53,7 @@ class PlaybackNavigationService return } - if (exoPlayer.currentMediaItem?.mediaId?.let { isTrackAvailable(it) } != false) { + if (isTrackAvailable(exoPlayer.currentMediaItemIndex)) { return } @@ -66,10 +67,16 @@ class PlaybackNavigationService false -> Direction.BACKWARD } - val nextTrack = findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer, 0) + val nextTrack = findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer) nextTrack?.let { exoPlayer.seekTo(it, 0) } - if (nextTrack == null || nextTrack < currentIndex) { + val wrapped = + when (direction) { + Direction.FORWARD -> nextTrack != null && nextTrack < currentIndex + Direction.BACKWARD -> nextTrack != null && nextTrack > currentIndex + } + + if (nextTrack == null || wrapped) { exoPlayer.pause() playbackSynchronizationService.cancelSynchronization() } @@ -80,42 +87,63 @@ class PlaybackNavigationService } private fun findAvailableTrackIndex( - currentItem: Int, + startIndex: Int, direction: Direction, exoPlayer: ExoPlayer, - iteration: Int, ): Int? { - if (isTrackAvailable(exoPlayer.getMediaItemAt(currentItem).mediaId)) { - return currentItem - } - - if (iteration > 4096) { + val count = exoPlayer.mediaItemCount + if (count == 0) { return null } - val foundItem = - when (direction) { - Direction.FORWARD -> (currentItem + 1) % exoPlayer.mediaItemCount - Direction.BACKWARD -> if (currentItem - 1 < 0) exoPlayer.mediaItemCount - 1 else currentItem - 1 + var index = startIndex + repeat(count) { + if (isTrackAvailable(index)) { + return index } - return findAvailableTrackIndex(foundItem, direction, exoPlayer, iteration + 1) + index = + when (direction) { + Direction.FORWARD -> (index + 1) % count + Direction.BACKWARD -> if (index - 1 < 0) count - 1 else index - 1 + } + } + + return null } - private fun isTrackAvailable(fileId: String): Boolean { - val mediaItem = - exoPlayer - .currentMediaItem - ?.localConfiguration - ?.tag as? DetailedItem + private fun isTrackAvailable(index: Int): Boolean { + if (index < 0 || index >= exoPlayer.mediaItemCount) { + return false + } + + val mediaItem = exoPlayer.getMediaItemAt(index) + + val bookId = + LissenMediaSourceFactory.MediaId + .fromString(mediaItem.mediaId) + ?.bookId ?: return false - return mediaProvider - .provideFileUri(mediaItem.id, fileId) - .fold( - onSuccess = { true }, - onFailure = { false }, - ) + val segments = + mediaItem + .requestMetadata + .extras + ?.let { BundleCompat.getParcelableArrayList(it, PlaybackService.FILE_SEGMENTS, FileClip::class.java) } + ?: return false + + if (segments.isEmpty()) { + return false + } + + return segments.all { clip -> + mediaProvider + .provideFileUri(bookId, clip.fileId) + .fold( + onSuccess = { true }, + onFailure = { false }, + ) + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt index a634162c..ba9822de 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt @@ -228,7 +228,10 @@ class PlaybackService : MediaLibraryService() { ?: ChapterPosition(0, 0.0) val negativeChapter = chapterIndex < 0 - val lastMoments = chapterIndex == book.chapters.lastIndex && (book.chapters.last().end - 5) < chapterOffset + val lastMoments = + !negativeChapter && + book.chapters.isNotEmpty() && + (book.chapters.last().end - 5) < (book.progress?.currentTime ?: 0.0) if (negativeChapter || lastMoments) { chapterIndex = 0 diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt index 0167bf4f..c67e7564 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt @@ -125,10 +125,10 @@ class PlaybackSynchronizationService currentChapterIndex = currentIndex } - playbackSession?.let { + playbackSession?.let { session -> requestSync( item = currentItem, - it = it, + session = session, overallProgress = overallProgress, ) } @@ -136,12 +136,12 @@ class PlaybackSynchronizationService private suspend fun requestSync( item: DetailedItem, - it: PlaybackSession, + session: PlaybackSession, overallProgress: PlaybackProgress, ): Unit? = mediaChannel .syncProgress( - sessionId = it.sessionId, + sessionId = session.sessionId, detailedItem = item, progress = overallProgress, ).foldAsync( @@ -196,14 +196,15 @@ class PlaybackSynchronizationService internal const val SYNC_INTERVAL_LONG = 45_000L internal const val SYNC_INTERVAL_SHORT = 5_000L -internal const val SHORT_SYNC_WINDOW = SYNC_INTERVAL_LONG * 2 - 1 + +private const val SHORT_SYNC_WINDOW_MS = 90_000L internal fun chooseSyncInterval( durationMs: Long, positionMs: Long, ): Long { - val nearStart = positionMs < SHORT_SYNC_WINDOW - val nearEnd = durationMs != C.TIME_UNSET && durationMs - positionMs < SHORT_SYNC_WINDOW + val nearStart = positionMs < SHORT_SYNC_WINDOW_MS + val nearEnd = durationMs != C.TIME_UNSET && durationMs - positionMs < SHORT_SYNC_WINDOW_MS return when (nearStart || nearEnd) { true -> SYNC_INTERVAL_SHORT diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt index 5930d2f0..e3d2c0dc 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt @@ -137,8 +137,8 @@ fun LibraryScreen( val library = libraryViewModel.getPager(searchRequested).collectAsLazyPagingItems() val libraryCount by libraryViewModel.totalCount.collectAsState() val expandedGroups by libraryViewModel.expandedGroups.collectAsState() - val groupBooks by libraryViewModel.groupBooks.collectAsState() - val groupLoading by libraryViewModel.groupLoading.collectAsState() + val groupBooks = libraryViewModel.groupBooks + val groupLoading = libraryViewModel.groupLoading val libraryGrouping by settingsViewModel.libraryGrouping.collectAsState(LibraryGrouping.NONE) val libraryListState = rememberLazyGridState() diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt index 7de53913..7a581392 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt @@ -234,11 +234,13 @@ private fun PlaybackProgressButton( val isPlaying by playerViewModel.isPlaying.collectAsState() val totalPosition by playerViewModel.totalPosition.collectAsState() + val totalDuration = remember(book.id) { book.chapters.sumOf { it.duration } } + val progress = calculateProgress( - item = book, libraryType = libraryType, totalPosition = totalPosition, + totalDuration = totalDuration, ) PlaybackButton( @@ -249,17 +251,14 @@ private fun PlaybackProgressButton( } private fun calculateProgress( - item: DetailedItem, libraryType: LibraryType?, totalPosition: Double, -): Float { - val totalDuration = item.chapters.sumOf { it.duration } - - return when (totalDuration > 0 && libraryType == LibraryType.LIBRARY) { + totalDuration: Double, +): Float = + when (totalDuration > 0 && libraryType == LibraryType.LIBRARY) { true -> (totalPosition / totalDuration).toFloat().coerceIn(0f, 1f).snapProgress() false -> 0f } -} @Composable private fun PlaybackButton( diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/QuickSettingsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/QuickSettingsComposable.kt index 38077ece..67980256 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/QuickSettingsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/QuickSettingsComposable.kt @@ -221,16 +221,6 @@ fun QuickSettingsComposable( } } -@Composable -private fun SectionHeader(title: String) { - Text( - text = title, - style = typography.labelMedium, - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp), - ) -} - @Composable private fun ToggleRow( title: String, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt index d895c018..87505de8 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt @@ -64,7 +64,7 @@ fun NavigationBarComposable( val playingQueueExpanded by playerViewModel.playingQueueExpanded.collectAsState() val hasEpisodes = book.chapters.isNotEmpty() - val isMetadataCached by contentCachingModelView.provideCacheState(book.id).collectAsState(initial = false) + val isMetadataCached by remember(book.id) { contentCachingModelView.provideCacheState(book.id) }.collectAsState(initial = false) val totalPosition by playerViewModel.totalPosition.collectAsState() val remainingChapters = (book.chapters.size - calculateChapterIndex(book, totalPosition)).coerceAtLeast(1) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt index c8dc24f5..77347f6f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt @@ -59,6 +59,8 @@ import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.android.awaitFrame +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.grakovne.lissen.R import org.grakovne.lissen.ui.components.withScrollbar @@ -107,6 +109,16 @@ fun PlayingQueueComposable( } } + val bookId = book?.id ?: "" + val cachedChapterIdsFlow = + remember(bookId) { + when (bookId.isEmpty()) { + true -> flowOf(emptySet()) + false -> cachingModelView.provideCachedChapterIds(bookId).map { it.toSet() } + } + } + val cachedChapterIds by cachedChapterIdsFlow.collectAsState(initial = emptySet()) + val playbackReady by viewModel.isPlaybackReady.collectAsState() val playingQueueExpanded by viewModel.playingQueueExpanded.collectAsState() @@ -298,21 +310,13 @@ fun PlayingQueueComposable( showingChapters, key = { _, chapter -> chapter.id }, ) { index, chapter -> - val bookId = book?.id ?: "" - - val cacheStateFlow = - remember(bookId, chapter.id) { - cachingModelView.provideCacheState(bookId = bookId, chapterId = chapter.id) - } - val isCached by cacheStateFlow.collectAsState(initial = false) - PlaylistItemComposable( track = chapter, onClick = { viewModel.setChapter(chapter) }, isSelected = chapter.id == currentTrackId?.id, modifier = Modifier.wrapContentWidth(), maxDuration = maxDuration, - isCached = isCached, + isCached = chapter.id in cachedChapterIds, ) if (index < showingChapters.size - 1) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt index 64da1355..b0934e73 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt @@ -36,6 +36,7 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.ui.screens.settings.advanced.AdvancedSettingsNavigationItemComposable import org.grakovne.lissen.ui.screens.settings.composable.GitHubLinkComposable import org.grakovne.lissen.ui.screens.settings.composable.LicenseFooterComposable +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -53,24 +54,9 @@ fun SettingsScreen( Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.settings_screen_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt index ae7bab36..dfddf400 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsComposable.kt @@ -39,6 +39,7 @@ import org.grakovne.lissen.common.shareFile import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.CachingModelView import org.grakovne.lissen.viewmodel.SettingsViewModel @@ -59,24 +60,9 @@ fun AdvancedSettingsComposable( Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_advanced_preferences_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.settings_screen_advanced_preferences_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AppearancePreferencesScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AppearancePreferencesScreen.kt index 0d21ea56..bb594022 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AppearancePreferencesScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AppearancePreferencesScreen.kt @@ -33,6 +33,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import org.grakovne.lissen.R import org.grakovne.lissen.ui.screens.settings.composable.ColorSchemeSettingsComposable import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -43,24 +44,9 @@ fun AppearancePreferencesScreen(onBack: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.appearance_preferences_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.appearance_preferences_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ClientCertificateSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ClientCertificateSettingsScreen.kt index 78be1308..cf9982b8 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ClientCertificateSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ClientCertificateSettingsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import org.grakovne.lissen.R +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -75,22 +76,9 @@ fun ClientCertificateSettingsScreen(onBack: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_client_cert_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - ) - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.settings_screen_client_cert_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConfigBackupSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConfigBackupSettingsScreen.kt index 7d6a7e99..9e7c6a07 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConfigBackupSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConfigBackupSettingsScreen.kt @@ -40,6 +40,7 @@ import org.grakovne.lissen.R import org.grakovne.lissen.common.restartApplication import org.grakovne.lissen.common.shareFile import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -80,24 +81,9 @@ fun ConfigBackupSettingsScreen(onBack: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.config_backup_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.config_backup_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConnectionSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConnectionSettingsScreen.kt index 4f7eaf8f..9b11f7f4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConnectionSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/ConnectionSettingsScreen.kt @@ -46,6 +46,7 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.ui.screens.settings.composable.DisconnectServerComposable import org.grakovne.lissen.ui.screens.settings.composable.ServerInfoComposable import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -63,22 +64,9 @@ fun ConnectionSettingsScreen( Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.connection_settings_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.connection_settings_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt index 8206ac0d..4d1468cd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt @@ -38,6 +38,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch import org.grakovne.lissen.R import org.grakovne.lissen.domain.connection.ServerRequestHeader +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel import kotlin.math.max @@ -55,26 +56,9 @@ fun CustomHeadersSettingsScreen(onBack: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.custom_headers_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { - onBack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.custom_headers_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlSettingsScreen.kt index 65baa2cd..f8cf845d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlSettingsScreen.kt @@ -52,6 +52,7 @@ import org.grakovne.lissen.ui.screens.common.hasLocationPermission import org.grakovne.lissen.ui.screens.common.localNetworkPermission import org.grakovne.lissen.ui.screens.common.locationPermission import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel import kotlin.math.max @@ -76,26 +77,9 @@ fun LocalUrlSettingsScreen(onBack: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_internal_connection_url_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { - onBack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.settings_screen_internal_connection_url_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/PlaybackPreferencesScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/PlaybackPreferencesScreen.kt index f1ce7255..42f6eb0c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/PlaybackPreferencesScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/PlaybackPreferencesScreen.kt @@ -39,6 +39,7 @@ import org.grakovne.lissen.ui.screens.settings.composable.EqualizerSettingsCompo import org.grakovne.lissen.ui.screens.settings.composable.PlaybackVolumeBoostSettingsComposable import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -54,24 +55,9 @@ fun PlaybackPreferencesScreen( Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.playback_preferences_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.playback_preferences_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt index 057afb7c..92b2a097 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt @@ -50,6 +50,7 @@ import org.grakovne.lissen.R import org.grakovne.lissen.common.withHaptic import org.grakovne.lissen.ui.components.LissenModalBottomSheet import org.grakovne.lissen.ui.components.slider.SeekTimeSlider +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -63,22 +64,9 @@ fun SeekSettingsScreen(onBack: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_seek_time_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.settings_screen_seek_time_title), + onBack = onBack, ) }, modifier = @@ -114,7 +102,7 @@ fun SeekSettingsScreen(onBack: () -> Unit) { title = stringResource(R.string.rewind_interval), currentSeconds = preferredSeekTime.rewind, onDismissRequest = { rewindExpanded = false }, - onUpdate = { viewModel.preferRewindRewind(it) }, + onUpdate = { viewModel.preferRewind(it) }, ) } @@ -123,7 +111,7 @@ fun SeekSettingsScreen(onBack: () -> Unit) { title = stringResource(R.string.forward_interval), currentSeconds = preferredSeekTime.forward, onDismissRequest = { forwardExpanded = false }, - onUpdate = { viewModel.preferForwardRewind(it) }, + onUpdate = { viewModel.preferForward(it) }, ) } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CacheSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CacheSettingsScreen.kt index 67866a09..00278ab2 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CacheSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CacheSettingsScreen.kt @@ -33,6 +33,7 @@ import org.grakovne.lissen.R import org.grakovne.lissen.ui.navigation.AppNavigationService import org.grakovne.lissen.ui.screens.settings.advanced.AdvancedSettingsNavigationItemComposable import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @@ -47,24 +48,9 @@ fun CacheSettingsScreen( Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.download_settings_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.download_settings_title), + onBack = onBack, ) }, modifier = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt index 0bcc8e45..76b4f4bf 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt @@ -78,6 +78,7 @@ import org.grakovne.lissen.ui.components.BookCoverKey import org.grakovne.lissen.ui.components.ImageFetcher.Companion.LocalOnlyKey import org.grakovne.lissen.ui.components.withScrollbar import org.grakovne.lissen.ui.extensions.withMinimumTime +import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar import org.grakovne.lissen.viewmodel.CachingModelView import org.grakovne.lissen.viewmodel.PlayerViewModel @@ -127,24 +128,9 @@ fun CachedItemsSettingsScreen( Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_cached_items_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.semantics { heading() }, - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.a11y_back), - tint = colorScheme.onSurface, - ) - } - }, + SettingsTopAppBar( + title = stringResource(R.string.settings_screen_cached_items_title), + onBack = onBack, ) }, ) { innerPadding -> diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerInfoComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerInfoComposable.kt index 78332d1a..04d069e2 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerInfoComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerInfoComposable.kt @@ -80,8 +80,6 @@ fun ServerInfoComposable( IconButton( onClick = { connectionInfoExpanded = true - // navController.showLogin() - // viewModel.logout() }, ) { Icon( diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/SettingsTopAppBar.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/SettingsTopAppBar.kt new file mode 100644 index 00000000..53562531 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/SettingsTopAppBar.kt @@ -0,0 +1,45 @@ +package org.grakovne.lissen.ui.screens.settings.composable + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import org.grakovne.lissen.R + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun SettingsTopAppBar( + title: String, + onBack: () -> Unit, +) { + TopAppBar( + title = { + Text( + text = title, + style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.semantics { heading() }, + color = colorScheme.onSurface, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.a11y_back), + tint = colorScheme.onSurface, + ) + } + }, + ) +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt index a5aa7b43..5ca8a91b 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -167,6 +167,8 @@ class CachingModelView chapterId: String, ): Flow = contentCachingManager.hasMetadataCached(bookId, chapterId) + fun provideCachedChapterIds(bookId: String): Flow> = contentCachingManager.provideCachedChapterIds(bookId) + fun fetchCachedItems() { viewModelScope.launch { withContext(Dispatchers.IO) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt index 7e153ac0..ab6ee541 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -1,5 +1,9 @@ package org.grakovne.lissen.viewmodel +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager @@ -68,11 +72,9 @@ class LibraryViewModel private val _expandedGroups = MutableStateFlow>(emptySet()) val expandedGroups: StateFlow> = _expandedGroups.asStateFlow() - private val _groupBooks = MutableStateFlow>>(emptyMap()) - val groupBooks: StateFlow>> = _groupBooks.asStateFlow() + val groupBooks: SnapshotStateMap> = mutableStateMapOf() - private val _groupLoading = MutableStateFlow>(emptySet()) - val groupLoading: StateFlow> = _groupLoading.asStateFlow() + val groupLoading: SnapshotStateList = mutableStateListOf() private val prefetchSemaphore = Semaphore(MAX_CONCURRENT_PREFETCH) @@ -171,8 +173,8 @@ class LibraryViewModel fun resetGroupExpansion() { _expandedGroups.value = emptySet() - _groupBooks.value = emptyMap() - _groupLoading.value = emptySet() + groupBooks.clear() + groupLoading.clear() } private fun LibraryEntry.groupId(): String? = @@ -182,7 +184,7 @@ class LibraryViewModel is LibraryEntry.BookEntry -> null } - private fun alreadyResolved(groupId: String): Boolean = _groupBooks.value.containsKey(groupId) || groupId in _groupLoading.value + private fun alreadyResolved(groupId: String): Boolean = groupBooks.containsKey(groupId) || groupId in groupLoading private suspend fun fetchGroupBooks(entry: LibraryEntry) { val groupId = entry.groupId() ?: return @@ -192,7 +194,7 @@ class LibraryViewModel val libraryId = preferences.getPreferredLibrary()?.id ?: return - _groupLoading.value = _groupLoading.value + groupId + groupLoading.add(groupId) val result = when (entry) { is LibraryEntry.SeriesEntry -> mediaChannel.fetchSeriesItems(libraryId = libraryId, seriesId = entry.id) @@ -207,11 +209,11 @@ class LibraryViewModel is LibraryEntry.SeriesEntry -> books.sortedBySeriesPosition() else -> books } - _groupBooks.value = _groupBooks.value + (groupId to ordered) + groupBooks[groupId] = ordered }, onFailure = { }, ) - _groupLoading.value = _groupLoading.value - groupId + groupLoading.remove(groupId) } fun applyLinkedSearch(token: String) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LoginViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LoginViewModel.kt index 57ac9f49..2b9dee60 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LoginViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LoginViewModel.kt @@ -94,7 +94,7 @@ class LoginViewModel mediaChannel.startOAuth( host = host, onSuccess = { _loginState.value = LoginState.Idle }, - onFailure = { onLoginFailure(it) }, + onFailure = { _loginState.value = onLoginFailure(it) }, ) } } @@ -133,12 +133,7 @@ class LoginViewModel } } - private fun onLoginFailure(error: OperationError): LoginState.Error { - viewModelScope.launch { - _loginState.value = LoginState.Error(error) - } - return LoginState.Error(error) - } + private fun onLoginFailure(error: OperationError): LoginState.Error = LoginState.Error(error) sealed class LoginState { data object Idle : LoginState() diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt index e4d69f0b..4ccec678 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -352,8 +352,8 @@ class SettingsViewModel download.saveAutoDownloadOption(option) } - fun preferForwardRewind(seconds: Int) { - Timber.d("User action: preferForwardSkip $seconds") + fun preferForward(seconds: Int) { + Timber.d("User action: preferForward $seconds") val current = _seekTime.value val updated = current.copy(forward = seconds) @@ -361,8 +361,8 @@ class SettingsViewModel _seekTime.value = updated } - fun preferRewindRewind(seconds: Int) { - Timber.d("User action: preferRewindSkip $seconds") + fun preferRewind(seconds: Int) { + Timber.d("User action: preferRewind $seconds") val current = _seekTime.value val updated = current.copy(rewind = seconds) diff --git a/app/src/main/kotlin/org/grakovne/lissen/widget/WidgetPlaybackAction.kt b/app/src/main/kotlin/org/grakovne/lissen/widget/WidgetPlaybackAction.kt new file mode 100644 index 00000000..f9f3b38d --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/widget/WidgetPlaybackAction.kt @@ -0,0 +1,27 @@ +package org.grakovne.lissen.widget + +import android.content.Context +import dagger.hilt.android.EntryPointAccessors +import timber.log.Timber + +suspend fun safelyRun( + playingItemId: String, + context: Context, + action: (WidgetPlaybackController) -> Unit, +) { + try { + val playbackController = + EntryPointAccessors + .fromApplication( + context = context.applicationContext, + entryPoint = WidgetPlaybackControllerEntryPoint::class.java, + ).widgetPlaybackController() + + when (playbackController.providePlayingItem()) { + null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) } + else -> action(playbackController) + } + } catch (ex: Exception) { + Timber.w("Unable to run $action on $playingItemId due to $ex") + } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/widget/cover/PlayerCoverWidget.kt b/app/src/main/kotlin/org/grakovne/lissen/widget/cover/PlayerCoverWidget.kt index 1f3687b7..441c5d6c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/widget/cover/PlayerCoverWidget.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/widget/cover/PlayerCoverWidget.kt @@ -35,13 +35,12 @@ import androidx.glance.semantics.testTag import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.media3.session.R.drawable.media3_icon_pause import androidx.media3.session.R.drawable.media3_icon_play -import dagger.hilt.android.EntryPointAccessors import org.grakovne.lissen.R.drawable import org.grakovne.lissen.ui.theme.WidgetBackgroundDark import org.grakovne.lissen.ui.theme.WidgetBackgroundLight -import org.grakovne.lissen.widget.WidgetPlaybackControllerEntryPoint import org.grakovne.lissen.widget.bitmapFromFile import org.grakovne.lissen.widget.bitmapFromResource +import org.grakovne.lissen.widget.safelyRun import org.grakovne.lissen.widget.state.PlayerStateWidget import timber.log.Timber import java.io.File @@ -167,21 +166,7 @@ class PlayerCoverTogglePlaybackAction : ActionCallback { ) { val playingItemId = parameters[PlayerCoverWidget.bookIdActionKey] ?: return - try { - val playbackController = - EntryPointAccessors - .fromApplication( - context = context.applicationContext, - entryPoint = WidgetPlaybackControllerEntryPoint::class.java, - ).widgetPlaybackController() - - when (playbackController.providePlayingItem()) { - null -> playbackController.prepareAndRun(playingItemId) { playbackController.togglePlayPause() } - else -> playbackController.togglePlayPause() - } - } catch (ex: Exception) { - Timber.w(ex, "Unable to toggle playback from PlayerCoverWidget for %s", playingItemId) - } + safelyRun(playingItemId = playingItemId, context = context) { it.togglePlayPause() } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/widget/state/PlayerStateWidget.kt b/app/src/main/kotlin/org/grakovne/lissen/widget/state/PlayerStateWidget.kt index 0ecbbeff..343fa5cb 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/widget/state/PlayerStateWidget.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/widget/state/PlayerStateWidget.kt @@ -42,13 +42,12 @@ import androidx.glance.text.FontFamily.Companion.SansSerif import androidx.glance.text.Text import androidx.glance.text.TextStyle import androidx.media3.session.R -import dagger.hilt.android.EntryPointAccessors import org.grakovne.lissen.R.drawable import org.grakovne.lissen.ui.theme.LightBackground import org.grakovne.lissen.ui.theme.MediumBackground -import org.grakovne.lissen.widget.WidgetPlaybackControllerEntryPoint import org.grakovne.lissen.widget.bitmapFromFile import org.grakovne.lissen.widget.bitmapFromResource +import org.grakovne.lissen.widget.safelyRun import org.grakovne.lissen.widget.state.PlayerStateWidget.Companion.bookIdKey import timber.log.Timber import java.io.File @@ -332,25 +331,3 @@ private fun provideAppLaunchIntent(context: Context): Intent? = .packageManager .getLaunchIntentForPackage(context.packageName) ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP } - -private suspend fun safelyRun( - playingItemId: String, - context: Context, - action: (org.grakovne.lissen.widget.WidgetPlaybackController) -> Unit, -) { - try { - val playbackController = - EntryPointAccessors - .fromApplication( - context = context.applicationContext, - entryPoint = WidgetPlaybackControllerEntryPoint::class.java, - ).widgetPlaybackController() - - when (playbackController.providePlayingItem()) { - null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) } - else -> action(playbackController) - } - } catch (ex: Exception) { - Timber.w("Unable to run $action on $playingItemId due to $ex") - } -} diff --git a/app/src/test/kotlin/org/grakovne/lissen/content/LissenMediaProviderTest.kt b/app/src/test/kotlin/org/grakovne/lissen/content/LissenMediaProviderTest.kt index 5031a22d..d96188d8 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/content/LissenMediaProviderTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/content/LissenMediaProviderTest.kt @@ -200,12 +200,12 @@ class LissenMediaProviderTest { runBlocking { every { preferences.isForceCache() } returns true coEvery { - localCacheRepository.searchBooks(libraryId = "l1", query = "test") + localCacheRepository.searchBooks(libraryId = "l1", query = "test", limit = 10) } returns OperationResult.Success(emptyList()) provider.searchBooks("l1", "test", 10) - coVerify { localCacheRepository.searchBooks("l1", "test") } + coVerify { localCacheRepository.searchBooks("l1", "test", 10) } coVerify(exactly = 0) { mediaChannel.searchBooks(any(), any(), any()) } } diff --git a/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistryTest.kt b/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistryTest.kt index 4e3907a6..d9e8c78b 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistryTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/CachingSessionRegistryTest.kt @@ -78,13 +78,28 @@ class CachingSessionRegistryTest { @Test fun `settling a registration without status leaves nothing in progress`() { val registry = CachingSessionRegistry() + val job = Job() - registry.register("book-1", Job()) - registry.settle("book-1") + registry.register("book-1", job) + registry.settle("book-1", job) assertFalse(registry.inProgress()) } + @Test + fun `settling a stale job does not clear the newer registration`() { + val registry = CachingSessionRegistry() + val first = Job() + val second = Job() + + registry.register("book-1", first) + registry.register("book-1", second) + + registry.settle("book-1", first) + + assertTrue(registry.inProgress()) + } + @Test fun `first status update replaces the pending registration`() { val registry = CachingSessionRegistry() diff --git a/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepositoryTest.kt b/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepositoryTest.kt index 41ae9609..e10d9176 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepositoryTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepositoryTest.kt @@ -1,8 +1,11 @@ package org.grakovne.lissen.content.cache.persistent.api +import androidx.sqlite.db.SupportSQLiteQuery import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.runBlocking import org.grakovne.lissen.common.LibraryOrderingConfiguration import org.grakovne.lissen.content.cache.persistent.OfflineBookStorageProperties @@ -11,9 +14,11 @@ import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityDe import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityRecentConverter import org.grakovne.lissen.content.cache.persistent.converter.MediaProgressEntityConverter import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao +import org.grakovne.lissen.content.cache.persistent.entity.AuthorEntry import org.grakovne.lissen.content.cache.persistent.entity.BookEntity import org.grakovne.lissen.content.cache.persistent.entity.GroupedEntry import org.grakovne.lissen.domain.LibraryEntry +import org.grakovne.lissen.domain.LibraryType import org.grakovne.lissen.persistence.preferences.LibraryPreferences import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -85,23 +90,18 @@ class CachedBookRepositoryTest { val byId = books.associateBy { it.id } val bySeries = books.filter { it.seriesId != null }.groupBy { it.seriesId } - coEvery { bookDao.countGroupedEntries(any()) } returns headers.size + coEvery { bookDao.countRaw(any()) } returns headers.size coEvery { bookDao.fetchGroupedEntries(any()) } returns headers coEvery { bookDao.fetchBooksByIds(any()) } answers { firstArg>().mapNotNull { byId[it] } } coEvery { bookDao.fetchBooksBySeriesIds(any()) } answers { firstArg>().flatMap { bySeries[it].orEmpty() } } } - private fun stubBooks(books: List) { - coEvery { bookDao.countCachedBooks(libraryId = any()) } returns books.size - coEvery { bookDao.fetchCachedBooks(any()) } returns books - } - @Test fun `empty library produces no entries`() = runBlocking { - coEvery { bookDao.countGroupedEntries(any()) } returns 0 + coEvery { bookDao.countRaw(any()) } returns 0 - assertTrue(repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.isEmpty()) + assertTrue(repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items.isEmpty()) } @Test @@ -123,7 +123,7 @@ class CachedBookRepositoryTest { ), ) - val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items + val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items assertEquals(2, entries.size) @@ -146,7 +146,15 @@ class CachedBookRepositoryTest { books = listOf(entity("b1", seriesId = "ser-x", seriesJson = null)), ) - val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.single() as LibraryEntry.SeriesEntry + val series = + repository + .fetchLibraryGrouped( + LIBRARY_ID, + pageSize = 20, + pageNumber = 0, + libraryType = null, + ).items + .single() as LibraryEntry.SeriesEntry assertEquals("ser-x", series.title) } @@ -163,7 +171,7 @@ class CachedBookRepositoryTest { books = listOf(entity("a"), entity("b"), entity("c")), ) - val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items + val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items assertEquals( listOf("a", "b", "c"), entries.map { (it as LibraryEntry.BookEntry).book.id }, @@ -178,7 +186,7 @@ class CachedBookRepositoryTest { books = (1..5).map { entity("b$it", seriesId = "ser", seriesJson = seriesJson("Series", "$it", "ser")) }, ) - val page = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0) + val page = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null) assertEquals(1, page.totalItems) assertEquals(1, page.items.size) } @@ -186,13 +194,13 @@ class CachedBookRepositoryTest { @Test fun `fetchSeriesItems returns only books of the requested series`() = runBlocking { - coEvery { bookDao.fetchBooksBySeriesIds(listOf("ser-dune")) } returns + coEvery { bookDao.fetchCachedBooks(any()) } returns listOf( entity("b1", seriesId = "ser-dune", seriesJson = seriesJson("Dune", "1", "ser-dune")), entity("b2", seriesId = "ser-dune", seriesJson = seriesJson("Dune", "2", "ser-dune")), ) - val books = repository.fetchSeriesItems(LIBRARY_ID, "ser-dune") + val books = repository.fetchSeriesItems(LIBRARY_ID, "ser-dune", libraryType = null) assertEquals(listOf("b1", "b2"), books.map { it.id }) } @@ -204,49 +212,92 @@ class CachedBookRepositoryTest { books = (1..5).map { entity("b$it", seriesId = "ser", seriesJson = seriesJson("Series", "$it", "ser")) }, ) - val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.single() + val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items.single() assertInstanceOf(LibraryEntry.SeriesEntry::class.java, series) assertEquals(listOf("b1", "b2", "b3", "b4", "b5"), (series as LibraryEntry.SeriesEntry).coverItemIds) assertEquals(5, series.bookCount) } @Test - fun `groups cached books by primary author sorted by name and skips authorless books`() = + fun `maps author rows into paged author entries`() = runBlocking { - stubBooks( + coEvery { bookDao.countRaw(any()) } returns 2 + coEvery { bookDao.fetchAuthorEntries(any()) } returns listOf( - entity("b1", author = "Frank Herbert"), - entity("b2", author = "Frank Herbert, Brian Herbert"), - entity("b3", author = "Andy Weir"), - entity("b4", author = null), - ), - ) + AuthorEntry(author = "Andy Weir", bookCount = 1), + AuthorEntry(author = "Frank Herbert", bookCount = 2), + ) - val entries = repository.fetchAuthorsGrouped(LIBRARY_ID) - assertEquals(2, entries.size) + val page = repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null) + assertEquals(2, page.totalItems) + assertEquals(2, page.items.size) - val first = entries[0] as LibraryEntry.AuthorEntry + val first = page.items[0] as LibraryEntry.AuthorEntry assertEquals("Andy Weir", first.id) assertEquals(1, first.bookCount) - val second = entries[1] as LibraryEntry.AuthorEntry + val second = page.items[1] as LibraryEntry.AuthorEntry assertEquals("Frank Herbert", second.id) assertEquals(2, second.bookCount) } @Test - fun `fetchAuthorItems returns only the requested author's books`() = + fun `author grouping hides completed books only for library type`() = runBlocking { - stubBooks( + every { preferences.getHideCompleted() } returns true + val queries = mutableListOf() + coEvery { bookDao.countRaw(capture(queries)) } returns 0 + + repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = LibraryType.LIBRARY) + repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = LibraryType.PODCAST) + + assertTrue(queries[0].sql.contains("isFinished")) + assertFalse(queries[1].sql.contains("isFinished")) + } + + @Test + fun `empty author grouping skips the page query`() = + runBlocking { + coEvery { bookDao.countRaw(any()) } returns 0 + + val page = repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null) + assertEquals(0, page.totalItems) + assertTrue(page.items.isEmpty()) + coVerify(exactly = 0) { bookDao.fetchAuthorEntries(any()) } + } + + @Test + fun `fetchAuthorItems filters by author key in sql`() = + runBlocking { + val query = slot() + coEvery { bookDao.fetchCachedBooks(capture(query)) } returns listOf( entity("b1", author = "Frank Herbert"), - entity("b2", author = "Andy Weir"), entity("b3", author = "Frank Herbert, Brian Herbert"), - ), - ) + ) - val books = repository.fetchAuthorItems(LIBRARY_ID, "Frank Herbert") + val books = repository.fetchAuthorItems(LIBRARY_ID, "Frank Herbert", libraryType = null) assertEquals(listOf("b1", "b3"), books.map { it.id }) + assertTrue(query.captured.sql.contains("WHERE libraryId = ?")) + assertTrue(query.captured.sql.contains("instr(author, ',')")) + } + + @Test + fun `series and author drill-down hide completed only for library type`() = + runBlocking { + every { preferences.getHideCompleted() } returns true + val queries = mutableListOf() + coEvery { bookDao.fetchCachedBooks(capture(queries)) } returns emptyList() + + repository.fetchSeriesItems(LIBRARY_ID, "ser", libraryType = LibraryType.LIBRARY) + repository.fetchSeriesItems(LIBRARY_ID, "ser", libraryType = LibraryType.PODCAST) + repository.fetchAuthorItems(LIBRARY_ID, "author", libraryType = LibraryType.LIBRARY) + repository.fetchAuthorItems(LIBRARY_ID, "author", libraryType = LibraryType.PODCAST) + + assertTrue(queries[0].sql.contains("isFinished")) + assertFalse(queries[1].sql.contains("isFinished")) + assertTrue(queries[2].sql.contains("isFinished")) + assertFalse(queries[3].sql.contains("isFinished")) } @Test @@ -372,9 +423,9 @@ class CachedBookRepositoryTest { @Test fun `countBooks delegates to the dao`() = runBlocking { - coEvery { bookDao.countCachedBooks(libraryId = LIBRARY_ID) } returns 3 + coEvery { bookDao.countRaw(any()) } returns 3 - assertEquals(3, repository.countBooks(LIBRARY_ID)) + assertEquals(3, repository.countBooks(LIBRARY_ID, libraryType = null)) } @Test @@ -383,7 +434,7 @@ class CachedBookRepositoryTest { every { preferences.getHideCompleted() } returns true coEvery { bookDao.fetchCachedBooks(any()) } returns listOf(entity("b1"), entity("b2")) - val books = repository.fetchBooks(LIBRARY_ID, pageNumber = 0, pageSize = 20) + val books = repository.fetchBooks(LIBRARY_ID, pageNumber = 0, pageSize = 20, libraryType = null) assertEquals(listOf("b1", "b2"), books.map { it.id }) } @@ -393,7 +444,7 @@ class CachedBookRepositoryTest { runBlocking { coEvery { bookDao.searchBooks(any()) } returns listOf(entity("b1")) - val books = repository.searchBooks(LIBRARY_ID, "query") + val books = repository.searchBooks(LIBRARY_ID, "query", limit = 20) assertEquals(listOf("b1"), books.map { it.id }) } @@ -404,14 +455,15 @@ class CachedBookRepositoryTest { val book1 = entity("b1") val book2 = entity("b2") coEvery { bookDao.fetchRecentlyListenedCachedBooks(libraryId = LIBRARY_ID) } returns listOf(book1, book2) - coEvery { bookDao.fetchMediaProgress("b1") } returns - org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity( - bookId = "b1", - currentTime = 10.0, - isFinished = false, - lastUpdate = 100L, + coEvery { bookDao.fetchMediaProgress(listOf("b1", "b2")) } returns + listOf( + org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity( + bookId = "b1", + currentTime = 10.0, + isFinished = false, + lastUpdate = 100L, + ), ) - coEvery { bookDao.fetchMediaProgress("b2") } returns null val recent1 = mockk() val recent2 = mockk() every { cachedBookEntityRecentConverter.apply(book1, 100L to 10.0) } returns recent1 diff --git a/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSqlTest.kt b/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSqlTest.kt new file mode 100644 index 00000000..a0b0f76d --- /dev/null +++ b/app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSqlTest.kt @@ -0,0 +1,27 @@ +package org.grakovne.lissen.content.cache.persistent.api + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class OrderingSqlTest { + @Test + fun `maps every library ordering option to its own column`() { + assertEquals("title", resolveOrderField("title")) + assertEquals("author", resolveOrderField("author")) + assertEquals("createdAt", resolveOrderField("createdAt")) + assertEquals("updatedAt", resolveOrderField("updatedAt")) + } + + @Test + fun `falls back to title for unknown fields`() { + assertEquals("title", resolveOrderField("duration")) + assertEquals("title", resolveOrderField("nonsense")) + } + + @Test + fun `normalizes direction and rejects garbage`() { + assertEquals("ASC", resolveOrderDirection("asc")) + assertEquals("DESC", resolveOrderDirection("desc")) + assertEquals("ASC", resolveOrderDirection("sideways")) + } +} diff --git a/app/src/test/kotlin/org/grakovne/lissen/domain/DownloadOptionTest.kt b/app/src/test/kotlin/org/grakovne/lissen/domain/DownloadOptionTest.kt index 75a484ac..87fd5132 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/domain/DownloadOptionTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/domain/DownloadOptionTest.kt @@ -48,6 +48,12 @@ class DownloadOptionTest { @Test fun `unknown string produces null`() = assertNull("something_else".makeDownloadOption()) + @Test + fun `malformed number_items suffix produces null instead of crashing`() { + assertNull("number_items_".makeDownloadOption()) + assertNull("number_items_abc".makeDownloadOption()) + } + @Test fun `singleton options roundtrip through makeId and makeDownloadOption`() { listOf(AllItemsDownloadOption, CurrentItemDownloadOption, RemainingItemsDownloadOption).forEach { option -> diff --git a/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactoryTest.kt b/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactoryTest.kt index b4aefa98..2307948a 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactoryTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenDataSourceFactoryTest.kt @@ -114,21 +114,21 @@ class LissenDataSourceFactoryTest { @Test fun `unapply returns null for non-lissen URI`() { val uri = stubUri(scheme = "https", segments = listOf("api", "items")) - val result = unapply(uri) + val result = parseLissenUri(uri) assertEquals(null, result) } @Test fun `unapply returns null for file URI`() { val uri = stubUri(scheme = "file", segments = listOf("data", "media_cache", "book1", "file1")) - val result = unapply(uri) + val result = parseLissenUri(uri) assertEquals(null, result) } @Test fun `unapply extracts bookId and fileId from valid lissen URI`() { val uri = stubUri(scheme = "lissen", segments = listOf("book-abc", "file-xyz")) - val result = unapply(uri) + val result = parseLissenUri(uri) assertEquals("book-abc" to "file-xyz", result) } diff --git a/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverterTest.kt b/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverterTest.kt index 61071622..18dc434c 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverterTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverterTest.kt @@ -15,55 +15,55 @@ class LissenMediaSchemeConverterTest { @Test fun `returns null for non-lissen scheme`() { val uri = stubUri(scheme = "https", segments = listOf("api", "items")) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `returns null for null scheme`() { val uri = stubUri(scheme = null, segments = listOf("book1", "file1")) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `returns null for lissen URI with single segment`() { val uri = stubUri(scheme = "lissen", segments = listOf("book1")) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `returns null for lissen URI with three segments`() { val uri = stubUri(scheme = "lissen", segments = listOf("book1", "file1", "extra")) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `returns null for lissen URI with empty segments`() { val uri = stubUri(scheme = "lissen", segments = listOf("", "")) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `returns null for lissen URI with empty first segment`() { val uri = stubUri(scheme = "lissen", segments = listOf("", "file1")) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `returns null for lissen URI with empty second segment`() { val uri = stubUri(scheme = "lissen", segments = listOf("book1", "")) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `returns null for empty segment list`() { val uri = stubUri(scheme = "lissen", segments = emptyList()) - assertNull(unapply(uri)) + assertNull(parseLissenUri(uri)) } @Test fun `parses valid lissen URI`() { val uri = stubUri(scheme = "lissen", segments = listOf("book-123", "file-456")) - val result = unapply(uri) + val result = parseLissenUri(uri) assertNotNull(result) assertEquals("book-123" to "file-456", result) } @@ -71,7 +71,7 @@ class LissenMediaSchemeConverterTest { @Test fun `parses lissen URI with underscore ids`() { val uri = stubUri(scheme = "lissen", segments = listOf("li_abc123", "ino_xyz789")) - val result = unapply(uri) + val result = parseLissenUri(uri) assertNotNull(result) assertEquals("li_abc123", result!!.first) assertEquals("ino_xyz789", result.second) @@ -80,7 +80,7 @@ class LissenMediaSchemeConverterTest { @Test fun `parses lissen URI with numeric ids`() { val uri = stubUri(scheme = "lissen", segments = listOf("123456", "789012")) - val result = unapply(uri) + val result = parseLissenUri(uri) assertNotNull(result) assertEquals("123456", result!!.first) assertEquals("789012", result.second) diff --git a/app/src/test/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModelTest.kt b/app/src/test/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModelTest.kt index 03b704c5..26632715 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModelTest.kt @@ -208,7 +208,7 @@ class LibraryViewModelTest { viewModel.toggleGroup(series()) - assertEquals(listOf("1", "2", "22"), viewModel.groupBooks.value["ser-1"]?.map { it.id }) + assertEquals(listOf("1", "2", "22"), viewModel.groupBooks["ser-1"]?.map { it.id }) } @Test @@ -220,7 +220,7 @@ class LibraryViewModelTest { viewModel.toggleGroup(author()) assertTrue("aut-1" in viewModel.expandedGroups.value) - assertEquals(listOf("b1"), viewModel.groupBooks.value["aut-1"]?.map { it.id }) + assertEquals(listOf("b1"), viewModel.groupBooks["aut-1"]?.map { it.id }) coVerify(exactly = 1) { mediaChannel.fetchAuthorBooks("lib-1", "aut-1") } } @@ -233,8 +233,8 @@ class LibraryViewModelTest { viewModel.toggleGroup(series()) assertTrue("ser-1" in viewModel.expandedGroups.value) - assertEquals(listOf("b1", "b2"), viewModel.groupBooks.value["ser-1"]?.map { it.id }) - assertTrue(viewModel.groupLoading.value.isEmpty()) + assertEquals(listOf("b1", "b2"), viewModel.groupBooks["ser-1"]?.map { it.id }) + assertTrue(viewModel.groupLoading.isEmpty()) } @Test @@ -280,7 +280,7 @@ class LibraryViewModelTest { viewModel.prefetchGroup(series()) - assertEquals(listOf("b1", "b2"), viewModel.groupBooks.value["ser-1"]?.map { it.id }) + assertEquals(listOf("b1", "b2"), viewModel.groupBooks["ser-1"]?.map { it.id }) assertFalse("ser-1" in viewModel.expandedGroups.value) } @@ -316,8 +316,8 @@ class LibraryViewModelTest { viewModel.resetGroupExpansion() assertTrue(viewModel.expandedGroups.value.isEmpty()) - assertTrue(viewModel.groupBooks.value.isEmpty()) - assertTrue(viewModel.groupLoading.value.isEmpty()) + assertTrue(viewModel.groupBooks.isEmpty()) + assertTrue(viewModel.groupLoading.isEmpty()) } } diff --git a/app/src/test/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModelTest.kt b/app/src/test/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModelTest.kt index f1a33847..f45a1c58 100644 --- a/app/src/test/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModelTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModelTest.kt @@ -307,26 +307,26 @@ class SettingsViewModelTest { @Nested inner class SeekTimePreference { @Test - fun `preferForwardRewind updates seek forward`() { - viewModel.preferForwardRewind(60) + fun `preferForward updates seek forward`() { + viewModel.preferForward(60) assertEquals(60, viewModel.seekTime.value.forward) } @Test - fun `preferRewindRewind updates seek rewind`() { - viewModel.preferRewindRewind(30) + fun `preferRewind updates seek rewind`() { + viewModel.preferRewind(30) assertEquals(30, viewModel.seekTime.value.rewind) } @Test - fun `preferForwardRewind preserves rewind value`() { - viewModel.preferForwardRewind(60) + fun `preferForward preserves rewind value`() { + viewModel.preferForward(60) assertEquals(SeekTime.Default.rewind, viewModel.seekTime.value.rewind) } @Test - fun `preferRewindRewind preserves forward value`() { - viewModel.preferRewindRewind(10) + fun `preferRewind preserves forward value`() { + viewModel.preferRewind(10) assertEquals(SeekTime.Default.forward, viewModel.seekTime.value.forward) } }