From 3dae5919dfe22740e31c01a60ccd6c6c8bccd944 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Tue, 3 Feb 2026 22:33:58 +0530 Subject: [PATCH 01/37] feat: Implement database migration to version 16 with a dedicated UI, enhance cache isolation by host and user, and update cache clearing functionality. --- .../16.json | 387 ++++++++++++++++++ .../grakovne/lissen/content/BookRepository.kt | 4 + .../cache/persistent/LocalCacheModule.kt | 9 +- .../cache/persistent/LocalCacheRepository.kt | 2 + .../cache/persistent/LocalCacheStorage.kt | 2 +- .../content/cache/persistent/Migrations.kt | 16 + .../persistent/api/CachedBookRepository.kt | 30 +- .../persistent/api/CachedLibraryRepository.kt | 19 +- .../persistent/api/FetchRequestBuilder.kt | 29 +- .../persistent/api/RecentRequestBuilder.kt | 26 +- .../persistent/api/SearchRequestBuilder.kt | 38 +- .../cache/persistent/dao/CachedBookDao.kt | 30 +- .../cache/persistent/dao/CachedLibraryDao.kt | 25 +- .../persistent/entity/CachedBookEntity.kt | 4 + .../persistent/entity/CachedLibraryEntity.kt | 2 + .../preferences/LissenSharedPreferences.kt | 5 + .../lissen/ui/navigation/AppNavHost.kt | 19 + .../grakovne/lissen/ui/navigation/Route.kt | 1 + .../ui/screens/migration/MigrationScreen.kt | 68 +++ .../advanced/AdvancedSettingsComposable.kt | 14 - .../advanced/cache/CacheSettingsScreen.kt | 15 + .../lissen/viewmodel/MigrationViewModel.kt | 59 +++ .../lissen/viewmodel/SettingsViewModel.kt | 10 + app/src/main/res/values/strings.xml | 11 +- 24 files changed, 759 insertions(+), 66 deletions(-) create mode 100644 app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/16.json create mode 100644 app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt diff --git a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/16.json b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/16.json new file mode 100644 index 000000000..197a9b970 --- /dev/null +++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/16.json @@ -0,0 +1,387 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "2ef84fe8d6dd00a7e5c5216a584fc3d4", + "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, `libraryType` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `host` TEXT NOT NULL, `username` TEXT 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": "libraryType", + "columnName": "libraryType", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesJson", + "columnName": "seriesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesNames", + "columnName": "seriesNames", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "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, `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": "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, `host` TEXT NOT NULL, `username` TEXT NOT NULL, PRIMARY KEY(`bookId`), FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "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`)" + } + ], + "foreignKeys": [ + { + "table": "detailed_books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "libraries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `host` TEXT NOT NULL, `username` 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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, '2ef84fe8d6dd00a7e5c5216a584fc3d4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt index 89146ce1a..945630167 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt @@ -127,6 +127,10 @@ class BookRepository ) } + suspend fun fetchLatestUpdate(libraryId: String) = localCacheRepository.fetchLatestUpdate(libraryId) + + suspend fun clearMetadataCache() = localCacheRepository.clearMetadataCache() + suspend fun fetchBooks( libraryId: String, pageSize: Int, 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 53d6041aa..86a6d6e28 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 @@ -9,6 +9,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao import org.grakovne.lissen.content.cache.persistent.dao.CachedLibraryDao +import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import javax.inject.Singleton @Module @@ -20,6 +21,7 @@ object LocalCacheModule { @Singleton fun provideAppDatabase( @ApplicationContext context: Context, + preferences: LissenSharedPreferences, ): LocalCacheStorage { val database = Room.databaseBuilder( @@ -43,7 +45,12 @@ object LocalCacheModule { .addMigrations(MIGRATION_12_13) .addMigrations(MIGRATION_13_14) .addMigrations(MIGRATION_14_15) - .build() + .addMigrations( + produceMigration15_16( + host = preferences.getHost() ?: "", + username = preferences.getUsername() ?: "", + ), + ).build() } @Provides 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 9bcf67941..ea074672e 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 @@ -194,4 +194,6 @@ class LocalCacheRepository } fun fetchBookFlow(bookId: String): Flow = cachedBookRepository.fetchBookFlow(bookId) + + suspend fun clearMetadataCache() = cachedBookRepository.deleteNonDownloadedBooks() } 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 fa70596cf..11e7249b4 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 @@ -18,7 +18,7 @@ import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity MediaProgressEntity::class, CachedLibraryEntity::class, ], - version = 15, + version = 16, 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 8e9b490c2..7b7980cc5 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 @@ -263,3 +263,19 @@ val MIGRATION_14_15 = db.execSQL("ALTER TABLE detailed_books ADD COLUMN libraryType TEXT") } } + +fun produceMigration15_16( + host: String, + username: String, +) = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE detailed_books ADD COLUMN host TEXT NOT NULL DEFAULT '$host'") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN username TEXT NOT NULL DEFAULT '$username'") + + db.execSQL("ALTER TABLE media_progress ADD COLUMN host TEXT NOT NULL DEFAULT '$host'") + db.execSQL("ALTER TABLE media_progress ADD COLUMN username TEXT NOT NULL DEFAULT '$username'") + + db.execSQL("ALTER TABLE libraries ADD COLUMN host TEXT NOT NULL DEFAULT '$host'") + db.execSQL("ALTER TABLE libraries ADD COLUMN username TEXT NOT NULL DEFAULT '$username'") + } +} 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 8f9be9b34..bdf86e9e4 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 @@ -58,12 +58,22 @@ class CachedBookRepository ?.let { bookDao.deleteBook(it) } } + suspend fun deleteNonDownloadedBooks() { + val host = preferences.getHost() ?: "" + val username = preferences.getUsername() ?: "" + + bookDao.deleteNonDownloadedBooks(host, username) + } + suspend fun cacheBook( book: DetailedItem, fetchedChapters: List, droppedChapters: List, ) { - bookDao.upsertCachedBook(book, fetchedChapters, droppedChapters) + val host = preferences.getHost() ?: "" + val username = preferences.getUsername() ?: "" + + bookDao.upsertCachedBook(book, host, username, fetchedChapters, droppedChapters) } fun provideCacheState(bookId: String) = bookDao.isBookCached(bookId) @@ -103,6 +113,8 @@ class CachedBookRepository libraryType = null, createdAt = 0, updatedAt = 0, + host = preferences.getHost() ?: "", + username = preferences.getUsername() ?: "", seriesNames = book.series, seriesJson = "[]", ) @@ -145,6 +157,8 @@ class CachedBookRepository .orderField(option) .orderDirection(direction) .downloadedOnly(downloadedOnly) + .host(preferences.getHost() ?: "") + .username(preferences.getUsername() ?: "") .build() return bookDao @@ -152,7 +166,11 @@ class CachedBookRepository .map { cachedBookEntityConverter.apply(it) } } - suspend fun countBooks(libraryId: String): Int = bookDao.countCachedBooks(libraryId = libraryId) + suspend fun countBooks(libraryId: String): Int { + val host = preferences.getHost() ?: "" + val username = preferences.getUsername() ?: "" + return bookDao.countCachedBooks(libraryId = libraryId, host = host, username = username) + } suspend fun searchBooks( libraryId: String, @@ -166,6 +184,8 @@ class CachedBookRepository .libraryId(libraryId) .orderField(option) .orderDirection(direction) + .host(preferences.getHost() ?: "") + .username(preferences.getUsername() ?: "") .build() return bookDao @@ -181,6 +201,8 @@ class CachedBookRepository RecentRequestBuilder() .libraryId(libraryId) .downloadedOnly(downloadedOnly) + .host(preferences.getHost() ?: "") + .username(preferences.getUsername() ?: "") .build() val recentBooks = bookDao.fetchRecentlyListenedCachedBooks(request) @@ -203,6 +225,8 @@ class CachedBookRepository RecentRequestBuilder() .libraryId(libraryId) .downloadedOnly(downloadedOnly) + .host(preferences.getHost() ?: "") + .username(preferences.getUsername() ?: "") .build() return bookDao @@ -237,6 +261,8 @@ class CachedBookRepository currentTime = progress.currentTotalTime, isFinished = progress.currentTotalTime == book.chapters.sumOf { it.duration }, lastUpdate = Instant.now().toEpochMilli(), + host = preferences.getHost() ?: "", + username = preferences.getUsername() ?: "", ) bookDao.upsertMediaProgress(entity) 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 a76d7ee3d..f6391eca5 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.lib.domain.Library +import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import javax.inject.Inject import javax.inject.Singleton @@ -12,11 +13,21 @@ class CachedLibraryRepository constructor( private val dao: CachedLibraryDao, private val converter: CachedLibraryEntityConverter, + private val preferences: LissenSharedPreferences, ) { - suspend fun cacheLibraries(libraries: List) = dao.updateLibraries(libraries) + suspend fun cacheLibraries(libraries: List) { + val host = preferences.getHost() ?: "" + val username = preferences.getUsername() ?: "" - suspend fun fetchLibraries() = - dao - .fetchLibraries() + dao.updateLibraries(libraries, host, username) + } + + suspend fun fetchLibraries(): List { + val host = preferences.getHost() ?: "" + val username = preferences.getUsername() ?: "" + + return dao + .fetchLibraries(host, username) .map { converter.apply(it) } + } } 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 8786a396b..d10cacc49 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 @@ -10,6 +10,8 @@ class FetchRequestBuilder { private var orderField: String = "title" private var orderDirection: String = "ASC" private var downloadedOnly: Boolean = false + private var host: String = "" + private var username: String = "" fun libraryId(id: String?) = apply { this.libraryId = id } @@ -23,10 +25,14 @@ class FetchRequestBuilder { fun downloadedOnly(enabled: Boolean) = apply { this.downloadedOnly = enabled } + fun host(host: String) = apply { this.host = host } + + fun username(username: String) = apply { this.username = username } + fun build(): SupportSQLiteQuery { val args = mutableListOf() - val whereClause = + val libraryClause = when (val libraryId = libraryId) { null -> "libraryId IS NULL" else -> { @@ -35,6 +41,16 @@ class FetchRequestBuilder { } } + val isolationClause = + when (downloadedOnly) { + true -> "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" + false -> { + args.add(host) + args.add(username) + "((host = ? AND username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + } + } + val field = when (orderField) { "title", "author", "duration" -> "detailed_books.$orderField" @@ -47,20 +63,13 @@ class FetchRequestBuilder { else -> "ASC" } - val joinClause = - when (downloadedOnly) { - true -> "INNER JOIN book_chapters ON detailed_books.id = book_chapters.bookId AND book_chapters.isCached = 1" - false -> "" - } - args.add(pageSize) args.add(pageNumber * pageSize) val sql = """ - SELECT DISTINCT detailed_books.* FROM detailed_books - $joinClause - WHERE $whereClause + SELECT detailed_books.* FROM detailed_books + WHERE $libraryClause AND $isolationClause ORDER BY $field $direction LIMIT ? OFFSET ? """.trimIndent() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt index eb05aecb6..fc7aa1a7d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt @@ -7,6 +7,8 @@ class RecentRequestBuilder { private var libraryId: String? = null private var downloadedOnly: Boolean = false private var limit: Int = 10 + private var host: String = "" + private var username: String = "" fun libraryId(id: String?) = apply { this.libraryId = id } @@ -14,30 +16,38 @@ class RecentRequestBuilder { fun limit(limit: Int) = apply { this.limit = limit } + fun host(host: String) = apply { this.host = host } + + fun username(username: String) = apply { this.username = username } + fun build(): SupportSQLiteQuery { val args = mutableListOf() - val whereClause = + val libraryClause = when (val libraryId = libraryId) { - null -> "libraryId IS NULL" + null -> "detailed_books.libraryId IS NULL" else -> { args.add(libraryId) - "(libraryId = ? OR libraryId IS NULL)" + "(detailed_books.libraryId = ? OR detailed_books.libraryId IS NULL)" } } - val joinClause = + val isolationClause = when (downloadedOnly) { - true -> "INNER JOIN book_chapters ON detailed_books.id = book_chapters.bookId AND book_chapters.isCached = 1" - false -> "" + true -> "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" + false -> { + args.add(host) + args.add(username) + "((detailed_books.host = ? AND detailed_books.username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + } } val sql = """ SELECT DISTINCT detailed_books.* FROM detailed_books INNER JOIN media_progress ON detailed_books.id = media_progress.bookId - $joinClause - WHERE $whereClause + WHERE $libraryClause + AND $isolationClause AND media_progress.currentTime > 1.0 AND media_progress.isFinished = 0 ORDER BY media_progress.lastUpdate DESC 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 63013211c..198728fe3 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,8 @@ class SearchRequestBuilder { private var searchQuery: String = "" private var orderField: String = "title" private var orderDirection: String = "ASC" + private var host: String = "" + private var username: String = "" fun libraryId(id: String?) = apply { this.libraryId = id } @@ -17,25 +19,33 @@ class SearchRequestBuilder { fun orderDirection(direction: String) = apply { this.orderDirection = direction } + fun host(host: String) = apply { this.host = host } + + fun username(username: String) = apply { this.username = username } + fun build(): SupportSQLiteQuery { val args = mutableListOf() - val whereClause = - buildString { - when (val libraryId = libraryId) { - null -> append("(libraryId IS NULL)") - else -> { - append("(libraryId = ? OR libraryId IS NULL)") - args.add(libraryId) - } + val libraryClause = + when (val libraryId = libraryId) { + null -> "(libraryId IS NULL)" + else -> { + args.add(libraryId) + "(libraryId = ? OR libraryId IS NULL)" } - append(" AND (title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)") - val pattern = "%$searchQuery%" - args.add(pattern) - args.add(pattern) - args.add(pattern) } + val searchClause = "(title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)" + val pattern = "%$searchQuery%" + args.add(pattern) + args.add(pattern) + args.add(pattern) + + val isolationClause = + "((host = ? AND username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + args.add(host) + args.add(username) + val field = when (orderField) { "title", "author", "duration" -> orderField @@ -51,7 +61,7 @@ class SearchRequestBuilder { val sql = """ SELECT * FROM detailed_books - WHERE $whereClause + WHERE $libraryClause AND $searchClause AND $isolationClause ORDER BY $field $direction """.trimIndent() 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 d9b165c79..610b55d50 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 @@ -29,6 +29,8 @@ interface CachedBookDao { @Transaction suspend fun upsertCachedBook( book: DetailedItem, + host: String, + username: String, fetchedChapters: List, droppedChapters: List, ) { @@ -47,6 +49,8 @@ interface CachedBookDao { publisher = book.publisher, createdAt = book.createdAt, updatedAt = book.updatedAt, + host = host, + username = username, seriesNames = book .series @@ -112,6 +116,8 @@ interface CachedBookDao { currentTime = progress.currentTime, isFinished = progress.isFinished, lastUpdate = progress.lastUpdate, + host = host, + username = username, ) } @@ -128,11 +134,16 @@ interface CachedBookDao { @Query( """ SELECT COUNT(*) FROM detailed_books - WHERE (:libraryId IS NULL AND libraryId IS NULL) - OR (libraryId = :libraryId) + WHERE ((:libraryId IS NULL AND libraryId IS NULL) OR (libraryId = :libraryId)) + AND host = :host + AND username = :username """, ) - suspend fun countCachedBooks(libraryId: String?): Int + suspend fun countCachedBooks( + libraryId: String?, + host: String, + username: String, + ): Int @Transaction @RawQuery @@ -240,6 +251,19 @@ interface CachedBookDao { @Delete suspend fun deleteBook(book: BookEntity) + @Transaction + @Query( + """ + DELETE FROM detailed_books + WHERE id NOT IN (SELECT DISTINCT bookId FROM book_chapters WHERE isCached = 1) + AND (host != :currentHost OR username != :currentUsername) + """, + ) + suspend fun deleteNonDownloadedBooks( + currentHost: String, + currentUsername: String, + ) + companion object { val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) val adapter = moshi.adapter>(type) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt index d6d7fe7d1..307f25634 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt @@ -11,18 +11,24 @@ import org.grakovne.lissen.lib.domain.Library @Dao interface CachedLibraryDao { @Transaction - suspend fun updateLibraries(libraries: List) { + suspend fun updateLibraries( + libraries: List, + host: String, + username: String, + ) { val entities = libraries.map { CachedLibraryEntity( id = it.id, title = it.title, type = it.type, + host = host, + username = username, ) } upsertLibraries(entities) - deleteLibrariesExcept(entities.map { it.id }) + deleteLibrariesExcept(entities.map { it.id }, host, username) } @Transaction @@ -30,12 +36,19 @@ interface CachedLibraryDao { suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity? @Transaction - @Query("SELECT * FROM libraries") - suspend fun fetchLibraries(): List + @Query("SELECT * FROM libraries WHERE host = :host AND username = :username") + suspend fun fetchLibraries( + host: String, + username: String, + ): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertLibraries(libraries: List) - @Query("DELETE FROM libraries WHERE id NOT IN (:ids)") - suspend fun deleteLibrariesExcept(ids: List) + @Query("DELETE FROM libraries WHERE id NOT IN (:ids) AND host = :host AND username = :username") + suspend fun deleteLibrariesExcept( + ids: List, + host: String, + username: String, + ) } 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 e0b7cbb5e..3134d9e3b 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 @@ -51,6 +51,8 @@ data class BookEntity( val seriesNames: String?, val createdAt: Long, val updatedAt: Long, + val host: String = "", + val username: String = "", ) : Serializable @Keep @@ -120,6 +122,8 @@ data class MediaProgressEntity( val currentTime: Double, val isFinished: Boolean, val lastUpdate: Long, + val host: String = "", + val username: String = "", ) : Serializable @Keep diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt index 975a47a2e..036c8ad35 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt @@ -17,4 +17,6 @@ data class CachedLibraryEntity( val id: String, val title: String, val type: LibraryType, + val host: String = "", + val username: String = "", ) : Serializable diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index 37bedba42..c4459a23a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -110,6 +110,10 @@ class LissenSharedPreferences } } + fun getDatabaseVersion() = sharedPreferences.getInt(KEY_DATABASE_VERSION, 0) + + fun setDatabaseVersion(version: Int) = sharedPreferences.edit().putInt(KEY_DATABASE_VERSION, version).apply() + fun getSslBypass() = sharedPreferences.getBoolean(KEY_BYPASS_SSL, false) fun saveSslBypass(enabled: Boolean) { @@ -639,6 +643,7 @@ class LissenSharedPreferences private const val KEY_SMART_REWIND_DURATION = "smart_rewind_duration" private const val KEY_SERVER_VERSION = "server_version" + private const val KEY_DATABASE_VERSION = "database_version" private const val KEY_DEVICE_ID = "device_id" diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt index 6e40bdb54..fb89c3ea4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt @@ -53,8 +53,15 @@ fun AppNavHost( val book = preferences.getPlayingBook() + val isMigrationNeeded by remember { + mutableStateOf( + preferences.getDatabaseVersion() < org.grakovne.lissen.viewmodel.MigrationViewModel.CURRENT_DATABASE_VERSION, + ) + } + val startDestination = when { + isMigrationNeeded -> ROUTE_MIGRATION appLaunchAction == AppLaunchAction.MANAGE_DOWNLOADS -> "$ROUTE_SETTINGS/cached_items" hasCredentials.not() -> ROUTE_LOGIN appLaunchAction == AppLaunchAction.CONTINUE_PLAYBACK && book != null -> @@ -97,6 +104,18 @@ fun AppNavHost( navController = navController, startDestination = startDestination, ) { + composable( + route = ROUTE_MIGRATION, + enterTransition = { EnterTransition.None }, + exitTransition = { fadeOut() }, + ) { + org.grakovne.lissen.ui.screens.migration.MigrationScreen( + onMigrationComplete = { + navigationService.showLibrary(clearHistory = true) + }, + ) + } + composable( route = "settings_screen/cached_items", enterTransition = { enterTransition }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/Route.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/Route.kt index 46d20725b..57b861e63 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/Route.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/Route.kt @@ -4,3 +4,4 @@ const val ROUTE_LIBRARY = "library_screen" const val ROUTE_PLAYER = "player_screen" const val ROUTE_SETTINGS = "settings_screen" const val ROUTE_LOGIN = "login_screen" +const val ROUTE_MIGRATION = "migration_screen" diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt new file mode 100644 index 000000000..f7b70e7ab --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt @@ -0,0 +1,68 @@ +package org.grakovne.lissen.ui.screens.migration + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import org.grakovne.lissen.R +import org.grakovne.lissen.ui.theme.Spacing +import org.grakovne.lissen.viewmodel.MigrationState +import org.grakovne.lissen.viewmodel.MigrationViewModel + +@Composable +fun MigrationScreen( + onMigrationComplete: () -> Unit, + viewModel: MigrationViewModel = hiltViewModel(), +) { + val state by viewModel.migrationState.observeAsState(MigrationState.Idle) + + LaunchedEffect(state) { + if (state is MigrationState.Completed) { + onMigrationComplete() + } + } + + LaunchedEffect(Unit) { + viewModel.startMigration() + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(Spacing.lg), + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp, + ) + + Spacer(modifier = Modifier.height(Spacing.xl)) + + Text( + text = stringResource(R.string.migration_screen_message), + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } +} 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 46ada8dfe..1ac34f5b3 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 @@ -115,20 +115,6 @@ fun AdvancedSettingsComposable( description = stringResource(R.string.settings_screen_crash_report_description), initialState = crashReporting, ) { viewModel.preferCrashReporting(it) } - - AdvancedSettingsSimpleItemComposable( - title = stringResource(R.string.settings_screen_clear_thumbnail_cache_title), - description = stringResource(R.string.settings_screen_clear_thumbnail_cache_hint), - onclick = { - scope.launch { cachingModelView.clearShortTermCache() } - Toast - .makeText( - context, - context.getString(R.string.settings_screen_clear_thumbnail_cache_success_toast), - Toast.LENGTH_SHORT, - ).show() - }, - ) } } }, 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 cc24a0f4d..8f5705c26 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 @@ -102,6 +102,21 @@ fun CacheSettingsScreen( description = stringResource(R.string.settings_screen_cached_items_hint), onclick = { navController.showCachedItemsSettings() }, ) + + val context = androidx.compose.ui.platform.LocalContext.current + val successMessage = stringResource(R.string.settings_screen_clear_metadata_cache_success) + + AdvancedSettingsNavigationItemComposable( + title = stringResource(R.string.settings_screen_clear_metadata_cache_title), + description = stringResource(R.string.settings_screen_clear_metadata_cache_hint), + onclick = { + viewModel.clearMetadataCache { + android.widget.Toast + .makeText(context, successMessage, android.widget.Toast.LENGTH_SHORT) + .show() + } + }, + ) } } }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt new file mode 100644 index 000000000..5c5f2ce40 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt @@ -0,0 +1,59 @@ +package org.grakovne.lissen.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao +import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences +import javax.inject.Inject + +@HiltViewModel +class MigrationViewModel + @Inject + constructor( + private val bookDao: CachedBookDao, + private val preferences: LissenSharedPreferences, + ) : ViewModel() { + private val _migrationState = MutableLiveData(MigrationState.Idle) + val migrationState: LiveData = _migrationState + + fun startMigration() { + viewModelScope.launch { + _migrationState.value = MigrationState.Running + + // Artificial delay for a better UX if migration is target-fast + delay(1500) + + try { + withContext(Dispatchers.IO) { + // Trigger DB initialization and migration by performing a simple query + bookDao.countCachedBooks(null, "", "") + } + + preferences.setDatabaseVersion(CURRENT_DATABASE_VERSION) + _migrationState.value = MigrationState.Completed + } catch (e: Exception) { + // In a real app, we might want to handle this more gracefully + _migrationState.value = MigrationState.Completed // Proceed anyway to avoid bricking + } + } + } + + companion object { + const val CURRENT_DATABASE_VERSION = 16 + } + } + +sealed class MigrationState { + data object Idle : MigrationState() + + data object Running : MigrationState() + + data object Completed : MigrationState() +} 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 cd5655b87..8b06b2836 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -33,6 +33,8 @@ class SettingsViewModel constructor( private val mediaChannel: LissenMediaProvider, private val preferences: LissenSharedPreferences, + private val bookRepository: org.grakovne.lissen.content.BookRepository, + private val cachedCoverProvider: org.grakovne.lissen.content.cache.temporary.CachedCoverProvider, ) : ViewModel() { private val _host: MutableLiveData = MutableLiveData(preferences.getHost()?.let { Host.external(it) }) val host = _host @@ -289,4 +291,12 @@ class SettingsViewModel host?.let { _host.postValue(it) } } + + fun clearMetadataCache(onComplete: () -> Unit) { + viewModelScope.launch { + bookRepository.clearMetadataCache() + cachedCoverProvider.clearCache() + onComplete() + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6b8e7099..4b8ea66e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,9 +153,6 @@ WiFi network name The app needs location access to detect Wi‑Fi name Allow - Clear thumbnail cache - Remove cached thumbnails to free up space. - Thumbnail cache cleared Delay automatic download Wait a short time before starting download Disable SSL verification @@ -233,4 +230,12 @@ Downloaded + + + Rearranging the library shelves… + + + Clear metadata cache + Remove metadata for non-downloaded books from other servers to free up space. + Metadata cache cleared \ No newline at end of file From 9d0f06a8f125fa7097f105da847aad5fc98e8a47 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Tue, 3 Feb 2026 22:41:21 +0530 Subject: [PATCH 02/37] feat: Filter cached book queries to only include books that have at least one cached chapter. --- .../lissen/content/cache/persistent/dao/CachedBookDao.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 610b55d50..a5a4f6491 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 @@ -172,6 +172,7 @@ interface CachedBookDao { @Query( """ SELECT * FROM detailed_books + WHERE EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1) ORDER BY title ASC, libraryId ASC LIMIT :pageSize OFFSET (:pageNumber * :pageSize) @@ -182,7 +183,12 @@ interface CachedBookDao { pageNumber: Int, ): List - @Query("SELECT COUNT(*) FROM detailed_books") + @Query( + """ + SELECT COUNT(*) FROM detailed_books + WHERE EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1) + """, + ) suspend fun fetchCachedItemsCount(): Int @Query( From 71cbf629a2f3b6cc245837cb107fed7798eec484 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Wed, 4 Feb 2026 13:48:43 +0530 Subject: [PATCH 03/37] feat: enhance cached items screen with storage statistics, detailed book/file sizes, download progress, individual file deletion, and a polished empty state. --- .../cache/persistent/ContentCachingManager.kt | 14 + .../cache/persistent/LocalCacheRepository.kt | 48 +++ .../OfflineBookStorageProperties.kt | 2 +- .../cache/CachedItemsSettingsScreen.kt | 376 +++++++++++++----- .../lissen/viewmodel/CachingModelView.kt | 32 ++ app/src/main/res/values/strings.xml | 6 + 6 files changed, 374 insertions(+), 104 deletions(-) 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 c98d7e848..0b641d40e 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 @@ -108,7 +108,21 @@ class ContentCachingManager droppedChapters = listOf(chapter), ) + val stillCachedChapters = + bookRepository + .fetchBook(item.id) + ?.chapters + ?.filter { it.available } + ?: emptyList() + + val stillNeededFiles = + stillCachedChapters + .flatMap { findRelatedFiles(it, item.files) } + .map { it.id } + .toSet() + findRequestedFiles(item, listOf(chapter)) + .filter { it.id !in stillNeededFiles } .forEach { file -> val binaryContent = properties.provideMediaCachePath(item.id, file.id) 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 ea074672e..d047c08c3 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 @@ -15,6 +15,7 @@ import org.grakovne.lissen.lib.domain.Library import org.grakovne.lissen.lib.domain.MediaProgress import org.grakovne.lissen.lib.domain.PagedItems import org.grakovne.lissen.lib.domain.PlaybackProgress +import org.grakovne.lissen.lib.domain.PlayingChapter import org.grakovne.lissen.lib.domain.RecentBook import org.grakovne.lissen.playback.service.calculateChapterIndex import timber.log.Timber @@ -148,6 +149,53 @@ class LocalCacheRepository suspend fun fetchLatestUpdate(libraryId: String) = cachedBookRepository.fetchLatestUpdate(libraryId) + fun calculateBookSize(book: DetailedItem): Long = + book.files.sumOf { file -> + storageProperties.provideMediaCachePath(book.id, file.id).length() + } + + fun calculateChapterSize( + bookId: String, + chapter: PlayingChapter, + files: List, + ): Long = + org.grakovne.lissen.content.cache.common.findRelatedFiles(chapter, files).sumOf { file -> + storageProperties.provideMediaCachePath(bookId, file.id).length() + } + + fun calculateTotalCacheSize(): Long { + val mediaFolder = storageProperties.baseFolder() + return calculateFolderSize(mediaFolder) + } + + private fun calculateFolderSize(folder: File): Long { + var size: Long = 0 + if (folder.exists()) { + val files = folder.listFiles() + if (files != null) { + for (file in files) { + size += + if (file.isDirectory) { + calculateFolderSize(file) + } else { + file.length() + } + } + } + } + return size + } + + fun getAvailableDiskSpace(): Long { + val mediaFolder = storageProperties.baseFolder() + return mediaFolder.freeSpace + } + + fun getTotalDiskSpace(): Long { + val mediaFolder = storageProperties.baseFolder() + return mediaFolder.totalSpace + } + /** * Fetches a detailed book item by its ID from the cached repository. * If the book is not found in the cache, returns `null`. 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 d4cc5268d..50267c962 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 @@ -12,7 +12,7 @@ class OfflineBookStorageProperties constructor( @ApplicationContext private val context: Context, ) { - private fun baseFolder(): File = + fun baseFolder(): File = context .getExternalFilesDir(MEDIA_CACHE_FOLDER) ?.takeIf { it.exists() || it.mkdirs() && it.canWrite() } 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 6856078a7..d3644410b 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 @@ -1,10 +1,19 @@ package org.grakovne.lissen.ui.screens.settings.advanced.cache +import android.text.format.Formatter import android.view.View +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,6 +27,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons @@ -25,19 +35,25 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.ExpandLess import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FileDownloadOff +import androidx.compose.material.icons.outlined.SdStorage import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -56,6 +72,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -87,6 +104,7 @@ fun CachedItemsSettingsScreen( ) { val view: View = LocalView.current val scope = rememberCoroutineScope() + val context = LocalContext.current var pullRefreshing by remember { mutableStateOf(false) } val cachedItems = viewModel.libraryPager.collectAsLazyPagingItems() @@ -106,6 +124,7 @@ fun CachedItemsSettingsScreen( withMinimumTime(minimumTime) { listOf( async { viewModel.fetchCachedItems() }, + async { viewModel.refreshStorageStats() }, ).awaitAll() } @@ -151,16 +170,20 @@ fun CachedItemsSettingsScreen( .pullRefresh(pullRefreshState) .fillMaxSize(), ) { - when (cachedItems.itemCount == 0) { - true -> CachedItemsFallbackComposable() - false -> - CachedItemsComposable( - cachedItems = cachedItems, - imageLoader = imageLoader, - viewModel = viewModel, - playerViewModel = playerViewModel, - onItemRemoved = { refreshContent(showPullRefreshing = false) }, - ) + Column(modifier = Modifier.fillMaxSize()) { + StorageHeader(viewModel) + + when (cachedItems.itemCount == 0) { + true -> PolishedCachedItemsEmptyState(onBack) + false -> + CachedItemsComposable( + cachedItems = cachedItems, + imageLoader = imageLoader, + viewModel = viewModel, + playerViewModel = playerViewModel, + onItemRemoved = { refreshContent(showPullRefreshing = false) }, + ) + } } PullRefreshIndicator( @@ -173,6 +196,115 @@ fun CachedItemsSettingsScreen( } } +@Composable +private fun StorageHeader(viewModel: CachingModelView) { + val stats by viewModel.storageStats.collectAsState(null) + val context = LocalContext.current + + stats?.let { + val usedFormatted = Formatter.formatFileSize(context, it.usedBytes) + val freeFormatted = Formatter.formatFileSize(context, it.freeBytes) + val progress = it.usedBytes.toFloat() / (it.usedBytes + it.freeBytes).coerceAtLeast(1L) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .background(colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.SdStorage, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.settings_screen_cached_items_storage_stats, usedFormatted, freeFormatted), + style = typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = colorScheme.onSurface, + ) + } + } + + Spacer(Modifier.height(12.dp)) + + LinearProgressIndicator( + progress = { progress }, + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + color = colorScheme.primary, + trackColor = colorScheme.onSurface.copy(alpha = 0.1f), + ) + } + } +} + +@Composable +private fun PolishedCachedItemsEmptyState(onBack: () -> Unit) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.FileDownloadOff, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = colorScheme.onSurface.copy(alpha = 0.2f), + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.settings_screen_cached_items_empty_title), + style = typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = colorScheme.onSurface, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.settings_screen_cached_items_empty_description), + style = typography.bodyLarge, + color = colorScheme.onSurface.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(32.dp)) + + Button( + onClick = onBack, + colors = + ButtonDefaults.buttonColors( + containerColor = colorScheme.primary, + contentColor = colorScheme.onPrimary, + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.height(48.dp), + ) { + Text( + text = stringResource(R.string.settings_screen_cached_items_empty_action), + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + ) + } + } +} + @Composable private fun CachedItemsComposable( cachedItems: LazyPagingItems, @@ -229,8 +361,16 @@ private fun CachedItemComposable( ) { val scope = rememberCoroutineScope() val context = LocalContext.current + val view = LocalView.current var expanded by remember { mutableStateOf(false) } + val isSingleFileBook = remember(book) { book.files.size <= 1 } + + val bookSize = + remember(book) { + Formatter.formatFileSize(context, viewModel.getBookSize(book)) + } + val imageRequest = remember(book.id) { ImageRequest @@ -244,59 +384,66 @@ private fun CachedItemComposable( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .background( + color = if (expanded) colorScheme.surfaceVariant.copy(alpha = 0.2f) else colorScheme.surface, + shape = RoundedCornerShape(12.dp), + ).animateContentSize() .clickable { expanded = expanded.not() } - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(8.dp), ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - AsyncShimmeringImage( - imageRequest = imageRequest, - imageLoader = imageLoader, - contentDescription = "${book.title} cover", - contentScale = ContentScale.FillBounds, - modifier = - Modifier - .size(64.dp) - .aspectRatio(1f) - .clip(RoundedCornerShape(4.dp)), - error = painterResource(R.drawable.cover_fallback), - ) - - Spacer(Modifier.width(spacing)) + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncShimmeringImage( + imageRequest = imageRequest, + imageLoader = imageLoader, + contentDescription = "${book.title} cover", + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .size(64.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)), + error = painterResource(R.drawable.cover_fallback), + ) - Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = book.title, - style = - typography.bodyMedium.copy( - fontWeight = FontWeight.SemiBold, - color = colorScheme.onBackground, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) + Spacer(Modifier.width(16.dp)) - Spacer(Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = book.title, + style = + typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onBackground, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) - Icon( - imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = colorScheme.onBackground, - ) - } + Text( + text = bookSize, + style = + typography.labelLarge.copy( + color = colorScheme.primary, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.padding(start = 8.dp), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { book .author ?.takeIf { it.isNotBlank() } ?.let { Text( - modifier = Modifier.padding(vertical = 2.dp), + modifier = Modifier.weight(1f), text = it, style = typography.bodyMedium.copy( @@ -306,11 +453,20 @@ private fun CachedItemComposable( overflow = TextOverflow.Ellipsis, ) } + + Icon( + imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = colorScheme.onBackground.copy(alpha = 0.4f), + ) } + } - Spacer(Modifier.width(spacing)) + Spacer(Modifier.width(8.dp)) - IconButton(onClick = { + IconButton(onClick = { + withHaptic(view) { scope .launch { dropCache( @@ -321,17 +477,30 @@ private fun CachedItemComposable( onItemRemoved() } - }) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - tint = colorScheme.onSurface, - ) } + }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null, + tint = colorScheme.error.copy(alpha = 0.8f), + ) } + } - if (expanded) { - CachedItemChapterComposable(book, onItemRemoved, viewModel, playerViewModel) + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .background(colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + CachedItemChapterComposable(book, onItemRemoved, viewModel, playerViewModel, isSingleFileBook) } } } @@ -343,10 +512,11 @@ private fun CachedItemChapterComposable( onItemRemoved: () -> Unit, viewModel: CachingModelView, playerViewModel: PlayerViewModel, + isSingleFileBook: Boolean, ) { val scope = rememberCoroutineScope() - - Spacer(modifier = Modifier.height(spacing)) + val context = LocalContext.current + val view = LocalView.current val availableChapters = item @@ -354,57 +524,61 @@ private fun CachedItemChapterComposable( .filter { it.available } availableChapters.forEachIndexed { index, chapter -> + val chapterSize = + remember(chapter) { + Formatter.formatFileSize(context, viewModel.getChapterSize(item.id, chapter, item.files)) + } + key(chapter.id) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = spacing / 2) - .padding(start = chapterIndent), + .padding(vertical = 8.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text(text = chapter.title, style = typography.bodyMedium) + if (!isSingleFileBook) { + Text( + text = chapterSize, + style = typography.labelMedium.copy(color = colorScheme.onSurface.copy(alpha = 0.5f)), + ) + } } - Box( - modifier = - Modifier - .size(48.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - scope.launch { - dropCache( - item = item, - chapter = chapter, - cachingModelView = viewModel, - playerViewModel = playerViewModel, - ) - onItemRemoved() - } - }, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - tint = colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) + if (!isSingleFileBook) { + IconButton( + onClick = { + withHaptic(view) { + scope.launch { + dropCache( + item = item, + chapter = chapter, + cachingModelView = viewModel, + playerViewModel = playerViewModel, + ) + onItemRemoved() + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp), + ) + } } } if (index < availableChapters.lastIndex) { HorizontalDivider( - thickness = 1.dp, - modifier = - Modifier.padding( - start = chapterIndent, - end = spacing, - ), + thickness = 0.5.dp, + modifier = Modifier.padding(horizontal = 8.dp), + color = colorScheme.onSurface.copy(alpha = 0.1f), ) } } @@ -449,7 +623,3 @@ private suspend fun dropCache( cachingModelView.dropCache(item.id) } - -private val thumbnailSize = 64.dp -private val spacing = 16.dp -private val chapterIndent = thumbnailSize + spacing 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 7f051daab..07c488652 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -49,6 +49,15 @@ class CachingModelView val totalCount: LiveData = _totalCount private val _bookCachingProgress = mutableMapOf>() + private val _storageStats = MutableStateFlow(null) + val storageStats: Flow = _storageStats + + data class StorageStats( + val usedBytes: Long, + val freeBytes: Long, + val totalBytes: Long, + ) + private val pageConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -79,8 +88,29 @@ class CachingModelView flow.value = progress } } + + refreshStorageStats() } + fun refreshStorageStats() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + val used = localCacheRepository.calculateTotalCacheSize() + val free = localCacheRepository.getAvailableDiskSpace() + val total = localCacheRepository.getTotalDiskSpace() + _storageStats.value = StorageStats(used, free, total) + } + } + } + + fun getBookSize(book: DetailedItem) = localCacheRepository.calculateBookSize(book) + + fun getChapterSize( + bookId: String, + chapter: PlayingChapter, + files: List, + ) = localCacheRepository.calculateChapterSize(bookId, chapter, files) + suspend fun clearShortTermCache() { withContext(Dispatchers.IO) { cachedCoverProvider.clearCache() @@ -113,6 +143,7 @@ class CachingModelView suspend fun dropCache(bookId: String) { contentCachingManager.dropCache(bookId) + refreshStorageStats() } suspend fun dropCompletedChapters(item: DetailedItem) { @@ -134,6 +165,7 @@ class CachingModelView chapter: PlayingChapter, ) { contentCachingManager.dropCache(item, chapter) + refreshStorageStats() } fun toggleCacheForce() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b8ea66e3..e4f55871a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -238,4 +238,10 @@ Clear metadata cache Remove metadata for non-downloaded books from other servers to free up space. Metadata cache cleared + Delete All + Are you sure you want to delete all downloaded content? + Used: %1$s • Free: %2$s + No downloads yet + Your downloaded books and chapters will appear here. + Go to Library \ No newline at end of file From 01f35213ccaa7aec4a8cce9b388d642dae387f9b Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Wed, 4 Feb 2026 23:46:36 +0530 Subject: [PATCH 04/37] feat: introduce a new download modal with enhanced options for segmented and atomic content caching, including UI and database schema updates. --- .../17.json | 393 ++++++++++++++ .../converter/BookResponseConverter.kt | 2 + .../library/model/BookResponse.kt | 2 +- .../converter/PodcastResponseConverter.kt | 1 + .../podcast/model/PodcastResponse.kt | 2 + .../lissen/content/LissenMediaProvider.kt | 2 +- .../content/cache/common/FindRelatedFiles.kt | 12 +- .../persistent/CalculateRequestedChapters.kt | 24 + .../cache/persistent/LocalCacheModule.kt | 3 +- .../cache/persistent/LocalCacheRepository.kt | 95 ++++ .../cache/persistent/LocalCacheStorage.kt | 2 +- .../content/cache/persistent/Migrations.kt | 7 + .../CachedBookEntityDetailedConverter.kt | 1 + .../cache/persistent/dao/CachedBookDao.kt | 1 + .../persistent/entity/CachedBookEntity.kt | 1 + .../ui/screens/common/DownloadOptionFormat.kt | 8 + .../ui/screens/details/BookDetailScreen.kt | 109 +++- .../screens/player/GlobalPlayerBottomSheet.kt | 59 +- .../lissen/ui/screens/player/PlayerScreen.kt | 49 +- .../ui/screens/player/composable/ActionRow.kt | 178 ++++++ .../player/composable/DownloadsComposable.kt | 505 +++++++++++++----- .../cache/CachedItemsSettingsScreen.kt | 198 +++++-- .../lissen/viewmodel/CachingModelView.kt | 8 +- .../res/drawable/available_offline_filled.xml | 31 +- app/src/main/res/values/strings.xml | 19 + .../grakovne/lissen/lib/domain/BookStorage.kt | 37 ++ .../lissen/lib/domain/DetailedItem.kt | 1 + .../lissen/lib/domain/DownloadOption.kt | 30 +- 28 files changed, 1467 insertions(+), 313 deletions(-) create mode 100644 app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json create mode 100644 app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt create mode 100644 lib/src/main/kotlin/org/grakovne/lissen/lib/domain/BookStorage.kt diff --git a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json new file mode 100644 index 000000000..753801817 --- /dev/null +++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json @@ -0,0 +1,393 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "a53f0ae7c18797decac21399bf0a1bcb", + "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, `libraryType` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `host` TEXT NOT NULL, `username` TEXT 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": "libraryType", + "columnName": "libraryType", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesJson", + "columnName": "seriesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesNames", + "columnName": "seriesNames", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "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, `duration` REAL NOT NULL, `size` INTEGER 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": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "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, `host` TEXT NOT NULL, `username` TEXT NOT NULL, PRIMARY KEY(`bookId`), FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "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`)" + } + ], + "foreignKeys": [ + { + "table": "detailed_books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "libraries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `host` TEXT NOT NULL, `username` 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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, 'a53f0ae7c18797decac21399bf0a1bcb')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt index 82041671a..ab0ae19e6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt @@ -77,6 +77,7 @@ class BookResponseConverter .audioFiles ?.sortedBy { it.index } ?.map { + timber.log.Timber.d("Mapping file ${it.ino} with size ${it.metadata.size}") BookFile( id = it.ino, name = @@ -84,6 +85,7 @@ class BookResponseConverter ?.tagTitle ?: (it.metadata.filename.removeSuffix(it.metadata.ext)), duration = it.duration, + size = it.metadata.size ?: 0L, mimeType = it.mimeType, ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt index c7bc40efd..00a9eb14e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt @@ -66,7 +66,7 @@ data class BookAudioFileResponse( data class AudioFileMetadata( val filename: String, val ext: String, - val size: Long, + val size: Long?, ) @Keep diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt index 89d4fc878..d8e072204 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt @@ -85,6 +85,7 @@ class PodcastResponseConverter id = it.audioFile.ino, name = it.title, duration = it.audioFile.duration, + size = it.audioFile.size ?: it.size ?: 0L, mimeType = it.audioFile.mimeType, ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt index 532371698..ba7d69b0e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt @@ -38,6 +38,7 @@ data class PodcastEpisodeResponse( val episode: String?, val pubDate: String?, val title: String, + val size: Long?, val audioFile: PodcastAudioFileResponse, ) @@ -46,5 +47,6 @@ data class PodcastEpisodeResponse( data class PodcastAudioFileResponse( val ino: String, val duration: Double, + val size: Long?, val mimeType: String, ) 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 0a1670f94..6c9666692 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt @@ -237,7 +237,7 @@ class LissenMediaProvider val localResult = localCacheRepository.fetchBook(bookId) val isDetailed = localResult - ?.let { it.chapters.isNotEmpty() || it.files.isNotEmpty() } + ?.let { (it.chapters.isNotEmpty() || it.files.isNotEmpty()) && (it.files.isEmpty() || it.files.all { f -> f.size > 0 }) } ?: false if (localResult != null && isDetailed) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt index cf0f0db8e..5fca2aa25 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt @@ -7,15 +7,21 @@ fun findRelatedFiles( chapter: PlayingChapter, files: List, ): List { - val chapterStartRounded = chapter.start.round() - val chapterEndRounded = chapter.end.round() - val startTimes = files .runningFold(0.0) { acc, file -> acc + file.duration } .dropLast(1) val fileStartTimes = files.zip(startTimes) + return findRelatedFilesByStartTimes(chapter, fileStartTimes) +} + +fun findRelatedFilesByStartTimes( + chapter: PlayingChapter, + fileStartTimes: List>, +): List { + val chapterStartRounded = chapter.start.round() + val chapterEndRounded = chapter.end.round() return fileStartTimes .filter { (file, fileStartTime) -> diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt index fc2bce5e5..51a89cb10 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt @@ -1,6 +1,7 @@ package org.grakovne.lissen.content.cache.persistent import org.grakovne.lissen.lib.domain.AllItemsDownloadOption +import org.grakovne.lissen.lib.domain.BookFile import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption import org.grakovne.lissen.lib.domain.DetailedItem import org.grakovne.lissen.lib.domain.DownloadOption @@ -13,6 +14,21 @@ fun calculateRequestedChapters( book: DetailedItem, option: DownloadOption, currentTotalPosition: Double, +): List { + val startTimes = + book.files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) + + val fileStartTimes = book.files.zip(startTimes) + return calculateRequestedChapters(book, option, currentTotalPosition, fileStartTimes) +} + +fun calculateRequestedChapters( + book: DetailedItem, + option: DownloadOption, + currentTotalPosition: Double, + fileStartTimes: List>, ): List { val chapterIndex = calculateChapterIndex(book, currentTotalPosition) @@ -24,10 +40,18 @@ fun calculateRequestedChapters( chapterIndex.coerceAtLeast(0), (chapterIndex + option.itemsNumber).coerceIn(chapterIndex..book.chapters.size), ) + RemainingItemsDownloadOption -> book.chapters.subList( chapterIndex.coerceIn(0, book.chapters.size), book.chapters.size, ) + + is org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption -> + book.chapters.filter { chapter -> + org.grakovne.lissen.content.cache.common + .findRelatedFilesByStartTimes(chapter, fileStartTimes) + .any { it.id in option.fileIds } + } } } 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 86a6d6e28..1612d2d21 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 @@ -50,7 +50,8 @@ object LocalCacheModule { host = preferences.getHost() ?: "", username = preferences.getUsername() ?: "", ), - ).build() + ).addMigrations(MIGRATION_16_17) + .build() } @Provides 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 d047c08c3..a7ce938bd 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 @@ -163,6 +163,101 @@ class LocalCacheRepository storageProperties.provideMediaCachePath(bookId, file.id).length() } + fun getBookStorageType(book: DetailedItem): org.grakovne.lissen.lib.domain.BookStorageType { + if (book.files.size <= 1) return org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH + + // Heuristic: If files match chapters exactly, it's virtually always Atomic in Audiobookshelf. + if (book.files.size == book.chapters.size) return org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC + + val mediaMap = calculateMediaMap(book) + + val hasSegmentedFile = + book.files.any { file -> + (mediaMap[file.id]?.size ?: 0) > 1 + } + + return if (hasSegmentedFile) { + org.grakovne.lissen.lib.domain.BookStorageType.SEGMENTED + } else { + org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC + } + } + + fun mapChaptersToVolumes(book: DetailedItem): List { + val storageType = getBookStorageType(book) + + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + val file = book.files.firstOrNull() ?: return emptyList() + return listOf( + org.grakovne.lissen.lib.domain.BookVolume( + id = file.id, + name = "Full Archive", + size = file.size, + chapters = book.chapters, + isDownloaded = storageProperties.provideMediaCachePath(book.id, file.id).exists(), + ), + ) + } + + val mediaMap = calculateMediaMap(book) + + return book.files.mapIndexed { index, file -> + val relatedChapters = mediaMap[file.id] ?: emptyList() + + val name = + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.SEGMENTED) { + "Volume ${index + 1}" + } else { + relatedChapters.firstOrNull()?.title ?: "Part ${index + 1}" + } + + org.grakovne.lissen.lib.domain.BookVolume( + id = file.id, + name = name, + size = file.size, + chapters = relatedChapters, + isDownloaded = storageProperties.provideMediaCachePath(book.id, file.id).exists(), + ) + } + } + + private fun calculateMediaMap(book: DetailedItem): Map> { + val result = mutableMapOf>() + val fileStartTimes = calculateFileStartTimes(book.files) + + var chapterIdx = 0 + for ((file, fileStart) in fileStartTimes) { + val fileEnd = fileStart + file.duration + + // Skip chapters that end before this file starts + while (chapterIdx < book.chapters.size && book.chapters[chapterIdx].end.round() <= fileStart.round()) { + chapterIdx++ + } + + var currentChapterIdx = chapterIdx + while (currentChapterIdx < book.chapters.size && book.chapters[currentChapterIdx].start.round() < fileEnd.round()) { + result.getOrPut(file.id) { mutableListOf() }.add(book.chapters[currentChapterIdx]) + currentChapterIdx++ + } + } + return result + } + + private fun calculateFileStartTimes( + files: List, + ): List> { + val startTimes = + files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) + + return files.zip(startTimes) + } + + private val PRECISION = 0.01 + + private fun Double.round(): Double = kotlin.math.round(this / PRECISION) * PRECISION + fun calculateTotalCacheSize(): Long { val mediaFolder = storageProperties.baseFolder() return calculateFolderSize(mediaFolder) 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 11e7249b4..a5f611721 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 @@ -18,7 +18,7 @@ import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity MediaProgressEntity::class, CachedLibraryEntity::class, ], - version = 16, + version = 17, 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 7b7980cc5..ba0ee731b 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 @@ -279,3 +279,10 @@ fun produceMigration15_16( db.execSQL("ALTER TABLE libraries ADD COLUMN username TEXT NOT NULL DEFAULT '$username'") } } + +val MIGRATION_16_17 = + object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE book_files ADD COLUMN size INTEGER NOT NULL DEFAULT 0") + } + } 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 3ba3513bd..b6586806e 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 @@ -33,6 +33,7 @@ class CachedBookEntityDetailedConverter id = fileEntity.bookFileId, name = fileEntity.name, duration = fileEntity.duration, + size = fileEntity.size, mimeType = fileEntity.mimeType, ) }, 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 a5a4f6491..928fb152a 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 @@ -72,6 +72,7 @@ interface CachedBookDao { bookFileId = file.id, name = file.name, duration = file.duration, + size = file.size, mimeType = file.mimeType, bookId = book.id, ) 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 3134d9e3b..301ff36c2 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 @@ -74,6 +74,7 @@ data class BookFileEntity( val bookFileId: String, val name: String, val duration: Double, + val size: Long, val mimeType: String, val bookId: String, ) : Serializable diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt index c1700cc65..c8e11d36d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt @@ -16,6 +16,10 @@ fun DownloadOption?.makeText( when (this) { null -> context.getString(R.string.downloads_menu_download_option_disable) + is org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption -> { + "Selected Volume" + } + CurrentItemDownloadOption -> { when (libraryType) { LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_current_chapter) @@ -61,4 +65,8 @@ fun DownloadOption?.makeText( ) } } + + is org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption -> { + "Multiple Files" + } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt index 8a147c9b7..7c6e6fda9 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -8,6 +8,12 @@ import android.text.style.StyleSpan import android.text.style.URLSpan import android.text.style.UnderlineSpan import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -235,6 +241,9 @@ fun BookDetailScreen( } } else { val book = bookDetail!! + val storageType = remember(book.id) { cachingModelView.getBookStorageType(book) } + val volumes = remember(book.id) { cachingModelView.getVolumes(book) } + val isPodcast = book.libraryType == LibraryType.PODCAST val chapters = if (isPodcast) book.chapters.reversed() else book.chapters val maxDuration = chapters.maxOfOrNull { it.duration } ?: 0.0 @@ -276,6 +285,12 @@ fun BookDetailScreen( ) } + // Downloaded Badge + if (volumes.all { it.isDownloaded }) { + ShimmeringDownloadedBadge() + Spacer(modifier = Modifier.height(12.dp)) + } + // Title & Author Text( text = book.title, @@ -545,39 +560,36 @@ fun BookDetailScreen( } } - if (downloadsExpanded) { + if (downloadsExpanded && bookDetail != null) { + val book = bookDetail!! + val storageType = remember(book.id) { cachingModelView.getBookStorageType(book) } + val volumes = remember(book.id) { cachingModelView.getVolumes(book) } + DownloadsComposable( - libraryType = preferredLibrary?.type ?: LibraryType.UNKNOWN, - hasCachedEpisodes = hasDownloadedChapters, + book = book, + storageType = storageType, + volumes = volumes, isOnline = isOnline, cachingInProgress = cacheProgress.status is CacheStatus.Caching, onRequestedDownload = { option -> - bookDetail?.let { - cachingModelView.cache( - mediaItem = it, - option = option, - ) - } + cachingModelView.cache( + mediaItem = book, + option = option, + ) }, onRequestedDrop = { - bookDetail?.let { - scope.launch { - cachingModelView.dropCache(it.id) - } + scope.launch { + cachingModelView.dropCache(book.id) } }, onRequestedDropCompleted = { - bookDetail?.let { - scope.launch { - cachingModelView.dropCompletedChapters(it) - } + scope.launch { + cachingModelView.dropCompletedChapters(book) } }, onRequestedStop = { - bookDetail?.let { - scope.launch { - cachingModelView.stopCaching(it) - } + scope.launch { + cachingModelView.stopCaching(book) } }, onDismissRequest = { downloadsExpanded = false }, @@ -586,6 +598,61 @@ fun BookDetailScreen( } } +@Composable +fun ShimmeringDownloadedBadge() { + val infiniteTransition = + androidx.compose.animation.core + .rememberInfiniteTransition(label = "shimmer") + val xShimmer by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = + androidx.compose.animation.core.infiniteRepeatable( + animation = + androidx.compose.animation.core + .tween(durationMillis = 1500, easing = androidx.compose.animation.core.LinearEasing), + ), + label = "shimmer", + ) + + val shimmerColors = + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.primary, + ) + + val brush = + Brush.linearGradient( + colors = shimmerColors, + start = + androidx.compose.ui.geometry + .Offset(xShimmer - 300f, xShimmer - 300f), + end = + androidx.compose.ui.geometry + .Offset(xShimmer, xShimmer), + ) + + Box( + modifier = + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(brush) + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.chapter_cached_indicator).uppercase(), + style = + typography.labelLarge.copy( + fontWeight = FontWeight.Black, + letterSpacing = 1.2.sp, + ), + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + @Composable fun DetailRow( icon: ImageVector, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt index d264f6c3f..5fb5e5f68 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt @@ -451,34 +451,37 @@ fun PlayerContent( ) val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(it.id).observeAsState(false) - DownloadsComposable( - libraryType = libraryViewModel.fetchPreferredLibraryType(), - hasCachedEpisodes = hasDownloadedChapters, - isOnline = isOnline, - cachingInProgress = cacheProgress.status is org.grakovne.lissen.lib.domain.CacheStatus.Caching, - onRequestedDownload = { option -> - cachingModelView.cache( - mediaItem = it, - option = option, - ) - }, - onRequestedDrop = { - scope.launch { - cachingModelView.dropCache(it.id) - } - }, - onRequestedDropCompleted = { - scope.launch { - cachingModelView.dropCompletedChapters(it) - } - }, - onRequestedStop = { - scope.launch { - cachingModelView.stopCaching(it) - } - }, - onDismissRequest = { downloadsExpanded = false }, - ) + it.let { book -> + DownloadsComposable( + book = book, + storageType = cachingModelView.getBookStorageType(book), + volumes = cachingModelView.getVolumes(book), + isOnline = isOnline, + cachingInProgress = cacheProgress.status is org.grakovne.lissen.lib.domain.CacheStatus.Caching, + onRequestedDownload = { option -> + cachingModelView.cache( + mediaItem = book, + option = option, + ) + }, + onRequestedDrop = { + scope.launch { + cachingModelView.dropCache(book.id) + } + }, + onRequestedDropCompleted = { + scope.launch { + cachingModelView.dropCompletedChapters(book) + } + }, + onRequestedStop = { + scope.launch { + cachingModelView.stopCaching(book) + } + }, + onDismissRequest = { downloadsExpanded = false }, + ) + } } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt index 8d16ab327..e92752d99 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt @@ -434,42 +434,37 @@ fun PlayerScreen( ) val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(playingBook?.id.orEmpty()).observeAsState(false) - DownloadsComposable( - libraryType = libraryViewModel.fetchPreferredLibraryType(), - hasCachedEpisodes = hasDownloadedChapters, - isOnline = isOnline, - cachingInProgress = cacheProgress.status is org.grakovne.lissen.lib.domain.CacheStatus.Caching, - onRequestedDownload = { option -> - playingBook?.let { + playingBook?.let { book -> + DownloadsComposable( + book = book, + storageType = cachingModelView.getBookStorageType(book), + volumes = cachingModelView.getVolumes(book), + isOnline = isOnline, + cachingInProgress = cacheProgress.status is org.grakovne.lissen.lib.domain.CacheStatus.Caching, + onRequestedDownload = { option -> cachingModelView.cache( - mediaItem = it, + mediaItem = book, option = option, ) - } - }, - onRequestedDrop = { - playingBook?.let { + }, + onRequestedDrop = { scope.launch { - cachingModelView.dropCache(it.id) + cachingModelView.dropCache(book.id) } - } - }, - onRequestedDropCompleted = { - playingBook?.let { + }, + onRequestedDropCompleted = { scope.launch { - cachingModelView.dropCompletedChapters(it) + cachingModelView.dropCompletedChapters(book) } - } - }, - onRequestedStop = { - playingBook?.let { + }, + onRequestedStop = { scope.launch { - cachingModelView.stopCaching(it) + cachingModelView.stopCaching(book) } - } - }, - onDismissRequest = { downloadsExpanded = false }, - ) + }, + onDismissRequest = { downloadsExpanded = false }, + ) + } } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt new file mode 100644 index 000000000..b1b7d017f --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt @@ -0,0 +1,178 @@ +package org.grakovne.lissen.ui.screens.player.composable + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun ActionRow( + title: String, + subtitle: String? = null, + icon: ImageVector, + trailingIcon: ImageVector? = null, + enabled: Boolean = true, + isDanger: Boolean = false, + isSuggested: Boolean = false, + onClick: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.96f else 1f, + label = "squish", + ) + + val floatY by animateFloatAsState( + targetValue = if (isSuggested && !isPressed) -2f else 0f, + label = "float", + ) + + val contentAlpha = if (enabled) 1f else 0.38f + val primaryColor = if (isDanger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + val textColor = if (isDanger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + val subtextColor = + if (isDanger) { + MaterialTheme.colorScheme.error.copy( + alpha = 0.6f, + ) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationY = floatY + }.clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = { + if (enabled) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + } + }, + ).padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(36.dp) + .then( + if (isSuggested && enabled) { + Modifier.border( + width = 1.dp, + color = primaryColor.copy(alpha = 0.4f), + shape = RoundedCornerShape(10.dp), + ) + } else { + Modifier + }, + ).background( + brush = + Brush.verticalGradient( + colors = + if (isSuggested) { + listOf( + primaryColor.copy(alpha = 0.2f * contentAlpha), + primaryColor.copy(alpha = 0.05f * contentAlpha), + ) + } else { + listOf( + primaryColor.copy(alpha = 0.1f * contentAlpha), + primaryColor.copy(alpha = 0.1f * contentAlpha), + ) + }, + ), + shape = RoundedCornerShape(10.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = primaryColor.copy(alpha = contentAlpha), + modifier = Modifier.size(18.dp), + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + letterSpacing = 0.1.sp, + ), + color = textColor.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = subtextColor.copy(alpha = contentAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (trailingIcon != null) { + Spacer(modifier = Modifier.width(12.dp)) + Icon( + imageVector = trailingIcon, + contentDescription = null, + tint = + (if (isDanger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant).copy( + alpha = + 0.3f * contentAlpha, + ), + modifier = Modifier.size(16.dp), + ) + } + } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt index 8022cd398..ba1a6c834 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt @@ -1,20 +1,37 @@ package org.grakovne.lissen.ui.screens.player.composable -import androidx.compose.foundation.clickable +import android.text.format.Formatter +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.AutoAwesomeMotion +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.StopCircle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -22,29 +39,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.grakovne.lissen.R -import org.grakovne.lissen.lib.domain.AllItemsDownloadOption -import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption -import org.grakovne.lissen.lib.domain.DownloadOption -import org.grakovne.lissen.lib.domain.LibraryType -import org.grakovne.lissen.lib.domain.NumberItemDownloadOption -import org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption +import org.grakovne.lissen.content.cache.common.findRelatedFilesByStartTimes +import org.grakovne.lissen.content.cache.persistent.calculateRequestedChapters import org.grakovne.lissen.ui.effects.WindowBlurEffect import org.grakovne.lissen.ui.screens.common.makeText @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadsComposable( + book: org.grakovne.lissen.lib.domain.DetailedItem, + storageType: org.grakovne.lissen.lib.domain.BookStorageType, + volumes: List, isOnline: Boolean, - libraryType: LibraryType, - hasCachedEpisodes: Boolean, cachingInProgress: Boolean, - onRequestedDownload: (DownloadOption) -> Unit, + onRequestedDownload: (org.grakovne.lissen.lib.domain.DownloadOption) -> Unit, onRequestedStop: () -> Unit, onRequestedDrop: () -> Unit, onRequestedDropCompleted: () -> Unit, @@ -53,6 +65,28 @@ fun DownloadsComposable( val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val libraryType = book.libraryType ?: org.grakovne.lissen.lib.domain.LibraryType.UNKNOWN + val hasCachedContent = volumes.any { it.isDownloaded } + val isFullBookDownloaded = volumes.all { it.isDownloaded } + + val currentChapterIndex = + org.grakovne.lissen.playback.service + .calculateChapterIndex(book, book.progress?.currentTime ?: 0.0) + val currentVolume = + volumes.find { volume -> + volume.chapters.any { it.id == book.chapters.getOrNull(currentChapterIndex)?.id } + } + + val remainingVolumes = + if (currentVolume != null) { + val currentIndex = volumes.indexOf(currentVolume) + volumes.drop(currentIndex + 1).filter { !it.isDownloaded } + } else { + emptyList() + } + + val completedVolumes = volumes.filter { it.isDownloaded && it.chapters.all { ch -> (book.progress?.currentTime ?: 0.0) >= ch.end } } + WindowBlurEffect() ModalBottomSheet( @@ -60,6 +94,7 @@ fun DownloadsComposable( containerColor = colorScheme.background, scrimColor = colorScheme.scrim.copy(alpha = 0.65f), onDismissRequest = onDismissRequest, + dragHandle = { DragHandle() }, content = { Column( modifier = @@ -69,154 +104,315 @@ fun DownloadsComposable( .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + val title = + when (libraryType) { + org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) + org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) + org.grakovne.lissen.lib.domain.LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) + } + Text( - text = - when (libraryType) { - LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) - LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) - LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) - }, - style = typography.bodyLarge, + text = title, + style = + typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + letterSpacing = 0.8.sp, + fontSize = 14.sp, + ), ) - Spacer(modifier = Modifier.height(8.dp)) - - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(DownloadOptions) { index, item -> - ListItem( - headlineContent = { - Row { - Text( - text = item.makeText(context, libraryType), - style = typography.bodyMedium, - color = - when (isOnline) { - true -> colorScheme.onBackground - false -> colorScheme.onBackground.copy(alpha = 0.4f) - }, - ) - } - }, + Spacer(modifier = Modifier.height(12.dp)) + + val fileStartTimes = + androidx.compose.runtime.remember(book.id) { + book.files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) + .let { book.files.zip(it) } + } + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Surface( + color = colorScheme.surfaceContainerLow.copy(alpha = 0.5f), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() - .clickable { - if (isOnline) { - onRequestedDownload(item) - onDismissRequest() - } - }, - ) - if (index < DownloadOptions.size - 1) { - HorizontalDivider() - } - } + .border( + width = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.1f), + shape = RoundedCornerShape(16.dp), + ), + ) { + Column { + // Scenario A: Monolith + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + val monolithVolume = volumes.firstOrNull() + if (monolithVolume != null && !monolithVolume.isDownloaded) { + ActionRow( + title = + stringResource( + R.string.download_modal_monolith_title, + Formatter.formatFileSize(context, monolithVolume.size), + ), + subtitle = stringResource(R.string.download_modal_monolith_description), + icon = Icons.Default.LibraryMusic, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + isSuggested = true, + onClick = { + onRequestedDownload(org.grakovne.lissen.lib.domain.AllItemsDownloadOption) + onDismissRequest() + }, + ) + } + } - if (cachingInProgress) { - item { - HorizontalDivider() - - ListItem( - headlineContent = { - Row { - Text( - text = stringResource(R.string.downloads_menu_download_option_stop_downloads), - color = colorScheme.error, - style = typography.bodyMedium, + // Scenario B: Segmented + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.SEGMENTED) { + if (currentVolume != null && !currentVolume.isDownloaded) { + val startChapter = book.chapters.indexOfFirst { it.id == currentVolume.chapters.firstOrNull()?.id } + 1 + val endChapter = book.chapters.indexOfFirst { it.id == currentVolume.chapters.lastOrNull()?.id } + 1 + val range = if (startChapter == endChapter) "$startChapter" else "$startChapter-$endChapter" + + ActionRow( + title = + stringResource( + R.string.download_modal_segmented_part_title, + volumes.indexOf(currentVolume) + 1, + Formatter.formatFileSize(context, currentVolume.size), + ), + subtitle = stringResource(R.string.download_modal_segmented_part_subtext, range), + icon = Icons.Default.MusicNote, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + isSuggested = true, + onClick = { + onRequestedDownload( + org.grakovne.lissen.lib.domain + .SpecificFilesDownloadOption(listOf(currentVolume.id)), + ) + onDismissRequest() + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), ) } - }, - modifier = - Modifier - .fillMaxWidth() - .clickable { - onRequestedStop() - onDismissRequest() - }, - ) - } - } - if (hasCachedEpisodes) { - item { - HorizontalDivider() - - ListItem( - headlineContent = { - Row { - val fullText = - when (libraryType) { - LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_option_clear_completed_chapters) - LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_option_clear_completed_episodes) - LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_option_clear_completed_items) + if (remainingVolumes.isNotEmpty()) { + val totalSize = remainingVolumes.sumOf { it.size } + val startPart = volumes.indexOf(remainingVolumes.first()) + 1 + val endPart = volumes.indexOf(remainingVolumes.last()) + 1 + val partRange = if (startPart == endPart) "$startPart" else "$startPart-$endPart" + + ActionRow( + title = + stringResource( + R.string.download_modal_segmented_remaining, + Formatter.formatFileSize(context, totalSize), + ), + subtitle = stringResource(R.string.download_modal_segmented_remaining_subtext, partRange), + icon = Icons.Default.AutoAwesomeMotion, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + isSuggested = currentVolume?.isDownloaded == true, + onClick = { + onRequestedDownload( + org.grakovne.lissen.lib.domain + .SpecificFilesDownloadOption(remainingVolumes.map { it.id }), + ) + onDismissRequest() + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + + if (!isFullBookDownloaded) { + val totalSize = volumes.sumOf { it.size } + ActionRow( + title = stringResource(R.string.downloads_menu_download_option_entire_book), + subtitle = Formatter.formatFileSize(context, totalSize), + icon = Icons.Default.Folder, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + onClick = { + onRequestedDownload(org.grakovne.lissen.lib.domain.AllItemsDownloadOption) + onDismissRequest() + }, + ) + } + } + + // Scenario C: Atomic + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC) { + AtomicOptions.forEachIndexed { index, option -> + val requestedChapters = + calculateRequestedChapters( + book = book, + option = option, + currentTotalPosition = book.progress?.currentTime ?: 0.0, + fileStartTimes = fileStartTimes, + ) + + val totalSize = + requestedChapters + .flatMap { chapter -> findRelatedFilesByStartTimes(chapter, fileStartTimes) } + .distinctBy { it.id } + .sumOf { it.size } + + val icon = + when (option) { + is org.grakovne.lissen.lib.domain.CurrentItemDownloadOption -> Icons.Default.MusicNote + is org.grakovne.lissen.lib.domain.NumberItemDownloadOption -> Icons.Default.Layers + is org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption -> Icons.AutoMirrored.Filled.QueueMusic + is org.grakovne.lissen.lib.domain.AllItemsDownloadOption -> Icons.Default.Folder + else -> Icons.Default.Folder } - val completedWord = "completed" - val startIndex = fullText.indexOf(completedWord, ignoreCase = true) - - Text( - text = - buildAnnotatedString { - if (startIndex != -1) { - append(fullText.substring(0, startIndex)) - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(fullText.substring(startIndex, startIndex + completedWord.length)) - } - append(fullText.substring(startIndex + completedWord.length)) - } else { - append(fullText) - } - }, - color = colorScheme.error, - style = typography.bodyMedium, + ActionRow( + title = option.makeText(context, libraryType), + subtitle = Formatter.formatFileSize(context, totalSize), + icon = icon, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + isSuggested = index == 0, + onClick = { + onRequestedDownload(option) + onDismissRequest() + }, ) + + if (index < AtomicOptions.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } } - }, + } + } + } + } + + if (cachingInProgress || hasCachedContent) { + item { Spacer(modifier = Modifier.height(16.dp)) } + + item { + Surface( + color = colorScheme.surfaceContainerLow.copy(alpha = 0.5f), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() - .clickable { - onRequestedDropCompleted() - onDismissRequest() - }, - ) - - HorizontalDivider() - - ListItem( - headlineContent = { - Row { - Text( - text = - when (libraryType) { - LibraryType.LIBRARY -> - stringResource( - R.string.downloads_menu_download_option_clear_chapters, - ) - - LibraryType.PODCAST -> - stringResource( - R.string.downloads_menu_download_option_clear_episodes, - ) - - LibraryType.UNKNOWN -> - stringResource( - R.string.downloads_menu_download_option_clear_items, - ) + .border( + width = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.1f), + shape = RoundedCornerShape(16.dp), + ), + ) { + Column { + if (cachingInProgress) { + ActionRow( + title = stringResource(R.string.downloads_menu_download_option_stop_downloads), + icon = Icons.Default.StopCircle, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + isDanger = true, + onClick = { + onRequestedStop() + onDismissRequest() + }, + ) + + if (hasCachedContent) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + } + + if (hasCachedContent) { + val showClearCompleted = + completedVolumes.isNotEmpty() || + ( + storageType == org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC && + book.chapters.any { it.available && (book.progress?.currentTime ?: 0.0) >= it.end } + ) + + if (showClearCompleted) { + ActionRow( + title = + when (libraryType) { + org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> + stringResource( + R.string.downloads_menu_download_option_clear_completed_chapters, + ) + org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> + stringResource( + R.string.downloads_menu_download_option_clear_completed_episodes, + ) + else -> stringResource(R.string.downloads_menu_download_option_clear_completed_items) + }, + icon = Icons.Default.DownloadDone, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + isDanger = true, + onClick = { + onRequestedDropCompleted() + onDismissRequest() + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + + ActionRow( + title = + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + stringResource(R.string.download_modal_remove_book) + } else { + when (libraryType) { + org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> + stringResource( + R.string.downloads_menu_download_option_clear_chapters, + ) + org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> + stringResource( + R.string.downloads_menu_download_option_clear_episodes, + ) + else -> stringResource(R.string.downloads_menu_download_option_clear_items) + } }, - color = colorScheme.error, - style = typography.bodyMedium, + icon = Icons.Default.DeleteSweep, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + isDanger = true, + onClick = { + onRequestedDrop() + onDismissRequest() + }, ) } - }, - modifier = - Modifier - .fillMaxWidth() - .clickable { - onRequestedDrop() - onDismissRequest() - }, - ) + } + } } } } @@ -225,11 +421,24 @@ fun DownloadsComposable( ) } -private val DownloadOptions = +@Composable +private fun DragHandle() { + Box( + modifier = + Modifier + .padding(vertical = 8.dp) + .size(width = 32.dp, height = 4.dp) + .background(color = colorScheme.onSurfaceVariant.copy(alpha = 0.4f), shape = RoundedCornerShape(2.dp)), + ) +} + +private val AtomicOptions = listOf( - CurrentItemDownloadOption, - NumberItemDownloadOption(5), - NumberItemDownloadOption(10), - RemainingItemsDownloadOption, - AllItemsDownloadOption, + org.grakovne.lissen.lib.domain.CurrentItemDownloadOption, + org.grakovne.lissen.lib.domain + .NumberItemDownloadOption(5), + org.grakovne.lissen.lib.domain + .NumberItemDownloadOption(10), + org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption, + org.grakovne.lissen.lib.domain.AllItemsDownloadOption, ) 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 d3644410b..549a7fefe 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 @@ -58,6 +58,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -94,6 +95,11 @@ import org.grakovne.lissen.ui.extensions.withMinimumTime import org.grakovne.lissen.viewmodel.CachingModelView import org.grakovne.lissen.viewmodel.PlayerViewModel +data class VolumeIdentifier( + val bookId: String, + val fileId: String, +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun CachedItemsSettingsScreen( @@ -109,6 +115,9 @@ fun CachedItemsSettingsScreen( var pullRefreshing by remember { mutableStateOf(false) } val cachedItems = viewModel.libraryPager.collectAsLazyPagingItems() + var selectionMode by remember { mutableStateOf(false) } + val selectedVolumes = remember { mutableStateListOf() } + fun refreshContent(showPullRefreshing: Boolean) { scope.launch { if (showPullRefreshing) { @@ -151,7 +160,7 @@ fun CachedItemsSettingsScreen( ) }, navigationIcon = { - IconButton(onClick = { onBack() }) { + IconButton(onClick = { if (selectionMode) selectionMode = false else onBack() }) { Icon( imageVector = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back", @@ -159,8 +168,53 @@ fun CachedItemsSettingsScreen( ) } }, + actions = { + if (cachedItems.itemCount > 0) { + IconButton(onClick = { + selectionMode = !selectionMode + if (!selectionMode) selectedVolumes.clear() + }) { + Text( + text = if (selectionMode) "Cancel" else "Edit", // Localization later + style = typography.labelLarge, + color = colorScheme.primary, + ) + } + } + }, ) }, + bottomBar = { + if (selectionMode && selectedVolumes.isNotEmpty()) { + val totalSizeToReclaim = calculateReclaimSize(selectedVolumes, cachedItems, viewModel) + val formattedSize = Formatter.formatFileSize(context, totalSizeToReclaim) + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(colorScheme.surface) + .padding(16.dp), + ) { + Button( + onClick = { + scope.launch { + deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) + withHaptic(view) { + selectionMode = false + selectedVolumes.clear() + refreshContent(false) + } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error), + ) { + Text(stringResource(R.string.manage_downloads_free_up, formattedSize)) + } + } + } + }, ) { innerPadding -> Box( modifier = @@ -181,6 +235,8 @@ fun CachedItemsSettingsScreen( imageLoader = imageLoader, viewModel = viewModel, playerViewModel = playerViewModel, + selectionMode = selectionMode, + selectedVolumes = selectedVolumes, onItemRemoved = { refreshContent(showPullRefreshing = false) }, ) } @@ -311,6 +367,8 @@ private fun CachedItemsComposable( imageLoader: ImageLoader, viewModel: CachingModelView, playerViewModel: PlayerViewModel, + selectionMode: Boolean, + selectedVolumes: MutableList, onItemRemoved: () -> Unit, ) { val state = rememberLazyListState() @@ -345,6 +403,8 @@ private fun CachedItemsComposable( imageLoader = imageLoader, viewModel = viewModel, playerViewModel = playerViewModel, + selectionMode = selectionMode, + selectedVolumes = selectedVolumes, onItemRemoved = onItemRemoved, ) } @@ -357,6 +417,8 @@ private fun CachedItemComposable( imageLoader: ImageLoader, viewModel: CachingModelView, playerViewModel: PlayerViewModel, + selectionMode: Boolean, + selectedVolumes: MutableList, onItemRemoved: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -465,10 +527,10 @@ private fun CachedItemComposable( Spacer(Modifier.width(8.dp)) - IconButton(onClick = { - withHaptic(view) { - scope - .launch { + if (!selectionMode) { + IconButton(onClick = { + withHaptic(view) { + scope.launch { dropCache( item = book, cachingModelView = viewModel, @@ -477,13 +539,14 @@ private fun CachedItemComposable( onItemRemoved() } + } + }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null, + tint = colorScheme.error.copy(alpha = 0.8f), + ) } - }) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - tint = colorScheme.error.copy(alpha = 0.8f), - ) } } @@ -500,64 +563,79 @@ private fun CachedItemComposable( .background(colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) .padding(horizontal = 8.dp, vertical = 4.dp), ) { - CachedItemChapterComposable(book, onItemRemoved, viewModel, playerViewModel, isSingleFileBook) + CachedItemVolumeComposable( + item = book, + onItemRemoved = onItemRemoved, + viewModel = viewModel, + playerViewModel = playerViewModel, + selectionMode = selectionMode, + selectedVolumes = selectedVolumes, + ) } } } } @Composable -private fun CachedItemChapterComposable( +private fun CachedItemVolumeComposable( item: DetailedItem, onItemRemoved: () -> Unit, viewModel: CachingModelView, playerViewModel: PlayerViewModel, - isSingleFileBook: Boolean, + selectionMode: Boolean, + selectedVolumes: MutableList, ) { val scope = rememberCoroutineScope() val context = LocalContext.current val view = LocalView.current - val availableChapters = - item - .chapters - .filter { it.available } + val volumes = remember(item) { viewModel.getVolumes(item).filter { it.isDownloaded } } - availableChapters.forEachIndexed { index, chapter -> - val chapterSize = - remember(chapter) { - Formatter.formatFileSize(context, viewModel.getChapterSize(item.id, chapter, item.files)) - } + volumes.forEachIndexed { index, volume -> + val volumeSize = remember(volume) { Formatter.formatFileSize(context, volume.size) } + val isSelected = selectedVolumes.contains(VolumeIdentifier(item.id, volume.id)) - key(chapter.id) { + key(volume.id) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 8.dp), + .clickable(enabled = selectionMode) { + val identifier = VolumeIdentifier(item.id, volume.id) + if (isSelected) selectedVolumes.remove(identifier) else selectedVolumes.add(identifier) + }.padding(vertical = 8.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { + if (selectionMode) { + androidx.compose.material3.Checkbox( + checked = isSelected, + onCheckedChange = { + val identifier = VolumeIdentifier(item.id, volume.id) + if (it) selectedVolumes.add(identifier) else selectedVolumes.remove(identifier) + }, + modifier = Modifier.padding(end = 8.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { - Text(text = chapter.title, style = typography.bodyMedium) - if (!isSingleFileBook) { - Text( - text = chapterSize, - style = typography.labelMedium.copy(color = colorScheme.onSurface.copy(alpha = 0.5f)), - ) - } + Text(text = volume.name, style = typography.bodyMedium) + Text( + text = volumeSize, + style = typography.labelMedium.copy(color = colorScheme.onSurface.copy(alpha = 0.5f)), + ) } - if (!isSingleFileBook) { + if (!selectionMode && volumes.size > 1) { IconButton( onClick = { withHaptic(view) { scope.launch { - dropCache( - item = item, - chapter = chapter, - cachingModelView = viewModel, - playerViewModel = playerViewModel, - ) + playerViewModel.book.value?.let { playingBook -> + if (playingBook.id == item.id) { + playerViewModel.clearPlayingBook() + } + } + viewModel.dropCache(item, volume.chapters.first()) // dropCache by chapter handles file deletion onItemRemoved() } } @@ -574,7 +652,7 @@ private fun CachedItemChapterComposable( } } - if (index < availableChapters.lastIndex) { + if (index < volumes.lastIndex) { HorizontalDivider( thickness = 0.5.dp, modifier = Modifier.padding(horizontal = 8.dp), @@ -585,6 +663,46 @@ private fun CachedItemChapterComposable( } } +private fun calculateReclaimSize( + selectedIds: List, + cachedItems: LazyPagingItems, + viewModel: CachingModelView, +): Long { + var total = 0L + selectedIds.forEach { selection -> + val book = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }.find { it.id == selection.bookId } + book?.let { + val volumes = viewModel.getVolumes(it) + val volume = volumes.find { v -> v.id == selection.fileId } + total += volume?.size ?: 0L + } + } + return total +} + +private suspend fun deleteSelectedVolumes( + selectedIds: List, + cachedItems: LazyPagingItems, + viewModel: CachingModelView, + playerViewModel: PlayerViewModel, +) { + selectedIds.forEach { selection -> + val book = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }.find { it.id == selection.bookId } + book?.let { + val volumes = viewModel.getVolumes(it) + val volume = volumes.find { v -> v.id == selection.fileId } + volume?.chapters?.firstOrNull()?.let { chapter -> + playerViewModel.book.value?.let { playingBook -> + if (playingBook.id == it.id) { + playerViewModel.clearPlayingBook() + } + } + viewModel.dropCache(it, chapter) + } + } + } +} + private suspend fun dropCache( item: DetailedItem, chapter: PlayingChapter, 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 07c488652..a3275b916 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -105,12 +105,16 @@ class CachingModelView fun getBookSize(book: DetailedItem) = localCacheRepository.calculateBookSize(book) - fun getChapterSize( + fun calculateChapterSize( bookId: String, - chapter: PlayingChapter, + chapter: org.grakovne.lissen.lib.domain.PlayingChapter, files: List, ) = localCacheRepository.calculateChapterSize(bookId, chapter, files) + fun getBookStorageType(book: DetailedItem) = localCacheRepository.getBookStorageType(book) + + fun getVolumes(book: DetailedItem) = localCacheRepository.mapChaptersToVolumes(book) + suspend fun clearShortTermCache() { withContext(Dispatchers.IO) { cachedCoverProvider.clearCache() diff --git a/app/src/main/res/drawable/available_offline_filled.xml b/app/src/main/res/drawable/available_offline_filled.xml index e483c8f77..797ebb5d8 100644 --- a/app/src/main/res/drawable/available_offline_filled.xml +++ b/app/src/main/res/drawable/available_offline_filled.xml @@ -3,32 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4f55871a..c3c443254 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -244,4 +244,23 @@ No downloads yet Your downloaded books and chapters will appear here. Go to Library + + + Download entire book (%1$s) + This book is optimized as a single archive. Individual chapter downloads are not available. + Remove book from downloads + + Download Part %1$d (%2$s) + Chapters %1$s + Download remaining parts (%1$s) + Parts %1$s + + Download remaining chapters (%1$s) + Download entire book (%1$s) + + Volume %1$d + Full Archive + Chapters %1$s + + Free up %1$s? \ No newline at end of file diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/BookStorage.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/BookStorage.kt new file mode 100644 index 000000000..c708642c3 --- /dev/null +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/BookStorage.kt @@ -0,0 +1,37 @@ +package org.grakovne.lissen.lib.domain + +import androidx.annotation.Keep +import java.io.Serializable + +/** + * Categorizes how an audiobook is physically stored on the server. + */ +@Keep +enum class BookStorageType { + /** + * The entire book is a single physical file (e.g., M4B). + */ + MONOLITH, + + /** + * The book is split into multiple files, each containing multiple chapters. + */ + SEGMENTED, + + /** + * Each file corresponds to exactly one chapter. + */ + ATOMIC +} + +/** + * Represents a physical storage unit (file) as a semantic "Volume" or "Part". + */ +@Keep +data class BookVolume( + val id: String, + val name: String, + val size: Long, + val chapters: List, + val isDownloaded: Boolean +) : Serializable diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt index b56de374a..9b480b291 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt @@ -32,6 +32,7 @@ data class BookFile( val id: String, val name: String, val duration: Double, + val size: Long, val mimeType: String, ) : Serializable diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt index 008f149ec..b31895c76 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt @@ -7,7 +7,11 @@ import java.io.Serializable sealed interface DownloadOption : Serializable class NumberItemDownloadOption( - val itemsNumber: Int, + val itemsNumber: Int, +) : DownloadOption + +class SpecificFilesDownloadOption( + val fileIds: List, ) : DownloadOption data object CurrentItemDownloadOption : DownloadOption @@ -17,19 +21,21 @@ data object RemainingItemsDownloadOption : DownloadOption data object AllItemsDownloadOption : DownloadOption fun DownloadOption?.makeId() = when (this) { - null -> "disabled" - AllItemsDownloadOption -> "all_items" - CurrentItemDownloadOption -> "current_item" - is NumberItemDownloadOption -> "number_items_$itemsNumber" - RemainingItemsDownloadOption -> "remaining_items" + null -> "disabled" + AllItemsDownloadOption -> "all_items" + CurrentItemDownloadOption -> "current_item" + is NumberItemDownloadOption -> "number_items_$itemsNumber" + is SpecificFilesDownloadOption -> "specific_files_${fileIds.joinToString(",")}" + RemainingItemsDownloadOption -> "remaining_items" } fun String?.makeDownloadOption(): DownloadOption? = when { - this == null -> null - this == "all_items" -> AllItemsDownloadOption - this == "current_item" -> CurrentItemDownloadOption - this == "remaining_items" -> RemainingItemsDownloadOption - startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt()) - else -> null + this == null -> null + this == "all_items" -> AllItemsDownloadOption + this == "current_item" -> CurrentItemDownloadOption + this == "remaining_items" -> RemainingItemsDownloadOption + startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt()) + startsWith("specific_files_") -> SpecificFilesDownloadOption(substringAfter("specific_files_").split(",")) + else -> null } From 714d9b23147e42e0adc72474cecebc30d59afbd3 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Wed, 4 Feb 2026 23:55:13 +0530 Subject: [PATCH 05/37] feat: Allow subtitles to span two lines and refine download modal monolith strings. --- .../grakovne/lissen/ui/screens/player/composable/ActionRow.kt | 2 +- app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt index b1b7d017f..9dc591dc8 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt @@ -155,7 +155,7 @@ fun ActionRow( text = subtitle, style = MaterialTheme.typography.labelSmall, color = subtextColor.copy(alpha = contentAlpha), - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3c443254..c21f7a9df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,8 +246,8 @@ Go to Library - Download entire book (%1$s) - This book is optimized as a single archive. Individual chapter downloads are not available. + Download book (%1$s) + High-fidelity single archive. Individual chapter downloads are not available. Remove book from downloads Download Part %1$d (%2$s) From de72283eee6a8408de7b9318177ea8d16e5160ea Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Thu, 5 Feb 2026 00:51:38 +0530 Subject: [PATCH 06/37] feat: Introduce `Queued` cache status, replace the shimmering download badge with a checkmark icon, and ensure reactive UI updates for caching progress. --- .../cache/persistent/ContentCachingManager.kt | 148 ++++++++++++------ .../cache/persistent/ContentCachingService.kt | 2 +- .../ui/components/DownloadProgressIcon.kt | 111 ++++++++++--- .../ui/screens/details/BookDetailScreen.kt | 104 ++++-------- .../composable/NavigationBarComposable.kt | 5 + .../lissen/viewmodel/CachingModelView.kt | 10 ++ .../grakovne/lissen/lib/domain/CacheStatus.kt | 3 +- 7 files changed, 236 insertions(+), 147 deletions(-) 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 0b641d40e..d5439368f 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 @@ -3,13 +3,15 @@ package org.grakovne.lissen.content.cache.persistent import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withContext import okhttp3.Request import org.grakovne.lissen.channel.audiobookshelf.common.api.RequestHeadersProvider import org.grakovne.lissen.channel.common.MediaChannel import org.grakovne.lissen.channel.common.createOkHttpClient import org.grakovne.lissen.content.cache.common.findRelatedFiles +import org.grakovne.lissen.content.cache.common.findRelatedFilesByStartTimes import org.grakovne.lissen.content.cache.common.withBlur import org.grakovne.lissen.content.cache.common.writeToFile import org.grakovne.lissen.content.cache.persistent.api.CachedBookRepository @@ -42,58 +44,91 @@ class ContentCachingManager option: DownloadOption, channel: MediaChannel, currentTotalPosition: Double, - ) = flow { - val context = coroutineContext - - val requestedChapters = - calculateRequestedChapters( - book = mediaItem, - option = option, - currentTotalPosition = currentTotalPosition, - ) + ) = channelFlow { + try { + send(CacheState(CacheStatus.Queued)) + + val fileStartTimes = + withContext(Dispatchers.Default) { + val startTimes = + mediaItem.files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) + mediaItem.files.zip(startTimes) + } - val existingChapters = - bookRepository - .fetchBook(bookId = mediaItem.id) - ?.chapters - ?.filter { it.available } - ?: emptyList() + val requestedChapters = + calculateRequestedChapters( + book = mediaItem, + option = option, + currentTotalPosition = currentTotalPosition, + fileStartTimes = fileStartTimes, + ) - val cachingChapters = requestedChapters - existingChapters.toSet() + val existingChapters = + withContext(Dispatchers.IO) { + bookRepository + .fetchBook(bookId = mediaItem.id) + ?.chapters + ?.filter { it.available } + ?: emptyList() + } - val requestedFiles = findRequestedFiles(mediaItem, cachingChapters) + val cachingChapters = requestedChapters - existingChapters.toSet() - if (requestedFiles.isEmpty()) { - emit(CacheState(CacheStatus.Completed)) - return@flow - } + val requestedFiles = + withContext(Dispatchers.Default) { + cachingChapters + .flatMap { findRelatedFilesByStartTimes(it, fileStartTimes) } + .distinctBy { it.id } + } - emit(CacheState(CacheStatus.Caching)) - - val mediaCachingResult = - cacheBookMedia( - mediaItem.id, - requestedFiles, - channel, - ) { withContext(context) { emit(CacheState(CacheStatus.Caching, it)) } } - - val coverCachingResult = cacheBookCover(mediaItem, channel) - val librariesCachingResult = cacheLibraries(channel) - - when { - listOf( - mediaCachingResult, - coverCachingResult, - librariesCachingResult, - ).all { it.status == CacheStatus.Completed } -> { - cacheBookInfo(mediaItem, requestedChapters) - emit(CacheState(CacheStatus.Completed)) + if (requestedFiles.isEmpty()) { + send(CacheState(CacheStatus.Completed)) + return@channelFlow } - else -> { - cachingChapters.map { dropCache(mediaItem, it) } - emit(CacheState(CacheStatus.Error)) + send(CacheState(CacheStatus.Caching, 0.0)) + + val mediaCachingDeferred = + async { + cacheBookMedia( + mediaItem.id, + requestedFiles, + channel, + ) { send(CacheState(CacheStatus.Caching, it)) } + } + + val coverCachingDeferred = async { cacheBookCover(mediaItem, channel) } + val librariesCachingDeferred = async { cacheLibraries(channel) } + + val mediaCachingResult = mediaCachingDeferred.await() + val coverCachingResult = coverCachingDeferred.await() + val librariesCachingResult = librariesCachingDeferred.await() + + when { + listOf( + mediaCachingResult, + coverCachingResult, + librariesCachingResult, + ).all { it.status == CacheStatus.Completed } -> { + cacheBookInfo(mediaItem, requestedChapters) + send(CacheState(CacheStatus.Completed)) + } + + else -> { + cachingChapters.map { dropCache(mediaItem, it) } + send(CacheState(CacheStatus.Error)) + } } + } catch (e: Exception) { + if (e !is kotlinx.coroutines.CancellationException) { + Timber.e(e, "Failed to cache media item") + send(CacheState(CacheStatus.Error)) + } + throw e + } finally { + // No additional terminal state needed if completed/error already sent } } @@ -202,14 +237,31 @@ class ContentCachingManager try { dest.outputStream().use { output -> body.byteStream().use { input -> - input.copyTo(output) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead = 0L + val contentLength = body.contentLength().takeIf { it > 0 } ?: file.size + + var lastReportedProgress = -1.0 + val reportThreshold = 0.01 // 1% + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + val fileProgress = totalBytesRead.toDouble() / contentLength.toDouble() + val overallProgress = (index.toDouble() + fileProgress) / files.size.toDouble() + + if (overallProgress - lastReportedProgress >= reportThreshold || overallProgress >= 1.0) { + onProgress(overallProgress) + lastReportedProgress = overallProgress + } + } } } } catch (ex: Exception) { return@withContext CacheState(CacheStatus.Error) } - - onProgress(files.size.takeIf { it != 0 }?.let { index / it.toDouble() } ?: 0.0) } 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 a34ab2c8d..add626d6d 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 @@ -131,7 +131,7 @@ class ContentCachingService : LifecycleService() { finish() } - private fun inProgress(): Boolean = executionStatuses.values.any { it.status == CacheStatus.Caching } + private fun inProgress(): Boolean = executionStatuses.values.any { it.status == CacheStatus.Caching || it.status == CacheStatus.Queued } private fun hasErrors(): Boolean = executionStatuses.values.any { it.status == CacheStatus.Error } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index 3c5a4b9e5..98acb7427 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -1,14 +1,21 @@ package org.grakovne.lissen.ui.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.stringResource @@ -25,35 +32,87 @@ import org.grakovne.lissen.lib.domain.CacheStatus @Composable fun DownloadProgressIcon( cacheState: CacheState, + isFullyDownloaded: Boolean, size: Dp = 24.dp, color: Color = LocalContentColor.current, ) { - if (cacheState.status is CacheStatus.Caching) { - val progress = cacheState.progress.coerceIn(0.0, 1.0).toFloat() - val progressPercent = (progress * 100).toInt() - val progressDescription = stringResource(R.string.download_progress_description, progressPercent) + Box(contentAlignment = Alignment.Center) { + when (cacheState.status) { + is CacheStatus.Queued -> { + CircularProgressIndicator( + modifier = Modifier.size(size - 4.dp), + strokeWidth = 2.dp, + color = colorScheme.primary, + trackColor = color.copy(alpha = 0.1f), + strokeCap = StrokeCap.Round, + ) + } - val iconSize = size - 2.dp - CircularProgressIndicator( - progress = { progress }, - modifier = - Modifier - .semantics(mergeDescendants = true) { - progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f) - contentDescription = progressDescription - }.size(iconSize), - strokeWidth = iconSize * 0.1f, - color = colorScheme.primary, - trackColor = color, - strokeCap = StrokeCap.Butt, - gapSize = 2.dp, - ) - } else { - Icon( - imageVector = Icons.Outlined.CloudDownload, - contentDescription = stringResource(R.string.player_screen_downloads_navigation), - modifier = Modifier.size(size), - tint = color, - ) + is CacheStatus.Caching -> { + val targetProgress = cacheState.progress.coerceIn(0.0, 1.0).toFloat() + val animatedProgress by animateFloatAsState( + targetValue = targetProgress, + animationSpec = tween(durationMillis = 500), + label = "progress", + ) + + val progressDescription = + stringResource( + R.string.download_progress_description, + (animatedProgress * 100).toInt(), + ) + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Modifier + .semantics(mergeDescendants = true) { + progressBarRangeInfo = ProgressBarRangeInfo(animatedProgress, 0f..1f) + contentDescription = progressDescription + }.size(size - 2.dp), + strokeWidth = (size - 2.dp) * 0.12f, + color = colorScheme.primary, + trackColor = color.copy(alpha = 0.1f), + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + ) + } + + is CacheStatus.Completed -> { + if (isFullyDownloaded) { + Icon( + imageVector = Icons.Filled.CloudDone, + contentDescription = null, + modifier = Modifier.size(size), + tint = colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Outlined.CloudDownload, + contentDescription = stringResource(R.string.player_screen_downloads_navigation), + modifier = Modifier.size(size).alpha(0.8f), + tint = color, + ) + } + } + + else -> { + if (isFullyDownloaded) { + Icon( + imageVector = Icons.Filled.CloudDone, + contentDescription = null, + modifier = Modifier.size(size), + tint = colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Outlined.CloudDownload, + contentDescription = stringResource(R.string.player_screen_downloads_navigation), + modifier = Modifier.size(size).alpha(0.8f), + tint = color, + ) + } + } + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt index 7c6e6fda9..57815c7ff 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -8,9 +8,7 @@ import android.text.style.StyleSpan import android.text.style.URLSpan import android.text.style.UnderlineSpan import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween @@ -43,6 +41,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AvTimer +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -148,6 +147,14 @@ fun BookDetailScreen( var downloadsExpanded by remember { mutableStateOf(false) } val scope = androidx.compose.runtime.rememberCoroutineScope() + val cacheVersion by cachingModelView.cacheVersion.collectAsState(initial = 0L) + val volumes = + remember(bookDetail, cacheProgress.status, cacheVersion) { + bookDetail?.let { cachingModelView.getVolumes(it) } + ?: emptyList() + } + val isFullyDownloaded = volumes.isNotEmpty() && volumes.all { it.isDownloaded } + LaunchedEffect(bookId) { timber.log.Timber.d("BookDetailScreen: Launched with bookId $bookId") } @@ -227,6 +234,7 @@ fun BookDetailScreen( IconButton(onClick = { downloadsExpanded = true }) { DownloadProgressIcon( cacheState = cacheProgress, + isFullyDownloaded = isFullyDownloaded, color = colorScheme.onSurface, ) } @@ -242,7 +250,6 @@ fun BookDetailScreen( } else { val book = bookDetail!! val storageType = remember(book.id) { cachingModelView.getBookStorageType(book) } - val volumes = remember(book.id) { cachingModelView.getVolumes(book) } val isPodcast = book.libraryType == LibraryType.PODCAST val chapters = if (isPodcast) book.chapters.reversed() else book.chapters @@ -285,20 +292,32 @@ fun BookDetailScreen( ) } - // Downloaded Badge - if (volumes.all { it.isDownloaded }) { - ShimmeringDownloadedBadge() - Spacer(modifier = Modifier.height(12.dp)) - } - // Title & Author - Text( - text = book.title, - style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), - color = colorScheme.onSurface, - textAlign = TextAlign.Center, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, modifier = Modifier.padding(horizontal = 24.dp), - ) + ) { + Text( + text = book.title, + style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f, fill = false), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (isFullyDownloaded) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } book.author?.takeIf { it.isNotBlank() }?.let { Text( @@ -598,61 +617,6 @@ fun BookDetailScreen( } } -@Composable -fun ShimmeringDownloadedBadge() { - val infiniteTransition = - androidx.compose.animation.core - .rememberInfiniteTransition(label = "shimmer") - val xShimmer by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1000f, - animationSpec = - androidx.compose.animation.core.infiniteRepeatable( - animation = - androidx.compose.animation.core - .tween(durationMillis = 1500, easing = androidx.compose.animation.core.LinearEasing), - ), - label = "shimmer", - ) - - val shimmerColors = - listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.primary, - ) - - val brush = - Brush.linearGradient( - colors = shimmerColors, - start = - androidx.compose.ui.geometry - .Offset(xShimmer - 300f, xShimmer - 300f), - end = - androidx.compose.ui.geometry - .Offset(xShimmer, xShimmer), - ) - - Box( - modifier = - Modifier - .clip(RoundedCornerShape(16.dp)) - .background(brush) - .padding(horizontal = 12.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.chapter_cached_indicator).uppercase(), - style = - typography.labelLarge.copy( - fontWeight = FontWeight.Black, - letterSpacing = 1.2.sp, - ), - color = MaterialTheme.colorScheme.onPrimary, - ) - } -} - @Composable fun DetailRow( icon: ImageVector, 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 71a07b542..189e00bbe 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 @@ -99,6 +99,10 @@ fun NavigationBarComposable( .CacheState(org.grakovne.lissen.lib.domain.CacheStatus.Idle), ) + val cacheVersion by contentCachingModelView.cacheVersion.collectAsState(initial = 0L) + val volumes = remember(book, cacheProgress.status, cacheVersion) { contentCachingModelView.getVolumes(book) } + val isFullyDownloaded = volumes.isNotEmpty() && volumes.all { it.isDownloaded } + // Get volume boost label val volumeBoostIsActive = preferredPlaybackVolumeBoost != null && preferredPlaybackVolumeBoost != PlaybackVolumeBoost.DISABLED @@ -134,6 +138,7 @@ fun NavigationBarComposable( icon = { DownloadProgressIcon( cacheState = cacheProgress, + isFullyDownloaded = isFullyDownloaded, size = iconSize, color = 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 a3275b916..bf77e4852 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -52,6 +52,9 @@ class CachingModelView private val _storageStats = MutableStateFlow(null) val storageStats: Flow = _storageStats + private val _cacheVersion = MutableStateFlow(0L) + val cacheVersion: Flow = _cacheVersion + data class StorageStats( val usedBytes: Long, val freeBytes: Long, @@ -86,6 +89,10 @@ class CachingModelView MutableStateFlow(progress) } flow.value = progress + + if (progress.status is CacheStatus.Completed || progress.status is CacheStatus.Error) { + _cacheVersion.value += 1 + } } } @@ -147,11 +154,13 @@ class CachingModelView suspend fun dropCache(bookId: String) { contentCachingManager.dropCache(bookId) + _cacheVersion.value += 1 refreshStorageStats() } suspend fun dropCompletedChapters(item: DetailedItem) { contentCachingManager.dropCompletedChapters(item) + _cacheVersion.value += 1 } fun stopCaching(item: DetailedItem) { @@ -169,6 +178,7 @@ class CachingModelView chapter: PlayingChapter, ) { contentCachingManager.dropCache(item, chapter) + _cacheVersion.value += 1 refreshStorageStats() } diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/CacheStatus.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/CacheStatus.kt index d6a76e4bb..bf54c9e0d 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/CacheStatus.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/CacheStatus.kt @@ -5,9 +5,8 @@ import androidx.annotation.Keep @Keep sealed class CacheStatus { data object Idle : CacheStatus() - + data object Queued : CacheStatus() data object Caching : CacheStatus() - data object Completed : CacheStatus() data object Error : CacheStatus() From a1064857c0cc006d25aef384d8a0d99f8cc362f4 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Thu, 5 Feb 2026 01:01:31 +0530 Subject: [PATCH 07/37] feat: enhance caching progress reporting with notification throttling, improved animation, and robust progress calculation. --- .../cache/persistent/ContentCachingManager.kt | 2 +- .../ContentCachingNotificationService.kt | 10 +++++--- .../cache/persistent/ContentCachingService.kt | 25 +++++++++++++------ .../ui/components/DownloadProgressIcon.kt | 7 +++++- 4 files changed, 31 insertions(+), 13 deletions(-) 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 d5439368f..4d8fd20de 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 @@ -249,7 +249,7 @@ class ContentCachingManager output.write(buffer, 0, bytesRead) totalBytesRead += bytesRead - val fileProgress = totalBytesRead.toDouble() / contentLength.toDouble() + val fileProgress = if (contentLength > 0) totalBytesRead.toDouble() / contentLength.toDouble() else 0.0 val overallProgress = (index.toDouble() + fileProgress) / files.size.toDouble() if (overallProgress - lastReportedProgress >= reportThreshold || overallProgress >= 1.0) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingNotificationService.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingNotificationService.kt index f3734d804..bc52a3469 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingNotificationService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingNotificationService.kt @@ -28,12 +28,16 @@ class ContentCachingNotificationService fun updateCachingNotification(items: List>): Notification { val cachingItems = items - .filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed).contains(state.status) } + .filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed, CacheStatus.Queued).contains(state.status) } val itemProgress = cachingItems.sumOf { (_, state) -> when (state.status) { - CacheStatus.Caching -> state.progress + CacheStatus.Caching -> { + val x = state.progress.coerceIn(0.0, 1.0) + 1.0 - (1.0 - x) * (1.0 - x) + } + CacheStatus.Completed -> 1.0 else -> 0.0 } @@ -80,7 +84,7 @@ class ContentCachingNotificationService companion object { private fun List>.provideCachingTitles() = this - .filter { (_, state) -> CacheStatus.Caching == state.status } + .filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Queued).contains(state.status) } .joinToString(", ") { (key, _) -> key.title } const val NOTIFICATION_ID = 2042025 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 add626d6d..e4a9303c1 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 @@ -37,6 +37,9 @@ class ContentCachingService : LifecycleService() { private val executionStatuses = mutableMapOf() private val executingCaching = mutableMapOf() + private var lastNotificationUpdate = 0L + private val notificationUpdateThrottle = 500L + override fun onStartCommand( intent: Intent?, flags: Int, @@ -110,16 +113,22 @@ class ContentCachingService : LifecycleService() { executionStatuses[item] = progress cacheProgressBus.emit(item, progress) - Timber.d("Caching progress updated: $progress") + val isTerminalState = + progress.status is CacheStatus.Completed || progress.status is CacheStatus.Error || progress.status is CacheStatus.Idle + val currentTime = System.currentTimeMillis() + val shouldUpdateNotification = isTerminalState || (currentTime - lastNotificationUpdate >= notificationUpdateThrottle) + + if (shouldUpdateNotification && inProgress() && hasErrors().not()) { + executionStatuses + .entries + .map { (item, status) -> item to status } + .let { notificationService.updateCachingNotification(it) } - when (inProgress() && hasErrors().not()) { - true -> - executionStatuses - .entries - .map { (item, status) -> item to status } - .let { notificationService.updateCachingNotification(it) } + lastNotificationUpdate = currentTime + } - false -> finish() + if (!inProgress() && !hasErrors()) { + finish() } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index 98acb7427..fa0a37d8d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -1,5 +1,6 @@ package org.grakovne.lissen.ui.components +import androidx.compose.animation.core.EaseOutQuart import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box @@ -52,7 +53,11 @@ fun DownloadProgressIcon( val targetProgress = cacheState.progress.coerceIn(0.0, 1.0).toFloat() val animatedProgress by animateFloatAsState( targetValue = targetProgress, - animationSpec = tween(durationMillis = 500), + animationSpec = + tween( + durationMillis = 800, + easing = EaseOutQuart, + ), label = "progress", ) From 41717a8f36a64c284403cc2854aedaab69379e74 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Thu, 5 Feb 2026 01:26:17 +0530 Subject: [PATCH 08/37] feat: Remove thumbnail cache clearing functionality and associated UI strings from multiple locales. --- .../persistence/preferences/LissenSharedPreferences.kt | 6 +++++- app/src/main/res/values-ar/strings.xml | 3 --- app/src/main/res/values-da/strings.xml | 3 --- app/src/main/res/values-de/strings.xml | 3 --- app/src/main/res/values-fr/strings.xml | 3 --- app/src/main/res/values-hr/strings.xml | 3 --- app/src/main/res/values-hu/strings.xml | 3 --- app/src/main/res/values-in/strings.xml | 3 --- app/src/main/res/values-it/strings.xml | 3 --- app/src/main/res/values-ko/strings.xml | 3 --- app/src/main/res/values-pl/strings.xml | 3 --- app/src/main/res/values-ru/strings.xml | 3 --- app/src/main/res/values-sl/strings.xml | 3 --- app/src/main/res/values-zh-rCN/strings.xml | 3 --- .../kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt | 6 +++--- 15 files changed, 8 insertions(+), 43 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index c4459a23a..72d626db1 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -387,7 +387,11 @@ class LissenSharedPreferences null -> null else -> { val adapter = moshi.adapter(DetailedItem::class.java) - adapter.fromJson(json) + try { + adapter.fromJson(json) + } catch (e: Exception) { + null + } } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 82c7f2ea1..3e8516089 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -144,9 +144,6 @@ اسم شبكة WiFi التطبيق يحتاج صلاحيات الموقع للتعرف على اسم شبكة الـWiFi السماح - مسح ذاكرة التخزين المؤقتة للصور المصغرة - إزالة الصور المصغّرة المخزنة لتحرير المساحة. - تم مسح ذاكرة الصور المصغّرة تأخير التنزيل التلقائي انتظر وقتاً قصيراً قبل البدء بالتنزيل تعطيل التحقق من SSL diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f9eed1bbf..c62e90e18 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -148,9 +148,6 @@ Wi-Fi-netværksnavn Appen behøver placeringsadgang for at kunne identificere Wi-Fi-netværket Tillad - Ryd billedmellemlager for forsidebillede - Ryd billedmellemlager for forsidebilleder for at frigøre lagerplads. - Forsidebilledermellemlager ryddet Udskyd automatisk hentning Vent et kort stykke tid, før hentningen påbegyndes Serveren er utilgængelig diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c9caaffdc..639f06f74 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -140,14 +140,11 @@ Bücher Podcasts Unbekannt - Vorschaubild-Cache gelöscht Lokale Netzwerkserveradresse Serverzugriff für Heim-WLAN definieren WLAN Netzwerk Name Die App benötigt Zugriff auf den Standort, um den WLAN-Namen zu erkennen Erlauben - Vorschaubild-Cache löschen - Vorschaubild-Cache löschen, um Speicherplatz freizugeben. Verzögere automatisches Herunterladen Warte eine kurze Zeit bevor der Download startet Verbindungstyp diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 812c5e075..9e3d50bde 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -149,9 +149,6 @@ Nom du réseau WiFi L\'application nécessite l\'accès à la localisation pour détecter le nom du Wi-Fi Autoriser - Nettoyer le cache des miniatures - Supprimer le cache des miniatures pour libérer de l\'espace. - Cache des miniatures nettoyé Reporter le téléchargement automatique Attendre un peu avant que le téléchargement ne commence Maximum diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 1a9fa1bc7..ef0fdbecb 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -144,9 +144,6 @@ Aplikaciji je potreban pristup lokaciji kako bi otkrila naziv Wi-Fi mreže Naziv WiFi mreže Dozvoli - Izbriši predmemoriju sličica - Uklonite predmemorirane sličice kako biste oslobodili prostor. - Predmemorija sličica izbrisana Odgodi automatsko preuzimanje Kratko pričekaj prije početka preuzimanja Onemogući SSL provjeru diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 1a91af9cf..83e2b9fad 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -149,9 +149,6 @@ Helyi hálózat Távoli hálózat Letöltés leállítása - Miniatűrök gyorsítótárának törlése - Távolítsa el a gyorsítótárban tárolt miniatűröket, hogy helyet szabadítson fel. - Miniatűrök gyorsítótára törölve Automatikus letöltés késleltetése Várjon egy kicsit, mielőtt elkezdené a letöltést SSL-ellenőrzés letiltása diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 3f8fa2607..9df87babd 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -150,9 +150,6 @@ Nama jaringan Wi-Fi Akses lokasi diperlukan agar dapat mendeteksi nama Wi-Fi Izinkan - Hapus cache thumbnail - Hapus cache thumbnail untuk membebaskan ruang. - Cache thumbnail berhasil dibersihkan Tunda unduhan otomatis Jeda singkat sebelum memulai unduhan Nonaktifkan verifikasi SSL diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3aa6e0e1d..5fefe8cf0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -144,9 +144,6 @@ Nome rete WiFi L\'app necessita dell\'accesso alla posizione per rilevare il nome del Wi-Fi Consenti - Pulisci cache delle miniature - Rimuovi le miniature in cache per liberare spazio. - Cache delle miniature pulita Tipo di connessione Rete locale Rete esterna diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 43cf3bd40..b1d42fe51 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -149,9 +149,6 @@ WiFi 네트워크 이름 앱이 WiFi 이름을 감지하려면 위치 접근 권한이 필요합니다. 허용 - 썸네일 캐시 삭제 - 공간 확보를 위해 썸네일 캐시 삭제 - 썸네일 캐시 삭제됨 자동 다운로드 지연 다운로드를 시작하기 전에 잠시 대기 SSL 인증 비활성화 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9c4664b7f..87816738e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -150,9 +150,6 @@ Nazwa sieci WiFi Aplikacja potrzebuje dostępu do lokalizacji aby wykryć nazwę WiFi Zezwól - Usuń pamięć podręczną miniatur - Usuń pamięć podręczną miniatur aby zwolnić miejsce. - Usunięto pamięć podręczną miniatur Opóźnij automatyczne pobieranie Zaczekaj krótki czas przed pobraniem Wyłącz weryfikację certyfikatu SSL diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c1a34daa7..3b733a79d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -147,9 +147,6 @@ Имя WiFi сети Для определения текущей Wi‑Fi сети приложению нужен доступ к местоположению Разрешить - Очистить кеш обложек - Удалить загруженные с сервера обложки - Кеш обложек очищен Откладывать автоматическую загрузку Ждать перед началом скачивания Тип подключения diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 126453287..f93b526bd 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -150,9 +150,6 @@ Ime omrežja Wi-Fi Aplikacija potrebuje dostop do lokacije za zaznavanje imena omrežja Wi-Fi Dovoli - Počisti predpomnilnik sličic - Odstranite predpomnjene sličice, da sprostite prostor. - Predpomnilnik sličic je bil izbrisan Zakasnitev samodejnega prenosa Preden začnete s prenosom, počakajte nekaj časa Onemogoči preverjanje SSL diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6fbb0dd2b..cf9af6d3a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -145,9 +145,6 @@ Wi-Fi 网络名称 该应用需要访问位置权限以检测 Wi-Fi 名称 允许 - 清除缩略图缓存 - 清除缓存的缩略图以释放空间。 - 缩略图缓存已清除 停止下载 延迟自动下载 开始下载前等待一小段时间 diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt index 9b480b291..d3177821d 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt @@ -21,9 +21,9 @@ data class DetailedItem( val progress: MediaProgress?, val libraryId: String?, val libraryType: LibraryType?, - val localProvided: Boolean, - val createdAt: Long, - val updatedAt: Long, + val localProvided: Boolean = false, + val createdAt: Long = 0, + val updatedAt: Long = 0, ) : Serializable @Keep From f936b5c861d2d130420efee3e1899f0eb395b6fc Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Thu, 5 Feb 2026 14:46:59 +0530 Subject: [PATCH 09/37] feat: Integrate Microsoft Clarity for analytics tracking and UI masking for sensitive UI elements. --- app/build.gradle.kts | 4 + app/proguard-rules.pro | 6 +- .../lissen/analytics/AnalyticsModule.kt | 16 ++++ .../lissen/analytics/ClarityComponent.kt | 75 +++++++++++++++++++ .../lissen/analytics/ClarityTracker.kt | 43 +++++++++++ .../grakovne/lissen/content/AuthRepository.kt | 5 ++ .../grakovne/lissen/content/BookRepository.kt | 3 + .../cache/persistent/ContentCachingManager.kt | 4 + .../lissen/playback/MediaRepository.kt | 8 ++ .../LibrarySearchActionComposable.kt | 2 + .../lissen/ui/screens/login/LoginScreen.kt | 4 + .../player/ChapterSearchActionComposable.kt | 2 + .../advanced/CustomHeaderComposable.kt | 5 +- .../settings/advanced/LocalUrlComposable.kt | 6 +- .../lissen/viewmodel/LibraryViewModel.kt | 8 +- .../lissen/viewmodel/SettingsViewModel.kt | 3 + gradle/libs.versions.toml | 2 + 17 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7cbac8275..d871bdfe2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,8 @@ android { buildConfigField("String", "ACRA_REPORT_LOGIN", "\"$acraReportLogin\"") buildConfigField("String", "ACRA_REPORT_PASSWORD", "\"$acraReportPassword\"") + buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"") + signingConfigs { create("release") { val envKeyStore = System.getenv("RELEASE_STORE_FILE") @@ -194,6 +196,8 @@ dependencies { implementation(libs.moshi) implementation(libs.moshi.kotlin) + implementation(libs.microsoft.clarity) + debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index cbbb0eeb9..e919402af 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,4 +17,8 @@ # Hilt and Dagger rules (usually bundled, but good to ensure) -keep class dagger.hilt.android.internal.** { *; } -keep class *__HiltBindingModule { *; } --keep class org.grakovne.lissen.**_HiltComponents$* { *; } \ No newline at end of file +-keep class org.grakovne.lissen.**_HiltComponents$* { *; } + +# Microsoft Clarity +-keep class com.microsoft.clarity.** { *; } +-keep interface com.microsoft.clarity.** { *; } \ No newline at end of file diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt new file mode 100644 index 000000000..0a948ab75 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/AnalyticsModule.kt @@ -0,0 +1,16 @@ +package org.grakovne.lissen.analytics + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import org.grakovne.lissen.common.RunningComponent + +@Module +@InstallIn(SingletonComponent::class) +interface AnalyticsModule { + @Binds + @IntoSet + fun bindClarityComponent(component: ClarityComponent): RunningComponent +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt new file mode 100644 index 000000000..54202a14f --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt @@ -0,0 +1,75 @@ +package org.grakovne.lissen.analytics + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import com.microsoft.clarity.Clarity +import com.microsoft.clarity.ClarityConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import org.grakovne.lissen.BuildConfig +import org.grakovne.lissen.common.RunningComponent +import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ClarityComponent + @Inject + constructor( + @ApplicationContext private val context: Context, + private val preferences: LissenSharedPreferences, + private val clarityTracker: ClarityTracker, + ) : RunningComponent, + Application.ActivityLifecycleCallbacks { + override fun onCreate() { + (context as? Application)?.registerActivityLifecycleCallbacks(this) + } + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) { + // Initialize Clarity only once with the first activity + if (initialized) return + + Timber.d("Initializing Microsoft Clarity with activity: ${activity.javaClass.simpleName}") + val config = ClarityConfig(BuildConfig.CLARITY_PROJECT_ID) + Clarity.initialize(activity, config) + + initialized = true + reidentifyUser() + } + + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) {} + + override fun onActivityDestroyed(activity: Activity) {} + + private var initialized = false + + private fun reidentifyUser() { + if (preferences.hasCredentials()) { + val username = preferences.getUsername() ?: return + val host = preferences.getHost() ?: return + + // Combine host and username for a unique identifier across servers + val identifier = "$username@$host" + clarityTracker.setUser(identifier) + } else { + // Fallback to device ID if not logged in + clarityTracker.setUser(preferences.getDeviceId()) + } + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt new file mode 100644 index 000000000..898e043c0 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt @@ -0,0 +1,43 @@ +package org.grakovne.lissen.analytics + +import com.microsoft.clarity.Clarity +import com.microsoft.clarity.ClarityConfig +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ClarityTracker + @Inject + constructor() { + fun setUser(userId: String) { + try { + Clarity.setCustomUserId(userId) + Timber.d("Clarity User ID set: $userId") + } catch (e: Exception) { + Timber.e(e, "Failed to set Clarity User ID") + } + } + + fun trackEvent(eventName: String) { + try { + Clarity.sendCustomEvent(eventName) + Timber.d("Clarity Event tracked: $eventName") + } catch (e: Exception) { + Timber.e(e, "Failed to track Clarity event: $eventName") + } + } + + fun trackEvent( + eventName: String, + value: String, + ) { + try { + // Clarity sendCustomEvent takes a string value for the event name/payload + Clarity.sendCustomEvent("$eventName: $value") + Timber.d("Clarity Event tracked: $eventName with value: $value") + } catch (e: Exception) { + Timber.e(e, "Failed to track Clarity event: $eventName") + } + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt index a3d7359cc..c857420ce 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt @@ -1,5 +1,6 @@ package org.grakovne.lissen.content +import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider import org.grakovne.lissen.channel.common.ChannelAuthService import org.grakovne.lissen.channel.common.OperationError @@ -19,6 +20,7 @@ class AuthRepository private val preferences: LissenSharedPreferences, private val audiobookshelfChannelProvider: AudiobookshelfChannelProvider, private val bookRepository: BookRepository, + private val clarityTracker: ClarityTracker, ) { suspend fun authorize( host: String, @@ -57,6 +59,9 @@ class AuthRepository refreshToken = account.refreshToken, ) + clarityTracker.setUser("${account.username}@$host") + clarityTracker.trackEvent("login_success") + // Trigger library fetch bookRepository .fetchLibraries() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt index 945630167..1d63117f7 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt @@ -3,6 +3,7 @@ package org.grakovne.lissen.content import android.net.Uri import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider import org.grakovne.lissen.channel.common.MediaChannel import org.grakovne.lissen.channel.common.OperationError @@ -32,6 +33,7 @@ class BookRepository private val localCacheRepository: LocalCacheRepository, private val cachedCoverProvider: CachedCoverProvider, private val networkService: NetworkService, + private val clarityTracker: ClarityTracker, ) { fun provideFileUri( libraryItemId: String, @@ -71,6 +73,7 @@ class BookRepository Timber.d("Syncing Progress for $itemId. $progress") localCacheRepository.syncProgress(itemId, progress) + clarityTracker.trackEvent("sync_progress") val channelSyncResult = providePreferredChannel() 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 4d8fd20de..286868188 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withContext import okhttp3.Request +import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.channel.audiobookshelf.common.api.RequestHeadersProvider import org.grakovne.lissen.channel.common.MediaChannel import org.grakovne.lissen.channel.common.createOkHttpClient @@ -38,6 +39,7 @@ class ContentCachingManager private val properties: OfflineBookStorageProperties, private val requestHeadersProvider: RequestHeadersProvider, private val preferences: LissenSharedPreferences, + private val clarityTracker: ClarityTracker, ) { fun cacheMediaItem( mediaItem: DetailedItem, @@ -47,6 +49,7 @@ class ContentCachingManager ) = channelFlow { try { send(CacheState(CacheStatus.Queued)) + clarityTracker.trackEvent("download_started") val fileStartTimes = withContext(Dispatchers.Default) { @@ -114,6 +117,7 @@ class ContentCachingManager ).all { it.status == CacheStatus.Completed } -> { cacheBookInfo(mediaItem, requestedChapters) send(CacheState(CacheStatus.Completed)) + clarityTracker.trackEvent("download_finished") } else -> { 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 d109317fe..a4466588c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.lib.domain.CurrentEpisodeTimerOption import org.grakovne.lissen.lib.domain.DetailedItem @@ -64,6 +65,7 @@ class MediaRepository private val preferences: LissenSharedPreferences, private val mediaChannel: LissenMediaProvider, private val shakeDetector: ShakeDetector, + private val clarityTracker: ClarityTracker, ) { fun getBookFlow(bookId: String): Flow = mediaChannel.fetchBookFlow(bookId) @@ -181,6 +183,7 @@ class MediaRepository override fun onFailure(t: Throwable) { Timber.e("Unable to add callback to player") + clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error") } }, MoreExecutors.directExecutor(), @@ -263,6 +266,9 @@ class MediaRepository position: Double? = null, ) { _timerOption.postValue(timerOption) + if (timerOption != null) { + clarityTracker.trackEvent("sleep_timer_set") + } when (timerOption) { is DurationTimerOption -> scheduleServiceTimer(timerOption.duration * 60.0, timerOption) @@ -564,6 +570,7 @@ class MediaRepository } context.startForegroundService(intent) + clarityTracker.trackEvent("playback_start") } private fun pause() { @@ -573,6 +580,7 @@ class MediaRepository } context.startService(intent) + clarityTracker.trackEvent("playback_pause") } private fun seekTo(position: Double) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt index d87e09d21..54a3377c3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R @Composable @@ -79,6 +80,7 @@ fun LibrarySearchActionComposable( modifier = Modifier .weight(1f) + .clarityMask() .focusRequester(focusRequester), textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), singleLine = true, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt index ddcb370f8..5bfe15d62 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/login/LoginScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.microsoft.clarity.modifiers.clarityMask import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import org.grakovne.lissen.R @@ -156,6 +157,7 @@ fun LoginScreen( Modifier .fillMaxWidth() .padding(vertical = 4.dp) + .clarityMask() .testTag("hostInput"), ) @@ -169,6 +171,7 @@ fun LoginScreen( Modifier .fillMaxWidth() .padding(vertical = 12.dp) + .clarityMask() .testTag("usernameInput"), ) @@ -194,6 +197,7 @@ fun LoginScreen( Modifier .fillMaxWidth() .padding(vertical = 4.dp) + .clarityMask() .testTag("passwordInput"), ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt index 7c99c4dc6..921f94631 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R @Composable @@ -63,6 +64,7 @@ fun ChapterSearchActionComposable(onSearchRequested: (String) -> Unit) { modifier = Modifier .weight(1f) + .clarityMask() .focusRequester(focusRequester), textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), singleLine = true, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt index e7f5ae97f..de4b0233c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader.Companion.clean @@ -53,7 +54,7 @@ fun CustomHeaderComposable( label = { Text(stringResource(R.string.custom_header_hint_name)) }, singleLine = true, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).clarityMask(), ) OutlinedTextField( @@ -62,7 +63,7 @@ fun CustomHeaderComposable( label = { Text(stringResource(R.string.custom_header_hint_value)) }, singleLine = true, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().clarityMask(), ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt index b7a193690..1329e9f46 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/LocalUrlComposable.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R import org.grakovne.lissen.lib.domain.connection.LocalUrl @@ -60,7 +61,8 @@ fun LocalUrlComposable( modifier = Modifier .fillMaxWidth() - .padding(bottom = 12.dp), + .padding(bottom = 12.dp) + .clarityMask(), ) OutlinedTextField( @@ -71,7 +73,7 @@ fun LocalUrlComposable( label = { Text(stringResource(R.string.hint_server_url_input)) }, singleLine = true, shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().clarityMask(), ) } 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 2a3efa097..d757f92d1 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.common.LibraryOrderingConfiguration import org.grakovne.lissen.common.NetworkService import org.grakovne.lissen.content.BookRepository @@ -44,6 +45,7 @@ class LibraryViewModel private val bookRepository: BookRepository, private val preferences: LissenSharedPreferences, private val networkService: NetworkService, + private val clarityTracker: ClarityTracker, ) : ViewModel() { private val _recentBooks = MutableLiveData>(emptyList()) val recentBooks: LiveData> = _recentBooks @@ -215,7 +217,10 @@ class LibraryViewModel } fun updateSearch(token: String) { - viewModelScope.launch { _searchToken.emit(token) } + _searchToken.value = token + if (token.isNotEmpty()) { + clarityTracker.trackEvent("search_performed") + } } fun fetchPreferredLibraryTitle(): String? = @@ -230,6 +235,7 @@ class LibraryViewModel ?: LibraryType.UNKNOWN fun refreshRecentListening() { + clarityTracker.trackEvent("library_refresh") viewModelScope.launch { withContext(Dispatchers.IO) { fetchRecentListening() 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 8b06b2836..c958d5e55 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.channel.audiobookshelf.Host import org.grakovne.lissen.channel.common.OperationResult import org.grakovne.lissen.common.ColorScheme @@ -35,6 +36,7 @@ class SettingsViewModel private val preferences: LissenSharedPreferences, private val bookRepository: org.grakovne.lissen.content.BookRepository, private val cachedCoverProvider: org.grakovne.lissen.content.cache.temporary.CachedCoverProvider, + private val clarityTracker: ClarityTracker, ) : ViewModel() { private val _host: MutableLiveData = MutableLiveData(preferences.getHost()?.let { Host.external(it) }) val host = _host @@ -186,6 +188,7 @@ class SettingsViewModel fun preferLibrary(library: Library) { _preferredLibrary.postValue(library) preferences.savePreferredLibrary(library) + clarityTracker.trackEvent("library_switched", library.title) } fun preferAutoDownloadNetworkType(type: NetworkTypeAutoCache) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index baae4d8a8..fb7054610 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ coreKtx = "1.17.0" timber = "5.0.1" moshi = "1.15.2" converterMoshi = "3.0.0" +clarity = "3.8.1" [libraries] acra-core = { module = "ch.acra:acra-core", version.ref = "acraCore" } @@ -84,6 +85,7 @@ converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.r moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } +microsoft-clarity = { module = "com.microsoft.clarity:clarity-compose", version.ref = "clarity" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } From 3a2ce89602dd4b0aebb691afd895a5c702abea8c Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Fri, 6 Feb 2026 21:17:07 +0530 Subject: [PATCH 10/37] feat: Implement analytics consent, overhaul persistent caching with database migrations, and update the migration screen UI. --- .../17.json | 28 +- .../18.json | 388 ++++++++++++++++++ .../19.json | 388 ++++++++++++++++++ .../lissen/analytics/ClarityComponent.kt | 16 + .../lissen/analytics/ClarityTracker.kt | 16 +- .../org/grakovne/lissen/common/HashUtils.kt | 12 + .../org/grakovne/lissen/common/Moshi.kt | 17 +- .../grakovne/lissen/content/AuthRepository.kt | 5 +- .../cache/persistent/ContentCachingManager.kt | 6 +- .../cache/persistent/ContentCachingService.kt | 4 +- .../cache/persistent/LocalCacheModule.kt | 6 +- .../cache/persistent/LocalCacheStorage.kt | 2 +- .../content/cache/persistent/Migrations.kt | 143 ++++++- .../persistent/api/CachedBookRepository.kt | 49 +-- .../persistent/api/CachedLibraryRepository.kt | 8 +- .../persistent/api/FetchRequestBuilder.kt | 20 +- .../persistent/api/RecentRequestBuilder.kt | 21 +- .../persistent/api/SearchRequestBuilder.kt | 25 +- .../cache/persistent/dao/CachedBookDao.kt | 36 +- .../cache/persistent/dao/CachedLibraryDao.kt | 20 +- .../persistent/entity/CachedBookEntity.kt | 11 +- .../persistent/entity/CachedLibraryEntity.kt | 4 +- .../preferences/LissenSharedPreferences.kt | 18 +- .../components/AnalyticsConsentBottomSheet.kt | 128 ++++++ .../ui/components/DownloadProgressIcon.kt | 15 +- .../ui/screens/common/DownloadOptionFormat.kt | 9 +- .../ui/screens/details/BookDetailScreen.kt | 4 +- .../ui/screens/library/LibraryScreen.kt | 9 + .../ui/screens/migration/MigrationScreen.kt | 78 +++- .../screens/player/GlobalPlayerBottomSheet.kt | 1 - .../lissen/ui/screens/player/PlayerScreen.kt | 1 - .../ui/screens/player/composable/ActionRow.kt | 2 +- .../player/composable/DownloadsComposable.kt | 26 +- .../cache/CachedItemsSettingsScreen.kt | 39 +- .../composable/ServerSettingsComposable.kt | 5 + .../lissen/viewmodel/CachingModelView.kt | 5 +- .../lissen/viewmodel/MigrationViewModel.kt | 27 +- .../lissen/viewmodel/SettingsViewModel.kt | 28 +- app/src/main/res/values/strings.xml | 14 + gradle/libs.versions.toml | 2 +- .../lissen/lib/domain/DetailedItem.kt | 67 ++- .../lissen/lib/domain/DownloadOption.kt | 24 +- 42 files changed, 1505 insertions(+), 222 deletions(-) create mode 100644 app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/18.json create mode 100644 app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/19.json create mode 100644 app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt create mode 100644 app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt diff --git a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json index 753801817..c63234937 100644 --- a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json +++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 17, - "identityHash": "a53f0ae7c18797decac21399bf0a1bcb", + "identityHash": "af5884c66f8cb262b565c42e33af35ac", "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, `libraryType` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `host` TEXT NOT NULL, `username` TEXT NOT NULL, PRIMARY KEY(`id`))", + "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, `libraryType` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `host` TEXT, `username` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -91,14 +91,12 @@ { "fieldPath": "host", "columnName": "host", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "username", "columnName": "username", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" } ], "primaryKey": { @@ -272,7 +270,7 @@ }, { "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, `host` TEXT NOT NULL, `username` TEXT NOT NULL, PRIMARY KEY(`bookId`), FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `currentTime` REAL NOT NULL, `isFinished` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, `host` TEXT, `username` TEXT, PRIMARY KEY(`bookId`), FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "bookId", @@ -301,14 +299,12 @@ { "fieldPath": "host", "columnName": "host", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "username", "columnName": "username", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" } ], "primaryKey": { @@ -344,7 +340,7 @@ }, { "tableName": "libraries", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `host` TEXT NOT NULL, `username` TEXT NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `host` TEXT, `username` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -367,14 +363,12 @@ { "fieldPath": "host", "columnName": "host", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "username", "columnName": "username", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" } ], "primaryKey": { @@ -387,7 +381,7 @@ ], "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, 'a53f0ae7c18797decac21399bf0a1bcb')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'af5884c66f8cb262b565c42e33af35ac')" ] } } \ No newline at end of file diff --git a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/18.json b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/18.json new file mode 100644 index 000000000..a7caa484a --- /dev/null +++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/18.json @@ -0,0 +1,388 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "58717f77244fed8e0b015049d0164838", + "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, `libraryType` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `host` TEXT, `username` TEXT, 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": "libraryType", + "columnName": "libraryType", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesJson", + "columnName": "seriesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesNames", + "columnName": "seriesNames", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "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, `duration` REAL NOT NULL, `mimeType` TEXT NOT NULL, `bookId` TEXT NOT NULL, `size` INTEGER NOT NULL DEFAULT 0, 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": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `host` TEXT, `username` TEXT, PRIMARY KEY(`bookId`), FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + } + ], + "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`)" + } + ], + "foreignKeys": [ + { + "table": "detailed_books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "libraries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `host` TEXT, `username` TEXT, 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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, '58717f77244fed8e0b015049d0164838')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/19.json b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/19.json new file mode 100644 index 000000000..1fec959fa --- /dev/null +++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/19.json @@ -0,0 +1,388 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "58717f77244fed8e0b015049d0164838", + "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, `libraryType` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `host` TEXT, `username` TEXT, 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": "libraryType", + "columnName": "libraryType", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesJson", + "columnName": "seriesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesNames", + "columnName": "seriesNames", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "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, `duration` REAL NOT NULL, `mimeType` TEXT NOT NULL, `bookId` TEXT NOT NULL, `size` INTEGER NOT NULL DEFAULT 0, 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": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `host` TEXT, `username` TEXT, PRIMARY KEY(`bookId`), FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + } + ], + "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`)" + } + ], + "foreignKeys": [ + { + "table": "detailed_books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "libraries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `host` TEXT, `username` TEXT, 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 + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, '58717f77244fed8e0b015049d0164838')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt index 54202a14f..63719871c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt @@ -24,6 +24,11 @@ class ClarityComponent ) : RunningComponent, Application.ActivityLifecycleCallbacks { override fun onCreate() { + if (BuildConfig.DEBUG) { + Timber.d("Skip Microsoft Clarity initialization for debug build") + return + } + (context as? Application)?.registerActivityLifecycleCallbacks(this) } @@ -38,10 +43,21 @@ class ClarityComponent val config = ClarityConfig(BuildConfig.CLARITY_PROJECT_ID) Clarity.initialize(activity, config) + applyConsent() + initialized = true reidentifyUser() } + fun updateConsent(accepted: Boolean) { + Clarity.consent(accepted, accepted) + } + + private fun applyConsent() { + val consent = preferences.getAnalyticsConsentState() ?: false + Clarity.consent(consent, consent) + } + override fun onActivityStarted(activity: Activity) {} override fun onActivityResumed(activity: Activity) {} diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt index 898e043c0..e9c57d0b5 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt @@ -2,6 +2,7 @@ package org.grakovne.lissen.analytics import com.microsoft.clarity.Clarity import com.microsoft.clarity.ClarityConfig +import org.grakovne.lissen.BuildConfig import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -11,15 +12,24 @@ class ClarityTracker @Inject constructor() { fun setUser(userId: String) { + if (BuildConfig.DEBUG) return + try { Clarity.setCustomUserId(userId) - Timber.d("Clarity User ID set: $userId") + Timber.d("Clarity User ID set: ${maskId(userId)}") } catch (e: Exception) { - Timber.e(e, "Failed to set Clarity User ID") + Timber.e(e, "Failed to set Clarity User ID: ${maskId(userId)}") } } + private fun maskId(id: String): String { + if (id.length <= 4) return "****" + return id.takeLast(4).padStart(id.length, '*') + } + fun trackEvent(eventName: String) { + if (BuildConfig.DEBUG) return + try { Clarity.sendCustomEvent(eventName) Timber.d("Clarity Event tracked: $eventName") @@ -32,6 +42,8 @@ class ClarityTracker eventName: String, value: String, ) { + if (BuildConfig.DEBUG) return + try { // Clarity sendCustomEvent takes a string value for the event name/payload Clarity.sendCustomEvent("$eventName: $value") diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt b/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt new file mode 100644 index 000000000..7a34e10ab --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt @@ -0,0 +1,12 @@ +package org.grakovne.lissen.common + +import java.security.MessageDigest + +fun sha256(input: String): String { + val bytes = + MessageDigest + .getInstance("SHA-256") + .digest(input.toByteArray()) + + return bytes.joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt index e4d7c66c5..be7f1168f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt @@ -7,12 +7,26 @@ import com.squareup.moshi.JsonWriter import com.squareup.moshi.Moshi import com.squareup.moshi.ToJson import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import timber.log.Timber +import java.lang.reflect.Type import java.util.UUID val moshi: Moshi = Moshi .Builder() .add( + object : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: Set, + moshi: Moshi, + ): JsonAdapter<*>? { + val adapter = moshi.nextAdapter(this, type, annotations) + Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}") + return adapter + } + }, + ).add( UUID::class.java, object : JsonAdapter() { @FromJson @@ -26,4 +40,5 @@ val moshi: Moshi = writer.value(value?.toString()) } }, - ).build() + ).addLast(KotlinJsonAdapterFactory()) + .build() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt index c857420ce..e8ecebfe8 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt @@ -59,7 +59,10 @@ class AuthRepository refreshToken = account.refreshToken, ) - clarityTracker.setUser("${account.username}@$host") + clarityTracker.setUser( + org.grakovne.lissen.common + .sha256("${account.username}@$host"), + ) clarityTracker.trackEvent("login_success") // Trigger library fetch 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 286868188..b54a6da4d 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 @@ -121,7 +121,7 @@ class ContentCachingManager } else -> { - cachingChapters.map { dropCache(mediaItem, it) } + cachingChapters.forEach { dropCache(mediaItem, it) } send(CacheState(CacheStatus.Error)) } } @@ -205,8 +205,6 @@ class ContentCachingManager chapterId: String, ) = bookRepository.provideCacheState(mediaItemId, chapterId) - fun hasDownloadedChapters(mediaItemId: String) = bookRepository.hasDownloadedChapters(mediaItemId) - private suspend fun cacheBookMedia( bookId: String, files: List, @@ -221,7 +219,7 @@ class ContentCachingManager preferences = preferences, ) - files.mapIndexed { index, file -> + files.forEachIndexed { index, file -> val uri = channel.provideFileUri(bookId, file.id) val requestBuilder = Request.Builder().url(uri.toString()) headers.forEach { requestBuilder.addHeader(it.name, it.value) } 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 e4a9303c1..983957100 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 @@ -106,7 +106,7 @@ class ContentCachingService : LifecycleService() { executor .run(mediaProvider.providePreferredChannel()) .onCompletion { - if (executionStatuses.isEmpty()) { + if (executionStatuses.isEmpty() || !inProgress()) { finish() } }.collect { progress -> @@ -127,7 +127,7 @@ class ContentCachingService : LifecycleService() { lastNotificationUpdate = currentTime } - if (!inProgress() && !hasErrors()) { + if (!inProgress()) { finish() } } 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 1612d2d21..d9144cad9 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 @@ -47,10 +47,12 @@ object LocalCacheModule { .addMigrations(MIGRATION_14_15) .addMigrations( produceMigration15_16( - host = preferences.getHost() ?: "", - username = preferences.getUsername() ?: "", + host = preferences.getHost(), + username = preferences.getUsername(), ), ).addMigrations(MIGRATION_16_17) + .addMigrations(MIGRATION_17_18) + .addMigrations(MIGRATION_18_19) .build() } 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 a5f611721..c22243723 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 @@ -18,7 +18,7 @@ import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity MediaProgressEntity::class, CachedLibraryEntity::class, ], - version = 17, + version = 19, 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 ba0ee731b..85cee08dd 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 @@ -265,18 +265,24 @@ val MIGRATION_14_15 = } fun produceMigration15_16( - host: String, - username: String, + host: String?, + username: String?, ) = object : Migration(15, 16) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE detailed_books ADD COLUMN host TEXT NOT NULL DEFAULT '$host'") - db.execSQL("ALTER TABLE detailed_books ADD COLUMN username TEXT NOT NULL DEFAULT '$username'") + // We add columns as nullable without a fixed DEFAULT value. + // If the user is logged in during migration, we can tag existing data. + // If they are logged out, existing data remains NULL (owned by "legacy/unknown"). + val hostClause = if (host != null) " DEFAULT '$host'" else "" + val usernameClause = if (username != null) " DEFAULT '$username'" else "" - db.execSQL("ALTER TABLE media_progress ADD COLUMN host TEXT NOT NULL DEFAULT '$host'") - db.execSQL("ALTER TABLE media_progress ADD COLUMN username TEXT NOT NULL DEFAULT '$username'") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN host TEXT$hostClause") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN username TEXT$usernameClause") - db.execSQL("ALTER TABLE libraries ADD COLUMN host TEXT NOT NULL DEFAULT '$host'") - db.execSQL("ALTER TABLE libraries ADD COLUMN username TEXT NOT NULL DEFAULT '$username'") + db.execSQL("ALTER TABLE media_progress ADD COLUMN host TEXT$hostClause") + db.execSQL("ALTER TABLE media_progress ADD COLUMN username TEXT$usernameClause") + + db.execSQL("ALTER TABLE libraries ADD COLUMN host TEXT$hostClause") + db.execSQL("ALTER TABLE libraries ADD COLUMN username TEXT$usernameClause") } } @@ -286,3 +292,124 @@ val MIGRATION_16_17 = db.execSQL("ALTER TABLE book_files ADD COLUMN size INTEGER NOT NULL DEFAULT 0") } } + +val MIGRATION_17_18 = + object : Migration(17, 18) { + override fun migrate(db: SupportSQLiteDatabase) { + // Recreate detailed_books to ensure host and username are nullable and correctly positioned + db.execSQL("ALTER TABLE detailed_books RENAME TO detailed_books_old") + db.execSQL( + """ + CREATE TABLE detailed_books ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + subtitle TEXT, + author TEXT, + narrator TEXT, + year TEXT, + abstract TEXT, + publisher TEXT, + duration INTEGER NOT NULL, + libraryId TEXT, + libraryType TEXT, + seriesJson TEXT, + seriesNames TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + host TEXT, + username TEXT + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO detailed_books ( + id, title, subtitle, author, narrator, year, abstract, publisher, + duration, libraryId, libraryType, seriesJson, seriesNames, createdAt, updatedAt, host, username + ) + SELECT + id, title, subtitle, author, narrator, year, abstract, publisher, + duration, libraryId, libraryType, seriesJson, seriesNames, createdAt, updatedAt, host, username + FROM detailed_books_old + """.trimIndent(), + ) + db.execSQL("DROP TABLE detailed_books_old") + + // Recreate libraries + db.execSQL("ALTER TABLE libraries RENAME TO libraries_old") + db.execSQL( + """ + CREATE TABLE libraries ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + type TEXT NOT NULL, + host TEXT, + username TEXT + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO libraries (id, title, type, host, username) + SELECT id, title, type, host, username FROM libraries_old + """.trimIndent(), + ) + db.execSQL("DROP TABLE libraries_old") + + // Recreate media_progress + db.execSQL("ALTER TABLE media_progress RENAME TO media_progress_old") + db.execSQL( + """ + CREATE TABLE media_progress ( + bookId TEXT NOT NULL PRIMARY KEY, + currentTime REAL NOT NULL, + isFinished INTEGER NOT NULL, + lastUpdate INTEGER NOT NULL, + host TEXT, + username TEXT, + FOREIGN KEY(bookId) REFERENCES detailed_books(id) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO media_progress (bookId, currentTime, isFinished, lastUpdate, host, username) + SELECT bookId, currentTime, isFinished, lastUpdate, host, username FROM media_progress_old + """.trimIndent(), + ) + db.execSQL("DROP TABLE media_progress_old") + db.execSQL("CREATE INDEX IF NOT EXISTS index_media_progress_bookId ON media_progress (bookId)") + + // Recreate book_files to move size to the end + db.execSQL("ALTER TABLE book_files RENAME TO book_files_old") + db.execSQL( + """ + CREATE TABLE book_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + bookFileId TEXT NOT NULL, + name TEXT NOT NULL, + duration REAL NOT NULL, + mimeType TEXT NOT NULL, + bookId TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(bookId) REFERENCES detailed_books(id) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO book_files (id, bookFileId, name, duration, mimeType, bookId, size) + SELECT id, bookFileId, name, duration, mimeType, bookId, size FROM book_files_old + """.trimIndent(), + ) + db.execSQL("DROP TABLE book_files_old") + db.execSQL("CREATE INDEX IF NOT EXISTS index_book_files_bookId ON book_files (bookId)") + } + } + +val MIGRATION_18_19 = + object : Migration(18, 19) { + override fun migrate(db: SupportSQLiteDatabase) { + // Schema realignment to fix identity hash mismatch + } + } 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 bdf86e9e4..97a2def86 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 @@ -59,10 +59,15 @@ class CachedBookRepository } suspend fun deleteNonDownloadedBooks() { - val host = preferences.getHost() ?: "" - val username = preferences.getUsername() ?: "" + val nonDownloadedIds = bookDao.fetchNonDownloadedBookIds() - bookDao.deleteNonDownloadedBooks(host, username) + nonDownloadedIds.forEach { bookId -> + properties + .provideBookCache(bookId) + .deleteRecursively() + } + + bookDao.deleteNonDownloadedBooks() } suspend fun cacheBook( @@ -70,8 +75,8 @@ class CachedBookRepository fetchedChapters: List, droppedChapters: List, ) { - val host = preferences.getHost() ?: "" - val username = preferences.getUsername() ?: "" + val host = preferences.getHost() + val username = preferences.getUsername() bookDao.upsertCachedBook(book, host, username, fetchedChapters, droppedChapters) } @@ -113,8 +118,8 @@ class CachedBookRepository libraryType = null, createdAt = 0, updatedAt = 0, - host = preferences.getHost() ?: "", - username = preferences.getUsername() ?: "", + host = preferences.getHost(), + username = preferences.getUsername(), seriesNames = book.series, seriesJson = "[]", ) @@ -128,8 +133,6 @@ class CachedBookRepository chapterId: String, ) = bookDao.isBookChapterCached(bookId, chapterId) - fun hasDownloadedChapters(bookId: String) = bookDao.hasDownloadedChapters(bookId) - suspend fun fetchCachedItems( pageSize: Int, pageNumber: Int, @@ -157,8 +160,8 @@ class CachedBookRepository .orderField(option) .orderDirection(direction) .downloadedOnly(downloadedOnly) - .host(preferences.getHost() ?: "") - .username(preferences.getUsername() ?: "") + .host(preferences.getHost()) + .username(preferences.getUsername()) .build() return bookDao @@ -167,8 +170,8 @@ class CachedBookRepository } suspend fun countBooks(libraryId: String): Int { - val host = preferences.getHost() ?: "" - val username = preferences.getUsername() ?: "" + val host = preferences.getHost() + val username = preferences.getUsername() return bookDao.countCachedBooks(libraryId = libraryId, host = host, username = username) } @@ -179,13 +182,13 @@ class CachedBookRepository val (option, direction) = buildOrdering() val request = - SearchRequestBuilder() - .searchQuery(query) + SearchRequestBuilder( + host = preferences.getHost(), + username = preferences.getUsername(), + ).searchQuery(query) .libraryId(libraryId) .orderField(option) .orderDirection(direction) - .host(preferences.getHost() ?: "") - .username(preferences.getUsername() ?: "") .build() return bookDao @@ -201,8 +204,8 @@ class CachedBookRepository RecentRequestBuilder() .libraryId(libraryId) .downloadedOnly(downloadedOnly) - .host(preferences.getHost() ?: "") - .username(preferences.getUsername() ?: "") + .host(preferences.getHost()) + .username(preferences.getUsername()) .build() val recentBooks = bookDao.fetchRecentlyListenedCachedBooks(request) @@ -225,8 +228,8 @@ class CachedBookRepository RecentRequestBuilder() .libraryId(libraryId) .downloadedOnly(downloadedOnly) - .host(preferences.getHost() ?: "") - .username(preferences.getUsername() ?: "") + .host(preferences.getHost()) + .username(preferences.getUsername()) .build() return bookDao @@ -261,8 +264,8 @@ class CachedBookRepository currentTime = progress.currentTotalTime, isFinished = progress.currentTotalTime == book.chapters.sumOf { it.duration }, lastUpdate = Instant.now().toEpochMilli(), - host = preferences.getHost() ?: "", - username = preferences.getUsername() ?: "", + host = preferences.getHost(), + username = preferences.getUsername(), ) bookDao.upsertMediaProgress(entity) 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 f6391eca5..6f21dc6a1 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 @@ -16,15 +16,15 @@ class CachedLibraryRepository private val preferences: LissenSharedPreferences, ) { suspend fun cacheLibraries(libraries: List) { - val host = preferences.getHost() ?: "" - val username = preferences.getUsername() ?: "" + val host = preferences.getHost() + val username = preferences.getUsername() dao.updateLibraries(libraries, host, username) } suspend fun fetchLibraries(): List { - val host = preferences.getHost() ?: "" - val username = preferences.getUsername() ?: "" + val host = preferences.getHost() + val username = preferences.getUsername() return dao .fetchLibraries(host, username) 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 d10cacc49..126e356b5 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 @@ -10,8 +10,8 @@ class FetchRequestBuilder { private var orderField: String = "title" private var orderDirection: String = "ASC" private var downloadedOnly: Boolean = false - private var host: String = "" - private var username: String = "" + private var host: String? = null + private var username: String? = null fun libraryId(id: String?) = apply { this.libraryId = id } @@ -25,9 +25,9 @@ class FetchRequestBuilder { fun downloadedOnly(enabled: Boolean) = apply { this.downloadedOnly = enabled } - fun host(host: String) = apply { this.host = host } + fun host(host: String?) = apply { this.host = host } - fun username(username: String) = apply { this.username = username } + fun username(username: String?) = apply { this.username = username } fun build(): SupportSQLiteQuery { val args = mutableListOf() @@ -45,9 +45,15 @@ class FetchRequestBuilder { when (downloadedOnly) { true -> "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" false -> { - args.add(host) - args.add(username) - "((host = ? AND username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + val host = host + val username = username + if (!host.isNullOrEmpty() && !username.isNullOrEmpty()) { + args.add(host) + args.add(username) + "((host = ? AND username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + } else { + "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt index fc7aa1a7d..fe5b752be 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt @@ -7,8 +7,8 @@ class RecentRequestBuilder { private var libraryId: String? = null private var downloadedOnly: Boolean = false private var limit: Int = 10 - private var host: String = "" - private var username: String = "" + private var host: String? = null + private var username: String? = null fun libraryId(id: String?) = apply { this.libraryId = id } @@ -16,9 +16,9 @@ class RecentRequestBuilder { fun limit(limit: Int) = apply { this.limit = limit } - fun host(host: String) = apply { this.host = host } + fun host(host: String?) = apply { this.host = host } - fun username(username: String) = apply { this.username = username } + fun username(username: String?) = apply { this.username = username } fun build(): SupportSQLiteQuery { val args = mutableListOf() @@ -36,9 +36,16 @@ class RecentRequestBuilder { when (downloadedOnly) { true -> "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" false -> { - args.add(host) - args.add(username) - "((detailed_books.host = ? AND detailed_books.username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + val host = host + val username = username + + if (!host.isNullOrEmpty() && !username.isNullOrEmpty()) { + args.add(host) + args.add(username) + "((detailed_books.host = ? AND detailed_books.username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + } else { + "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" + } } } 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 198728fe3..5c4ead5cc 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 @@ -3,13 +3,14 @@ package org.grakovne.lissen.content.cache.persistent.api import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery -class SearchRequestBuilder { +class SearchRequestBuilder( + private val host: String?, + private val username: String?, +) { private var libraryId: String? = null private var searchQuery: String = "" private var orderField: String = "title" private var orderDirection: String = "ASC" - private var host: String = "" - private var username: String = "" fun libraryId(id: String?) = apply { this.libraryId = id } @@ -19,10 +20,6 @@ class SearchRequestBuilder { fun orderDirection(direction: String) = apply { this.orderDirection = direction } - fun host(host: String) = apply { this.host = host } - - fun username(username: String) = apply { this.username = username } - fun build(): SupportSQLiteQuery { val args = mutableListOf() @@ -42,9 +39,17 @@ class SearchRequestBuilder { args.add(pattern) val isolationClause = - "((host = ? AND username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" - args.add(host) - args.add(username) + run { + val host = host + val username = username + if (!host.isNullOrEmpty() && !username.isNullOrEmpty()) { + args.add(host) + args.add(username) + "((host = ? AND username = ?) OR EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1))" + } else { + "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" + } + } val field = when (orderField) { 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 928fb152a..b6100db7a 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 @@ -29,8 +29,8 @@ interface CachedBookDao { @Transaction suspend fun upsertCachedBook( book: DetailedItem, - host: String, - username: String, + host: String?, + username: String?, fetchedChapters: List, droppedChapters: List, ) { @@ -136,14 +136,14 @@ interface CachedBookDao { """ SELECT COUNT(*) FROM detailed_books WHERE ((:libraryId IS NULL AND libraryId IS NULL) OR (libraryId = :libraryId)) - AND host = :host - AND username = :username + AND ((:host IS NULL AND host IS NULL) OR (host = :host)) + AND ((:username IS NULL AND username IS NULL) OR (username = :username)) """, ) suspend fun countCachedBooks( libraryId: String?, - host: String, - username: String, + host: String?, + username: String?, ): Int @Transaction @@ -206,16 +206,6 @@ interface CachedBookDao { chapterId: String, ): LiveData - @Query( - """ - SELECT COUNT(*) > 0 - FROM book_chapters - WHERE bookId = :bookId - AND isCached = 1 - """, - ) - fun hasDownloadedChapters(bookId: String): LiveData - @Query( """ SELECT MAX(mp.lastUpdate) @@ -258,18 +248,22 @@ interface CachedBookDao { @Delete suspend fun deleteBook(book: BookEntity) + @Query( + """ + SELECT id FROM detailed_books + WHERE id NOT IN (SELECT DISTINCT bookId FROM book_chapters WHERE isCached = 1) + """, + ) + suspend fun fetchNonDownloadedBookIds(): List + @Transaction @Query( """ DELETE FROM detailed_books WHERE id NOT IN (SELECT DISTINCT bookId FROM book_chapters WHERE isCached = 1) - AND (host != :currentHost OR username != :currentUsername) """, ) - suspend fun deleteNonDownloadedBooks( - currentHost: String, - currentUsername: String, - ) + suspend fun deleteNonDownloadedBooks() companion object { val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt index 307f25634..5fae079a3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt @@ -13,8 +13,8 @@ interface CachedLibraryDao { @Transaction suspend fun updateLibraries( libraries: List, - host: String, - username: String, + host: String?, + username: String?, ) { val entities = libraries.map { @@ -36,19 +36,23 @@ interface CachedLibraryDao { suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity? @Transaction - @Query("SELECT * FROM libraries WHERE host = :host AND username = :username") + @Query( + "SELECT * FROM libraries WHERE ((:host IS NULL AND host IS NULL) OR (host = :host)) AND ((:username IS NULL AND username IS NULL) OR (username = :username))", + ) suspend fun fetchLibraries( - host: String, - username: String, + host: String?, + username: String?, ): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertLibraries(libraries: List) - @Query("DELETE FROM libraries WHERE id NOT IN (:ids) AND host = :host AND username = :username") + @Query( + "DELETE FROM libraries WHERE id NOT IN (:ids) AND ((:host IS NULL AND host IS NULL) OR (host = :host)) AND ((:username IS NULL AND username IS NULL) OR (username = :username))", + ) suspend fun deleteLibrariesExcept( ids: List, - host: String, - username: String, + host: String?, + username: String?, ) } 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 301ff36c2..08bb31726 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 @@ -1,6 +1,7 @@ package org.grakovne.lissen.content.cache.persistent.entity import androidx.annotation.Keep +import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey @@ -51,8 +52,8 @@ data class BookEntity( val seriesNames: String?, val createdAt: Long, val updatedAt: Long, - val host: String = "", - val username: String = "", + val host: String? = null, + val username: String? = null, ) : Serializable @Keep @@ -74,9 +75,9 @@ data class BookFileEntity( val bookFileId: String, val name: String, val duration: Double, - val size: Long, val mimeType: String, val bookId: String, + @ColumnInfo(defaultValue = "0") val size: Long = 0L, ) : Serializable @Keep @@ -123,8 +124,8 @@ data class MediaProgressEntity( val currentTime: Double, val isFinished: Boolean, val lastUpdate: Long, - val host: String = "", - val username: String = "", + val host: String? = null, + val username: String? = null, ) : Serializable @Keep diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt index 036c8ad35..479b84e31 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt @@ -17,6 +17,6 @@ data class CachedLibraryEntity( val id: String, val title: String, val type: LibraryType, - val host: String = "", - val username: String = "", + val host: String? = null, + val username: String? = null, ) : Serializable diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index 72d626db1..13ae67cfd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -389,7 +389,7 @@ class LissenSharedPreferences val adapter = moshi.adapter(DetailedItem::class.java) try { adapter.fromJson(json) - } catch (e: Exception) { + } catch (e: Throwable) { null } } @@ -482,6 +482,21 @@ class LissenSharedPreferences putBoolean(KEY_SMART_REWIND_ENABLED, enabled) } + fun getAnalyticsConsentState(): Boolean? { + if (!sharedPreferences.contains(KEY_ANALYTICS_CONSENT)) return null + return sharedPreferences.getBoolean(KEY_ANALYTICS_CONSENT, false) + } + + fun saveAnalyticsConsentState(accepted: Boolean?) { + sharedPreferences.edit { + if (accepted == null) { + remove(KEY_ANALYTICS_CONSENT) + } else { + putBoolean(KEY_ANALYTICS_CONSENT, accepted) + } + } + } + fun getSmartRewindEnabled(): Boolean = sharedPreferences.getBoolean(KEY_SMART_REWIND_ENABLED, false) fun saveSmartRewindThreshold(threshold: SmartRewindInactivityThreshold) = @@ -672,6 +687,7 @@ class LissenSharedPreferences private const val KEY_SHOW_PLAYER_NAV_BUTTONS = "show_player_nav_buttons" private const val KEY_SHAKE_TO_RESET_TIMER = "shake_to_reset_timer" private const val KEY_SKIP_SILENCE_ENABLED = "skip_silence_enabled" + private const val KEY_ANALYTICS_CONSENT = "analytics_consent" private const val KEY_PLAYING_BOOK = "playing_book" private const val KEY_VOLUME_BOOST = "volume_boost" diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt new file mode 100644 index 000000000..878de2ec5 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt @@ -0,0 +1,128 @@ +package org.grakovne.lissen.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.grakovne.lissen.R +import org.grakovne.lissen.ui.theme.FoxOrange +import org.grakovne.lissen.ui.theme.Spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnalyticsConsentBottomSheet( + onAccept: () -> Unit, + onDecline: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = { /* User must make a choice */ }, + sheetState = sheetState, + containerColor = colorScheme.background, + dragHandle = null, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .padding(top = 48.dp, bottom = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.analytics_consent_title), + style = + typography.headlineSmall.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.5).sp, + lineHeight = 28.sp, + fontSize = 22.sp, + ), + textAlign = TextAlign.Center, + color = colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(Spacing.md)) + + Text( + text = stringResource(R.string.analytics_consent_message), + style = + typography.bodyLarge.copy( + lineHeight = 24.sp, + letterSpacing = 0.2.sp, + ), + textAlign = TextAlign.Center, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + ) + + Spacer(modifier = Modifier.height(48.dp)) + + Button( + onClick = onAccept, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = CircleShape, + colors = + ButtonDefaults.buttonColors( + containerColor = FoxOrange, + contentColor = Color.White, + ), + elevation = ButtonDefaults.buttonElevation(0.dp), + ) { + Text( + text = stringResource(R.string.analytics_consent_accept), + style = typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + + // Increased gap to prevent accidental clicks + Spacer(modifier = Modifier.height(Spacing.md)) + + TextButton( + onClick = onDecline, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp) + .background( + color = colorScheme.onSurface.copy(alpha = 0.05f), + shape = CircleShape, + ), + shape = CircleShape, + ) { + Text( + text = stringResource(R.string.analytics_consent_decline), + style = + typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + ), + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index fa0a37d8d..18354e356 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -40,8 +40,13 @@ fun DownloadProgressIcon( Box(contentAlignment = Alignment.Center) { when (cacheState.status) { is CacheStatus.Queued -> { + val queuedDescription = stringResource(R.string.accessibility_id_download_queued) + CircularProgressIndicator( - modifier = Modifier.size(size - 4.dp), + modifier = + Modifier + .semantics { contentDescription = queuedDescription } + .size(size - 4.dp), strokeWidth = 2.dp, color = colorScheme.primary, trackColor = color.copy(alpha = 0.1f), @@ -87,14 +92,14 @@ fun DownloadProgressIcon( if (isFullyDownloaded) { Icon( imageVector = Icons.Filled.CloudDone, - contentDescription = null, + contentDescription = stringResource(R.string.accessibility_id_download_complete), modifier = Modifier.size(size), tint = colorScheme.primary, ) } else { Icon( imageVector = Icons.Outlined.CloudDownload, - contentDescription = stringResource(R.string.player_screen_downloads_navigation), + contentDescription = stringResource(R.string.accessibility_id_download_available), modifier = Modifier.size(size).alpha(0.8f), tint = color, ) @@ -105,14 +110,14 @@ fun DownloadProgressIcon( if (isFullyDownloaded) { Icon( imageVector = Icons.Filled.CloudDone, - contentDescription = null, + contentDescription = stringResource(R.string.accessibility_id_download_complete), modifier = Modifier.size(size), tint = colorScheme.primary, ) } else { Icon( imageVector = Icons.Outlined.CloudDownload, - contentDescription = stringResource(R.string.player_screen_downloads_navigation), + contentDescription = stringResource(R.string.accessibility_id_download_available), modifier = Modifier.size(size).alpha(0.8f), tint = color, ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt index c8e11d36d..0aaa379e7 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt @@ -8,6 +8,7 @@ import org.grakovne.lissen.lib.domain.DownloadOption import org.grakovne.lissen.lib.domain.LibraryType import org.grakovne.lissen.lib.domain.NumberItemDownloadOption import org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption +import org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption fun DownloadOption?.makeText( context: Context, @@ -16,8 +17,8 @@ fun DownloadOption?.makeText( when (this) { null -> context.getString(R.string.downloads_menu_download_option_disable) - is org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption -> { - "Selected Volume" + is SpecificFilesDownloadOption -> { + context.getString(R.string.downloads_menu_download_option_selected_volume) } CurrentItemDownloadOption -> { @@ -65,8 +66,4 @@ fun DownloadOption?.makeText( ) } } - - is org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption -> { - "Multiple Files" - } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt index 57815c7ff..59e17d7e5 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -143,7 +143,6 @@ fun BookDetailScreen( val totalPosition by playerViewModel.totalPosition.observeAsState(0.0) val cacheProgress: CacheState by cachingModelView.getProgress(bookId).collectAsState(initial = CacheState(CacheStatus.Idle)) - val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(bookId).observeAsState(false) var downloadsExpanded by remember { mutableStateOf(false) } val scope = androidx.compose.runtime.rememberCoroutineScope() @@ -312,7 +311,7 @@ fun BookDetailScreen( Spacer(modifier = Modifier.width(8.dp)) Icon( imageVector = Icons.Default.CheckCircle, - contentDescription = null, + contentDescription = stringResource(R.string.accessibility_id_fully_downloaded), tint = colorScheme.primary, modifier = Modifier.size(20.dp), ) @@ -582,7 +581,6 @@ fun BookDetailScreen( if (downloadsExpanded && bookDetail != null) { val book = bookDetail!! val storageType = remember(book.id) { cachingModelView.getBookStorageType(book) } - val volumes = remember(book.id) { cachingModelView.getVolumes(book) } DownloadsComposable( book = book, 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 7a4f29f46..125f163b5 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 @@ -65,6 +65,7 @@ import org.grakovne.lissen.common.NetworkService import org.grakovne.lissen.common.withHaptic import org.grakovne.lissen.lib.domain.LibraryType import org.grakovne.lissen.lib.domain.RecentBook +import org.grakovne.lissen.ui.components.AnalyticsConsentBottomSheet import org.grakovne.lissen.ui.components.withScrollbar import org.grakovne.lissen.ui.extensions.withMinimumTime import org.grakovne.lissen.ui.navigation.AppNavigationService @@ -102,6 +103,7 @@ fun LibraryScreen( val activity = LocalActivity.current val recentBooks: List by libraryViewModel.recentBooks.observeAsState(emptyList()) + val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState() var pullRefreshing by remember { mutableStateOf(false) } val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false) @@ -479,4 +481,11 @@ fun LibraryScreen( }, ) } + + if (analyticsConsent == null) { + AnalyticsConsentBottomSheet( + onAccept = { settingsViewModel.updateAnalyticsConsent(true) }, + onDecline = { settingsViewModel.updateAnalyticsConsent(false) }, + ) + } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt index f7b70e7ab..d4b4601e9 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt @@ -4,11 +4,18 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -48,21 +55,68 @@ fun MigrationScreen( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(Spacing.lg), + modifier = + Modifier + .padding(Spacing.lg) + .padding(bottom = Spacing.xl), ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - strokeWidth = 4.dp, - ) + when (state) { + is MigrationState.Error -> { + Icon( + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) - Spacer(modifier = Modifier.height(Spacing.xl)) + Spacer(modifier = Modifier.height(Spacing.xl)) - Text( - text = stringResource(R.string.migration_screen_message), - style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onBackground, - ) + Text( + text = stringResource(R.string.migration_screen_error_message), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(modifier = Modifier.height(Spacing.xl)) + + Button( + onClick = { viewModel.retryMigration() }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) { + Text(text = stringResource(R.string.migration_screen_retry_button)) + } + + Spacer(modifier = Modifier.height(Spacing.md)) + + TextButton( + onClick = { onMigrationComplete() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.migration_screen_dismiss_button), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp, + ) + + Spacer(modifier = Modifier.height(Spacing.xl)) + + Text( + text = stringResource(R.string.migration_screen_message), + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt index 5fb5e5f68..f94745135 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt @@ -449,7 +449,6 @@ fun PlayerContent( org.grakovne.lissen.content.cache.persistent .CacheState(org.grakovne.lissen.lib.domain.CacheStatus.Idle), ) - val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(it.id).observeAsState(false) it.let { book -> DownloadsComposable( diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt index e92752d99..ad59c2ad0 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt @@ -432,7 +432,6 @@ fun PlayerScreen( org.grakovne.lissen.content.cache.persistent .CacheState(org.grakovne.lissen.lib.domain.CacheStatus.Idle), ) - val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(playingBook?.id.orEmpty()).observeAsState(false) playingBook?.let { book -> DownloadsComposable( diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt index 9dc591dc8..810342e3b 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt @@ -154,7 +154,7 @@ fun ActionRow( Text( text = subtitle, style = MaterialTheme.typography.labelSmall, - color = subtextColor.copy(alpha = contentAlpha), + color = subtextColor.copy(alpha = subtextColor.alpha * contentAlpha), maxLines = 2, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt index ba1a6c834..1ef04fa8e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt @@ -176,9 +176,24 @@ fun DownloadsComposable( // Scenario B: Segmented if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.SEGMENTED) { if (currentVolume != null && !currentVolume.isDownloaded) { - val startChapter = book.chapters.indexOfFirst { it.id == currentVolume.chapters.firstOrNull()?.id } + 1 - val endChapter = book.chapters.indexOfFirst { it.id == currentVolume.chapters.lastOrNull()?.id } + 1 - val range = if (startChapter == endChapter) "$startChapter" else "$startChapter-$endChapter" + val startIdx = book.chapters.indexOfFirst { it.id == currentVolume.chapters.firstOrNull()?.id } + val startChapter = if (startIdx >= 0) startIdx + 1 else null + + val endIdx = book.chapters.indexOfFirst { it.id == currentVolume.chapters.lastOrNull()?.id } + val endChapter = if (endIdx >= 0) endIdx + 1 else null + + val range = + when { + startChapter != null && endChapter != null -> + if (startChapter == + endChapter + ) { + "$startChapter" + } else { + "$startChapter-$endChapter" + } + else -> "?" + } ActionRow( title = @@ -242,7 +257,10 @@ fun DownloadsComposable( } if (!isFullBookDownloaded) { - val totalSize = volumes.sumOf { it.size } + val totalSize = + volumes + .filter { !it.isDownloaded } + .sumOf { it.size } ActionRow( title = stringResource(R.string.downloads_menu_download_option_entire_book), subtitle = Formatter.formatFileSize(context, totalSize), 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 549a7fefe..5ede6e84f 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 @@ -175,7 +175,14 @@ fun CachedItemsSettingsScreen( if (!selectionMode) selectedVolumes.clear() }) { Text( - text = if (selectionMode) "Cancel" else "Edit", // Localization later + text = + if (selectionMode) { + stringResource( + R.string.settings_screen_cached_items_cancel, + ) + } else { + stringResource(R.string.settings_screen_cached_items_edit) + }, style = typography.labelLarge, color = colorScheme.primary, ) @@ -186,7 +193,10 @@ fun CachedItemsSettingsScreen( }, bottomBar = { if (selectionMode && selectedVolumes.isNotEmpty()) { - val totalSizeToReclaim = calculateReclaimSize(selectedVolumes, cachedItems, viewModel) + val totalSizeToReclaim = + remember(selectedVolumes.toList(), cachedItems.itemCount) { + calculateReclaimSize(selectedVolumes, cachedItems, viewModel) + } val formattedSize = Formatter.formatFileSize(context, totalSizeToReclaim) Box( @@ -198,9 +208,9 @@ fun CachedItemsSettingsScreen( ) { Button( onClick = { - scope.launch { - deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) - withHaptic(view) { + withHaptic(view) { + scope.launch { + deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) selectionMode = false selectedVolumes.clear() refreshContent(false) @@ -426,8 +436,6 @@ private fun CachedItemComposable( val view = LocalView.current var expanded by remember { mutableStateOf(false) } - val isSingleFileBook = remember(book) { book.files.size <= 1 } - val bookSize = remember(book) { Formatter.formatFileSize(context, viewModel.getBookSize(book)) @@ -668,15 +676,22 @@ private fun calculateReclaimSize( cachedItems: LazyPagingItems, viewModel: CachingModelView, ): Long { + if (selectedIds.isEmpty()) return 0L + + val selectedByBook = selectedIds.groupBy { it.bookId } var total = 0L - selectedIds.forEach { selection -> - val book = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }.find { it.id == selection.bookId } - book?.let { - val volumes = viewModel.getVolumes(it) - val volume = volumes.find { v -> v.id == selection.fileId } + + for (i in 0 until cachedItems.itemCount) { + val book = cachedItems[i] ?: continue + val selectionsForBook = selectedByBook[book.id] ?: continue + + val volumes = viewModel.getVolumes(book) + selectionsForBook.forEach { selection -> + val volume = volumes.find { it.id == selection.fileId } total += volume?.size ?: 0L } } + return total } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt index 5aad71968..96aa66fe6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.microsoft.clarity.modifiers.clarityMask import org.grakovne.lissen.R import org.grakovne.lissen.channel.audiobookshelf.HostType import org.grakovne.lissen.ui.navigation.AppNavigationService @@ -87,6 +88,7 @@ fun ServerSettingsComposable( color = colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.clarityMask(), ) } } @@ -127,6 +129,7 @@ fun ServerSettingsComposable( InfoRow( label = stringResource(R.string.settings_screen_connected_as_title), value = it, + modifier = Modifier.clarityMask(), ) HorizontalDivider() @@ -164,8 +167,10 @@ fun ServerSettingsComposable( fun InfoRow( label: String, value: String, + modifier: Modifier = Modifier, ) { ListItem( + modifier = modifier, headlineContent = { Row( modifier = Modifier.fillMaxWidth(), 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 bf77e4852..0b94fdeed 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -161,6 +161,7 @@ class CachingModelView suspend fun dropCompletedChapters(item: DetailedItem) { contentCachingManager.dropCompletedChapters(item) _cacheVersion.value += 1 + refreshStorageStats() } fun stopCaching(item: DetailedItem) { @@ -191,15 +192,11 @@ class CachingModelView fun localCacheUsing() = preferences.isForceCache() - fun provideCacheState(bookId: String): LiveData = contentCachingManager.hasMetadataCached(bookId) - fun provideCacheState( bookId: String, chapterId: String, ): LiveData = contentCachingManager.hasMetadataCached(bookId, chapterId) - fun hasDownloadedChapters(bookId: String): LiveData = contentCachingManager.hasDownloadedChapters(bookId) - fun fetchCachedItems() { viewModelScope.launch { withContext(Dispatchers.IO) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt index 5c5f2ce40..e4510947e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt @@ -24,12 +24,23 @@ class MigrationViewModel val migrationState: LiveData = _migrationState fun startMigration() { + if (_migrationState.value != MigrationState.Idle) { + return + } + + executeMigration() + } + + fun retryMigration() { + executeMigration() + } + + private fun executeMigration() { viewModelScope.launch { _migrationState.value = MigrationState.Running // Artificial delay for a better UX if migration is target-fast delay(1500) - try { withContext(Dispatchers.IO) { // Trigger DB initialization and migration by performing a simple query @@ -39,14 +50,18 @@ class MigrationViewModel preferences.setDatabaseVersion(CURRENT_DATABASE_VERSION) _migrationState.value = MigrationState.Completed } catch (e: Exception) { - // In a real app, we might want to handle this more gracefully - _migrationState.value = MigrationState.Completed // Proceed anyway to avoid bricking + if (e is kotlinx.coroutines.CancellationException) { + throw e + } + + timber.log.Timber.e(e, "Migration failed") + _migrationState.value = MigrationState.Error(e) } } } companion object { - const val CURRENT_DATABASE_VERSION = 16 + const val CURRENT_DATABASE_VERSION = 19 } } @@ -56,4 +71,8 @@ sealed class MigrationState { data object Running : MigrationState() data object Completed : MigrationState() + + data class Error( + val throwable: Throwable, + ) : MigrationState() } 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 c958d5e55..d7e4354b0 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import org.grakovne.lissen.analytics.ClarityComponent import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.channel.audiobookshelf.Host import org.grakovne.lissen.channel.common.OperationResult @@ -37,6 +38,7 @@ class SettingsViewModel private val bookRepository: org.grakovne.lissen.content.BookRepository, private val cachedCoverProvider: org.grakovne.lissen.content.cache.temporary.CachedCoverProvider, private val clarityTracker: ClarityTracker, + private val clarityComponent: ClarityComponent, ) : ViewModel() { private val _host: MutableLiveData = MutableLiveData(preferences.getHost()?.let { Host.external(it) }) val host = _host @@ -98,6 +100,9 @@ class SettingsViewModel val smartRewindThreshold = preferences.smartRewindThresholdFlow.asLiveData() val smartRewindDuration = preferences.smartRewindDurationFlow.asLiveData() + private val _analyticsConsent = MutableLiveData(preferences.getAnalyticsConsentState()) + val analyticsConsent: LiveData = _analyticsConsent + fun preferCrashReporting(value: Boolean) { _crashReporting.postValue(value) preferences.saveAcraEnabled(value) @@ -141,6 +146,12 @@ class SettingsViewModel preferences.clearPreferences() } + fun updateAnalyticsConsent(accepted: Boolean) { + _analyticsConsent.postValue(accepted) + preferences.saveAnalyticsConsentState(accepted) + clarityComponent.updateConsent(accepted) + } + fun refreshConnectionInfo() { fetchConnectionHost() @@ -188,7 +199,7 @@ class SettingsViewModel fun preferLibrary(library: Library) { _preferredLibrary.postValue(library) preferences.savePreferredLibrary(library) - clarityTracker.trackEvent("library_switched", library.title) + clarityTracker.trackEvent("library_switched", library.id) } fun preferAutoDownloadNetworkType(type: NetworkTypeAutoCache) { @@ -297,9 +308,18 @@ class SettingsViewModel fun clearMetadataCache(onComplete: () -> Unit) { viewModelScope.launch { - bookRepository.clearMetadataCache() - cachedCoverProvider.clearCache() - onComplete() + try { + bookRepository.clearMetadataCache() + cachedCoverProvider.clearCache() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) { + throw e + } + + timber.log.Timber.e(e, "Failed to clear metadata cache") + } finally { + onComplete() + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c21f7a9df..5b85cbb2f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -233,6 +233,9 @@ Rearranging the library shelves… + Library update failed. Should we try again? + Try again + Skip for now Clear metadata cache @@ -263,4 +266,15 @@ Chapters %1$s Free up %1$s? + Edit + Cancel + Download complete + Download available + Download queued + Selected Volume + Fully downloaded + Help us polish the experience. + Help us squash bugs by sharing anonymous technical data. Your library and personal info stay 100% private. + Help Improve Kahani 🚀 + Maybe later \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb7054610..b1c368314 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ coreKtx = "1.17.0" timber = "5.0.1" moshi = "1.15.2" converterMoshi = "3.0.0" -clarity = "3.8.1" +clarity = "3.8.0" [libraries] acra-core = { module = "ch.acra:acra-core", version.ref = "acraCore" } diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt index d3177821d..7c6135003 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt @@ -5,62 +5,57 @@ import com.squareup.moshi.JsonClass import java.io.Serializable @Keep -@JsonClass(generateAdapter = true) data class DetailedItem( - val id: String, - val title: String, - val subtitle: String?, - val author: String?, - val narrator: String?, - val publisher: String?, - val series: List, - val year: String?, - val abstract: String?, - val files: List, - val chapters: List, - val progress: MediaProgress?, - val libraryId: String?, - val libraryType: LibraryType?, + val id: String = "", + val title: String = "", + val subtitle: String? = null, + val author: String? = null, + val narrator: String? = null, + val publisher: String? = null, + val series: List = emptyList(), + val year: String? = null, + val abstract: String? = null, + val files: List = emptyList(), + val chapters: List = emptyList(), + val progress: MediaProgress? = null, + val libraryId: String? = null, + val libraryType: LibraryType? = null, val localProvided: Boolean = false, val createdAt: Long = 0, val updatedAt: Long = 0, ) : Serializable @Keep -@JsonClass(generateAdapter = true) data class BookFile( - val id: String, - val name: String, - val duration: Double, - val size: Long, - val mimeType: String, + val id: String = "", + val name: String = "", + val duration: Double = 0.0, + val mimeType: String = "", + val size: Long = 0, ) : Serializable @Keep -@JsonClass(generateAdapter = true) data class MediaProgress( - val currentTime: Double, - val isFinished: Boolean, - val lastUpdate: Long, + val currentTime: Double = 0.0, + val isFinished: Boolean = false, + val lastUpdate: Long = 0, ) : Serializable @Keep -@JsonClass(generateAdapter = true) data class PlayingChapter( - val available: Boolean, - val podcastEpisodeState: BookChapterState?, - val duration: Double, - val start: Double, - val end: Double, - val title: String, - val id: String, + val available: Boolean = false, + val podcastEpisodeState: BookChapterState? = null, + val duration: Double = 0.0, + val start: Double = 0.0, + val end: Double = 0.0, + val title: String = "", + val id: String = "", ) : Serializable @Keep -@JsonClass(generateAdapter = true) data class BookSeries( - val serialNumber: String?, - val name: String, + val serialNumber: String? = null, + val name: String = "", ) : Serializable @Keep diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt index b31895c76..ba1167aa8 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt @@ -3,6 +3,8 @@ package org.grakovne.lissen.lib.domain import androidx.annotation.Keep import java.io.Serializable +import java.util.Base64 + @Keep sealed interface DownloadOption : Serializable @@ -25,7 +27,13 @@ fun DownloadOption?.makeId() = when (this) { AllItemsDownloadOption -> "all_items" CurrentItemDownloadOption -> "current_item" is NumberItemDownloadOption -> "number_items_$itemsNumber" - is SpecificFilesDownloadOption -> "specific_files_${fileIds.joinToString(",")}" + is SpecificFilesDownloadOption -> { + val payload = fileIds + .map { Base64.getUrlEncoder().withoutPadding().encodeToString(it.toByteArray()) } + .joinToString(",") + "specific_files_$payload" + } + RemainingItemsDownloadOption -> "remaining_items" } @@ -35,7 +43,19 @@ fun String?.makeDownloadOption(): DownloadOption? = when { this == "current_item" -> CurrentItemDownloadOption this == "remaining_items" -> RemainingItemsDownloadOption startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt()) - startsWith("specific_files_") -> SpecificFilesDownloadOption(substringAfter("specific_files_").split(",")) + startsWith("specific_files_") -> { + val payload = substringAfter("specific_files_") + + val fileIds = when { + payload.isEmpty() -> emptyList() + else -> payload + .split(",") + .map { String(Base64.getUrlDecoder().decode(it)) } + } + + SpecificFilesDownloadOption(fileIds) + } + else -> null } From ecd3041bc404788dd9e0d415c09ac3f4058f42a4 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Fri, 6 Feb 2026 21:25:11 +0530 Subject: [PATCH 11/37] feat: Update CachedItemsSettingsScreen to navigate to the library when the cache is empty, providing a new `onNavigateToLibrary` callback. --- .../kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt | 3 +++ .../settings/advanced/cache/CachedItemsSettingsScreen.kt | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt index fb89c3ea4..713607ac5 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt @@ -129,6 +129,9 @@ fun AppNavHost( navController.popBackStack() } }, + onNavigateToLibrary = { + navigationService.showLibrary(clearHistory = true) + }, imageLoader = imageLoader, ) } 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 5ede6e84f..cef34f482 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 @@ -104,6 +104,7 @@ data class VolumeIdentifier( @Composable fun CachedItemsSettingsScreen( onBack: () -> Unit, + onNavigateToLibrary: () -> Unit, imageLoader: ImageLoader, viewModel: CachingModelView = hiltViewModel(), playerViewModel: PlayerViewModel = hiltViewModel(), @@ -238,7 +239,7 @@ fun CachedItemsSettingsScreen( StorageHeader(viewModel) when (cachedItems.itemCount == 0) { - true -> PolishedCachedItemsEmptyState(onBack) + true -> PolishedCachedItemsEmptyState(onNavigateToLibrary) false -> CachedItemsComposable( cachedItems = cachedItems, From 331716d9015af0b84b2b9ce519c66c629582784b Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Fri, 6 Feb 2026 22:31:58 +0530 Subject: [PATCH 12/37] feat: Implement bulk selection for cached items in settings, add metadata refresh, and refine cache cleanup logic. --- .../cache/persistent/ContentCachingManager.kt | 5 + .../cache/CachedItemsSettingsScreen.kt | 95 +++++++++++++------ .../lissen/viewmodel/CachingModelView.kt | 16 ++++ app/src/main/res/values/strings.xml | 2 +- 4 files changed, 86 insertions(+), 32 deletions(-) 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 b54a6da4d..eaa3ef925 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 @@ -154,6 +154,11 @@ class ContentCachingManager ?.filter { it.available } ?: emptyList() + if (stillCachedChapters.isEmpty()) { + dropCache(item.id) + return + } + val stillNeededFiles = stillCachedChapters .flatMap { findRelatedFiles(it, item.files) } 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 cef34f482..57a27b04f 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 @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -42,6 +43,7 @@ import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -50,7 +52,9 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -66,6 +70,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -89,7 +94,6 @@ import org.grakovne.lissen.common.withHaptic import org.grakovne.lissen.lib.domain.DetailedItem import org.grakovne.lissen.lib.domain.PlayingChapter import org.grakovne.lissen.ui.components.AsyncShimmeringImage -import org.grakovne.lissen.ui.components.BookCoverFetcher.Companion.LocalOnlyKey import org.grakovne.lissen.ui.components.withScrollbar import org.grakovne.lissen.ui.extensions.withMinimumTime import org.grakovne.lissen.viewmodel.CachingModelView @@ -135,6 +139,7 @@ fun CachedItemsSettingsScreen( listOf( async { viewModel.fetchCachedItems() }, async { viewModel.refreshStorageStats() }, + async { viewModel.refreshMetadata() }, ).awaitAll() } @@ -171,7 +176,7 @@ fun CachedItemsSettingsScreen( }, actions = { if (cachedItems.itemCount > 0) { - IconButton(onClick = { + TextButton(onClick = { selectionMode = !selectionMode if (!selectionMode) selectedVolumes.clear() }) { @@ -199,29 +204,42 @@ fun CachedItemsSettingsScreen( calculateReclaimSize(selectedVolumes, cachedItems, viewModel) } val formattedSize = Formatter.formatFileSize(context, totalSizeToReclaim) + val playingBook by playerViewModel.book.observeAsState() Box( modifier = Modifier .fillMaxWidth() - .background(colorScheme.surface) - .padding(16.dp), + .background(Color.Transparent) + .navigationBarsPadding() + .padding(bottom = if (playingBook != null) 16.dp else 0.dp), ) { - Button( - onClick = { - withHaptic(view) { - scope.launch { - deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) - selectionMode = false - selectedVolumes.clear() - refreshContent(false) - } - } - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error), + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), ) { - Text(stringResource(R.string.manage_downloads_free_up, formattedSize)) + Button( + onClick = { + withHaptic(view) { + scope.launch { + deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) + selectionMode = false + selectedVolumes.clear() + refreshContent(false) + } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorScheme.error, + contentColor = colorScheme.onError, + ), + ) { + Text(stringResource(R.string.manage_downloads_free_up, formattedSize)) + } } } } @@ -447,7 +465,6 @@ private fun CachedItemComposable( ImageRequest .Builder(context) .data(book.id) - .apply { extras[LocalOnlyKey] = true } .build() } @@ -536,6 +553,24 @@ private fun CachedItemComposable( Spacer(Modifier.width(8.dp)) + if (selectionMode) { + val downloadedVolumes = remember(book) { viewModel.getVolumes(book).filter { it.isDownloaded } } + val bookVolumes = downloadedVolumes.map { VolumeIdentifier(book.id, it.id) } + val isFullySelected = selectedVolumes.containsAll(bookVolumes) + + Checkbox( + checked = isFullySelected, + onCheckedChange = { checked -> + if (checked) { + bookVolumes.forEach { if (!selectedVolumes.contains(it)) selectedVolumes.add(it) } + } else { + selectedVolumes.removeAll(bookVolumes) + } + }, + modifier = Modifier.padding(end = 8.dp), + ) + } + if (!selectionMode) { IconButton(onClick = { withHaptic(view) { @@ -615,17 +650,6 @@ private fun CachedItemVolumeComposable( }.padding(vertical = 8.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (selectionMode) { - androidx.compose.material3.Checkbox( - checked = isSelected, - onCheckedChange = { - val identifier = VolumeIdentifier(item.id, volume.id) - if (it) selectedVolumes.add(identifier) else selectedVolumes.remove(identifier) - }, - modifier = Modifier.padding(end = 8.dp), - ) - } - Column(modifier = Modifier.weight(1f)) { Text(text = volume.name, style = typography.bodyMedium) Text( @@ -634,7 +658,16 @@ private fun CachedItemVolumeComposable( ) } - if (!selectionMode && volumes.size > 1) { + if (selectionMode) { + androidx.compose.material3.Checkbox( + checked = isSelected, + onCheckedChange = { + val identifier = VolumeIdentifier(item.id, volume.id) + if (it) selectedVolumes.add(identifier) else selectedVolumes.remove(identifier) + }, + modifier = Modifier.padding(start = 8.dp), + ) + } else if (volumes.size > 1) { IconButton( onClick = { withHaptic(view) { 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 0b94fdeed..e4e336834 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -205,6 +205,22 @@ class CachingModelView } } + fun refreshMetadata() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + localCacheRepository + .fetchDetailedItems(Int.MAX_VALUE, 0) + .fold( + onSuccess = { it.items }, + onFailure = { emptyList() }, + ).forEach { localCacheRepository.cacheBookMetadata(it) } + + _cacheVersion.value += 1 + refreshStorageStats() + } + } + } + suspend fun fetchLatestUpdate(libraryId: String) = localCacheRepository.fetchLatestUpdate(libraryId) companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b85cbb2f..b7da46879 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -266,7 +266,7 @@ Chapters %1$s Free up %1$s? - Edit + Select Cancel Download complete Download available From dba6ec34bed27e9857642398bb0b7d62864a9c10 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Fri, 6 Feb 2026 23:50:41 +0530 Subject: [PATCH 13/37] feat: Implement progressive image loading with blurred thumbnails, add dynamic cover art updates for media items, and enable forced library synchronization on pull-to-refresh. --- .../grakovne/lissen/content/BookRepository.kt | 49 ++++++++++++++++++- .../playback/service/PlaybackService.kt | 29 ++++++++++- .../ui/components/AsyncShimmeringImage.kt | 45 ++++++++++++++--- .../ui/screens/details/BookDetailScreen.kt | 15 ++++++ .../ui/screens/library/LibraryScreen.kt | 2 +- .../lissen/viewmodel/LibraryViewModel.kt | 16 +++++- 6 files changed, 143 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt index 1d63117f7..0ba8df5d3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt @@ -1,8 +1,17 @@ package org.grakovne.lissen.content import android.net.Uri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider import org.grakovne.lissen.channel.common.MediaChannel @@ -35,6 +44,8 @@ class BookRepository private val networkService: NetworkService, private val clarityTracker: ClarityTracker, ) { + private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + fun provideFileUri( libraryItemId: String, chapterId: String, @@ -169,7 +180,10 @@ class BookRepository pageNumber = pageNumber, ).also { it.foldAsync( - onSuccess = { result -> localCacheRepository.cacheBooks(result.items) }, + onSuccess = { result -> + localCacheRepository.cacheBooks(result.items) + backgroundScope.launch { prefetchCovers(result.items) } + }, onFailure = {}, ) } @@ -331,7 +345,10 @@ class BookRepository when { remoteTime > localTime -> { providePreferredChannel().fetchBook(id).foldAsync( - onSuccess = { localCacheRepository.cacheBookMetadata(it) }, + onSuccess = { + localCacheRepository.cacheBookMetadata(it) + backgroundScope.launch { prefetchCovers(listOf(it.toBook())) } + }, onFailure = {}, ) } @@ -362,6 +379,34 @@ class BookRepository } } + private suspend fun prefetchCovers(books: List) { + if (!networkService.isNetworkAvailable() || preferences.isForceCache()) { + return + } + + yield() + delay(2000) // Initial delay to prioritize core metadata and thumbnails + + withContext(Dispatchers.IO) { + books.forEach { book -> + fetchBookCover(book.id, null) + delay(100) + yield() + } + } + } + + private fun DetailedItem.toBook() = + Book( + id = this.id, + subtitle = this.subtitle, + series = this.series.joinToString { it.name }, + title = this.title, + author = this.author, + duration = this.chapters.sumOf { it.duration }, + libraryId = this.libraryId ?: "", + ) + private fun OperationResult.getOrNull(): T? = when (this) { is OperationResult.Success -> data 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 ec58ec327..646c53927 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 @@ -196,7 +196,7 @@ class PlaybackService : MediaSessionService() { .Builder() .setTitle(file.name) .setArtist(book.title) - .setArtworkUri(fetchCover(book)) + .build() val mediaItem = MediaItem @@ -204,7 +204,7 @@ class PlaybackService : MediaSessionService() { .setMediaId(file.id) .setUri(apply(book.id, file.id)) .setTag(book) - .setMediaMetadata(mediaData.build()) + .setMediaMetadata(mediaData) .build() ProgressiveMediaSource @@ -233,6 +233,31 @@ class PlaybackService : MediaSessionService() { playbackSynchronizationService.startPlaybackSynchronization(book) } + val updateCover = + async { + val artworkUri = fetchCover(book) ?: return@async + + withContext(Dispatchers.Main) { + for (i in 0 until exoPlayer.mediaItemCount) { + val currentItem = exoPlayer.getMediaItemAt(i) + val updatedMetadata = + currentItem + .mediaMetadata + .buildUpon() + .setArtworkUri(artworkUri) + .build() + + val updatedItem = + currentItem + .buildUpon() + .setMediaMetadata(updatedMetadata) + .build() + + exoPlayer.replaceMediaItem(i, updatedItem) + } + } + } + awaitAll(prepareSession, prepareQueue) val intent = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt index 96a3bcf1c..be3492286 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt @@ -3,6 +3,9 @@ package org.grakovne.lissen.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -10,9 +13,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp import coil3.ImageLoader import coil3.compose.AsyncImage import coil3.request.ImageRequest @@ -21,6 +25,7 @@ import com.valentinilk.shimmer.shimmer @Composable fun AsyncShimmeringImage( imageRequest: ImageRequest, + thumbnailRequest: ImageRequest? = null, imageLoader: ImageLoader, contentDescription: String, contentScale: ContentScale, @@ -28,23 +33,40 @@ fun AsyncShimmeringImage( error: Painter, onLoadingStateChanged: (Boolean) -> Unit = {}, ) { - var isLoading by remember { mutableStateOf(true) } - onLoadingStateChanged(isLoading) + var isMainLoading by remember { mutableStateOf(true) } + var isThumbnailLoaded by remember { mutableStateOf(false) } Box( modifier = modifier, contentAlignment = Alignment.Center, ) { - if (isLoading) { + // Thumbnail / Preview layer + if (thumbnailRequest != null && isMainLoading) { + AsyncImage( + model = thumbnailRequest, + imageLoader = imageLoader, + contentDescription = null, + contentScale = contentScale, + modifier = + Modifier + .fillMaxSize() + .blur(if (isThumbnailLoaded) 20.dp else 0.dp), + onSuccess = { isThumbnailLoaded = true }, + ) + } + + // Shimmer fallback if no thumbnail is loaded yet + if (isMainLoading && !isThumbnailLoaded) { Box( modifier = Modifier .fillMaxSize() .shimmer() - .background(androidx.compose.material3.MaterialTheme.colorScheme.surfaceVariant), + .background(MaterialTheme.colorScheme.surfaceVariant), ) } + // Main Image layer AsyncImage( model = imageRequest, imageLoader = imageLoader, @@ -52,14 +74,23 @@ fun AsyncShimmeringImage( contentScale = contentScale, modifier = Modifier.fillMaxSize(), onSuccess = { - isLoading = false + isMainLoading = false onLoadingStateChanged(false) }, onError = { - isLoading = false + isMainLoading = false onLoadingStateChanged(false) }, error = error, ) + + // Center Loader (Instagram style) + if (isMainLoading && isThumbnailLoaded) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + strokeWidth = 4.dp, + ) + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt index 59e17d7e5..92dca6084 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -98,6 +98,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.text.HtmlCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import coil3.Extras import coil3.ImageLoader import coil3.compose.AsyncImage import coil3.request.ImageRequest @@ -110,6 +111,7 @@ import org.grakovne.lissen.lib.domain.CacheStatus import org.grakovne.lissen.lib.domain.DetailedItem import org.grakovne.lissen.lib.domain.LibraryType import org.grakovne.lissen.ui.components.AsyncShimmeringImage +import org.grakovne.lissen.ui.components.BookCoverFetcher import org.grakovne.lissen.ui.components.DownloadProgressIcon import org.grakovne.lissen.ui.extensions.formatTime import org.grakovne.lissen.ui.navigation.AppNavigationService @@ -272,6 +274,18 @@ fun BookDetailScreen( .build() } + val thumbnailRequest: ImageRequest = + remember(book.id) { + ImageRequest + .Builder(context) + .data(book.id) + .size(200, 200) + .memoryCacheKey("${book.id}_thumbnail") + .diskCacheKey("${book.id}_thumbnail") + .apply { extras[BookCoverFetcher.LocalOnlyKey] = true } + .build() + } + Box( modifier = Modifier @@ -283,6 +297,7 @@ fun BookDetailScreen( ) { AsyncShimmeringImage( imageRequest = imageRequest, + thumbnailRequest = thumbnailRequest, imageLoader = imageLoader, contentDescription = "${book.title} cover", contentScale = ContentScale.Crop, 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 125f163b5..cffcffc97 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 @@ -146,7 +146,7 @@ fun LibraryScreen( withMinimumTime(minimumTime) { listOf( async { settingsViewModel.fetchLibraries() }, - async { libraryViewModel.refreshLibrary() }, + async { libraryViewModel.refreshLibrary(forceRefresh = showPullRefreshing) }, async { libraryViewModel.fetchRecentListening() }, ).awaitAll() } 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 d757f92d1..4d06aed8a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -243,9 +243,23 @@ class LibraryViewModel } } - fun refreshLibrary() { + fun refreshLibrary(forceRefresh: Boolean = false) { viewModelScope.launch { withContext(Dispatchers.IO) { + if (forceRefresh && networkService.isServerAvailable.value && !preferences.isForceCache()) { + val libraryId = preferences.getPreferredLibrary()?.id + + if (libraryId != null) { + bookRepository.syncLibraryPage( + libraryId = libraryId, + pageSize = PAGE_SIZE, + pageNumber = 0, + ) + } + + bookRepository.syncRepositories() + } + when (searchRequested.value) { true -> searchPagingSource?.invalidate() else -> defaultPagingSource.value?.invalidate() From b8a4d87a82449c8c064694da8981d06eba905e55 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 00:05:48 +0530 Subject: [PATCH 14/37] Fix: Adjust `_preparingBookId` clearing to occur upon playback start or when preparation completes without immediate play. --- .../kotlin/org/grakovne/lissen/playback/MediaRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 a4466588c..fab598e7c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -162,6 +162,7 @@ class MediaRepository updateShakeDetectorState(preferences.getShakeToResetTimer(), isPlaying) if (isPlaying) { + _preparingBookId.postValue(null) _playingBook.value?.let { startUpdatingProgress(it) } } else { handler.removeCallbacksAndMessages(null) @@ -215,7 +216,6 @@ class MediaRepository preferences.savePlayingBook(it) _isPlaybackReady.postValue(true) - _preparingBookId.postValue(null) pendingChapterIndex?.let { index -> setChapter(index) @@ -225,6 +225,8 @@ class MediaRepository if (_playAfterPrepare.value == true) { _playAfterPrepare.postValue(false) play() + } else { + _preparingBookId.postValue(null) } } } From 684ec8cc279cffb88ba513ac1e89366e9f4875f5 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 00:39:29 +0530 Subject: [PATCH 15/37] feat: Optimize playback readiness by decoupling cover art updates, enhance migration SQL safety, and refine UI/analytics state management with various fixes. --- .../lissen/analytics/ClarityComponent.kt | 8 ++-- .../content/cache/persistent/Migrations.kt | 7 ++- .../playback/service/PlaybackService.kt | 44 +++++++++---------- .../ui/components/AsyncShimmeringImage.kt | 4 +- .../ui/screens/library/LibraryScreen.kt | 2 +- .../lissen/viewmodel/MigrationViewModel.kt | 4 ++ 6 files changed, 38 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt index 63719871c..9d711bb90 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt @@ -37,19 +37,19 @@ class ClarityComponent savedInstanceState: Bundle?, ) { // Initialize Clarity only once with the first activity - if (initialized) return + if (isClarityInitialized) return Timber.d("Initializing Microsoft Clarity with activity: ${activity.javaClass.simpleName}") val config = ClarityConfig(BuildConfig.CLARITY_PROJECT_ID) Clarity.initialize(activity, config) + isClarityInitialized = true applyConsent() - - initialized = true reidentifyUser() } fun updateConsent(accepted: Boolean) { + if (!isClarityInitialized) return Clarity.consent(accepted, accepted) } @@ -73,7 +73,7 @@ class ClarityComponent override fun onActivityDestroyed(activity: Activity) {} - private var initialized = false + private var isClarityInitialized = false private fun reidentifyUser() { if (preferences.hasCredentials()) { 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 85cee08dd..41ca93de2 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 @@ -272,8 +272,11 @@ fun produceMigration15_16( // We add columns as nullable without a fixed DEFAULT value. // If the user is logged in during migration, we can tag existing data. // If they are logged out, existing data remains NULL (owned by "legacy/unknown"). - val hostClause = if (host != null) " DEFAULT '$host'" else "" - val usernameClause = if (username != null) " DEFAULT '$username'" else "" + val safeHost = host?.replace("'", "''") + val safeUsername = username?.replace("'", "''") + + val hostClause = if (safeHost != null) " DEFAULT '$safeHost'" else "" + val usernameClause = if (safeUsername != null) " DEFAULT '$safeUsername'" else "" db.execSQL("ALTER TABLE detailed_books ADD COLUMN host TEXT$hostClause") db.execSQL("ALTER TABLE detailed_books ADD COLUMN username TEXT$usernameClause") 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 646c53927..63048031f 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 @@ -233,30 +233,30 @@ class PlaybackService : MediaSessionService() { playbackSynchronizationService.startPlaybackSynchronization(book) } - val updateCover = - async { - val artworkUri = fetchCover(book) ?: return@async - - withContext(Dispatchers.Main) { - for (i in 0 until exoPlayer.mediaItemCount) { - val currentItem = exoPlayer.getMediaItemAt(i) - val updatedMetadata = - currentItem - .mediaMetadata - .buildUpon() - .setArtworkUri(artworkUri) - .build() - - val updatedItem = - currentItem - .buildUpon() - .setMediaMetadata(updatedMetadata) - .build() - - exoPlayer.replaceMediaItem(i, updatedItem) - } + // Fire-and-forget: Update cover in background without blocking playback readiness. + launch { + val artworkUri = fetchCover(book) ?: return@launch + + withContext(Dispatchers.Main) { + for (i in 0 until exoPlayer.mediaItemCount) { + val currentItem = exoPlayer.getMediaItemAt(i) + val updatedMetadata = + currentItem + .mediaMetadata + .buildUpon() + .setArtworkUri(artworkUri) + .build() + + val updatedItem = + currentItem + .buildUpon() + .setMediaMetadata(updatedMetadata) + .build() + + exoPlayer.replaceMediaItem(i, updatedItem) } } + } awaitAll(prepareSession, prepareQueue) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt index be3492286..0b6f5a006 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt @@ -33,8 +33,8 @@ fun AsyncShimmeringImage( error: Painter, onLoadingStateChanged: (Boolean) -> Unit = {}, ) { - var isMainLoading by remember { mutableStateOf(true) } - var isThumbnailLoaded by remember { mutableStateOf(false) } + var isMainLoading by remember(imageRequest) { mutableStateOf(true) } + var isThumbnailLoaded by remember(thumbnailRequest) { mutableStateOf(false) } Box( modifier = modifier, 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 cffcffc97..dbd056e81 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 @@ -103,7 +103,7 @@ fun LibraryScreen( val activity = LocalActivity.current val recentBooks: List by libraryViewModel.recentBooks.observeAsState(emptyList()) - val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState() + val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState(true) var pullRefreshing by remember { mutableStateOf(false) } val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false) diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt index e4510947e..9c4db1fde 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt @@ -32,6 +32,10 @@ class MigrationViewModel } fun retryMigration() { + if (_migrationState.value == MigrationState.Running) { + return + } + executeMigration() } From 5b66d4cdb50ff0ad41835e5356ec0651d62d1e1e Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 01:10:36 +0530 Subject: [PATCH 16/37] fix: forced server availability check, pull down to refresh, continue listening flicker on pull down to refresh --- .../grakovne/lissen/common/NetworkService.kt | 12 +++--- .../cache/persistent/dao/CachedBookDao.kt | 33 +++++++++++---- .../ui/screens/library/LibraryScreen.kt | 4 +- .../lissen/viewmodel/LibraryViewModel.kt | 42 ++++--------------- 4 files changed, 39 insertions(+), 52 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt index c56cc90d5..7fb83043a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt @@ -54,37 +54,37 @@ class NetworkService val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - checkServerAvailability() + refreshServerAvailability() } override fun onLost(network: Network) { if (cachedNetworkHandle == network.getNetworkHandle()) { cachedSsid = null } - checkServerAvailability() + refreshServerAvailability() } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities, ) { - checkServerAvailability() + refreshServerAvailability() } } connectivityManager.registerNetworkCallback(networkRequest, networkCallback) - checkServerAvailability() + refreshServerAvailability() scope.launch { preferences.hostFlow.collect { - checkServerAvailability() + refreshServerAvailability() } } } private var checkJob: Job? = null - private fun checkServerAvailability() { + fun refreshServerAvailability() { checkJob?.cancel() checkJob = scope.launch { 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 b6100db7a..c8c5abf5b 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 @@ -122,7 +122,7 @@ interface CachedBookDao { ) } - upsertBook(bookEntity) + safeUpsertBook(bookEntity) upsertBookFiles(bookFiles) upsertBookChapters(bookChapters) mediaProgress?.let { upsertMediaProgress(it) } @@ -220,18 +220,35 @@ interface CachedBookDao { @Query("SELECT * FROM detailed_books WHERE id = :bookId") suspend fun fetchBook(bookId: String): BookEntity? - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBook(book: BookEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBooks(books: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertBookIgnore(book: BookEntity): Long - @Query("SELECT * FROM detailed_books WHERE id IN (:bookIds)") - suspend fun fetchBooks(bookIds: List): List + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertBooksIgnore(books: List): List @Update suspend fun updateBook(book: BookEntity) + @Update + suspend fun updateBooks(books: List) + + @Transaction + suspend fun safeUpsertBook(book: BookEntity) { + val result = insertBookIgnore(book) + if (result == -1L) { + updateBook(book) + } + } + + @Transaction + suspend fun upsertBooks(books: List) { + insertBooksIgnore(books) + updateBooks(books) + } + + @Query("SELECT * FROM detailed_books WHERE id IN (:bookIds)") + suspend fun fetchBooks(bookIds: List): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertBookFiles(files: List) 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 dbd056e81..8e79d7f7f 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 @@ -106,7 +106,6 @@ fun LibraryScreen( val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState(true) var pullRefreshing by remember { mutableStateOf(false) } - val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false) val searchRequested by libraryViewModel.searchRequested.observeAsState(false) val preparingError by playerViewModel.preparingError.observeAsState(false) @@ -147,7 +146,6 @@ fun LibraryScreen( listOf( async { settingsViewModel.fetchLibraries() }, async { libraryViewModel.refreshLibrary(forceRefresh = showPullRefreshing) }, - async { libraryViewModel.fetchRecentListening() }, ).awaitAll() } @@ -162,7 +160,7 @@ fun LibraryScreen( } val hasContent = library.itemCount > 0 || recentBooks.isNotEmpty() - val isLoading = pullRefreshing || recentBookRefreshing || library.loadState.refresh is LoadState.Loading + val isLoading = pullRefreshing || library.loadState.refresh is LoadState.Loading isLoading && !hasContent } 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 4d06aed8a..1c72dd934 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -50,9 +50,6 @@ class LibraryViewModel private val _recentBooks = MutableLiveData>(emptyList()) val recentBooks: LiveData> = _recentBooks - private val _recentBookUpdating = MutableLiveData(false) - val recentBookUpdating: LiveData = _recentBookUpdating - private val _searchRequested = MutableLiveData(false) val searchRequested: LiveData = _searchRequested @@ -94,7 +91,6 @@ class LibraryViewModel val localCacheUpdated = latestLocalUpdate?.let { it > localCacheUpdatedAt } ?: true if (emptyContent || libraryChanged || orderingChanged || (isLocalCacheUsing && localCacheUpdated)) { - refreshRecentListening() refreshLibrary() currentLibraryId = preferences.getPreferredLibrary()?.id ?: "" @@ -104,10 +100,6 @@ class LibraryViewModel } init { - viewModelScope.launch { - downloadedOnlyFlow.collect { refreshRecentListening() } - } - viewModelScope.launch { combine( preferences.preferredLibraryIdFlow, @@ -135,7 +127,6 @@ class LibraryViewModel if (isAvailable) { Timber.d("Server is reachable. Triggering repository sync.") bookRepository.syncRepositories() - refreshRecentListening() refreshLibrary() } } @@ -202,7 +193,6 @@ class LibraryViewModel private fun syncLibrary(libraryId: String) { viewModelScope.launch(Dispatchers.IO) { bookRepository.syncRepositories() - refreshRecentListening() defaultPagingSource.value?.invalidate() } } @@ -234,19 +224,16 @@ class LibraryViewModel ?.type ?: LibraryType.UNKNOWN - fun refreshRecentListening() { - clarityTracker.trackEvent("library_refresh") - viewModelScope.launch { - withContext(Dispatchers.IO) { - fetchRecentListening() - } - } - } - fun refreshLibrary(forceRefresh: Boolean = false) { viewModelScope.launch { withContext(Dispatchers.IO) { - if (forceRefresh && networkService.isServerAvailable.value && !preferences.isForceCache()) { + if (forceRefresh) { + networkService.refreshServerAvailability() + } + + val shouldSync = (forceRefresh || networkService.isServerAvailable.value) && !preferences.isForceCache() + + if (shouldSync) { val libraryId = preferences.getPreferredLibrary()?.id if (libraryId != null) { @@ -268,21 +255,6 @@ class LibraryViewModel } } - fun fetchRecentListening() { - _recentBookUpdating.postValue(true) - - val preferredLibrary = - preferences.getPreferredLibrary()?.id ?: run { - _recentBookUpdating.postValue(false) - return - } - - viewModelScope.launch { - bookRepository.fetchRecentListenedBooks(preferredLibrary) - _recentBookUpdating.postValue(false) - } - } - companion object { private const val EMPTY_SEARCH = "" private const val PAGE_SIZE = 20 From 52c1e279a4bc44357e458b7ced81e9f843d8485c Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 01:20:16 +0530 Subject: [PATCH 17/37] fix: show spinner for player buttons when the playback is being prepared --- .../composables/MiniPlayerComposable.kt | 27 ++++++++++++---- .../screens/player/GlobalPlayerBottomSheet.kt | 10 +++--- .../composable/PlaybackButtonsComposable.kt | 31 ++++++++++++++----- 3 files changed, 48 insertions(+), 20 deletions(-) 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 58ad17fca..2aa0b4080 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 @@ -71,6 +71,10 @@ fun MiniPlayerComposable( val view: View = LocalView.current val isPlaying: Boolean by playerViewModel.isPlaying.observeAsState(false) + val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false) + val preparingBookId by playerViewModel.preparingBookId.observeAsState(null) + val preparingError by playerViewModel.preparingError.observeAsState(false) + var backgroundVisible by remember { mutableStateOf(true) } val dismissState = @@ -260,12 +264,23 @@ fun MiniPlayerComposable( IconButton( onClick = { withHaptic(view) { playerViewModel.togglePlayPause() } }, ) { - Icon( - imageVector = if (isPlaying) AppIcons.PauseCircleNegative else AppIcons.PlayCircleNegative, - contentDescription = if (isPlaying) "Pause" else "Play", - tint = colorScheme.onSurface, - modifier = Modifier.size(38.dp), - ) + val isError = preparingError + val isLoading = preparingBookId != null + + if (isLoading) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = colorScheme.onSurface, + strokeWidth = 3.dp, + ) + } else { + Icon( + imageVector = if (isPlaying) AppIcons.PauseCircleNegative else AppIcons.PlayCircleNegative, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = colorScheme.onSurface, + modifier = Modifier.size(38.dp), + ) + } } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt index f94745135..a8803677e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt @@ -391,12 +391,10 @@ fun PlayerContent( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 16.dp), ) { - if (isPlaybackReady) { - PlaybackButtonsComposable( - viewModel = playerViewModel, - settingsViewModel = settingsViewModel, - ) - } + PlaybackButtonsComposable( + viewModel = playerViewModel, + settingsViewModel = settingsViewModel, + ) } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt index 0abc30dff..8bb23e0c6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt @@ -41,6 +41,10 @@ fun PlaybackButtonsComposable( val book by viewModel.book.observeAsState() val chapters = book?.chapters ?: emptyList() + val isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false) + val preparingBookId by viewModel.preparingBookId.observeAsState(null) + val preparingError by viewModel.preparingError.observeAsState(false) + val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) val showNavButtons by settingsViewModel.showPlayerNavButtons.observeAsState(true) @@ -94,14 +98,25 @@ fun PlaybackButtonsComposable( shadowElevation = 4.dp, ) { Box(contentAlignment = Alignment.Center) { - GlowIcon( - imageVector = if (isPlaying) Icons.Filled.Pause else AppIcons.PlayFilled, - contentDescription = null, - tint = colorScheme.onPrimary, - glowColor = colorScheme.onPrimary.copy(alpha = 0.5f), - glowRadius = 8.dp, - modifier = Modifier.size(42.dp), - ) + val isError = preparingError + val isLoading = preparingBookId != null + + if (isLoading) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = colorScheme.onPrimary, + strokeWidth = 3.dp, + ) + } else { + GlowIcon( + imageVector = if (isPlaying) Icons.Filled.Pause else AppIcons.PlayFilled, + contentDescription = null, + tint = colorScheme.onPrimary, + glowColor = colorScheme.onPrimary.copy(alpha = 0.5f), + glowRadius = 8.dp, + modifier = Modifier.size(42.dp), + ) + } } } } From fc0fa44ca9805098f3c05aa605ffc8ac5be42f16 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 01:25:21 +0530 Subject: [PATCH 18/37] fix: initial launch playback freeze by moving the exo player initialization out of the main thread --- .../org/grakovne/lissen/playback/service/PlaybackService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 63048031f..871868e4d 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 @@ -12,6 +12,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope @@ -60,7 +61,7 @@ class PlaybackService : MediaSessionService() { lateinit var playbackTimer: PlaybackTimer @Inject - lateinit var mediaCache: Cache + lateinit var mediaCache: dagger.Lazy private var session: MediaSession? = null @@ -181,7 +182,7 @@ class PlaybackService : MediaSessionService() { val sourceFactory = LissenDataSourceFactory( baseContext = baseContext, - mediaCache = mediaCache, + mediaCache = mediaCache.get(), requestHeadersProvider = requestHeadersProvider, sharedPreferences = sharedPreferences, mediaProvider = mediaProvider, From 74fc2608d2e9de50a62be308a8ce6e965718906a Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 01:28:45 +0530 Subject: [PATCH 19/37] add changelog generation --- .github/workflows/release.yml | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00211b7ea..d86dc46b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,15 +44,38 @@ jobs: RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + - name: Build Changelog + id: build_changelog + uses: mikepenz/release-changelog-builder-action@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + configuration: | + { + "categories": [ + { + "title": "## 🚀 Features", + "labels": ["feature", "enhancement"] + }, + { + "title": "## 🐛 Bug Fixes", + "labels": ["fix", "bug"] + }, + { + "title": "## 📦 Dependencies & Maintenance", + "labels": ["chore", "deps", "dependency", "refactor"] + } + ], + "template": "#{{CHANGELOG}}" + } + - name: Create Release uses: softprops/action-gh-release@v1 with: files: app/build/outputs/apk/release/app-release.apk tag_name: v${{ steps.get_version.outputs.version }} name: Kahani v${{ steps.get_version.outputs.version }} - body: | - Automated Release for Kahani. - Commit: ${{ github.sha }} + body: ${{ steps.build_changelog.outputs.changelog }} draft: false prerelease: false env: From 826141a1979c1d86ccafb1e07d112d6bb8ba0ef4 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 01:38:28 +0530 Subject: [PATCH 20/37] Add update checker service --- .../domain/update/UpdateCheckerService.kt | 146 ++++++++++++++++++ .../lissen/ui/activity/AppActivity.kt | 10 ++ app/src/main/res/values/strings.xml | 23 +-- 3 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt diff --git a/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt new file mode 100644 index 000000000..b135c4acc --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt @@ -0,0 +1,146 @@ +package org.grakovne.lissen.domain.update + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.grakovne.lissen.BuildConfig +import org.grakovne.lissen.R +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpdateCheckerService + @Inject + constructor( + @ApplicationContext private valcontext: Context, + ) { + private val client = + OkHttpClient + .Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + suspend fun checkForUpdates() { + withContext(Dispatchers.IO) { + try { + val request = + Request + .Builder() + .url(GITHUB_RELEASES_URL) + .header("Accept", "application/vnd.github.v3+json") + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.e("Update check failed: ${response.code}") + return@withContext + } + + val json = response.body?.string() ?: return@withContext + val releaseNode = JSONObject(json) + val tagName = releaseNode.optString("tag_name", "") + val htmlUrl = releaseNode.optString("html_url", "") + + if (tagName.isNotEmpty() && isNewerVersion(tagName, BuildConfig.VERSION_NAME)) { + showUpdateNotification(tagName, htmlUrl) + } + } catch (e: Exception) { + Timber.e(e, "Failed to check for updates") + } + } + } + + private fun isNewerVersion( + remoteTag: String, + localVersion: String, + ): Boolean { + val remote = remoteTag.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } + val local = localVersion.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } + + val length = maxOf(remote.size, local.size) + for (i in 0 until length) { + val r = remote.getOrElse(i) { 0 } + val l = local.getOrElse(i) { 0 } + if (r > l) return true + if (r < l) return false + } + return false + } + + private fun showUpdateNotification( + version: String, + url: String, + ) { + val channelId = "app_updates" + val notificationId = 1001 + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = context.getString(R.string.notification_channel_updates_name) + val descriptionText = context.getString(R.string.notification_channel_updates_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel(channelId, name, importance).apply { + description = descriptionText + } + val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val builder = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_update_available_title, version)) + .setContentText(context.getString(R.string.notification_update_available_body)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + + try { + with(NotificationManagerCompat.from(context)) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + if (androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notify(notificationId, builder.build()) + } + } else { + notify(notificationId, builder.build()) + } + } + } catch (e: SecurityException) { + Timber.e(e, "Permission denied for notifications") + } + } + + companion object { + private const val GITHUB_RELEASES_URL = "https://api.github.com/repos/SurjitSahoo/kahani-android/releases/latest" + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/activity/AppActivity.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/activity/AppActivity.kt index 0147388f8..9880963f7 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/activity/AppActivity.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/activity/AppActivity.kt @@ -7,10 +7,13 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.grakovne.lissen.common.NetworkService +import org.grakovne.lissen.domain.update.UpdateCheckerService import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import org.grakovne.lissen.ui.navigation.AppLaunchAction import org.grakovne.lissen.ui.navigation.AppNavHost @@ -31,6 +34,9 @@ class AppActivity : ComponentActivity() { @Inject lateinit var networkService: NetworkService + @Inject + lateinit var updateCheckerService: UpdateCheckerService + private lateinit var appNavigationService: AppNavigationService override fun onCreate(savedInstanceState: Bundle?) { @@ -56,6 +62,10 @@ class AppActivity : ComponentActivity() { ) } } + + lifecycleScope.launch { + updateCheckerService.checkForUpdates() + } } private fun getLaunchAction(intent: Intent?) = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7da46879..10fe5f6d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -221,22 +221,22 @@ Appearance Playback & Downloads About - + Boost Speed Download Timer - + Downloaded - + Rearranging the library shelves… Library update failed. Should we try again? Try again Skip for now - + Clear metadata cache Remove metadata for non-downloaded books from other servers to free up space. @@ -247,24 +247,24 @@ No downloads yet Your downloaded books and chapters will appear here. Go to Library - + Download book (%1$s) High-fidelity single archive. Individual chapter downloads are not available. Remove book from downloads - + Download Part %1$d (%2$s) Chapters %1$s Download remaining parts (%1$s) Parts %1$s - + Download remaining chapters (%1$s) Download entire book (%1$s) - + Volume %1$d Full Archive Chapters %1$s - + Free up %1$s? Select Cancel @@ -277,4 +277,9 @@ Help us squash bugs by sharing anonymous technical data. Your library and personal info stay 100% private. Help Improve Kahani 🚀 Maybe later + + App Updates + Notifications about new versions + Kahani %1$s Available + A new version is available. Tap to download. \ No newline at end of file From 1b5bd369b14ddcb128c318f8e0386fe63951d1f0 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 01:49:02 +0530 Subject: [PATCH 21/37] fix book deletion glitch --- .../cache/persistent/dao/CachedBookDao.kt | 21 +++++++++++++------ .../domain/update/UpdateCheckerService.kt | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) 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 c8c5abf5b..9c095f524 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 @@ -64,6 +64,9 @@ interface CachedBookDao { }, ) + val existingBook = fetchCachedBook(book.id) + val existingChapters = existingBook?.chapters?.associateBy { it.bookChapterId } ?: emptyMap() + val bookFiles = book .files @@ -78,17 +81,12 @@ interface CachedBookDao { ) } - val cachedBookChapters = - fetchCachedBook(book.id) - ?.chapters - ?: emptyList() - val bookChapters = book .chapters .map { chapter -> val fetched = fetchedChapters.any { it.id == chapter.id } - val exists = cachedBookChapters.any { it.bookChapterId == chapter.id && it.isCached } + val exists = existingChapters[chapter.id]?.isCached == true val dropped = droppedChapters.any { it.id == chapter.id } val cached = @@ -123,8 +121,13 @@ interface CachedBookDao { } safeUpsertBook(bookEntity) + + deleteBookFiles(book.id) upsertBookFiles(bookFiles) + + deleteBookChapters(book.id) upsertBookChapters(bookChapters) + mediaProgress?.let { upsertMediaProgress(it) } } @@ -265,6 +268,12 @@ interface CachedBookDao { @Delete suspend fun deleteBook(book: BookEntity) + @Query("DELETE FROM book_chapters WHERE bookId = :bookId") + suspend fun deleteBookChapters(bookId: String) + + @Query("DELETE FROM book_files WHERE bookId = :bookId") + suspend fun deleteBookFiles(bookId: String) + @Query( """ SELECT id FROM detailed_books diff --git a/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt index b135c4acc..72b206bd8 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt @@ -26,7 +26,7 @@ import javax.inject.Singleton class UpdateCheckerService @Inject constructor( - @ApplicationContext private valcontext: Context, + @ApplicationContext private val context: Context, ) { private val client = OkHttpClient @@ -114,7 +114,7 @@ class UpdateCheckerService val builder = NotificationCompat .Builder(context, channelId) - .setSmallIcon(R.drawable.ic_notification) + .setSmallIcon(R.drawable.ic_notification_silhouette) .setContentTitle(context.getString(R.string.notification_update_available_title, version)) .setContentText(context.getString(R.string.notification_update_available_body)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) From cc16ea0f37f6cf14b3f09d7a7e92052bdea50432 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 01:53:20 +0530 Subject: [PATCH 22/37] add code rabbit --- .coderabbit.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..9ea37492a --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,3 @@ +language: "en-US" +reviews: + auto_title_placeholder: "@coderabbit title" \ No newline at end of file From 1eefb8f2215457da3652eba6df00654718c14a6e Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 12:45:46 +0530 Subject: [PATCH 23/37] feat: Prevent analytics consent bottom sheet dismissal by swipe and refactor download option type references in player downloads. --- .github/workflows/release.yml | 2 +- .../lissen/analytics/ClarityComponent.kt | 12 ++- .../lissen/content/LissenMediaProvider.kt | 4 +- .../domain/update/UpdateCheckerService.kt | 23 +++--- .../playback/service/PlaybackService.kt | 46 ++++++----- .../components/AnalyticsConsentBottomSheet.kt | 7 +- .../ui/screens/migration/MigrationScreen.kt | 11 +-- .../player/composable/DownloadsComposable.kt | 78 ++++++++++--------- .../cache/CachedItemsSettingsScreen.kt | 9 ++- 9 files changed, 107 insertions(+), 85 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d86dc46b7..0829a81d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: } - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: app/build/outputs/apk/release/app-release.apk tag_name: v${{ steps.get_version.outputs.version }} diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt index 9d711bb90..309de8b1c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt @@ -81,11 +81,19 @@ class ClarityComponent val host = preferences.getHost() ?: return // Combine host and username for a unique identifier across servers - val identifier = "$username@$host" - clarityTracker.setUser(identifier) + val rawIdentifier = "$username@$host" + val hashedIdentifier = hashIdentifier(rawIdentifier) + + clarityTracker.setUser(hashedIdentifier) } else { // Fallback to device ID if not logged in clarityTracker.setUser(preferences.getDeviceId()) } } + + private fun hashIdentifier(input: String): String { + val digest = java.security.MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { "%02x".format(it) } + } } 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 6c9666692..0289f5c30 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt @@ -289,7 +289,7 @@ class LissenMediaProvider username: String, password: String, ): OperationResult { - Timber.d("Authorizing for $username@$host") + Timber.d("Authorizing for ***@$host") return provideAuthService().authorize(host, username, password) { onPostLogin(host, it) } } @@ -298,7 +298,7 @@ class LissenMediaProvider onSuccess: () -> Unit, onFailure: (OperationError) -> Unit, ) { - Timber.d("Starting OAuth for $host") + Timber.d("Starting OAuth") return provideAuthService() .startOAuth( diff --git a/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt index 72b206bd8..c6fa0e44c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt @@ -45,19 +45,20 @@ class UpdateCheckerService .header("Accept", "application/vnd.github.v3+json") .build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) { - Timber.e("Update check failed: ${response.code}") - return@withContext - } + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.e("Update check failed: ${response.code}") + return@withContext + } - val json = response.body?.string() ?: return@withContext - val releaseNode = JSONObject(json) - val tagName = releaseNode.optString("tag_name", "") - val htmlUrl = releaseNode.optString("html_url", "") + val json = response.body?.string() ?: return@withContext + val releaseNode = JSONObject(json) + val tagName = releaseNode.optString("tag_name", "") + val htmlUrl = releaseNode.optString("html_url", "") - if (tagName.isNotEmpty() && isNewerVersion(tagName, BuildConfig.VERSION_NAME)) { - showUpdateNotification(tagName, htmlUrl) + if (tagName.isNotEmpty() && isNewerVersion(tagName, BuildConfig.VERSION_NAME)) { + showUpdateNotification(tagName, htmlUrl) + } } } catch (e: Exception) { Timber.e(e, "Failed to check for updates") 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 871868e4d..cb454f996 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 @@ -65,6 +65,8 @@ class PlaybackService : MediaSessionService() { private var session: MediaSession? = null + private var artworkJob: kotlinx.coroutines.Job? = null + private var smartRewindApplied = false private val playerServiceScope = MainScope() @@ -175,6 +177,7 @@ class PlaybackService : MediaSessionService() { private suspend fun preparePlayback(book: DetailedItem) { exoPlayer.playWhenReady = false smartRewindApplied = false + artworkJob?.cancel() withContext(Dispatchers.IO) { val prepareQueue = @@ -235,29 +238,30 @@ class PlaybackService : MediaSessionService() { } // Fire-and-forget: Update cover in background without blocking playback readiness. - launch { - val artworkUri = fetchCover(book) ?: return@launch - - withContext(Dispatchers.Main) { - for (i in 0 until exoPlayer.mediaItemCount) { - val currentItem = exoPlayer.getMediaItemAt(i) - val updatedMetadata = - currentItem - .mediaMetadata - .buildUpon() - .setArtworkUri(artworkUri) - .build() - - val updatedItem = - currentItem - .buildUpon() - .setMediaMetadata(updatedMetadata) - .build() - - exoPlayer.replaceMediaItem(i, updatedItem) + artworkJob = + launch { + val artworkUri = fetchCover(book) ?: return@launch + + withContext(Dispatchers.Main) { + for (i in 0 until exoPlayer.mediaItemCount) { + val currentItem = exoPlayer.getMediaItemAt(i) + val updatedMetadata = + currentItem + .mediaMetadata + .buildUpon() + .setArtworkUri(artworkUri) + .build() + + val updatedItem = + currentItem + .buildUpon() + .setMediaMetadata(updatedMetadata) + .build() + + exoPlayer.replaceMediaItem(i, updatedItem) + } } } - } awaitAll(prepareSession, prepareQueue) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt index 878de2ec5..1c550a56c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState @@ -35,7 +36,11 @@ fun AnalyticsConsentBottomSheet( onAccept: () -> Unit, onDecline: () -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) ModalBottomSheet( onDismissRequest = { /* User must make a choice */ }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt index d4b4601e9..fc6384ad6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt @@ -90,15 +90,8 @@ fun MigrationScreen( Spacer(modifier = Modifier.height(Spacing.md)) - TextButton( - onClick = { onMigrationComplete() }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.migration_screen_dismiss_button), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // Dismiss button is intentionally hidden on error to prevent bypassing failed migration. + // Users must retry or clear app data (via system settings) if issues persist. } else -> { diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt index 1ef04fa8e..9ae84caff 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt @@ -45,18 +45,29 @@ import androidx.compose.ui.unit.sp import org.grakovne.lissen.R import org.grakovne.lissen.content.cache.common.findRelatedFilesByStartTimes import org.grakovne.lissen.content.cache.persistent.calculateRequestedChapters +import org.grakovne.lissen.lib.domain.AllItemsDownloadOption +import org.grakovne.lissen.lib.domain.BookStorageType +import org.grakovne.lissen.lib.domain.BookVolume +import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption +import org.grakovne.lissen.lib.domain.DetailedItem +import org.grakovne.lissen.lib.domain.DownloadOption +import org.grakovne.lissen.lib.domain.LibraryType +import org.grakovne.lissen.lib.domain.NumberItemDownloadOption +import org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption +import org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption +import org.grakovne.lissen.playback.service.calculateChapterIndex import org.grakovne.lissen.ui.effects.WindowBlurEffect import org.grakovne.lissen.ui.screens.common.makeText @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadsComposable( - book: org.grakovne.lissen.lib.domain.DetailedItem, - storageType: org.grakovne.lissen.lib.domain.BookStorageType, - volumes: List, + book: DetailedItem, + storageType: BookStorageType, + volumes: List, isOnline: Boolean, cachingInProgress: Boolean, - onRequestedDownload: (org.grakovne.lissen.lib.domain.DownloadOption) -> Unit, + onRequestedDownload: (DownloadOption) -> Unit, onRequestedStop: () -> Unit, onRequestedDrop: () -> Unit, onRequestedDropCompleted: () -> Unit, @@ -65,13 +76,12 @@ fun DownloadsComposable( val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val libraryType = book.libraryType ?: org.grakovne.lissen.lib.domain.LibraryType.UNKNOWN + val libraryType = book.libraryType ?: LibraryType.UNKNOWN val hasCachedContent = volumes.any { it.isDownloaded } val isFullBookDownloaded = volumes.all { it.isDownloaded } val currentChapterIndex = - org.grakovne.lissen.playback.service - .calculateChapterIndex(book, book.progress?.currentTime ?: 0.0) + calculateChapterIndex(book, book.progress?.currentTime ?: 0.0) val currentVolume = volumes.find { volume -> volume.chapters.any { it.id == book.chapters.getOrNull(currentChapterIndex)?.id } @@ -106,9 +116,9 @@ fun DownloadsComposable( ) { val title = when (libraryType) { - org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) - org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) - org.grakovne.lissen.lib.domain.LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) + LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) + LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) + LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) } Text( @@ -151,7 +161,7 @@ fun DownloadsComposable( ) { Column { // Scenario A: Monolith - if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + if (storageType == BookStorageType.MONOLITH) { val monolithVolume = volumes.firstOrNull() if (monolithVolume != null && !monolithVolume.isDownloaded) { ActionRow( @@ -166,7 +176,7 @@ fun DownloadsComposable( enabled = isOnline, isSuggested = true, onClick = { - onRequestedDownload(org.grakovne.lissen.lib.domain.AllItemsDownloadOption) + onRequestedDownload(AllItemsDownloadOption) onDismissRequest() }, ) @@ -174,7 +184,7 @@ fun DownloadsComposable( } // Scenario B: Segmented - if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.SEGMENTED) { + if (storageType == BookStorageType.SEGMENTED) { if (currentVolume != null && !currentVolume.isDownloaded) { val startIdx = book.chapters.indexOfFirst { it.id == currentVolume.chapters.firstOrNull()?.id } val startChapter = if (startIdx >= 0) startIdx + 1 else null @@ -209,8 +219,7 @@ fun DownloadsComposable( isSuggested = true, onClick = { onRequestedDownload( - org.grakovne.lissen.lib.domain - .SpecificFilesDownloadOption(listOf(currentVolume.id)), + SpecificFilesDownloadOption(listOf(currentVolume.id)), ) onDismissRequest() }, @@ -242,8 +251,7 @@ fun DownloadsComposable( isSuggested = currentVolume?.isDownloaded == true, onClick = { onRequestedDownload( - org.grakovne.lissen.lib.domain - .SpecificFilesDownloadOption(remainingVolumes.map { it.id }), + SpecificFilesDownloadOption(remainingVolumes.map { it.id }), ) onDismissRequest() }, @@ -268,7 +276,7 @@ fun DownloadsComposable( trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, enabled = isOnline, onClick = { - onRequestedDownload(org.grakovne.lissen.lib.domain.AllItemsDownloadOption) + onRequestedDownload(AllItemsDownloadOption) onDismissRequest() }, ) @@ -276,7 +284,7 @@ fun DownloadsComposable( } // Scenario C: Atomic - if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC) { + if (storageType == BookStorageType.ATOMIC) { AtomicOptions.forEachIndexed { index, option -> val requestedChapters = calculateRequestedChapters( @@ -294,10 +302,10 @@ fun DownloadsComposable( val icon = when (option) { - is org.grakovne.lissen.lib.domain.CurrentItemDownloadOption -> Icons.Default.MusicNote - is org.grakovne.lissen.lib.domain.NumberItemDownloadOption -> Icons.Default.Layers - is org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption -> Icons.AutoMirrored.Filled.QueueMusic - is org.grakovne.lissen.lib.domain.AllItemsDownloadOption -> Icons.Default.Folder + is CurrentItemDownloadOption -> Icons.Default.MusicNote + is NumberItemDownloadOption -> Icons.Default.Layers + is RemainingItemsDownloadOption -> Icons.AutoMirrored.Filled.QueueMusic + is AllItemsDownloadOption -> Icons.Default.Folder else -> Icons.Default.Folder } @@ -369,7 +377,7 @@ fun DownloadsComposable( val showClearCompleted = completedVolumes.isNotEmpty() || ( - storageType == org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC && + storageType == BookStorageType.ATOMIC && book.chapters.any { it.available && (book.progress?.currentTime ?: 0.0) >= it.end } ) @@ -377,11 +385,11 @@ fun DownloadsComposable( ActionRow( title = when (libraryType) { - org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> + LibraryType.LIBRARY -> stringResource( R.string.downloads_menu_download_option_clear_completed_chapters, ) - org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> + LibraryType.PODCAST -> stringResource( R.string.downloads_menu_download_option_clear_completed_episodes, ) @@ -405,15 +413,15 @@ fun DownloadsComposable( ActionRow( title = - if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + if (storageType == BookStorageType.MONOLITH) { stringResource(R.string.download_modal_remove_book) } else { when (libraryType) { - org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> + LibraryType.LIBRARY -> stringResource( R.string.downloads_menu_download_option_clear_chapters, ) - org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> + LibraryType.PODCAST -> stringResource( R.string.downloads_menu_download_option_clear_episodes, ) @@ -452,11 +460,9 @@ private fun DragHandle() { private val AtomicOptions = listOf( - org.grakovne.lissen.lib.domain.CurrentItemDownloadOption, - org.grakovne.lissen.lib.domain - .NumberItemDownloadOption(5), - org.grakovne.lissen.lib.domain - .NumberItemDownloadOption(10), - org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption, - org.grakovne.lissen.lib.domain.AllItemsDownloadOption, + CurrentItemDownloadOption, + NumberItemDownloadOption(5), + NumberItemDownloadOption(10), + RemainingItemsDownloadOption, + AllItemsDownloadOption, ) 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 57a27b04f..99a39c189 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 @@ -677,7 +677,12 @@ private fun CachedItemVolumeComposable( playerViewModel.clearPlayingBook() } } - viewModel.dropCache(item, volume.chapters.first()) // dropCache by chapter handles file deletion + volume.chapters.forEach { chapter -> + viewModel.dropCache( + item = item, + chapter = chapter, + ) + } // dropCache by chapter handles file deletion onItemRemoved() } } @@ -740,7 +745,7 @@ private suspend fun deleteSelectedVolumes( book?.let { val volumes = viewModel.getVolumes(it) val volume = volumes.find { v -> v.id == selection.fileId } - volume?.chapters?.firstOrNull()?.let { chapter -> + volume?.chapters?.forEach { chapter -> playerViewModel.book.value?.let { playingBook -> if (playingBook.id == it.id) { playerViewModel.clearPlayingBook() From a509960e6dd5d93d632a4811dcbbd8b3559da5b7 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 14:08:04 +0530 Subject: [PATCH 24/37] feat: Implement structured concurrency for playback operations and refine smart rewind logic in `PlaybackService`, and adjust coroutine execution in `PlaybackSynchronizationService`. --- .../playback/service/PlaybackService.kt | 73 ++++++++----------- .../service/PlaybackSynchronizationService.kt | 49 +++++++------ 2 files changed, 58 insertions(+), 64 deletions(-) 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 cb454f996..5fa796a64 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 @@ -67,6 +67,8 @@ class PlaybackService : MediaSessionService() { private var artworkJob: kotlinx.coroutines.Job? = null + private var playbackJob: kotlinx.coroutines.Job? = null + private var smartRewindApplied = false private val playerServiceScope = MainScope() @@ -115,26 +117,38 @@ class PlaybackService : MediaSessionService() { } ACTION_PLAY -> { - playerServiceScope - .launch { - checkAndApplySmartRewind() - exoPlayer.prepare() - exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) - exoPlayer.playWhenReady = true - } + playbackJob?.cancel() + playbackJob = + playerServiceScope + .launch { + checkAndApplySmartRewind() + if (exoPlayer.playbackState == ExoPlayer.STATE_IDLE) { + exoPlayer.prepare() + } + exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) + exoPlayer.playWhenReady = true + } return START_STICKY } ACTION_PAUSE -> { - pause() + playbackJob?.cancel() + playbackJob = + playerServiceScope + .launch { + smartRewindApplied = false + exoPlayer.playWhenReady = false + } return START_NOT_STICKY } ACTION_SET_PLAYBACK -> { val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem book?.let { - playerServiceScope - .launch { preparePlayback(it) } + playbackJob?.cancel() + playbackJob = + playerServiceScope + .launch { preparePlayback(it) } } return START_NOT_STICKY } @@ -223,12 +237,8 @@ class PlaybackService : MediaSessionService() { val startPosition = calculateSmartRewindPosition(book) val currentPosition = book.progress?.currentTime ?: 0.0 - if (startPosition < currentPosition) { - Timber.d("Smart rewind applied. Seeking to $startPosition from $currentPosition") - smartRewindApplied = true - } - seek(book.files, startPosition) + smartRewindApplied = true } } @@ -283,26 +293,17 @@ class PlaybackService : MediaSessionService() { val item = exoPlayer.currentMediaItem?.localConfiguration?.tag as? DetailedItem ?: return - withContext(Dispatchers.IO) { - val book = - mediaProvider - .fetchBook(item.id) - .fold( - onSuccess = { it }, - onFailure = { item }, - ) + val startPosition = calculateSmartRewindPosition(item) + val currentPosition = item.progress?.currentTime ?: 0.0 + if (startPosition < currentPosition) { withContext(Dispatchers.Main) { - val startPosition = calculateSmartRewindPosition(book) - val currentPosition = book.progress?.currentTime ?: 0.0 - - if (startPosition < currentPosition) { - Timber.d("Smart rewind applied (on resume). Seeking to $startPosition from $currentPosition") - seek(book.files, startPosition) - smartRewindApplied = true - } + Timber.d("Smart rewind applied (on resume). Seeking to $startPosition from $currentPosition") + seek(item.files, startPosition) } } + + smartRewindApplied = true } private fun calculateSmartRewindPosition(book: DetailedItem): Double = @@ -347,16 +348,6 @@ class PlaybackService : MediaSessionService() { Timber.d("Timer canceled.") } - private fun pause() { - smartRewindApplied = false - playerServiceScope - .launch { - exoPlayer.playWhenReady = false - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - } - private fun seek( items: List, position: Double?, 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 bbd60a40d..a0cb116a5 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 @@ -52,6 +52,7 @@ class PlaybackSynchronizationService fun startPlaybackSynchronization(item: DetailedItem) { serviceScope.coroutineContext.cancelChildren() currentItem = item + runSync() } fun cancelSynchronization() { @@ -90,34 +91,36 @@ class PlaybackSynchronizationService } private fun runSync() { - val elapsedMs = exoPlayer.currentPosition - val overallProgress = getProgress(elapsedMs) ?: return + serviceScope.launch { + val elapsedMs = exoPlayer.currentPosition + val overallProgress = getProgress(elapsedMs) ?: return@launch - Timber.d("Trying to sync $overallProgress for ${currentItem?.id}") + Timber.d("Trying to sync $overallProgress for ${currentItem?.id}") - serviceScope.launch(Dispatchers.IO) { - if (syncMutex.tryLock().not()) { - Timber.d("Sync is already running") - return@launch - } + launch(Dispatchers.IO) { + if (syncMutex.tryLock().not()) { + Timber.d("Sync is already running") + return@launch + } - try { - val currentIndex = - currentItem - ?.let { calculateChapterIndex(it, overallProgress.currentTotalTime) } - ?: return@launch + try { + val currentIndex = + currentItem + ?.let { calculateChapterIndex(it, overallProgress.currentTotalTime) } + ?: return@launch - if (playbackSession == null || playbackSession?.itemId != currentItem?.id || currentIndex != currentChapterIndex) { - openPlaybackSession(overallProgress) - currentChapterIndex = currentIndex - } + if (playbackSession == null || playbackSession?.itemId != currentItem?.id || currentIndex != currentChapterIndex) { + openPlaybackSession(overallProgress) + currentChapterIndex = currentIndex + } - mediaChannel.syncLocalProgress(currentItem?.id ?: return@launch, overallProgress) - playbackSession?.let { requestSync(it, overallProgress) } - } catch (e: Exception) { - Timber.e(e, "Error during sync") - } finally { - syncMutex.unlock() + mediaChannel.syncLocalProgress(currentItem?.id ?: return@launch, overallProgress) + playbackSession?.let { requestSync(it, overallProgress) } + } catch (e: Exception) { + Timber.e(e, "Error during sync") + } finally { + syncMutex.unlock() + } } } } From 5b3247addbf3e99deac980c80968a802746b1e0a Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 15:30:08 +0530 Subject: [PATCH 25/37] feat: Implement animated download completion effect and theme-aware inline success indicators. --- .../ui/components/DownloadProgressIcon.kt | 58 ++++++++++++++- .../ui/screens/details/BookDetailScreen.kt | 73 +++++++++++++------ .../org/grakovne/lissen/ui/theme/Color.kt | 3 + 3 files changed, 109 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index 18354e356..72c16e654 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -3,6 +3,7 @@ package org.grakovne.lissen.ui.components import androidx.compose.animation.core.EaseOutQuart import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -19,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.contentDescription @@ -37,7 +39,59 @@ fun DownloadProgressIcon( size: Dp = 24.dp, color: Color = LocalContentColor.current, ) { + val isDark = isSystemInDarkTheme() + val successColor = + if (isDark) { + org.grakovne.lissen.ui.theme.DownloadSuccessDark + } else { + org.grakovne.lissen.ui.theme.DownloadSuccessLight + } + + val shineAlpha by animateFloatAsState( + targetValue = + if (isFullyDownloaded && + (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle) + ) { + 1f + } else { + 0f + }, + animationSpec = tween(durationMillis = 1000, easing = EaseOutQuart), + label = "shineAlpha", + ) + + val shineScale by animateFloatAsState( + targetValue = + if (isFullyDownloaded && + (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle) + ) { + 1.5f + } else { + 1f + }, + animationSpec = tween(durationMillis = 1000, easing = EaseOutQuart), + label = "shineScale", + ) + Box(contentAlignment = Alignment.Center) { + if (isFullyDownloaded && shineAlpha > 0f) { + Icon( + imageVector = Icons.Filled.CloudDone, + contentDescription = null, + modifier = + Modifier + .size(size) + .graphicsLayer { + scaleX = shineScale + scaleY = shineScale + alpha = shineAlpha * (1f - (shineScale - 1f) * 2f).coerceIn(0f, 1f) + }, + tint = + successColor + .copy(alpha = 0.5f), + ) + } + when (cacheState.status) { is CacheStatus.Queued -> { val queuedDescription = stringResource(R.string.accessibility_id_download_queued) @@ -94,7 +148,7 @@ fun DownloadProgressIcon( imageVector = Icons.Filled.CloudDone, contentDescription = stringResource(R.string.accessibility_id_download_complete), modifier = Modifier.size(size), - tint = colorScheme.primary, + tint = successColor, ) } else { Icon( @@ -112,7 +166,7 @@ fun DownloadProgressIcon( imageVector = Icons.Filled.CloudDone, contentDescription = stringResource(R.string.accessibility_id_download_complete), modifier = Modifier.size(size), - tint = colorScheme.primary, + tint = successColor, ) } else { Icon( diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt index 92dca6084..c086c5b67 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -38,6 +38,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AvTimer @@ -86,6 +88,8 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.buildAnnotatedString @@ -307,31 +311,54 @@ fun BookDetailScreen( } // Title & Author - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.padding(horizontal = 24.dp), - ) { - Text( - text = book.title, - style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), - color = colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f, fill = false), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + val downloadCheckmarkColor = + if (isSystemInDarkTheme()) { + org.grakovne.lissen.ui.theme.DownloadSuccessDark + } else { + org.grakovne.lissen.ui.theme.DownloadSuccessLight + } - if (isFullyDownloaded) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = stringResource(R.string.accessibility_id_fully_downloaded), - tint = colorScheme.primary, - modifier = Modifier.size(20.dp), - ) + val titleText = + buildAnnotatedString { + append(book.title) + if (isFullyDownloaded) { + append(" ") + appendInlineContent("download_checkmark", "[downloaded]") + } } - } + + val inlineContent = + mapOf( + "download_checkmark" to + InlineTextContent( + Placeholder( + width = 18.sp, + height = 18.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = stringResource(R.string.accessibility_id_fully_downloaded), + tint = downloadCheckmarkColor, + modifier = Modifier.size(18.dp), + ) + }, + ) + + Text( + text = titleText, + inlineContent = inlineContent, + style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) book.author?.takeIf { it.isNotBlank() }?.let { Text( diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt index 8e01ea36a..a02ae9f4e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt @@ -11,3 +11,6 @@ val MediumBackground = Color(0xFFDADADA) val SurfaceContainerLight = Color(0xFFEEEEEE) val TertiaryContainerDark = Color(0xFF1A1A1A) + +val DownloadSuccessLight = Color(0xFF475569) +val DownloadSuccessDark = Color(0xFF94A3B8) From a05001c41ddbb24750202ce94020ebd0e96a3680 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 16:05:22 +0530 Subject: [PATCH 26/37] feat: Implement an animated download success shine effect in `DownloadProgressIcon` and remove the redundant inline checkmark from `BookDetailScreen`. --- .../ui/components/DownloadProgressIcon.kt | 71 +++++++++++-------- .../ui/screens/details/BookDetailScreen.kt | 39 +--------- 2 files changed, 43 insertions(+), 67 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index 72c16e654..e4d02cc6d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -1,12 +1,16 @@ package org.grakovne.lissen.ui.components +import androidx.compose.animation.core.EaseOutExpo import androidx.compose.animation.core.EaseOutQuart import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material3.CircularProgressIndicator @@ -14,7 +18,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -38,6 +46,7 @@ fun DownloadProgressIcon( isFullyDownloaded: Boolean, size: Dp = 24.dp, color: Color = LocalContentColor.current, + showShine: Boolean = false, ) { val isDark = isSystemInDarkTheme() val successColor = @@ -47,48 +56,50 @@ fun DownloadProgressIcon( org.grakovne.lissen.ui.theme.DownloadSuccessLight } + var triggerShine by remember { mutableStateOf(false) } + var wasCaching by remember { mutableStateOf(false) } + + LaunchedEffect(cacheState.status) { + if (cacheState.status is CacheStatus.Caching) { + wasCaching = true + } + + if (wasCaching && (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle) && isFullyDownloaded) { + triggerShine = true + wasCaching = false + } + } + val shineAlpha by animateFloatAsState( - targetValue = - if (isFullyDownloaded && - (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle) - ) { - 1f - } else { - 0f - }, - animationSpec = tween(durationMillis = 1000, easing = EaseOutQuart), + targetValue = if (showShine && triggerShine) 1f else 0f, + animationSpec = tween(durationMillis = 1500, easing = EaseOutExpo), label = "shineAlpha", + finishedListener = { + if (it == 0f) triggerShine = false + }, ) val shineScale by animateFloatAsState( - targetValue = - if (isFullyDownloaded && - (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle) - ) { - 1.5f - } else { - 1f - }, - animationSpec = tween(durationMillis = 1000, easing = EaseOutQuart), + targetValue = if (showShine && triggerShine) 3.5f else 1f, + animationSpec = tween(durationMillis = 1500, easing = EaseOutExpo), label = "shineScale", ) Box(contentAlignment = Alignment.Center) { - if (isFullyDownloaded && shineAlpha > 0f) { - Icon( - imageVector = Icons.Filled.CloudDone, - contentDescription = null, + if (showShine && triggerShine && shineAlpha > 0f) { + Box( modifier = Modifier .size(size) - .graphicsLayer { - scaleX = shineScale - scaleY = shineScale - alpha = shineAlpha * (1f - (shineScale - 1f) * 2f).coerceIn(0f, 1f) - }, - tint = - successColor - .copy(alpha = 0.5f), + .graphicsLayer( + scaleX = shineScale, + scaleY = shineScale, + alpha = shineAlpha * (1f - (shineScale - 1f) / 2.5f).coerceIn(0f, 1f), + clip = false, + ).background( + color = successColor.copy(alpha = 0.6f), + shape = CircleShape, + ), ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt index c086c5b67..c6d26c458 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -241,6 +241,7 @@ fun BookDetailScreen( cacheState = cacheProgress, isFullyDownloaded = isFullyDownloaded, color = colorScheme.onSurface, + showShine = true, ) } }, @@ -311,44 +312,8 @@ fun BookDetailScreen( } // Title & Author - val downloadCheckmarkColor = - if (isSystemInDarkTheme()) { - org.grakovne.lissen.ui.theme.DownloadSuccessDark - } else { - org.grakovne.lissen.ui.theme.DownloadSuccessLight - } - - val titleText = - buildAnnotatedString { - append(book.title) - if (isFullyDownloaded) { - append(" ") - appendInlineContent("download_checkmark", "[downloaded]") - } - } - - val inlineContent = - mapOf( - "download_checkmark" to - InlineTextContent( - Placeholder( - width = 18.sp, - height = 18.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ), - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = stringResource(R.string.accessibility_id_fully_downloaded), - tint = downloadCheckmarkColor, - modifier = Modifier.size(18.dp), - ) - }, - ) - Text( - text = titleText, - inlineContent = inlineContent, + text = book.title, style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), color = colorScheme.onSurface, textAlign = TextAlign.Center, From 1d7160ef40e0a73cbe3915d55015f6bf16aba5e9 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 18:56:44 +0530 Subject: [PATCH 27/37] feat: Refactor `VerticalScrollbar` to accept a lazy color lambda and a `Set` of ignored items, improve `LazyColumn` keying for better performance, refine scroll behavior, and update project context documentation. --- .agent/rules/project-context.md | 5 +++- .../lissen/ui/components/VerticalScrollbar.kt | 10 +++++--- .../ui/screens/library/LibraryScreen.kt | 25 ++++++++++++++----- .../cache/CachedItemsSettingsScreen.kt | 4 ++- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.agent/rules/project-context.md b/.agent/rules/project-context.md index 5b1caf9db..f92fbeab1 100644 --- a/.agent/rules/project-context.md +++ b/.agent/rules/project-context.md @@ -17,6 +17,9 @@ This is an offline-first Android client app for the [audiobookshelf](https://git - If the server is reachable, and some chapters of any audiobook are downloaded, i.e., available offline, then the offline track should be given priority for playback. - If the offline track is deleted/cleared while the book is being played, the player should attempt to fallback to the online URL if the server is reachable, otherwise pause playback and persist the last playback state (track ID, playback position/timestamp, current chapter/index, and playback status) plus a flag indicating offline content was removed; ensure the rule notes that these persisted fields are used to resume or report playback state and are written atomically to the player state store. - The app must be fully functional for downloaded content when offline. +- The app should support multi-server / multi-user connections. A user should be able to connect to a server, download a few books for offline listening, then disconnect and connect to a new server and download few more books, and listen to all the books offline, and then connecting back to the previous server should push (sync) progress updates from the books which were downloaded earlier for offline listening. +- The app should also support uploading books that were downloaded from a server to another server (sharing) +- The app should support importing audiobooks from the phone storage directly without any server connection. Optionally, the user should be able to upload these audiobooks to the server along with the progress. ## Ensure Stability @@ -40,4 +43,4 @@ This is an offline-first Android client app for the [audiobookshelf](https://git - **ABSOLUTELY NO** hardcoded user-facing strings in UI code. All strings must be extracted to `strings.xml` and accessed via `stringResource`. - Use `associateBy` or proper indexing for collection lookups (O(1)) instead of nested loops (O(N^2)) when synchronizing data. - Avoid expensive operations on the main thread. -- No code duplication, keep the code clean and easy to maintain. +- No code duplication, keep the code clean and easy to maintain. \ No newline at end of file diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt index 186238fa8..09095323b 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt @@ -51,9 +51,9 @@ import timber.log.Timber fun Modifier.withScrollbar( state: LazyListState, - color: Color, + color: () -> Color, totalItems: Int?, - ignoreItems: List = emptyList(), + ignoreItems: Set = emptySet(), ): Modifier { try { return baseScrollbar { atEnd -> @@ -67,6 +67,10 @@ fun Modifier.withScrollbar( key is String && ignoreItems.contains(key) } + if (items.isEmpty()) { + return@baseScrollbar + } + val itemsSize = items.sumOf { it.size } val count = totalItems ?: layoutInfo.totalItemsCount @@ -87,7 +91,7 @@ fun Modifier.withScrollbar( ?.let { (itemSize * it.index - it.offset) / totalSize * canvasSize } ?: 0f - drawScrollbarThumb(atEnd, thumbSize, startOffset, color) + drawScrollbarThumb(atEnd, thumbSize, startOffset, color()) } } } catch (ex: Exception) { 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 8e79d7f7f..0a9691774 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 @@ -181,11 +181,18 @@ fun LibraryScreen( ) val titleTextStyle = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - val titleHeightDp = with(LocalDensity.current) { titleTextStyle.lineHeight.toPx().toDp() } + val density = LocalDensity.current + val titleHeightDp = + remember(titleTextStyle.lineHeight, density) { + with(density) { titleTextStyle.lineHeight.toPx().toDp() } + } val playingBook by playerViewModel.book.observeAsState() + val onBackgroundColor = MaterialTheme.colorScheme.onBackground val context = LocalContext.current + val scrollbarIgnoreItems = remember { setOf("recent_books", "library_title") } + fun isRecentVisible(): Boolean { val hasContent = recentBooks.isEmpty().not() @@ -224,8 +231,11 @@ fun LibraryScreen( } LaunchedEffect(recentBooks.isNotEmpty()) { + val isScrolling = libraryListState.isScrollInProgress + val isSearching = libraryViewModel.searchRequested.value == true val needsScroll = libraryListState.firstVisibleItemIndex <= 1 - if (recentBooks.isNotEmpty() && needsScroll) { + + if (recentBooks.isNotEmpty() && needsScroll && !isScrolling && !isSearching) { libraryListState.animateScrollToItem(0) } } @@ -345,9 +355,9 @@ fun LibraryScreen( .imePadding() .withScrollbar( state = libraryListState, - color = colorScheme.onBackground.copy(alpha = scrollbarAlpha), + color = { onBackgroundColor.copy(alpha = scrollbarAlpha) }, totalItems = libraryCount, - ignoreItems = listOf("recent_books", "library_title"), + ignoreItems = scrollbarIgnoreItems, ), contentPadding = PaddingValues(horizontal = Spacing.md), ) { @@ -443,8 +453,11 @@ fun LibraryScreen( } else -> - items(count = library.itemCount, key = { "library_item_$it" }) { - val book = library[it] ?: return@items + items( + count = library.itemCount, + key = { index -> library.peek(index)?.id ?: "library_item_$index" }, + ) { index -> + val book = library[index] ?: return@items BookComposable( book = book, 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 99a39c189..4bf4f3e2e 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 @@ -415,13 +415,15 @@ private fun CachedItemsComposable( animationSpec = tween(durationMillis = 300), ) + val onBackground = colorScheme.onBackground + LazyColumn( state = state, modifier = Modifier .withScrollbar( state = state, - color = colorScheme.onBackground.copy(alpha = scrollbarAlpha), + color = { onBackground.copy(alpha = scrollbarAlpha) }, totalItems = itemsCount, ).fillMaxSize(), ) { From b31fcce033c49507542ed3b1148fbe468a01f806 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 19:01:21 +0530 Subject: [PATCH 28/37] feat: Display a loading indicator and disable the delete button while cached items are being removed. --- .../cache/CachedItemsSettingsScreen.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 4bf4f3e2e..562a97347 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 @@ -44,6 +44,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -118,6 +119,8 @@ fun CachedItemsSettingsScreen( val context = LocalContext.current var pullRefreshing by remember { mutableStateOf(false) } + var isDeleting by remember { mutableStateOf(false) } + val cachedItems = viewModel.libraryPager.collectAsLazyPagingItems() var selectionMode by remember { mutableStateOf(false) } @@ -224,21 +227,34 @@ fun CachedItemsSettingsScreen( onClick = { withHaptic(view) { scope.launch { + isDeleting = true deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) selectionMode = false selectedVolumes.clear() refreshContent(false) + isDeleting = false } } }, modifier = Modifier.fillMaxWidth(), + enabled = !isDeleting, colors = ButtonDefaults.buttonColors( containerColor = colorScheme.error, contentColor = colorScheme.onError, + disabledContainerColor = colorScheme.error.copy(alpha = 0.5f), + disabledContentColor = colorScheme.onError.copy(alpha = 0.5f), ), ) { - Text(stringResource(R.string.manage_downloads_free_up, formattedSize)) + if (isDeleting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = colorScheme.onError, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.manage_downloads_free_up, formattedSize)) + } } } } From 6833e85522c817c114c3c888bc071d9e08fbb06a Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 19:08:40 +0530 Subject: [PATCH 29/37] feat: Display a loading indicator in BookDetailScreen while book details are being fetched by introducing an `isFetchingDetails` state in PlayerViewModel. --- .../grakovne/lissen/ui/screens/details/BookDetailScreen.kt | 3 ++- .../kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt index c6d26c458..7837cc13e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -146,6 +146,7 @@ fun BookDetailScreen( val isOnline by playerViewModel.isOnline.collectAsState(initial = false) val preparingBookId by playerViewModel.preparingBookId.observeAsState(null) val currentChapterIndex by playerViewModel.currentChapterIndex.observeAsState(-1) + val isFetchingDetails by playerViewModel.isFetchingDetails.observeAsState(false) val totalPosition by playerViewModel.totalPosition.observeAsState(0.0) val cacheProgress: CacheState by cachingModelView.getProgress(bookId).collectAsState(initial = CacheState(CacheStatus.Idle)) @@ -249,7 +250,7 @@ fun BookDetailScreen( }, modifier = Modifier.systemBarsPadding(), ) { innerPadding -> - if (bookDetail == null) { + if (bookDetail == null || (isFetchingDetails && bookDetail?.chapters?.isEmpty() == true)) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt index 3a4f860ae..109bfc20b 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt @@ -56,6 +56,9 @@ class PlayerViewModel val preparingError: LiveData = mediaRepository.mediaPreparingError val preparingBookId: LiveData = mediaRepository.preparingBookId + private val _isFetchingDetails = MutableLiveData(false) + val isFetchingDetails: LiveData = _isFetchingDetails + private val _searchRequested = MutableLiveData(false) val searchRequested: LiveData = _searchRequested @@ -154,7 +157,9 @@ class PlayerViewModel fun fetchBook(bookId: String) { viewModelScope.launch { + _isFetchingDetails.postValue(true) mediaRepository.fetchBook(bookId) + _isFetchingDetails.postValue(false) } } From 9e7d44bf76b558f75eb28730ece19bbbdd378efc Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 19:34:40 +0530 Subject: [PATCH 30/37] refactor: delegate book metadata caching to `LocalCacheRepository` and remove the now redundant `cacheBookInfo` function. --- .../content/cache/persistent/ContentCachingManager.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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 eaa3ef925..f901bbe7b 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 @@ -40,6 +40,7 @@ class ContentCachingManager private val requestHeadersProvider: RequestHeadersProvider, private val preferences: LissenSharedPreferences, private val clarityTracker: ClarityTracker, + private val localCacheRepository: LocalCacheRepository, ) { fun cacheMediaItem( mediaItem: DetailedItem, @@ -115,7 +116,7 @@ class ContentCachingManager coverCachingResult, librariesCachingResult, ).all { it.status == CacheStatus.Completed } -> { - cacheBookInfo(mediaItem, requestedChapters) + localCacheRepository.cacheBookMetadata(mediaItem) send(CacheState(CacheStatus.Completed)) clarityTracker.trackEvent("download_finished") } @@ -307,14 +308,6 @@ class ContentCachingManager } } - private suspend fun cacheBookInfo( - book: DetailedItem, - fetchedChapters: List, - ): CacheState = - bookRepository - .cacheBook(book, fetchedChapters, emptyList()) - .let { CacheState(CacheStatus.Completed) } - private suspend fun cacheLibraries(channel: MediaChannel): CacheState = channel .fetchLibraries() From 229ed1648a854f42b73398405e2613b557ddcf3e Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 20:25:06 +0530 Subject: [PATCH 31/37] feat: Migrate crash reporting from ACRA to Firebase Crashlytics and integrate Firebase Analytics. --- .gitignore | 3 -- app/build.gradle.kts | 16 +++---- app/google-services.json | 48 +++++++++++++++++++ .../org/grakovne/lissen/LissenApplication.kt | 47 ++---------------- .../grakovne/lissen/common/CrashHandler.kt | 36 ++++++++++++++ .../preferences/LissenSharedPreferences.kt | 7 +-- .../lissen/ui/components/VerticalScrollbar.kt | 5 +- .../lissen/viewmodel/SettingsViewModel.kt | 4 +- build.gradle.kts | 2 + gradle/libs.versions.toml | 12 +++-- 10 files changed, 112 insertions(+), 68 deletions(-) create mode 100644 app/google-services.json create mode 100644 app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt diff --git a/.gitignore b/.gitignore index 51ec856e5..7f8bd3910 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,6 @@ render.experimental.xml *.jks *.keystore -# Google Services (e.g. APIs or Firebase) -google-services.json - # Android Profiling *.hprof diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d871bdfe2..8fc8607d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,8 @@ plugins { id("com.google.dagger.hilt.android") id("org.jmailen.kotlinter") version "5.2.0" id("com.google.devtools.ksp") + alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) } kotlinter { @@ -60,12 +62,6 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - val acraReportLogin = "8yJ59n0UToCja8LR" - val acraReportPassword = "kuW9TV7BbJByuIAc" - - buildConfigField("String", "ACRA_REPORT_LOGIN", "\"$acraReportLogin\"") - buildConfigField("String", "ACRA_REPORT_PASSWORD", "\"$acraReportPassword\"") - buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"") signingConfigs { @@ -185,10 +181,6 @@ dependencies { implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) - implementation(libs.acra.core) - implementation(libs.acra.http) - implementation(libs.acra.toast) - implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) @@ -198,6 +190,10 @@ dependencies { implementation(libs.microsoft.clarity) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) + implementation(libs.firebase.analytics) + debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..35baeb16b --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "139954826172", + "project_id": "kahani-app-android", + "storage_bucket": "kahani-app-android.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:139954826172:android:0f4f76f1de010ba41699b9", + "android_client_info": { + "package_name": "com.kahani.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAB8o-65tB6TzUbdaqVAP9XnTl1wgC6dks" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:139954826172:android:1d7d97392c6e248f1699b9", + "android_client_info": { + "package_name": "com.kahani.app.debug" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAB8o-65tB6TzUbdaqVAP9XnTl1wgC6dks" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt index d7ff73962..f6003e225 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt @@ -3,13 +3,6 @@ package org.grakovne.lissen import android.app.Application import android.content.Context import dagger.hilt.android.HiltAndroidApp -import org.acra.ReportField -import org.acra.config.httpSender -import org.acra.config.toast -import org.acra.data.StringFormat -import org.acra.ktx.initAcra -import org.acra.security.TLS -import org.acra.sender.HttpSender import org.grakovne.lissen.common.RunningComponent import timber.log.Timber import javax.inject.Inject @@ -19,11 +12,6 @@ class LissenApplication : Application() { @Inject lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> - override fun attachBaseContext(base: Context) { - super.attachBaseContext(base) - initCrashReporting() - } - override fun onCreate() { super.onCreate() appContext = applicationContext @@ -32,6 +20,9 @@ class LissenApplication : Application() { Timber.plant(Timber.DebugTree()) } + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(org.grakovne.lissen.common.CrashHandler(this, defaultHandler)) + runningComponents.forEach { try { it.onCreate() @@ -41,38 +32,6 @@ class LissenApplication : Application() { } } - private fun initCrashReporting() { - initAcra { - sharedPreferencesName = "secure_prefs" - - buildConfigClass = BuildConfig::class.java - reportFormat = StringFormat.JSON - - httpSender { - uri = "https://acrarium.grakovne.org/report" - basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN - basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD - httpMethod = HttpSender.Method.POST - dropReportsOnTimeout = false - tlsProtocols = listOf(TLS.V1_3, TLS.V1_2) - } - - toast { - text = getString(R.string.app_crach_toast) - } - - reportContent = - listOf( - ReportField.APP_VERSION_NAME, - ReportField.APP_VERSION_CODE, - ReportField.ANDROID_VERSION, - ReportField.PHONE_MODEL, - ReportField.STACK_TRACE, - ReportField.ENVIRONMENT, - ) - } - } - companion object { lateinit var appContext: Context private set diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt new file mode 100644 index 000000000..3977fecaa --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt @@ -0,0 +1,36 @@ +package org.grakovne.lissen.common + +import android.content.Context +import android.os.Looper +import android.widget.Toast +import org.grakovne.lissen.R + +class CrashHandler( + private val context: Context, + private val defaultHandler: Thread.UncaughtExceptionHandler?, +) : Thread.UncaughtExceptionHandler { + override fun uncaughtException( + thread: Thread, + ex: Throwable, + ) { + try { + object : Thread() { + override fun run() { + Looper.prepare() + Toast.makeText(context, R.string.app_crach_toast, Toast.LENGTH_LONG).show() + Looper.loop() + } + }.start() + + Thread.sleep(TOAST_TIMEOUT) + } catch (e: Exception) { + // Ignore errors in the crash handler itself to avoid infinite loops + } finally { + defaultHandler?.uncaughtException(thread, ex) + } + } + + companion object { + private const val TOAST_TIMEOUT = 2500L + } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index 13ae67cfd..4a36f938d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -102,11 +102,11 @@ class LissenSharedPreferences } } - fun getAcraEnabled() = sharedPreferences.getBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, true) + fun getCrashReportingEnabled() = sharedPreferences.getBoolean(KEY_CRASH_REPORTING_ENABLED, true) - fun saveAcraEnabled(enabled: Boolean) { + fun saveCrashReportingEnabled(enabled: Boolean) { sharedPreferences.edit { - putBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, enabled) + putBoolean(KEY_CRASH_REPORTING_ENABLED, enabled) } } @@ -691,6 +691,7 @@ class LissenSharedPreferences private const val KEY_PLAYING_BOOK = "playing_book" private const val KEY_VOLUME_BOOST = "volume_boost" + private const val KEY_CRASH_REPORTING_ENABLED = "crash_reporting_enabled" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val TRANSFORMATION = "AES/GCM/NoPadding" diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt index 09095323b..49465019d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt @@ -44,9 +44,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow -import org.acra.ACRA import timber.log.Timber fun Modifier.withScrollbar( @@ -96,7 +97,7 @@ fun Modifier.withScrollbar( } } catch (ex: Exception) { Timber.w("Unable to apply scrollbar due to ${ex.message}") - ACRA.errorReporter.handleSilentException(ex) + Firebase.crashlytics.recordException(ex) return this } } 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 d7e4354b0..c4ab8f4e0 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -81,7 +81,7 @@ class SettingsViewModel val seekTime = preferences.seekTimeFlow.asLiveData() - private val _crashReporting = MutableLiveData(preferences.getAcraEnabled()) + private val _crashReporting = MutableLiveData(preferences.getCrashReportingEnabled()) val crashReporting = _crashReporting private val _bypassSsl = MutableLiveData(preferences.getSslBypass()) @@ -105,7 +105,7 @@ class SettingsViewModel fun preferCrashReporting(value: Boolean) { _crashReporting.postValue(value) - preferences.saveAcraEnabled(value) + preferences.saveCrashReportingEnabled(value) } fun preferBypassSsl(value: Boolean) { diff --git a/build.gradle.kts b/build.gradle.kts index 4ac84b6cf..79565bc1a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,6 @@ plugins { id("com.google.devtools.ksp") version "2.2.21-2.0.4" apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.android.library) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b1c368314..60d6775e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,4 @@ [versions] -acraCore = "5.13.1" agp = "8.13.2" browser = "1.9.0" coilCompose = "3.3.0" @@ -33,11 +32,11 @@ timber = "5.0.1" moshi = "1.15.2" converterMoshi = "3.0.0" clarity = "3.8.0" +googleServices = "4.4.2" +firebaseCrashlyticsPlugin = "3.0.3" +firebaseBom = "33.9.0" [libraries] -acra-core = { module = "ch.acra:acra-core", version.ref = "acraCore" } -acra-http = { module = "ch.acra:acra-http", version.ref = "acraCore" } -acra-toast = { module = "ch.acra:acra-toast", version.ref = "acraCore" } androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } androidx-glance = { module = "androidx.glance:glance", version.ref = "glance" } androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } @@ -86,9 +85,14 @@ moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } microsoft-clarity = { module = "com.microsoft.clarity:clarity-compose", version.ref = "clarity" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } From 6ddf4e2dc33943f609cd1d58f83d8025f3abb840 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 20:47:05 +0530 Subject: [PATCH 32/37] feat: Add Firebase Crashlytics exception reporting to various error handling paths. --- .../org/grakovne/lissen/LissenApplication.kt | 5 +- .../grakovne/lissen/common/CrashHandler.kt | 46 +++++++++---------- .../cache/persistent/ContentCachingManager.kt | 4 ++ .../preferences/LissenSharedPreferences.kt | 3 ++ .../lissen/playback/MediaRepository.kt | 6 +++ 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt index f6003e225..6b93f5eae 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt @@ -21,7 +21,10 @@ class LissenApplication : Application() { } val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler(org.grakovne.lissen.common.CrashHandler(this, defaultHandler)) + Thread.setDefaultUncaughtExceptionHandler( + org.grakovne.lissen.common + .CrashHandler(this, defaultHandler), + ) runningComponents.forEach { try { diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt index 3977fecaa..658ed3448 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt @@ -6,31 +6,31 @@ import android.widget.Toast import org.grakovne.lissen.R class CrashHandler( - private val context: Context, - private val defaultHandler: Thread.UncaughtExceptionHandler?, + private val context: Context, + private val defaultHandler: Thread.UncaughtExceptionHandler?, ) : Thread.UncaughtExceptionHandler { - override fun uncaughtException( - thread: Thread, - ex: Throwable, - ) { - try { - object : Thread() { - override fun run() { - Looper.prepare() - Toast.makeText(context, R.string.app_crach_toast, Toast.LENGTH_LONG).show() - Looper.loop() - } - }.start() - - Thread.sleep(TOAST_TIMEOUT) - } catch (e: Exception) { - // Ignore errors in the crash handler itself to avoid infinite loops - } finally { - defaultHandler?.uncaughtException(thread, ex) + override fun uncaughtException( + thread: Thread, + ex: Throwable, + ) { + try { + object : Thread() { + override fun run() { + Looper.prepare() + Toast.makeText(context, R.string.app_crach_toast, Toast.LENGTH_LONG).show() + Looper.loop() } - } + }.start() - companion object { - private const val TOAST_TIMEOUT = 2500L + Thread.sleep(TOAST_TIMEOUT) + } catch (e: Exception) { + // Ignore errors in the crash handler itself to avoid infinite loops + } finally { + defaultHandler?.uncaughtException(thread, ex) } + } + + companion object { + private const val TOAST_TIMEOUT = 2500L + } } 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 f901bbe7b..2c992dbd9 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 @@ -1,6 +1,8 @@ package org.grakovne.lissen.content.cache.persistent import android.content.Context +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -268,6 +270,7 @@ class ContentCachingManager } } } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return@withContext CacheState(CacheStatus.Error) } } @@ -297,6 +300,7 @@ class ContentCachingManager .withBlur(context, width = 300) // Trigger thumbnail transformation .writeToFile(thumbFile) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return@fold CacheState(CacheStatus.Error) } }, diff --git a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index 4a36f938d..b9fd884d4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -6,6 +6,8 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import androidx.core.content.edit +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import com.squareup.moshi.Types import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose @@ -740,6 +742,7 @@ class LissenSharedPreferences return try { String(cipher.doFinal(cipherText)) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) null } } 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 fab598e7c..a21a24cd3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -22,6 +22,8 @@ import androidx.media3.session.SessionToken import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.MoreExecutors +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -184,6 +186,7 @@ class MediaRepository override fun onFailure(t: Throwable) { Timber.e("Unable to add callback to player") + Firebase.crashlytics.recordException(t) clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error") } }, @@ -317,6 +320,7 @@ class MediaRepository toneGen.release() }, 200) } catch (e: Exception) { + Firebase.crashlytics.recordException(e) Timber.e(e, "Failed to play reset sound") } } @@ -356,6 +360,7 @@ class MediaRepository seekTo(chapterStartsAt) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return } } @@ -384,6 +389,7 @@ class MediaRepository seekTo(absolutePosition) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return } } From e1833a48a6247995a09e6ff9cf42a040d36b56e5 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sat, 7 Feb 2026 21:36:26 +0530 Subject: [PATCH 33/37] feat: Implement initial retry logic for server availability checks with varying delays and enhance related logging for server status and library state. --- .../org/grakovne/lissen/common/NetworkService.kt | 14 +++++++++++++- .../grakovne/lissen/viewmodel/LibraryViewModel.kt | 10 ++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt index 7fb83043a..650d83ab3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt @@ -83,6 +83,7 @@ class NetworkService } private var checkJob: Job? = null + private var initialRetryCount = 0 fun refreshServerAvailability() { checkJob?.cancel() @@ -113,9 +114,16 @@ class NetworkService } _isServerAvailable.emit(true) + initialRetryCount = MAX_INITIAL_RETRIES // Stop retries once connected } catch (e: Exception) { - Timber.e(e, "Server reachability check failed for $hostUrl") + Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${initialRetryCount + 1})") _isServerAvailable.emit(false) + + if (initialRetryCount < MAX_INITIAL_RETRIES) { + initialRetryCount++ + delay(300L) + refreshServerAvailability() + } } } } @@ -168,4 +176,8 @@ class NetworkService override fun onDestroy() { scope.cancel() } + + private companion object { + private const val MAX_INITIAL_RETRIES = 3 + } } 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 1c72dd934..b7bcd57ef 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -73,7 +73,11 @@ class LibraryViewModel networkService.isServerAvailable, preferences.forceCacheFlow, ) { isServerAvailable, isForceCache -> - !isServerAvailable || isForceCache + val downloadedOnly = !isServerAvailable || isForceCache + Timber.d( + "Library State Calculation: isServerAvailable=$isServerAvailable, isForceCache=$isForceCache -> downloadedOnly=$downloadedOnly", + ) + downloadedOnly } private var currentLibraryId = "" @@ -125,9 +129,11 @@ class LibraryViewModel .isServerAvailable .collect { isAvailable -> if (isAvailable) { - Timber.d("Server is reachable. Triggering repository sync.") + Timber.i("Server Availability Event: Server is now REACHABLE. Triggering repository sync.") bookRepository.syncRepositories() refreshLibrary() + } else { + Timber.i("Server Availability Event: Server is now UNREACHABLE.") } } } From 92828f4845bedbb260c5d2f7a8be15e88bd56b53 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sun, 8 Feb 2026 00:14:30 +0530 Subject: [PATCH 34/37] feat: Implement optimized library synchronization using `updatedAt` timestamps and minified book fetches to efficiently update local book data, and add `addedAt`/`updatedAt` fields to the `Book` model. --- .../org/grakovne/lissen/LissenApplication.kt | 37 ++++-- .../common/api/AudioBookshelfRepository.kt | 25 ++++ .../converter/LibraryPageResponseConverter.kt | 2 + .../library/LibraryAudiobookshelfChannel.kt | 5 + .../library/model/LibraryItemsResponse.kt | 2 + .../podcast/PodcastAudiobookshelfChannel.kt | 5 + .../lissen/channel/common/MediaChannel.kt | 2 + .../grakovne/lissen/common/CrashHandler.kt | 2 +- .../grakovne/lissen/content/BookRepository.kt | 121 +++++++++++++----- .../persistent/api/CachedBookRepository.kt | 8 +- .../persistent/api/FetchRequestBuilder.kt | 2 +- .../persistent/api/SearchRequestBuilder.kt | 2 +- .../converter/CachedBookEntityConverter.kt | 2 + .../ui/screens/library/LibraryScreen.kt | 7 +- .../lissen/viewmodel/LibraryViewModel.kt | 2 +- .../org/grakovne/lissen/lib/domain/Book.kt | 2 + 16 files changed, 178 insertions(+), 48 deletions(-) diff --git a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt index 6b93f5eae..149c8c266 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt @@ -9,6 +9,9 @@ import javax.inject.Inject @HiltAndroidApp class LissenApplication : Application() { + @Inject + lateinit var preferences: org.grakovne.lissen.persistence.preferences.LissenSharedPreferences + @Inject lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> @@ -16,21 +19,37 @@ class LissenApplication : Application() { super.onCreate() appContext = applicationContext - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - } + // Initialize core services first + try { + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } - val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler( - org.grakovne.lissen.common - .CrashHandler(this, defaultHandler), - ) + val isCrashReportingEnabled = preferences.getCrashReportingEnabled() + com.google.firebase.crashlytics.FirebaseCrashlytics + .getInstance() + .setCrashlyticsCollectionEnabled(isCrashReportingEnabled) + + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler( + org.grakovne.lissen.common + .CrashHandler(this, defaultHandler), + ) + } catch (e: Exception) { + // Fallback logging if core services fail + android.util.Log.e("LissenApplication", "Failed to initialize core services", e) + } + // Initialize components with individual error handling runningComponents.forEach { try { it.onCreate() } catch (ex: Exception) { - Timber.e("Unable to register Running component due to: ${ex.message}") + Timber.e(ex, "Unable to register Running component: ${ex.message}") + com.google.firebase.crashlytics.FirebaseCrashlytics + .getInstance() + .recordException(ex) } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt index b16a4bd25..3b9a4b5e4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt @@ -85,6 +85,18 @@ class AudioBookshelfRepository ) } + suspend fun fetchLibraryMinified(libraryId: String): OperationResult = + audioBookShelfApiService.makeRequest { + it.fetchLibraryItems( + libraryId = libraryId, + pageSize = Int.MAX_VALUE, + pageNumber = 0, + sort = "addedAt", + desc = "1", + minified = "1", + ) + } + suspend fun fetchPodcastItems( libraryId: String, pageSize: Int, @@ -103,6 +115,19 @@ class AudioBookshelfRepository ) } + suspend fun fetchPodcastMinified(libraryId: String): OperationResult = + audioBookShelfApiService + .makeRequest { + it.fetchPodcastItems( + libraryId = libraryId, + pageSize = Int.MAX_VALUE, + pageNumber = 0, + sort = "addedAt", + desc = "1", + minified = "1", + ) + } + suspend fun fetchBook(itemId: String): OperationResult = audioBookShelfApiService.makeRequest { it.fetchLibraryItem( diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt index 9d085f80c..864b7ce39 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt @@ -27,6 +27,8 @@ class LibraryPageResponseConverter author = it.media.metadata.authorName, duration = 0.0, libraryId = libraryId, + addedAt = it.addedAt ?: 0L, + updatedAt = it.updatedAt ?: 0L, ) }.let { PagedItems( 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 462c885ff..b67fb2bf4 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 @@ -74,6 +74,11 @@ class LibraryAudiobookshelfChannel ).map { libraryPageResponseConverter.apply(it, libraryId) } } + override suspend fun fetchLibraryMinified(libraryId: String): OperationResult> = + dataRepository + .fetchLibraryMinified(libraryId) + .map { libraryPageResponseConverter.apply(it, libraryId).items } + override suspend fun searchBooks( libraryId: String, query: String, diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt index 25fea5ba3..7fef1f761 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt @@ -15,6 +15,8 @@ data class LibraryItemsResponse( @JsonClass(generateAdapter = true) data class LibraryItem( val id: String, + val addedAt: Long?, + val updatedAt: Long?, val media: Media, ) 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 65b19f45e..7936d2ab2 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 @@ -71,6 +71,11 @@ class PodcastAudiobookshelfChannel ).map { podcastPageResponseConverter.apply(it, libraryId) } } + override suspend fun fetchLibraryMinified(libraryId: String): OperationResult> = + dataRepository + .fetchPodcastMinified(libraryId) + .map { podcastPageResponseConverter.apply(it, libraryId).items } + override suspend fun searchBooks( libraryId: String, query: String, diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/common/MediaChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/common/MediaChannel.kt index bf6556fb3..4cf86d52f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/common/MediaChannel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/common/MediaChannel.kt @@ -57,5 +57,7 @@ interface MediaChannel { suspend fun fetchRecentListenedBooks(libraryId: String): OperationResult> + suspend fun fetchLibraryMinified(libraryId: String): OperationResult> + suspend fun fetchBook(bookId: String): OperationResult } diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt index 658ed3448..27e0adb27 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt @@ -31,6 +31,6 @@ class CrashHandler( } companion object { - private const val TOAST_TIMEOUT = 2500L + private const val TOAST_TIMEOUT = 1000L } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt index 0ba8df5d3..6b1af4a47 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt @@ -165,31 +165,10 @@ class BookRepository return localResult } - val localItems = - localResult.fold( - onSuccess = { it.items }, - onFailure = { emptyList() }, - ) - - if (localItems.isEmpty() || localItems.size < pageSize) { - Timber.d("Local cache miss (or partial) for page $pageNumber. Fetching from remote.") - return providePreferredChannel() - .fetchBooks( - libraryId = libraryId, - pageSize = pageSize, - pageNumber = pageNumber, - ).also { - it.foldAsync( - onSuccess = { result -> - localCacheRepository.cacheBooks(result.items) - backgroundScope.launch { prefetchCovers(result.items) } - }, - onFailure = {}, - ) - } - } - - return localResult + return localResult.fold( + onSuccess = { OperationResult.Success(it) }, + onFailure = { OperationResult.Success(PagedItems(emptyList(), pageNumber, 0)) }, + ) } suspend fun syncLibraryPage( @@ -197,9 +176,88 @@ class BookRepository pageSize: Int, pageNumber: Int, ): OperationResult = - providePreferredChannel() - .fetchBooks(libraryId, pageSize, pageNumber) - .map { localCacheRepository.cacheBooks(it.items) } + OperationResult + .Success(Unit) + .also { backgroundScope.launch { syncAllLibraries() } } + + private suspend fun syncAllLibraries() { + val librariesResult = fetchLibraries() + val libraries = + when (librariesResult) { + is OperationResult.Success -> librariesResult.data + is OperationResult.Error -> return + } + + libraries.forEach { library -> + syncFullLibrary(library.id) + } + } + + private suspend fun syncFullLibrary(libraryId: String) { + val remoteItemsResult = providePreferredChannel().fetchLibraryMinified(libraryId) + + val remoteItems = + when (remoteItemsResult) { + is OperationResult.Success -> remoteItemsResult.data + is OperationResult.Error -> return + } + + // Fast Sync: Immediately cache basic metadata (Title, Author, ID) + // This makes new books visible and searchable instantly. + // Existing details (Chapters, Duration) are preserved by the repository logic. + localCacheRepository.cacheBooks(remoteItems) + + val knownBooks = + localCacheRepository + .fetchBooks(libraryId, Int.MAX_VALUE, 0, false) + .fold( + onSuccess = { it.items }, + onFailure = { emptyList() }, + ).associateBy { it.id } + + val newOrUpdatedBooks = + remoteItems.filter { remote -> + val local = knownBooks[remote.id] + + // If local is null (shouldn't be, since we just cached), or remote is newer + // Note: addedAt/updatedAt are now available in Book + local == null || (remote.updatedAt > local.updatedAt) + } + + if (newOrUpdatedBooks.isEmpty()) { + Timber.d("Local library is up to date. Sync finished.") + return + } + + Timber.d("Found ${newOrUpdatedBooks.size} new or updated books. Syncing details...") + + newOrUpdatedBooks + .chunked(20) + .forEach { chunk -> + withContext(Dispatchers.IO) { + chunk + .map { book -> + async { + providePreferredChannel() + .fetchBook(book.id) + .foldAsync( + onSuccess = { + localCacheRepository.cacheBookMetadata(it) + it + }, + onFailure = { null }, + ) + } + }.awaitAll() + .filterNotNull() + .let { items -> + backgroundScope.launch { + prefetchCovers(items.map { item -> item.toBook() }) + } + } + } + } + } suspend fun fetchLibraries(): OperationResult> { Timber.d("Fetching List of libraries") @@ -321,8 +379,11 @@ class BookRepository ) } - suspend fun syncRepositories() { - val libraryId = preferences.getPreferredLibrary()?.id ?: return + suspend fun syncRepositories(overrideLibraryId: String? = null) { + val libraryId = overrideLibraryId ?: preferences.getPreferredLibrary()?.id ?: return + + syncFullLibrary(libraryId) + val remoteRecents = providePreferredChannel().fetchRecentListenedBooks(libraryId).getOrNull() ?: emptyList() val localRecents = localCacheRepository 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 97a2def86..112d92494 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 @@ -102,8 +102,8 @@ class CachedBookRepository author = book.author, subtitle = book.subtitle, seriesNames = book.series, - duration = book.duration.toInt(), - libraryType = null, // currently not available here + duration = if (book.duration > 0) book.duration.toInt() else existing.duration, + updatedAt = if (book.updatedAt > 0) book.updatedAt else existing.updatedAt, ) ?: BookEntity( id = book.id, title = book.title, @@ -116,8 +116,8 @@ class CachedBookRepository duration = book.duration.toInt(), libraryId = book.libraryId, libraryType = null, - createdAt = 0, - updatedAt = 0, + createdAt = book.addedAt, + updatedAt = book.updatedAt, host = preferences.getHost(), username = preferences.getUsername(), seriesNames = book.series, 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 126e356b5..e5137dc69 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 @@ -76,7 +76,7 @@ class FetchRequestBuilder { """ SELECT detailed_books.* FROM detailed_books WHERE $libraryClause AND $isolationClause - ORDER BY $field $direction + ORDER BY $field $direction, detailed_books.id ASC LIMIT ? OFFSET ? """.trimIndent() 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 5c4ead5cc..f87b2f918 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 @@ -67,7 +67,7 @@ class SearchRequestBuilder( """ SELECT * FROM detailed_books WHERE $libraryClause AND $searchClause AND $isolationClause - ORDER BY $field $direction + ORDER BY $field $direction, id ASC """.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 c72b6bcb0..e10a72ed6 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 @@ -36,5 +36,7 @@ class CachedBookEntityConverter }, duration = entity.duration.toDouble(), libraryId = entity.libraryId ?: "", + addedAt = entity.createdAt, + updatedAt = entity.updatedAt, ) } 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 0a9691774..d9c0db656 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 @@ -359,7 +359,12 @@ fun LibraryScreen( totalItems = libraryCount, ignoreItems = scrollbarIgnoreItems, ), - contentPadding = PaddingValues(horizontal = Spacing.md), + contentPadding = + PaddingValues( + start = Spacing.md, + end = Spacing.md, + bottom = Spacing.md, + ), ) { item(key = "recent_books") { val showRecent = isRecentVisible() 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 b7bcd57ef..6ec2aed7c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -198,7 +198,7 @@ class LibraryViewModel private fun syncLibrary(libraryId: String) { viewModelScope.launch(Dispatchers.IO) { - bookRepository.syncRepositories() + bookRepository.syncRepositories(overrideLibraryId = libraryId) defaultPagingSource.value?.invalidate() } } diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt index da1249f3d..cda19f770 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt @@ -11,4 +11,6 @@ data class Book( val author: String?, val duration: Double = 0.0, val libraryId: String = "", + val addedAt: Long = 0, + val updatedAt: Long = 0, ) From 073c97d4ef7a7603314b6f0a81603c4efc0987dc Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sun, 8 Feb 2026 01:17:02 +0530 Subject: [PATCH 35/37] fix: Improve analytics robustness, enhance playback service thread safety, correct UI text, and refine mini-player state management.** --- .agent/rules/project-context.md | 2 + .github/workflows/release.yml | 2 +- .../lissen/analytics/ClarityTracker.kt | 6 +- .../common/api/AudioBookshelfRepository.kt | 4 +- .../converter/BookResponseConverter.kt | 1 - .../grakovne/lissen/common/CrashHandler.kt | 18 +++- .../org/grakovne/lissen/common/HashUtils.kt | 2 +- .../org/grakovne/lissen/common/Moshi.kt | 24 ++--- .../grakovne/lissen/common/NetworkService.kt | 67 +++++++------ .../grakovne/lissen/content/AuthRepository.kt | 14 ++- .../grakovne/lissen/content/BookRepository.kt | 15 ++- .../cache/persistent/ContentCachingService.kt | 22 ++--- .../cache/persistent/LocalCacheModule.kt | 4 +- .../cache/persistent/LocalCacheRepository.kt | 17 +++- .../persistent/api/SearchRequestBuilder.kt | 10 +- .../persistent/entity/CachedBookEntity.kt | 1 - .../domain/update/UpdateCheckerService.kt | 13 ++- .../playback/service/PlaybackService.kt | 13 +-- .../service/PlaybackSynchronizationService.kt | 93 +++++++++++-------- .../ui/components/AsyncShimmeringImage.kt | 6 ++ .../ui/components/DownloadProgressIcon.kt | 2 +- .../ui/screens/library/LibraryScreen.kt | 2 +- .../composables/MiniPlayerComposable.kt | 12 ++- .../composable/PlaybackButtonsComposable.kt | 3 - .../cache/CachedItemsSettingsScreen.kt | 28 ++++-- .../lissen/viewmodel/CachingModelView.kt | 60 +++++++++--- .../lissen/viewmodel/LibraryViewModel.kt | 11 ++- .../lissen/viewmodel/PlayerViewModel.kt | 9 +- .../lissen/viewmodel/SettingsViewModel.kt | 17 ++-- app/src/main/res/values/strings.xml | 3 +- .../lissen/lib/domain/DownloadOption.kt | 29 ++++-- 31 files changed, 329 insertions(+), 181 deletions(-) diff --git a/.agent/rules/project-context.md b/.agent/rules/project-context.md index f92fbeab1..beb609a78 100644 --- a/.agent/rules/project-context.md +++ b/.agent/rules/project-context.md @@ -17,6 +17,8 @@ This is an offline-first Android client app for the [audiobookshelf](https://git - If the server is reachable, and some chapters of any audiobook are downloaded, i.e., available offline, then the offline track should be given priority for playback. - If the offline track is deleted/cleared while the book is being played, the player should attempt to fallback to the online URL if the server is reachable, otherwise pause playback and persist the last playback state (track ID, playback position/timestamp, current chapter/index, and playback status) plus a flag indicating offline content was removed; ensure the rule notes that these persisted fields are used to resume or report playback state and are written atomically to the player state store. - The app must be fully functional for downloaded content when offline. + +## Future Plans (Not to be implemented now) - The app should support multi-server / multi-user connections. A user should be able to connect to a server, download a few books for offline listening, then disconnect and connect to a new server and download few more books, and listen to all the books offline, and then connecting back to the previous server should push (sync) progress updates from the books which were downloaded earlier for offline listening. - The app should also support uploading books that were downloaded from a server to another server (sharing) - The app should support importing audiobooks from the phone storage directly without any server connection. Optionally, the user should be able to upload these audiobooks to the server along with the progress. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0829a81d9..d2bfe229c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: "labels": ["chore", "deps", "dependency", "refactor"] } ], - "template": "#{{CHANGELOG}}" + "template": "{{CHANGELOG}}" } - name: Create Release diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt index e9c57d0b5..1bde75279 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt @@ -46,10 +46,10 @@ class ClarityTracker try { // Clarity sendCustomEvent takes a string value for the event name/payload - Clarity.sendCustomEvent("$eventName: $value") - Timber.d("Clarity Event tracked: $eventName with value: $value") + Clarity.sendCustomEvent("$eventName: [REDACTED]") + Timber.d("Clarity Event tracked: $eventName with value: [REDACTED]") } catch (e: Exception) { - Timber.e(e, "Failed to track Clarity event: $eventName") + Timber.e(e, "Failed to track Clarity event: $eventName with value: [REDACTED]") } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt index 3b9a4b5e4..9f4f05d05 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt @@ -89,7 +89,7 @@ class AudioBookshelfRepository audioBookShelfApiService.makeRequest { it.fetchLibraryItems( libraryId = libraryId, - pageSize = Int.MAX_VALUE, + pageSize = 10000, pageNumber = 0, sort = "addedAt", desc = "1", @@ -120,7 +120,7 @@ class AudioBookshelfRepository .makeRequest { it.fetchPodcastItems( libraryId = libraryId, - pageSize = Int.MAX_VALUE, + pageSize = 10000, pageNumber = 0, sort = "addedAt", desc = "1", diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt index ab0ae19e6..650645cae 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt @@ -77,7 +77,6 @@ class BookResponseConverter .audioFiles ?.sortedBy { it.index } ?.map { - timber.log.Timber.d("Mapping file ${it.ino} with size ${it.metadata.size}") BookFile( id = it.ino, name = diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt index 27e0adb27..904206e4c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt @@ -6,18 +6,32 @@ import android.widget.Toast import org.grakovne.lissen.R class CrashHandler( - private val context: Context, + context: Context, private val defaultHandler: Thread.UncaughtExceptionHandler?, ) : Thread.UncaughtExceptionHandler { + private val context = context.applicationContext + override fun uncaughtException( thread: Thread, ex: Throwable, ) { try { + // Ensure the crash is reported before any UI logic or delays + com.google.firebase.crashlytics.FirebaseCrashlytics + .getInstance() + .recordException(ex) + object : Thread() { override fun run() { Looper.prepare() - Toast.makeText(context, R.string.app_crach_toast, Toast.LENGTH_LONG).show() + Toast.makeText(context, R.string.app_crash_toast, Toast.LENGTH_LONG).show() + + // Schedule the looper to quit so the thread doesn't block indefinitely + android.os.Handler(Looper.myLooper()!!).postDelayed( + { Looper.myLooper()?.quit() }, + TOAST_TIMEOUT + 500, + ) + Looper.loop() } }.start() diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt b/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt index 7a34e10ab..a601006c5 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt @@ -6,7 +6,7 @@ fun sha256(input: String): String { val bytes = MessageDigest .getInstance("SHA-256") - .digest(input.toByteArray()) + .digest(input.toByteArray(Charsets.UTF_8)) return bytes.joinToString("") { "%02x".format(it) } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt index be7f1168f..1f9edf74d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt @@ -15,18 +15,6 @@ val moshi: Moshi = Moshi .Builder() .add( - object : JsonAdapter.Factory { - override fun create( - type: Type, - annotations: Set, - moshi: Moshi, - ): JsonAdapter<*>? { - val adapter = moshi.nextAdapter(this, type, annotations) - Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}") - return adapter - } - }, - ).add( UUID::class.java, object : JsonAdapter() { @FromJson @@ -40,5 +28,17 @@ val moshi: Moshi = writer.value(value?.toString()) } }, + ).add( + object : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: Set, + moshi: Moshi, + ): JsonAdapter<*>? { + val adapter = moshi.nextAdapter(this, type, annotations) + Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}") + return adapter + } + }, ).addLast(KotlinJsonAdapterFactory()) .build() diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt index 650d83ab3..d4b1215d0 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt @@ -89,43 +89,50 @@ class NetworkService checkJob?.cancel() checkJob = scope.launch { - delay(500) - val isConnectedToInternet = isNetworkAvailable() - _networkStatus.emit(isConnectedToInternet) + refreshServerAvailabilitySync() + } + } - if (!isConnectedToInternet) { - _isServerAvailable.emit(false) - return@launch - } + suspend fun refreshServerAvailabilitySync(): Boolean { + delay(500) + val isConnectedToInternet = isNetworkAvailable() + _networkStatus.emit(isConnectedToInternet) - val hostUrl = preferences.getHost() - if (hostUrl.isNullOrBlank()) { - _isServerAvailable.emit(false) - return@launch - } + if (!isConnectedToInternet) { + _isServerAvailable.emit(false) + return false + } - try { - val url = java.net.URL(hostUrl) - val port = if (url.port == -1) url.defaultPort else url.port - val address = java.net.InetSocketAddress(url.host, port) + val hostUrl = preferences.getHost() + if (hostUrl.isNullOrBlank()) { + _isServerAvailable.emit(false) + return false + } - java.net.Socket().use { socket -> - socket.connect(address, 2000) - } + return try { + val url = java.net.URL(hostUrl) + val port = if (url.port == -1) url.defaultPort else url.port + val address = java.net.InetSocketAddress(url.host, port) - _isServerAvailable.emit(true) - initialRetryCount = MAX_INITIAL_RETRIES // Stop retries once connected - } catch (e: Exception) { - Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${initialRetryCount + 1})") - _isServerAvailable.emit(false) + java.net.Socket().use { socket -> + socket.connect(address, 2000) + } - if (initialRetryCount < MAX_INITIAL_RETRIES) { - initialRetryCount++ - delay(300L) - refreshServerAvailability() - } - } + _isServerAvailable.emit(true) + initialRetryCount = MAX_INITIAL_RETRIES // Stop retries once connected + true + } catch (e: Exception) { + Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${initialRetryCount + 1})") + _isServerAvailable.emit(false) + + if (initialRetryCount < MAX_INITIAL_RETRIES) { + initialRetryCount++ + delay(300L) + refreshServerAvailabilitySync() + } else { + false } + } } fun isNetworkAvailable(): Boolean { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt index e8ecebfe8..db56421c7 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt @@ -59,11 +59,15 @@ class AuthRepository refreshToken = account.refreshToken, ) - clarityTracker.setUser( - org.grakovne.lissen.common - .sha256("${account.username}@$host"), - ) - clarityTracker.trackEvent("login_success") + try { + clarityTracker.setUser( + org.grakovne.lissen.common + .sha256("${account.username}@$host"), + ) + clarityTracker.trackEvent("login_success") + } catch (e: Exception) { + Timber.e(e, "Failed to send login analytics to Clarity") + } // Trigger library fetch bookRepository diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt index 6b1af4a47..dac5fc237 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt @@ -167,7 +167,10 @@ class BookRepository return localResult.fold( onSuccess = { OperationResult.Success(it) }, - onFailure = { OperationResult.Success(PagedItems(emptyList(), pageNumber, 0)) }, + onFailure = { + Timber.e("Failed to fetch books from local cache: ${it.message}") + OperationResult.Success(PagedItems(emptyList(), pageNumber, 0)) + }, ) } @@ -178,7 +181,15 @@ class BookRepository ): OperationResult = OperationResult .Success(Unit) - .also { backgroundScope.launch { syncAllLibraries() } } + .also { + backgroundScope.launch { + try { + syncAllLibraries() + } catch (e: Exception) { + Timber.e(e, "Failed to sync all libraries") + } + } + } private suspend fun syncAllLibraries() { val librariesResult = fetchLibraries() 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 983957100..3a1b5ecfd 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 @@ -34,10 +34,12 @@ class ContentCachingService : LifecycleService() { @Inject lateinit var notificationService: ContentCachingNotificationService - private val executionStatuses = mutableMapOf() - private val executingCaching = mutableMapOf() + private val executionStatuses = java.util.concurrent.ConcurrentHashMap() + private val executingCaching = java.util.concurrent.ConcurrentHashMap() - private var lastNotificationUpdate = 0L + private val lastNotificationUpdate = + java.util.concurrent.atomic + .AtomicLong(0L) private val notificationUpdateThrottle = 500L override fun onStartCommand( @@ -105,26 +107,24 @@ class ContentCachingService : LifecycleService() { executor .run(mediaProvider.providePreferredChannel()) - .onCompletion { - if (executionStatuses.isEmpty() || !inProgress()) { - finish() - } - }.collect { progress -> + .collect { progress -> executionStatuses[item] = progress cacheProgressBus.emit(item, progress) val isTerminalState = progress.status is CacheStatus.Completed || progress.status is CacheStatus.Error || progress.status is CacheStatus.Idle + val currentTime = System.currentTimeMillis() - val shouldUpdateNotification = isTerminalState || (currentTime - lastNotificationUpdate >= notificationUpdateThrottle) + val lastUpdate = lastNotificationUpdate.get() + val shouldUpdateNotification = isTerminalState || (currentTime - lastUpdate >= notificationUpdateThrottle) - if (shouldUpdateNotification && inProgress() && hasErrors().not()) { + if (shouldUpdateNotification && (isTerminalState || (inProgress() && hasErrors().not()))) { executionStatuses .entries .map { (item, status) -> item to status } .let { notificationService.updateCachingNotification(it) } - lastNotificationUpdate = currentTime + lastNotificationUpdate.set(currentTime) } if (!inProgress()) { 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 d9144cad9..729adac13 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 @@ -47,8 +47,8 @@ object LocalCacheModule { .addMigrations(MIGRATION_14_15) .addMigrations( produceMigration15_16( - host = preferences.getHost(), - username = preferences.getUsername(), + host = preferences.getHost() ?: "", + username = preferences.getUsername() ?: "", ), ).addMigrations(MIGRATION_16_17) .addMigrations(MIGRATION_17_18) 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 a7ce938bd..b8ec25af7 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 @@ -23,6 +23,12 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton +enum class VolumeLabelType { + FULL_ARCHIVE, + VOLUME, + PART, +} + @Singleton class LocalCacheRepository @Inject @@ -183,7 +189,10 @@ class LocalCacheRepository } } - fun mapChaptersToVolumes(book: DetailedItem): List { + fun mapChaptersToVolumes( + book: DetailedItem, + nameResolver: (VolumeLabelType, Int) -> String, + ): List { val storageType = getBookStorageType(book) if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { @@ -191,7 +200,7 @@ class LocalCacheRepository return listOf( org.grakovne.lissen.lib.domain.BookVolume( id = file.id, - name = "Full Archive", + name = nameResolver(VolumeLabelType.FULL_ARCHIVE, 0), size = file.size, chapters = book.chapters, isDownloaded = storageProperties.provideMediaCachePath(book.id, file.id).exists(), @@ -206,9 +215,9 @@ class LocalCacheRepository val name = if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.SEGMENTED) { - "Volume ${index + 1}" + nameResolver(VolumeLabelType.VOLUME, index + 1) } else { - relatedChapters.firstOrNull()?.title ?: "Part ${index + 1}" + relatedChapters.firstOrNull()?.title ?: nameResolver(VolumeLabelType.PART, index + 1) } org.grakovne.lissen.lib.domain.BookVolume( 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 f87b2f918..84f04efb6 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 @@ -32,8 +32,8 @@ class SearchRequestBuilder( } } - val searchClause = "(title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)" - val pattern = "%$searchQuery%" + val searchClause = "(title LIKE ? ESCAPE '\\' OR author LIKE ? ESCAPE '\\' OR seriesNames LIKE ? ESCAPE '\\')" + val pattern = "%${searchQuery.escapeSqlLike()}%" args.add(pattern) args.add(pattern) args.add(pattern) @@ -72,4 +72,10 @@ class SearchRequestBuilder( return SimpleSQLiteQuery(sql, args.toTypedArray()) } + + private fun String.escapeSqlLike() = + this + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") } 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 08bb31726..02734f5bb 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 @@ -105,7 +105,6 @@ data class BookChapterEntity( val isCached: Boolean, ) : Serializable -@Keep @Entity( tableName = "media_progress", foreignKeys = [ diff --git a/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt index c6fa0e44c..37c57ae54 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt @@ -70,8 +70,17 @@ class UpdateCheckerService remoteTag: String, localVersion: String, ): Boolean { - val remote = remoteTag.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } - val local = localVersion.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } + val remote = + remoteTag + .removePrefix("v") + .split(".") + .map { it.takeWhile { char -> char.isDigit() }.toIntOrNull() ?: 0 } + + val local = + localVersion + .removePrefix("v") + .split(".") + .map { it.takeWhile { char -> char.isDigit() }.toIntOrNull() ?: 0 } val length = maxOf(remote.size, local.size) for (i in 0 until length) { 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 5fa796a64..16d13010f 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 @@ -31,6 +31,7 @@ import org.grakovne.lissen.lib.domain.TimerOption import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import org.grakovne.lissen.playback.MediaSessionProvider import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @UnstableApi @@ -69,7 +70,7 @@ class PlaybackService : MediaSessionService() { private var playbackJob: kotlinx.coroutines.Job? = null - private var smartRewindApplied = false + private var smartRewindApplied = AtomicBoolean(false) private val playerServiceScope = MainScope() @@ -136,7 +137,7 @@ class PlaybackService : MediaSessionService() { playbackJob = playerServiceScope .launch { - smartRewindApplied = false + smartRewindApplied.set(false) exoPlayer.playWhenReady = false } return START_NOT_STICKY @@ -190,7 +191,7 @@ class PlaybackService : MediaSessionService() { @OptIn(UnstableApi::class) private suspend fun preparePlayback(book: DetailedItem) { exoPlayer.playWhenReady = false - smartRewindApplied = false + smartRewindApplied.set(false) artworkJob?.cancel() withContext(Dispatchers.IO) { @@ -238,7 +239,7 @@ class PlaybackService : MediaSessionService() { val currentPosition = book.progress?.currentTime ?: 0.0 seek(book.files, startPosition) - smartRewindApplied = true + smartRewindApplied.set(true) } } @@ -287,7 +288,7 @@ class PlaybackService : MediaSessionService() { } private suspend fun checkAndApplySmartRewind() { - if (smartRewindApplied) { + if (smartRewindApplied.get()) { return } @@ -303,7 +304,7 @@ class PlaybackService : MediaSessionService() { } } - smartRewindApplied = true + smartRewindApplied.set(true) } private fun calculateSmartRewindPosition(book: DetailedItem): Double = 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 a0cb116a5..0d49fe99d 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext import org.grakovne.lissen.channel.common.OperationError import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.lib.domain.DetailedItem @@ -94,8 +95,11 @@ class PlaybackSynchronizationService serviceScope.launch { val elapsedMs = exoPlayer.currentPosition val overallProgress = getProgress(elapsedMs) ?: return@launch + val item = currentItem ?: return@launch + val session = playbackSession + val lastChapterIndex = currentChapterIndex - Timber.d("Trying to sync $overallProgress for ${currentItem?.id}") + Timber.d("Trying to sync $overallProgress for ${item.id}") launch(Dispatchers.IO) { if (syncMutex.tryLock().not()) { @@ -104,18 +108,19 @@ class PlaybackSynchronizationService } try { - val currentIndex = - currentItem - ?.let { calculateChapterIndex(it, overallProgress.currentTotalTime) } - ?: return@launch - - if (playbackSession == null || playbackSession?.itemId != currentItem?.id || currentIndex != currentChapterIndex) { - openPlaybackSession(overallProgress) - currentChapterIndex = currentIndex + val currentIndex = calculateChapterIndex(item, overallProgress.currentTotalTime) + + var activeSession = session + if (activeSession == null || activeSession.itemId != item.id || currentIndex != lastChapterIndex) { + activeSession = openPlaybackSession(item, overallProgress) + withContext(Dispatchers.Main) { + playbackSession = activeSession + currentChapterIndex = currentIndex + } } - mediaChannel.syncLocalProgress(currentItem?.id ?: return@launch, overallProgress) - playbackSession?.let { requestSync(it, overallProgress) } + mediaChannel.syncLocalProgress(item.id, overallProgress) + activeSession?.let { requestSync(it, item, overallProgress) } } catch (e: Exception) { Timber.e(e, "Error during sync") } finally { @@ -126,39 +131,45 @@ class PlaybackSynchronizationService } private suspend fun requestSync( - it: PlaybackSession, + session: PlaybackSession, + item: DetailedItem, overallProgress: PlaybackProgress, - ): Unit? = - mediaChannel - .syncProgress( - sessionId = it.sessionId, - itemId = it.itemId, - progress = overallProgress, - ).foldAsync( - onSuccess = {}, - onFailure = { - when (it.code) { - OperationError.NotFoundError -> openPlaybackSession(overallProgress) - else -> Unit + ) = mediaChannel + .syncProgress( + sessionId = session.sessionId, + itemId = session.itemId, + progress = overallProgress, + ).foldAsync( + onSuccess = {}, + onFailure = { + when (it.code) { + OperationError.NotFoundError -> { + val newSession = openPlaybackSession(item, overallProgress) + withContext(Dispatchers.Main) { + playbackSession = newSession + } } - }, - ) + else -> Unit + } + }, + ) - private suspend fun openPlaybackSession(overallProgress: PlaybackProgress) = - currentItem - ?.let { item -> - val chapterIndex = calculateChapterIndex(item, overallProgress.currentTotalTime) - mediaChannel - .startPlayback( - itemId = item.id, - deviceId = sharedPreferences.getDeviceId(), - supportedMimeTypes = MimeTypeProvider.getSupportedMimeTypes(), - chapterId = item.chapters[chapterIndex].id, - ).fold( - onSuccess = { playbackSession = it }, - onFailure = {}, - ) - } + private suspend fun openPlaybackSession( + item: DetailedItem, + overallProgress: PlaybackProgress, + ): PlaybackSession? { + val chapterIndex = calculateChapterIndex(item, overallProgress.currentTotalTime) + return mediaChannel + .startPlayback( + itemId = item.id, + deviceId = sharedPreferences.getDeviceId(), + supportedMimeTypes = MimeTypeProvider.getSupportedMimeTypes(), + chapterId = item.chapters[chapterIndex].id, + ).fold( + onSuccess = { it }, + onFailure = { null }, + ) + } private fun getProgress(currentElapsedMs: Long): PlaybackProgress? { val currentItem = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt index 0b6f5a006..ceea479cd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,6 +37,11 @@ fun AsyncShimmeringImage( var isMainLoading by remember(imageRequest) { mutableStateOf(true) } var isThumbnailLoaded by remember(thumbnailRequest) { mutableStateOf(false) } + LaunchedEffect(imageRequest) { + isMainLoading = true + onLoadingStateChanged(true) + } + Box( modifier = modifier, contentAlignment = Alignment.Center, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index e4d02cc6d..5130ba5bb 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -75,7 +75,7 @@ fun DownloadProgressIcon( animationSpec = tween(durationMillis = 1500, easing = EaseOutExpo), label = "shineAlpha", finishedListener = { - if (it == 0f) triggerShine = false + if (it == 1f) triggerShine = false }, ) 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 d9c0db656..c960fdf65 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 @@ -103,7 +103,7 @@ fun LibraryScreen( val activity = LocalActivity.current val recentBooks: List by libraryViewModel.recentBooks.observeAsState(emptyList()) - val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState(true) + val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState() var pullRefreshing by remember { mutableStateOf(false) } val searchRequested by libraryViewModel.searchRequested.observeAsState(false) 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 2aa0b4080..0c3b6713a 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 @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme @@ -71,7 +73,6 @@ fun MiniPlayerComposable( val view: View = LocalView.current val isPlaying: Boolean by playerViewModel.isPlaying.observeAsState(false) - val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false) val preparingBookId by playerViewModel.preparingBookId.observeAsState(null) val preparingError by playerViewModel.preparingError.observeAsState(false) @@ -268,11 +269,18 @@ fun MiniPlayerComposable( val isLoading = preparingBookId != null if (isLoading) { - androidx.compose.material3.CircularProgressIndicator( + CircularProgressIndicator( modifier = Modifier.size(32.dp), color = colorScheme.onSurface, strokeWidth = 3.dp, ) + } else if (isError) { + Icon( + imageVector = Icons.Rounded.Error, + contentDescription = "Error", + tint = colorScheme.error, + modifier = Modifier.size(32.dp), + ) } else { Icon( imageVector = if (isPlaying) AppIcons.PauseCircleNegative else AppIcons.PlayCircleNegative, diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt index 8bb23e0c6..587689c67 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt @@ -41,9 +41,7 @@ fun PlaybackButtonsComposable( val book by viewModel.book.observeAsState() val chapters = book?.chapters ?: emptyList() - val isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false) val preparingBookId by viewModel.preparingBookId.observeAsState(null) - val preparingError by viewModel.preparingError.observeAsState(false) val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) val showNavButtons by settingsViewModel.showPlayerNavButtons.observeAsState(true) @@ -98,7 +96,6 @@ fun PlaybackButtonsComposable( shadowElevation = 4.dp, ) { Box(contentAlignment = Alignment.Center) { - val isError = preparingError val isLoading = preparingBookId != null if (isLoading) { 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 562a97347..09c6b3b7f 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 @@ -758,18 +758,26 @@ private suspend fun deleteSelectedVolumes( viewModel: CachingModelView, playerViewModel: PlayerViewModel, ) { - selectedIds.forEach { selection -> - val book = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }.find { it.id == selection.bookId } - book?.let { - val volumes = viewModel.getVolumes(it) + val bookMap = + (0 until cachedItems.itemCount) + .mapNotNull { cachedItems[it] } + .associateBy { it.id } + + val selectionsByBook = selectedIds.groupBy { it.bookId } + + selectionsByBook.forEach { (bookId, selections) -> + val book = bookMap[bookId] ?: return@forEach + val playingBook = playerViewModel.book.value + val volumes = viewModel.getVolumes(book) + + if (playingBook?.id == book.id) { + playerViewModel.clearPlayingBook() + } + + selections.forEach { selection -> val volume = volumes.find { v -> v.id == selection.fileId } volume?.chapters?.forEach { chapter -> - playerViewModel.book.value?.let { playingBook -> - if (playingBook.id == it.id) { - playerViewModel.clearPlayingBook() - } - } - viewModel.dropCache(it, chapter) + viewModel.dropCache(book, chapter) } } } 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 e4e336834..2cd53b8d8 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -16,6 +16,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.grakovne.lissen.content.cache.persistent.CacheState @@ -91,7 +92,7 @@ class CachingModelView flow.value = progress if (progress.status is CacheStatus.Completed || progress.status is CacheStatus.Error) { - _cacheVersion.value += 1 + _cacheVersion.update { it + 1 } } } } @@ -120,7 +121,25 @@ class CachingModelView fun getBookStorageType(book: DetailedItem) = localCacheRepository.getBookStorageType(book) - fun getVolumes(book: DetailedItem) = localCacheRepository.mapChaptersToVolumes(book) + fun getVolumes(book: DetailedItem) = + localCacheRepository.mapChaptersToVolumes(book) { type, index -> + when (type) { + org.grakovne.lissen.content.cache.persistent.VolumeLabelType.FULL_ARCHIVE -> + context.getString( + org.grakovne.lissen.R.string.download_volume_full_archive, + ) + org.grakovne.lissen.content.cache.persistent.VolumeLabelType.VOLUME -> + context.getString( + org.grakovne.lissen.R.string.download_volume_name, + index, + ) + org.grakovne.lissen.content.cache.persistent.VolumeLabelType.PART -> + context.getString( + org.grakovne.lissen.R.string.download_volume_part, + index, + ) + } + } suspend fun clearShortTermCache() { withContext(Dispatchers.IO) { @@ -154,13 +173,13 @@ class CachingModelView suspend fun dropCache(bookId: String) { contentCachingManager.dropCache(bookId) - _cacheVersion.value += 1 + _cacheVersion.update { it + 1 } refreshStorageStats() } suspend fun dropCompletedChapters(item: DetailedItem) { contentCachingManager.dropCompletedChapters(item) - _cacheVersion.value += 1 + _cacheVersion.update { it + 1 } refreshStorageStats() } @@ -179,7 +198,7 @@ class CachingModelView chapter: PlayingChapter, ) { contentCachingManager.dropCache(item, chapter) - _cacheVersion.value += 1 + _cacheVersion.update { it + 1 } refreshStorageStats() } @@ -208,14 +227,28 @@ class CachingModelView fun refreshMetadata() { viewModelScope.launch { withContext(Dispatchers.IO) { - localCacheRepository - .fetchDetailedItems(Int.MAX_VALUE, 0) - .fold( - onSuccess = { it.items }, - onFailure = { emptyList() }, - ).forEach { localCacheRepository.cacheBookMetadata(it) } - - _cacheVersion.value += 1 + var page = 0 + var hasMore = true + + while (hasMore) { + val items = + localCacheRepository + .fetchDetailedItems(BATCH_SIZE, page) + .fold( + onSuccess = { it.items }, + onFailure = { emptyList() }, + ) + + items.forEach { localCacheRepository.cacheBookMetadata(it) } + + if (items.size < BATCH_SIZE) { + hasMore = false + } else { + page++ + } + } + + _cacheVersion.update { it + 1 } refreshStorageStats() } } @@ -225,5 +258,6 @@ class CachingModelView companion object { private const val PAGE_SIZE = 20 + private const val BATCH_SIZE = 50 } } 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 6ec2aed7c..910c65f61 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -233,11 +233,14 @@ class LibraryViewModel fun refreshLibrary(forceRefresh: Boolean = false) { viewModelScope.launch { withContext(Dispatchers.IO) { - if (forceRefresh) { - networkService.refreshServerAvailability() - } + val isAvailable = + if (forceRefresh) { + networkService.refreshServerAvailabilitySync() + } else { + networkService.isServerAvailable.value + } - val shouldSync = (forceRefresh || networkService.isServerAvailable.value) && !preferences.isForceCache() + val shouldSync = (forceRefresh || isAvailable) && !preferences.isForceCache() if (shouldSync) { val libraryId = preferences.getPreferredLibrary()?.id diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt index 109bfc20b..747a4a485 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt @@ -157,9 +157,12 @@ class PlayerViewModel fun fetchBook(bookId: String) { viewModelScope.launch { - _isFetchingDetails.postValue(true) - mediaRepository.fetchBook(bookId) - _isFetchingDetails.postValue(false) + try { + _isFetchingDetails.postValue(true) + mediaRepository.fetchBook(bookId) + } finally { + _isFetchingDetails.postValue(false) + } } } 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 c4ab8f4e0..edc01eaed 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -309,14 +309,19 @@ class SettingsViewModel fun clearMetadataCache(onComplete: () -> Unit) { viewModelScope.launch { try { - bookRepository.clearMetadataCache() - cachedCoverProvider.clearCache() - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) { - throw e + try { + bookRepository.clearMetadataCache() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + timber.log.Timber.e(e, "Failed to clear book metadata cache") } - timber.log.Timber.e(e, "Failed to clear metadata cache") + try { + cachedCoverProvider.clearCache() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + timber.log.Timber.e(e, "Failed to clear cover cache") + } } finally { onComplete() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10fe5f6d5..81c6d868f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,7 @@ No saved books yet Audiobookshelf server is required No internet connection - Sorry for the application crash\nLogs have been sent for analysis + Sorry for the application crash\nLogs have been sent for analysis Playback speed System Light @@ -263,6 +263,7 @@ Volume %1$d Full Archive + Part %1$d Chapters %1$s Free up %1$s? diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt index ba1167aa8..4d57d68d6 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt @@ -42,18 +42,29 @@ fun String?.makeDownloadOption(): DownloadOption? = when { this == "all_items" -> AllItemsDownloadOption this == "current_item" -> CurrentItemDownloadOption this == "remaining_items" -> RemainingItemsDownloadOption - startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt()) + startsWith("number_items_") -> { + try { + NumberItemDownloadOption(substringAfter("number_items_").toInt()) + } catch (e: NumberFormatException) { + null + } + } + startsWith("specific_files_") -> { - val payload = substringAfter("specific_files_") + try { + val payload = substringAfter("specific_files_") - val fileIds = when { - payload.isEmpty() -> emptyList() - else -> payload - .split(",") - .map { String(Base64.getUrlDecoder().decode(it)) } - } + val fileIds = when { + payload.isEmpty() -> emptyList() + else -> payload + .split(",") + .map { String(Base64.getUrlDecoder().decode(it)) } + } - SpecificFilesDownloadOption(fileIds) + SpecificFilesDownloadOption(fileIds) + } catch (e: IllegalArgumentException) { + null + } } else -> null From 382da191ea8662e1bcfd3ee97cc6cccf833ad9b3 Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sun, 8 Feb 2026 01:24:32 +0530 Subject: [PATCH 36/37] fix: correct typo from "app_crach_toast" to "app_crash_toast" in multiple language string resources --- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fi/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-hr/strings.xml | 2 +- app/src/main/res/values-hu/strings.xml | 2 +- app/src/main/res/values-in/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ka/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sl/strings.xml | 2 +- app/src/main/res/values-sv/strings.xml | 2 +- app/src/main/res/values-ta/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3e8516089..696ad2664 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -102,7 +102,7 @@ لم يتم حفظ أي كتب بعد خادم Audiobookshelf مطلوب لا يوجد اتصال بالإنترنت - عذراً على تعطل التطبيق\nتم إرسال السجلات للتحليل + عذراً على تعطل التطبيق\nتم إرسال السجلات للتحليل البحث حسب العنوان، المؤلف، أو السلسلة يتم الاستماع الآن رؤوس مخصصة diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index c62e90e18..d6c7ea8fc 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -37,7 +37,7 @@ Ingen hentede bøger Audiobookshelf-server er påkrævet Ingen internetforbindelse - Beklager appnedbruddet\nLogfiler er sendt til analyse + Beklager appnedbruddet\nLogfiler er sendt til analyse Afspilningshastighed System Lys diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 639f06f74..92927fac5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -24,7 +24,7 @@ Die Bibliothek ist leer Audiobookshelf Server ist notwendig Keine Internetverbindung - Entschuldigung für den Absturz der Anwendung\nDie Protokolle wurden zur Analyse gesendet + Entschuldigung für den Absturz der Anwendung\nDie Protokolle wurden zur Analyse gesendet Farbschema Suche nach Titel, Autor oder Serie Aktuelle Wiedergabe diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ee3944ac9..61148c06a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -26,7 +26,7 @@ Error de conexión La biblioteca esta vacía Aún no hay libros guardados - Lamentamos el fallo de la aplicación\nLos registros se han enviado para su análisis + Lamentamos el fallo de la aplicación\nLos registros se han enviado para su análisis Seguir escuchando Capítulo %1$d de %2$s Artículo %1$d de %2$s diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index b3ef731fd..85902b209 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -87,7 +87,7 @@ Mukautetut otsakkeet Lataa kirja Koko podcast - Pahoittelut sovelluksen kaatumisesta.\nVirheraportti on lähetetty tutkittavaksi. + Pahoittelut sovelluksen kaatumisesta.\nVirheraportti on lähetetty tutkittavaksi. Mukautetut otsakkeet Poista ajastin käytöstä Paina avataksesi sovelluksen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9e3d50bde..5a9ed7a8f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -33,7 +33,7 @@ Aucun livre enregistré pour l\'instant Un serveur Audiobookshelf est requis Pas de connexion internet - Désolé pour le crash de l\'application\nLes journaux ont été transmis pour analyse + Désolé pour le crash de l\'application\nLes journaux ont été transmis pour analyse Vitesse de lecture Système Clair diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index ef0fdbecb..063f8acce 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -34,7 +34,7 @@ Nema preuzetih knjiga Potreban je Audiobookshelf poslužitelj Nema internetske veze - Ispričavamo se zbog rušenja aplikacije\nDnevnici su poslani na analizu + Ispričavamo se zbog rušenja aplikacije\nDnevnici su poslani na analizu Brzina reprodukcije Sustav Svijetla diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 83e2b9fad..d29986bf2 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -49,7 +49,7 @@ Kiszolgáló verziója Amikor a rész véget ér Amikor a fejezet véget ér - Elnézést kérünk az alkalmazás összeomlásáért\nA naplókat elküldtük kivizsgálásra + Elnézést kérünk az alkalmazás összeomlásáért\nA naplókat elküldtük kivizsgálásra %1$d perc Időzítő kikapcsolása Elalvás időzítő diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9df87babd..efce3fa2d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -38,7 +38,7 @@ Belum ada buku tersimpan Server Audiobookshelf diperlukan Tidak tersambung ke internet - Kerusakan aplikasi tercatat\nLog telah dikirim untuk dianalisis + Kerusakan aplikasi tercatat\nLog telah dikirim untuk dianalisis Kecepatan pemutaran Sistem Terang diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5fefe8cf0..b6feaed1e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -30,7 +30,7 @@ La libreria è vuota Nessun libro ancora salvato È necessario il server Audiobookshelf - Ci scusiamo per il blocco dell\'applicazione\nI log sono stati inviati per l\'analisi + Ci scusiamo per il blocco dell\'applicazione\nI log sono stati inviati per l\'analisi Velocità di riproduzione Sistema Chiaro diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 28e8d918c..08856fdc8 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -33,7 +33,7 @@ შენახული წიგნები ჯერ-ჯერობით არ არის საჭიროა Audiobookshelf სერვერი ინტერნეტთან კავშირი არ არის - ბოდიშს გიხდით აპლიკაციის ხარვეზისთვის\nჟურნალები ანალიზისთვის გაგზავნილია დამმუშავებელთან + ბოდიშს გიხდით აპლიკაციის ხარვეზისთვის\nჟურნალები ანალიზისთვის გაგზავნილია დამმუშავებელთან მოსმენის სიჩქარე სისტემის მიხედვით ღია diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b1d42fe51..cdde4d17b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -30,7 +30,7 @@ 저장된 책이 없습니다 Audiobookshelf 서버가 필요합니다 인터넷 연결이 없습니다 - 애플리케이션 오류가 발생했습니다\n관련 로그가 전달되었습니다 + 애플리케이션 오류가 발생했습니다\n관련 로그가 전달되었습니다 재생 속도 시스템 라이트 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7073c44af..b3a42e8f7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -28,7 +28,7 @@ Gebruikersnaam ontbreekt Voorkeuren Geen internetverbinding - Excuses voor de crash van de applicatie\nLogboeken zijn verzonden voor analyse + Excuses voor de crash van de applicatie\nLogboeken zijn verzonden voor analyse Hele boek Timer Alle inhoud diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 87816738e..d3f80c4f7 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -38,7 +38,7 @@ Brak pobranych książek Serwer Audiobookshelf jest wymagany Brak połączenia internetowego - Przykro nam ale aplikacja zamknęła się z powodu błędu\nLogi zostały wysłane do dalszej analizy + Przykro nam ale aplikacja zamknęła się z powodu błędu\nLogi zostały wysłane do dalszej analizy Prędkość odtwarzania System Jasny diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3b733a79d..da4b86df2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -31,7 +31,7 @@ Загруженных книг нет Для работы приложения необходим сервер Audiobookshelf Нет подключения к интернету - Извините за ошибку \nДанные уже отправлены для анализа + Извините за ошибку \nДанные уже отправлены для анализа Скорость воспроизведения Системная Светлая diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index f93b526bd..72693a313 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -33,7 +33,7 @@ Ni še shranjenih knjig Potreben je strežnik Audiobookshelf Ni internetne povezave - Se opravičujemo za zrušitev aplikacije\nDnevniki so bili poslani v analizo + Se opravičujemo za zrušitev aplikacije\nDnevniki so bili poslani v analizo Hitrost predvajanja Sistem Svetlo diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 26c9d4690..5977efa37 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -28,7 +28,7 @@ Innehåll %1$d av %2$s Lösenord saknas Ingen internetanslutning - Ursäkta oss att applikationen kraschade\nLoggar har skickats för analys + Ursäkta oss att applikationen kraschade\nLoggar har skickats för analys Ljus Mörk Färgschema diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 2261633d2..54e54c795 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -34,7 +34,7 @@ சேமித்த புத்தகங்கள் இதுவரை இல்லை ஆடியோபுக்செல்ஃப் சேவையகம் தேவை இணைய இணைப்பு இல்லை - விண்ணப்ப விபத்துக்கு மன்னிக்கவும்\n பதிவுகள் பகுப்பாய்விற்கு அனுப்பப்பட்டுள்ளன + விண்ணப்ப விபத்துக்கு மன்னிக்கவும்\n பதிவுகள் பகுப்பாய்விற்கு அனுப்பப்பட்டுள்ளன பின்னணி விரைவு மண்டலம் ஒளி diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1ea7937ea..1909bafb0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -34,7 +34,7 @@ Henüz kaydedilmiş kitap yok Audiobookshelf sunucusu gerekli İnternet bağlantısı yok - Uygulama çökmesi için özür dileriz\nGünlükler analiz için gönderildi + Uygulama çökmesi için özür dileriz\nGünlükler analiz için gönderildi Oynatma hızı Sistem Açık diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cf9af6d3a..cfec2aaae 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -56,7 +56,7 @@ 查看已下载内容 空媒体库 需要 Audiobookshelf 服务器 - 应用意外退出,我们深表歉意\n日志已发送,用于问题分析 + 应用意外退出,我们深表歉意\n日志已发送,用于问题分析 浅色 深色 颜色主题 From 49ec6f3aa1e9b73986fecdd83941c6910c9a2ebf Mon Sep 17 00:00:00 2001 From: Surjit Kumar Sahoo Date: Sun, 8 Feb 2026 01:25:50 +0530 Subject: [PATCH 37/37] chore: bump app version to 1.1.0 and update version code --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index a07bc6f99..8ae26beb5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,5 +3,5 @@ android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true -appVersionName=1.0.0 -appVersionCode=10000 +appVersionName=1.1.0 +appVersionCode=10100