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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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