diff --git a/.agent/rules/project-context.md b/.agent/rules/project-context.md index 5b1caf9db..beb609a78 100644 --- a/.agent/rules/project-context.md +++ b/.agent/rules/project-context.md @@ -18,6 +18,11 @@ This is an offline-first Android client app for the [audiobookshelf](https://git - If the offline track is deleted/cleared while the book is being played, the player should attempt to fallback to the online URL if the server is reachable, otherwise pause playback and persist the last playback state (track ID, playback position/timestamp, current chapter/index, and playback status) plus a flag indicating offline content was removed; ensure the rule notes that these persisted fields are used to resume or report playback state and are written atomically to the player state store. - The app must be fully functional for downloaded content when offline. +## Future Plans (Not to be implemented now) +- The app should support multi-server / multi-user connections. A user should be able to connect to a server, download a few books for offline listening, then disconnect and connect to a new server and download few more books, and listen to all the books offline, and then connecting back to the previous server should push (sync) progress updates from the books which were downloaded earlier for offline listening. +- The app should also support uploading books that were downloaded from a server to another server (sharing) +- The app should support importing audiobooks from the phone storage directly without any server connection. Optionally, the user should be able to upload these audiobooks to the server along with the progress. + ## Ensure Stability - Ensure null-safety when converting data (e.g., check for division by zero in percentage calculations). @@ -40,4 +45,4 @@ This is an offline-first Android client app for the [audiobookshelf](https://git - **ABSOLUTELY NO** hardcoded user-facing strings in UI code. All strings must be extracted to `strings.xml` and accessed via `stringResource`. - Use `associateBy` or proper indexing for collection lookups (O(1)) instead of nested loops (O(N^2)) when synchronizing data. - Avoid expensive operations on the main thread. -- No code duplication, keep the code clean and easy to maintain. +- No code duplication, keep the code clean and easy to maintain. \ No newline at end of file diff --git a/.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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00211b7ea..d2bfe229c 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 + uses: softprops/action-gh-release@v2 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: diff --git a/.gitignore b/.gitignore index 51ec856e5..7f8bd3910 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,6 @@ render.experimental.xml *.jks *.keystore -# Google Services (e.g. APIs or Firebase) -google-services.json - # Android Profiling *.hprof diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7cbac8275..8fc8607d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,8 @@ plugins { id("com.google.dagger.hilt.android") id("org.jmailen.kotlinter") version "5.2.0" id("com.google.devtools.ksp") + alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) } kotlinter { @@ -60,11 +62,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - val acraReportLogin = "8yJ59n0UToCja8LR" - val acraReportPassword = "kuW9TV7BbJByuIAc" - - buildConfigField("String", "ACRA_REPORT_LOGIN", "\"$acraReportLogin\"") - buildConfigField("String", "ACRA_REPORT_PASSWORD", "\"$acraReportPassword\"") + buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"") signingConfigs { create("release") { @@ -183,10 +181,6 @@ dependencies { implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) - implementation(libs.acra.core) - implementation(libs.acra.http) - implementation(libs.acra.toast) - implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) @@ -194,6 +188,12 @@ dependencies { implementation(libs.moshi) implementation(libs.moshi.kotlin) + implementation(libs.microsoft.clarity) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) + implementation(libs.firebase.analytics) + debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..35baeb16b --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "139954826172", + "project_id": "kahani-app-android", + "storage_bucket": "kahani-app-android.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:139954826172:android:0f4f76f1de010ba41699b9", + "android_client_info": { + "package_name": "com.kahani.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAB8o-65tB6TzUbdaqVAP9XnTl1wgC6dks" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:139954826172:android:1d7d97392c6e248f1699b9", + "android_client_info": { + "package_name": "com.kahani.app.debug" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAB8o-65tB6TzUbdaqVAP9XnTl1wgC6dks" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/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/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/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..c63234937 --- /dev/null +++ b/app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/17.json @@ -0,0 +1,387 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "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, `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, `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, `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, '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/LissenApplication.kt b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt index d7ff73962..149c8c266 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt @@ -3,13 +3,6 @@ package org.grakovne.lissen import android.app.Application import android.content.Context import dagger.hilt.android.HiltAndroidApp -import org.acra.ReportField -import org.acra.config.httpSender -import org.acra.config.toast -import org.acra.data.StringFormat -import org.acra.ktx.initAcra -import org.acra.security.TLS -import org.acra.sender.HttpSender import org.grakovne.lissen.common.RunningComponent import timber.log.Timber import javax.inject.Inject @@ -17,62 +10,50 @@ import javax.inject.Inject @HiltAndroidApp class LissenApplication : Application() { @Inject - lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> + lateinit var preferences: org.grakovne.lissen.persistence.preferences.LissenSharedPreferences - override fun attachBaseContext(base: Context) { - super.attachBaseContext(base) - initCrashReporting() - } + @Inject + lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> override fun onCreate() { super.onCreate() appContext = applicationContext - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) + // Initialize core services first + try { + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + + val isCrashReportingEnabled = preferences.getCrashReportingEnabled() + com.google.firebase.crashlytics.FirebaseCrashlytics + .getInstance() + .setCrashlyticsCollectionEnabled(isCrashReportingEnabled) + + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler( + org.grakovne.lissen.common + .CrashHandler(this, defaultHandler), + ) + } catch (e: Exception) { + // Fallback logging if core services fail + android.util.Log.e("LissenApplication", "Failed to initialize core services", e) } + // Initialize components with individual error handling runningComponents.forEach { try { it.onCreate() } catch (ex: Exception) { - Timber.e("Unable to register Running component due to: ${ex.message}") + Timber.e(ex, "Unable to register Running component: ${ex.message}") + com.google.firebase.crashlytics.FirebaseCrashlytics + .getInstance() + .recordException(ex) } } } - private fun initCrashReporting() { - initAcra { - sharedPreferencesName = "secure_prefs" - - buildConfigClass = BuildConfig::class.java - reportFormat = StringFormat.JSON - - httpSender { - uri = "https://acrarium.grakovne.org/report" - basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN - basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD - httpMethod = HttpSender.Method.POST - dropReportsOnTimeout = false - tlsProtocols = listOf(TLS.V1_3, TLS.V1_2) - } - - toast { - text = getString(R.string.app_crach_toast) - } - - reportContent = - listOf( - ReportField.APP_VERSION_NAME, - ReportField.APP_VERSION_CODE, - ReportField.ANDROID_VERSION, - ReportField.PHONE_MODEL, - ReportField.STACK_TRACE, - ReportField.ENVIRONMENT, - ) - } - } - companion object { lateinit var appContext: Context private set diff --git a/app/src/main/kotlin/org/grakovne/lissen/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..309de8b1c --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt @@ -0,0 +1,99 @@ +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() { + if (BuildConfig.DEBUG) { + Timber.d("Skip Microsoft Clarity initialization for debug build") + return + } + + (context as? Application)?.registerActivityLifecycleCallbacks(this) + } + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) { + // Initialize Clarity only once with the first activity + 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() + reidentifyUser() + } + + fun updateConsent(accepted: Boolean) { + if (!isClarityInitialized) return + 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) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) {} + + override fun onActivityDestroyed(activity: Activity) {} + + private var isClarityInitialized = 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 rawIdentifier = "$username@$host" + val hashedIdentifier = hashIdentifier(rawIdentifier) + + clarityTracker.setUser(hashedIdentifier) + } else { + // Fallback to device ID if not logged in + clarityTracker.setUser(preferences.getDeviceId()) + } + } + + private fun hashIdentifier(input: String): String { + val digest = java.security.MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { "%02x".format(it) } + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt new file mode 100644 index 000000000..1bde75279 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt @@ -0,0 +1,55 @@ +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 + +@Singleton +class ClarityTracker + @Inject + constructor() { + fun setUser(userId: String) { + if (BuildConfig.DEBUG) return + + try { + Clarity.setCustomUserId(userId) + Timber.d("Clarity User ID set: ${maskId(userId)}") + } catch (e: Exception) { + 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") + } catch (e: Exception) { + Timber.e(e, "Failed to track Clarity event: $eventName") + } + } + + fun trackEvent( + eventName: String, + value: String, + ) { + if (BuildConfig.DEBUG) return + + try { + // Clarity sendCustomEvent takes a string value for the event name/payload + Clarity.sendCustomEvent("$eventName: [REDACTED]") + Timber.d("Clarity Event tracked: $eventName with value: [REDACTED]") + } catch (e: Exception) { + Timber.e(e, "Failed to track Clarity event: $eventName with value: [REDACTED]") + } + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt index b16a4bd25..9f4f05d05 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt @@ -85,6 +85,18 @@ class AudioBookshelfRepository ) } + suspend fun fetchLibraryMinified(libraryId: String): OperationResult = + audioBookShelfApiService.makeRequest { + it.fetchLibraryItems( + libraryId = libraryId, + pageSize = 10000, + pageNumber = 0, + sort = "addedAt", + desc = "1", + minified = "1", + ) + } + suspend fun fetchPodcastItems( libraryId: String, pageSize: Int, @@ -103,6 +115,19 @@ class AudioBookshelfRepository ) } + suspend fun fetchPodcastMinified(libraryId: String): OperationResult = + audioBookShelfApiService + .makeRequest { + it.fetchPodcastItems( + libraryId = libraryId, + pageSize = 10000, + pageNumber = 0, + sort = "addedAt", + desc = "1", + minified = "1", + ) + } + suspend fun fetchBook(itemId: String): OperationResult = audioBookShelfApiService.makeRequest { it.fetchLibraryItem( diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt index 9d085f80c..864b7ce39 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt @@ -27,6 +27,8 @@ class LibraryPageResponseConverter author = it.media.metadata.authorName, duration = 0.0, libraryId = libraryId, + addedAt = it.addedAt ?: 0L, + updatedAt = it.updatedAt ?: 0L, ) }.let { PagedItems( diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt index 462c885ff..b67fb2bf4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt @@ -74,6 +74,11 @@ class LibraryAudiobookshelfChannel ).map { libraryPageResponseConverter.apply(it, libraryId) } } + override suspend fun fetchLibraryMinified(libraryId: String): OperationResult> = + dataRepository + .fetchLibraryMinified(libraryId) + .map { libraryPageResponseConverter.apply(it, libraryId).items } + override suspend fun searchBooks( libraryId: String, query: String, diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt index 82041671a..650645cae 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt @@ -84,6 +84,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/library/model/LibraryItemsResponse.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt index 25fea5ba3..7fef1f761 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt @@ -15,6 +15,8 @@ data class LibraryItemsResponse( @JsonClass(generateAdapter = true) data class LibraryItem( val id: String, + val addedAt: Long?, + val updatedAt: Long?, val media: Media, ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt index 65b19f45e..7936d2ab2 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt @@ -71,6 +71,11 @@ class PodcastAudiobookshelfChannel ).map { podcastPageResponseConverter.apply(it, libraryId) } } + override suspend fun fetchLibraryMinified(libraryId: String): OperationResult> = + dataRepository + .fetchPodcastMinified(libraryId) + .map { podcastPageResponseConverter.apply(it, libraryId).items } + override suspend fun searchBooks( libraryId: String, query: String, diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/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/channel/common/MediaChannel.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/common/MediaChannel.kt index bf6556fb3..4cf86d52f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/common/MediaChannel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/common/MediaChannel.kt @@ -57,5 +57,7 @@ interface MediaChannel { suspend fun fetchRecentListenedBooks(libraryId: String): OperationResult> + suspend fun fetchLibraryMinified(libraryId: String): OperationResult> + suspend fun fetchBook(bookId: String): OperationResult } diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt new file mode 100644 index 000000000..904206e4c --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt @@ -0,0 +1,50 @@ +package org.grakovne.lissen.common + +import android.content.Context +import android.os.Looper +import android.widget.Toast +import org.grakovne.lissen.R + +class CrashHandler( + context: Context, + private val defaultHandler: Thread.UncaughtExceptionHandler?, +) : Thread.UncaughtExceptionHandler { + private val context = context.applicationContext + + override fun uncaughtException( + thread: Thread, + ex: Throwable, + ) { + try { + // Ensure the crash is reported before any UI logic or delays + com.google.firebase.crashlytics.FirebaseCrashlytics + .getInstance() + .recordException(ex) + + object : Thread() { + override fun run() { + Looper.prepare() + Toast.makeText(context, R.string.app_crash_toast, Toast.LENGTH_LONG).show() + + // Schedule the looper to quit so the thread doesn't block indefinitely + android.os.Handler(Looper.myLooper()!!).postDelayed( + { Looper.myLooper()?.quit() }, + TOAST_TIMEOUT + 500, + ) + + Looper.loop() + } + }.start() + + Thread.sleep(TOAST_TIMEOUT) + } catch (e: Exception) { + // Ignore errors in the crash handler itself to avoid infinite loops + } finally { + defaultHandler?.uncaughtException(thread, ex) + } + } + + companion object { + private const val TOAST_TIMEOUT = 1000L + } +} 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..a601006c5 --- /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(Charsets.UTF_8)) + + return bytes.joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt index e4d7c66c5..1f9edf74d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt @@ -7,6 +7,8 @@ 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 = @@ -26,4 +28,17 @@ val moshi: Moshi = writer.value(value?.toString()) } }, - ).build() + ).add( + object : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: Set, + moshi: Moshi, + ): JsonAdapter<*>? { + val adapter = moshi.nextAdapter(this, type, annotations) + Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}") + return adapter + } + }, + ).addLast(KotlinJsonAdapterFactory()) + .build() diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt index c56cc90d5..d4b1215d0 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt @@ -54,70 +54,85 @@ 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 var initialRetryCount = 0 - private fun checkServerAvailability() { + fun refreshServerAvailability() { checkJob?.cancel() checkJob = scope.launch { - delay(500) - val isConnectedToInternet = isNetworkAvailable() - _networkStatus.emit(isConnectedToInternet) + refreshServerAvailabilitySync() + } + } - if (!isConnectedToInternet) { - _isServerAvailable.emit(false) - return@launch - } + suspend fun refreshServerAvailabilitySync(): Boolean { + delay(500) + val isConnectedToInternet = isNetworkAvailable() + _networkStatus.emit(isConnectedToInternet) - val hostUrl = preferences.getHost() - if (hostUrl.isNullOrBlank()) { - _isServerAvailable.emit(false) - return@launch - } + if (!isConnectedToInternet) { + _isServerAvailable.emit(false) + return false + } - try { - val url = java.net.URL(hostUrl) - val port = if (url.port == -1) url.defaultPort else url.port - val address = java.net.InetSocketAddress(url.host, port) + val hostUrl = preferences.getHost() + if (hostUrl.isNullOrBlank()) { + _isServerAvailable.emit(false) + return false + } - java.net.Socket().use { socket -> - socket.connect(address, 2000) - } + return try { + val url = java.net.URL(hostUrl) + val port = if (url.port == -1) url.defaultPort else url.port + val address = java.net.InetSocketAddress(url.host, port) - _isServerAvailable.emit(true) - } catch (e: Exception) { - Timber.e(e, "Server reachability check failed for $hostUrl") - _isServerAvailable.emit(false) - } + java.net.Socket().use { socket -> + socket.connect(address, 2000) + } + + _isServerAvailable.emit(true) + initialRetryCount = MAX_INITIAL_RETRIES // Stop retries once connected + true + } catch (e: Exception) { + Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${initialRetryCount + 1})") + _isServerAvailable.emit(false) + + if (initialRetryCount < MAX_INITIAL_RETRIES) { + initialRetryCount++ + delay(300L) + refreshServerAvailabilitySync() + } else { + false } + } } fun isNetworkAvailable(): Boolean { @@ -168,4 +183,8 @@ class NetworkService override fun onDestroy() { scope.cancel() } + + private companion object { + private const val MAX_INITIAL_RETRIES = 3 + } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt index a3d7359cc..db56421c7 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,16 @@ class AuthRepository refreshToken = account.refreshToken, ) + try { + clarityTracker.setUser( + org.grakovne.lissen.common + .sha256("${account.username}@$host"), + ) + clarityTracker.trackEvent("login_success") + } catch (e: Exception) { + Timber.e(e, "Failed to send login analytics to Clarity") + } + // Trigger library fetch bookRepository .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 89146ce1a..dac5fc237 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,18 @@ 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 import org.grakovne.lissen.channel.common.OperationError @@ -32,7 +42,10 @@ class BookRepository private val localCacheRepository: LocalCacheRepository, private val cachedCoverProvider: CachedCoverProvider, private val networkService: NetworkService, + private val clarityTracker: ClarityTracker, ) { + private val backgroundScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + fun provideFileUri( libraryItemId: String, chapterId: String, @@ -71,6 +84,7 @@ class BookRepository Timber.d("Syncing Progress for $itemId. $progress") localCacheRepository.syncProgress(itemId, progress) + clarityTracker.trackEvent("sync_progress") val channelSyncResult = providePreferredChannel() @@ -127,6 +141,10 @@ class BookRepository ) } + suspend fun fetchLatestUpdate(libraryId: String) = localCacheRepository.fetchLatestUpdate(libraryId) + + suspend fun clearMetadataCache() = localCacheRepository.clearMetadataCache() + suspend fun fetchBooks( libraryId: String, pageSize: Int, @@ -147,28 +165,13 @@ class BookRepository return localResult } - val localItems = - localResult.fold( - onSuccess = { it.items }, - onFailure = { emptyList() }, - ) - - if (localItems.isEmpty() || localItems.size < pageSize) { - Timber.d("Local cache miss (or partial) for page $pageNumber. Fetching from remote.") - return providePreferredChannel() - .fetchBooks( - libraryId = libraryId, - pageSize = pageSize, - pageNumber = pageNumber, - ).also { - it.foldAsync( - onSuccess = { result -> localCacheRepository.cacheBooks(result.items) }, - onFailure = {}, - ) - } - } - - return localResult + return localResult.fold( + onSuccess = { OperationResult.Success(it) }, + onFailure = { + Timber.e("Failed to fetch books from local cache: ${it.message}") + OperationResult.Success(PagedItems(emptyList(), pageNumber, 0)) + }, + ) } suspend fun syncLibraryPage( @@ -176,9 +179,96 @@ class BookRepository pageSize: Int, pageNumber: Int, ): OperationResult = - providePreferredChannel() - .fetchBooks(libraryId, pageSize, pageNumber) - .map { localCacheRepository.cacheBooks(it.items) } + OperationResult + .Success(Unit) + .also { + backgroundScope.launch { + try { + syncAllLibraries() + } catch (e: Exception) { + Timber.e(e, "Failed to sync all libraries") + } + } + } + + private suspend fun syncAllLibraries() { + val librariesResult = fetchLibraries() + val libraries = + when (librariesResult) { + is OperationResult.Success -> librariesResult.data + is OperationResult.Error -> return + } + + libraries.forEach { library -> + syncFullLibrary(library.id) + } + } + + private suspend fun syncFullLibrary(libraryId: String) { + val remoteItemsResult = providePreferredChannel().fetchLibraryMinified(libraryId) + + val remoteItems = + when (remoteItemsResult) { + is OperationResult.Success -> remoteItemsResult.data + is OperationResult.Error -> return + } + + // Fast Sync: Immediately cache basic metadata (Title, Author, ID) + // This makes new books visible and searchable instantly. + // Existing details (Chapters, Duration) are preserved by the repository logic. + localCacheRepository.cacheBooks(remoteItems) + + val knownBooks = + localCacheRepository + .fetchBooks(libraryId, Int.MAX_VALUE, 0, false) + .fold( + onSuccess = { it.items }, + onFailure = { emptyList() }, + ).associateBy { it.id } + + val newOrUpdatedBooks = + remoteItems.filter { remote -> + val local = knownBooks[remote.id] + + // If local is null (shouldn't be, since we just cached), or remote is newer + // Note: addedAt/updatedAt are now available in Book + local == null || (remote.updatedAt > local.updatedAt) + } + + if (newOrUpdatedBooks.isEmpty()) { + Timber.d("Local library is up to date. Sync finished.") + return + } + + Timber.d("Found ${newOrUpdatedBooks.size} new or updated books. Syncing details...") + + newOrUpdatedBooks + .chunked(20) + .forEach { chunk -> + withContext(Dispatchers.IO) { + chunk + .map { book -> + async { + providePreferredChannel() + .fetchBook(book.id) + .foldAsync( + onSuccess = { + localCacheRepository.cacheBookMetadata(it) + it + }, + onFailure = { null }, + ) + } + }.awaitAll() + .filterNotNull() + .let { items -> + backgroundScope.launch { + prefetchCovers(items.map { item -> item.toBook() }) + } + } + } + } + } suspend fun fetchLibraries(): OperationResult> { Timber.d("Fetching List of libraries") @@ -300,8 +390,11 @@ class BookRepository ) } - suspend fun syncRepositories() { - val libraryId = preferences.getPreferredLibrary()?.id ?: return + suspend fun syncRepositories(overrideLibraryId: String? = null) { + val libraryId = overrideLibraryId ?: preferences.getPreferredLibrary()?.id ?: return + + syncFullLibrary(libraryId) + val remoteRecents = providePreferredChannel().fetchRecentListenedBooks(libraryId).getOrNull() ?: emptyList() val localRecents = localCacheRepository @@ -324,7 +417,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 = {}, ) } @@ -355,6 +451,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/content/LissenMediaProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt index 0a1670f94..0289f5c30 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) { @@ -289,7 +289,7 @@ class LissenMediaProvider username: String, password: String, ): OperationResult { - Timber.d("Authorizing for $username@$host") + Timber.d("Authorizing for ***@$host") return provideAuthService().authorize(host, username, password) { onPostLogin(host, it) } } @@ -298,7 +298,7 @@ class LissenMediaProvider onSuccess: () -> Unit, onFailure: (OperationError) -> Unit, ) { - Timber.d("Starting OAuth for $host") + Timber.d("Starting OAuth") return provideAuthService() .startOAuth( diff --git a/app/src/main/kotlin/org/grakovne/lissen/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/ContentCachingManager.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt index c98d7e848..2c992dbd9 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt @@ -1,15 +1,20 @@ package org.grakovne.lissen.content.cache.persistent import android.content.Context +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flow +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 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 @@ -36,64 +41,101 @@ class ContentCachingManager private val properties: OfflineBookStorageProperties, private val requestHeadersProvider: RequestHeadersProvider, private val preferences: LissenSharedPreferences, + private val clarityTracker: ClarityTracker, + private val localCacheRepository: LocalCacheRepository, ) { fun cacheMediaItem( mediaItem: DetailedItem, 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)) + clarityTracker.trackEvent("download_started") + + 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 } -> { + localCacheRepository.cacheBookMetadata(mediaItem) + send(CacheState(CacheStatus.Completed)) + clarityTracker.trackEvent("download_finished") + } + + else -> { + cachingChapters.forEach { 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 } } @@ -108,7 +150,26 @@ class ContentCachingManager droppedChapters = listOf(chapter), ) + val stillCachedChapters = + bookRepository + .fetchBook(item.id) + ?.chapters + ?.filter { it.available } + ?: emptyList() + + if (stillCachedChapters.isEmpty()) { + dropCache(item.id) + return + } + + 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) @@ -152,8 +213,6 @@ class ContentCachingManager chapterId: String, ) = bookRepository.provideCacheState(mediaItemId, chapterId) - fun hasDownloadedChapters(mediaItemId: String) = bookRepository.hasDownloadedChapters(mediaItemId) - private suspend fun cacheBookMedia( bookId: String, files: List, @@ -168,7 +227,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) } @@ -188,14 +247,32 @@ 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 = 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) { + onProgress(overallProgress) + lastReportedProgress = overallProgress + } + } } } } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return@withContext CacheState(CacheStatus.Error) } - - onProgress(files.size.takeIf { it != 0 }?.let { index / it.toDouble() } ?: 0.0) } CacheState(CacheStatus.Completed) @@ -223,6 +300,7 @@ class ContentCachingManager .withBlur(context, width = 300) // Trigger thumbnail transformation .writeToFile(thumbFile) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return@fold CacheState(CacheStatus.Error) } }, @@ -234,14 +312,6 @@ class ContentCachingManager } } - private suspend fun cacheBookInfo( - book: DetailedItem, - fetchedChapters: List, - ): CacheState = - bookRepository - .cacheBook(book, fetchedChapters, emptyList()) - .let { CacheState(CacheStatus.Completed) } - private suspend fun cacheLibraries(channel: MediaChannel): CacheState = channel .fetchLibraries() 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 a34ab2c8d..3a1b5ecfd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt @@ -34,8 +34,13 @@ class ContentCachingService : LifecycleService() { @Inject lateinit var notificationService: ContentCachingNotificationService - private val executionStatuses = mutableMapOf() - private val executingCaching = mutableMapOf() + private val executionStatuses = java.util.concurrent.ConcurrentHashMap() + private val executingCaching = java.util.concurrent.ConcurrentHashMap() + + private val lastNotificationUpdate = + java.util.concurrent.atomic + .AtomicLong(0L) + private val notificationUpdateThrottle = 500L override fun onStartCommand( intent: Intent?, @@ -102,24 +107,28 @@ class ContentCachingService : LifecycleService() { executor .run(mediaProvider.providePreferredChannel()) - .onCompletion { - if (executionStatuses.isEmpty()) { - finish() - } - }.collect { progress -> + .collect { progress -> 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 lastUpdate = lastNotificationUpdate.get() + val shouldUpdateNotification = isTerminalState || (currentTime - lastUpdate >= notificationUpdateThrottle) - when (inProgress() && hasErrors().not()) { - true -> - executionStatuses - .entries - .map { (item, status) -> item to status } - .let { notificationService.updateCachingNotification(it) } + if (shouldUpdateNotification && (isTerminalState || (inProgress() && hasErrors().not()))) { + executionStatuses + .entries + .map { (item, status) -> item to status } + .let { notificationService.updateCachingNotification(it) } - false -> finish() + lastNotificationUpdate.set(currentTime) + } + + if (!inProgress()) { + finish() } } } @@ -131,7 +140,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/content/cache/persistent/LocalCacheModule.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt index 53d6041aa..729adac13 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt @@ -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,6 +45,14 @@ object LocalCacheModule { .addMigrations(MIGRATION_12_13) .addMigrations(MIGRATION_13_14) .addMigrations(MIGRATION_14_15) + .addMigrations( + produceMigration15_16( + 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/LocalCacheRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt index 9bcf67941..b8ec25af7 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt @@ -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 @@ -22,6 +23,12 @@ import java.io.File import javax.inject.Inject import javax.inject.Singleton +enum class VolumeLabelType { + FULL_ARCHIVE, + VOLUME, + PART, +} + @Singleton class LocalCacheRepository @Inject @@ -148,6 +155,151 @@ 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 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, + nameResolver: (VolumeLabelType, Int) -> String, + ): 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 = nameResolver(VolumeLabelType.FULL_ARCHIVE, 0), + 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) { + nameResolver(VolumeLabelType.VOLUME, index + 1) + } else { + relatedChapters.firstOrNull()?.title ?: nameResolver(VolumeLabelType.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) + } + + 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`. @@ -194,4 +346,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..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 = 15, + 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 8e9b490c2..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 @@ -263,3 +263,156 @@ 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) { + // 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 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") + + 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") + } +} + +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") + } + } + +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/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/content/cache/persistent/api/CachedBookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt index 8f9be9b34..112d92494 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt @@ -58,12 +58,27 @@ class CachedBookRepository ?.let { bookDao.deleteBook(it) } } + suspend fun deleteNonDownloadedBooks() { + val nonDownloadedIds = bookDao.fetchNonDownloadedBookIds() + + nonDownloadedIds.forEach { bookId -> + properties + .provideBookCache(bookId) + .deleteRecursively() + } + + bookDao.deleteNonDownloadedBooks() + } + 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) @@ -87,8 +102,8 @@ class CachedBookRepository author = book.author, subtitle = book.subtitle, seriesNames = book.series, - duration = book.duration.toInt(), - libraryType = null, // currently not available here + duration = if (book.duration > 0) book.duration.toInt() else existing.duration, + updatedAt = if (book.updatedAt > 0) book.updatedAt else existing.updatedAt, ) ?: BookEntity( id = book.id, title = book.title, @@ -101,8 +116,10 @@ class CachedBookRepository duration = book.duration.toInt(), libraryId = book.libraryId, libraryType = null, - createdAt = 0, - updatedAt = 0, + createdAt = book.addedAt, + updatedAt = book.updatedAt, + host = preferences.getHost(), + username = preferences.getUsername(), seriesNames = book.series, seriesJson = "[]", ) @@ -116,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, @@ -145,6 +160,8 @@ class CachedBookRepository .orderField(option) .orderDirection(direction) .downloadedOnly(downloadedOnly) + .host(preferences.getHost()) + .username(preferences.getUsername()) .build() return bookDao @@ -152,7 +169,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, @@ -161,8 +182,10 @@ 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) @@ -181,6 +204,8 @@ class CachedBookRepository RecentRequestBuilder() .libraryId(libraryId) .downloadedOnly(downloadedOnly) + .host(preferences.getHost()) + .username(preferences.getUsername()) .build() val recentBooks = bookDao.fetchRecentlyListenedCachedBooks(request) @@ -203,6 +228,8 @@ class CachedBookRepository RecentRequestBuilder() .libraryId(libraryId) .downloadedOnly(downloadedOnly) + .host(preferences.getHost()) + .username(preferences.getUsername()) .build() return bookDao @@ -237,6 +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(), ) 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..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 @@ -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..e5137dc69 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt @@ -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? = null + private var username: String? = null 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,22 @@ class FetchRequestBuilder { } } + val isolationClause = + when (downloadedOnly) { + true -> "EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)" + false -> { + 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) { "title", "author", "duration" -> "detailed_books.$orderField" @@ -47,21 +69,14 @@ 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 - ORDER BY $field $direction + SELECT detailed_books.* FROM detailed_books + WHERE $libraryClause AND $isolationClause + ORDER BY $field $direction, detailed_books.id ASC LIMIT ? OFFSET ? """.trimIndent() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt index eb05aecb6..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,6 +7,8 @@ class RecentRequestBuilder { private var libraryId: String? = null private var downloadedOnly: Boolean = false private var limit: Int = 10 + private var host: String? = null + private var username: String? = null fun libraryId(id: String?) = apply { this.libraryId = id } @@ -14,30 +16,45 @@ 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 -> { + 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)" + } + } } 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..84f04efb6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt @@ -3,7 +3,10 @@ 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" @@ -20,20 +23,32 @@ class SearchRequestBuilder { 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)" + } + } + + val searchClause = "(title LIKE ? ESCAPE '\\' OR author LIKE ? ESCAPE '\\' OR seriesNames LIKE ? ESCAPE '\\')" + val pattern = "%${searchQuery.escapeSqlLike()}%" + args.add(pattern) + args.add(pattern) + args.add(pattern) + + val isolationClause = + 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)" } - append(" AND (title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)") - val pattern = "%$searchQuery%" - args.add(pattern) - args.add(pattern) - args.add(pattern) } val field = @@ -51,10 +66,16 @@ class SearchRequestBuilder { val sql = """ SELECT * FROM detailed_books - WHERE $whereClause - ORDER BY $field $direction + WHERE $libraryClause AND $searchClause AND $isolationClause + ORDER BY $field $direction, id ASC """.trimIndent() return SimpleSQLiteQuery(sql, args.toTypedArray()) } + + private fun String.escapeSqlLike() = + this + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt index c72b6bcb0..e10a72ed6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt @@ -36,5 +36,7 @@ class CachedBookEntityConverter }, duration = entity.duration.toDouble(), libraryId = entity.libraryId ?: "", + addedAt = entity.createdAt, + updatedAt = entity.updatedAt, ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/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 d9b165c79..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 @@ -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 @@ -60,6 +64,9 @@ interface CachedBookDao { }, ) + val existingBook = fetchCachedBook(book.id) + val existingChapters = existingBook?.chapters?.associateBy { it.bookChapterId } ?: emptyMap() + val bookFiles = book .files @@ -68,22 +75,18 @@ interface CachedBookDao { bookFileId = file.id, name = file.name, duration = file.duration, + size = file.size, mimeType = file.mimeType, bookId = book.id, ) } - 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 = @@ -112,12 +115,19 @@ interface CachedBookDao { currentTime = progress.currentTime, isFinished = progress.isFinished, lastUpdate = progress.lastUpdate, + host = host, + username = username, ) } - upsertBook(bookEntity) + safeUpsertBook(bookEntity) + + deleteBookFiles(book.id) upsertBookFiles(bookFiles) + + deleteBookChapters(book.id) upsertBookChapters(bookChapters) + mediaProgress?.let { upsertMediaProgress(it) } } @@ -128,11 +138,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 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?): Int + suspend fun countCachedBooks( + libraryId: String?, + host: String?, + username: String?, + ): Int @Transaction @RawQuery @@ -161,6 +176,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) @@ -171,7 +187,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( @@ -188,16 +209,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) @@ -212,18 +223,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.IGNORE) + suspend fun insertBookIgnore(book: BookEntity): Long - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBooks(books: List) - - @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) @@ -240,6 +268,29 @@ 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 + 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) + """, + ) + suspend fun deleteNonDownloadedBooks() + 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..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 @@ -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,23 @@ interface CachedLibraryDao { suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity? @Transaction - @Query("SELECT * FROM libraries") - suspend fun fetchLibraries(): List + @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?, + ): 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 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?, + ) } 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..02734f5bb 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt @@ -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,6 +52,8 @@ data class BookEntity( val seriesNames: String?, val createdAt: Long, val updatedAt: Long, + val host: String? = null, + val username: String? = null, ) : Serializable @Keep @@ -74,6 +77,7 @@ data class BookFileEntity( val duration: Double, val mimeType: String, val bookId: String, + @ColumnInfo(defaultValue = "0") val size: Long = 0L, ) : Serializable @Keep @@ -101,7 +105,6 @@ data class BookChapterEntity( val isCached: Boolean, ) : Serializable -@Keep @Entity( tableName = "media_progress", foreignKeys = [ @@ -120,6 +123,8 @@ data class MediaProgressEntity( val currentTime: Double, val isFinished: Boolean, val lastUpdate: Long, + 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 975a47a2e..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,4 +17,6 @@ data class CachedLibraryEntity( val id: String, val title: String, val type: LibraryType, + val host: String? = null, + val username: String? = null, ) : Serializable 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..37c57ae54 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt @@ -0,0 +1,156 @@ +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 val context: 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() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.e("Update check failed: ${response.code}") + return@withContext + } + + val json = response.body?.string() ?: return@withContext + val releaseNode = JSONObject(json) + val tagName = releaseNode.optString("tag_name", "") + val htmlUrl = releaseNode.optString("html_url", "") + + 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(".") + .map { it.takeWhile { char -> char.isDigit() }.toIntOrNull() ?: 0 } + + val local = + localVersion + .removePrefix("v") + .split(".") + .map { it.takeWhile { char -> char.isDigit() }.toIntOrNull() ?: 0 } + + val length = maxOf(remote.size, local.size) + for (i in 0 until length) { + 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_silhouette) + .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/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index 37bedba42..b9fd884d4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -6,6 +6,8 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import androidx.core.content.edit +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import com.squareup.moshi.Types import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose @@ -102,14 +104,18 @@ class LissenSharedPreferences } } - fun getAcraEnabled() = sharedPreferences.getBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, true) + fun getCrashReportingEnabled() = sharedPreferences.getBoolean(KEY_CRASH_REPORTING_ENABLED, true) - fun saveAcraEnabled(enabled: Boolean) { + fun saveCrashReportingEnabled(enabled: Boolean) { sharedPreferences.edit { - putBoolean(org.acra.ACRA.PREF_ENABLE_ACRA, enabled) + putBoolean(KEY_CRASH_REPORTING_ENABLED, enabled) } } + 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) { @@ -383,7 +389,11 @@ class LissenSharedPreferences null -> null else -> { val adapter = moshi.adapter(DetailedItem::class.java) - adapter.fromJson(json) + try { + adapter.fromJson(json) + } catch (e: Throwable) { + null + } } } } @@ -474,6 +484,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) = @@ -639,6 +664,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" @@ -663,9 +689,11 @@ 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" + private const val KEY_CRASH_REPORTING_ENABLED = "crash_reporting_enabled" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val TRANSFORMATION = "AES/GCM/NoPadding" @@ -714,6 +742,7 @@ class LissenSharedPreferences return try { String(cipher.doFinal(cipherText)) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) null } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt index d109317fe..a21a24cd3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -22,6 +22,8 @@ import androidx.media3.session.SessionToken import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.MoreExecutors +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -31,6 +33,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 +67,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) @@ -160,6 +164,7 @@ class MediaRepository updateShakeDetectorState(preferences.getShakeToResetTimer(), isPlaying) if (isPlaying) { + _preparingBookId.postValue(null) _playingBook.value?.let { startUpdatingProgress(it) } } else { handler.removeCallbacksAndMessages(null) @@ -181,6 +186,8 @@ class MediaRepository override fun onFailure(t: Throwable) { Timber.e("Unable to add callback to player") + Firebase.crashlytics.recordException(t) + clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error") } }, MoreExecutors.directExecutor(), @@ -212,7 +219,6 @@ class MediaRepository preferences.savePlayingBook(it) _isPlaybackReady.postValue(true) - _preparingBookId.postValue(null) pendingChapterIndex?.let { index -> setChapter(index) @@ -222,6 +228,8 @@ class MediaRepository if (_playAfterPrepare.value == true) { _playAfterPrepare.postValue(false) play() + } else { + _preparingBookId.postValue(null) } } } @@ -263,6 +271,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) @@ -309,6 +320,7 @@ class MediaRepository toneGen.release() }, 200) } catch (e: Exception) { + Firebase.crashlytics.recordException(e) Timber.e(e, "Failed to play reset sound") } } @@ -348,6 +360,7 @@ class MediaRepository seekTo(chapterStartsAt) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return } } @@ -376,6 +389,7 @@ class MediaRepository seekTo(absolutePosition) } catch (ex: Exception) { + Firebase.crashlytics.recordException(ex) return } } @@ -564,6 +578,7 @@ class MediaRepository } context.startForegroundService(intent) + clarityTracker.trackEvent("playback_start") } private fun pause() { @@ -573,6 +588,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/playback/service/PlaybackService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt index ec58ec327..16d13010f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt @@ -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 @@ -30,6 +31,7 @@ import org.grakovne.lissen.lib.domain.TimerOption import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import org.grakovne.lissen.playback.MediaSessionProvider import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @UnstableApi @@ -60,11 +62,15 @@ class PlaybackService : MediaSessionService() { lateinit var playbackTimer: PlaybackTimer @Inject - lateinit var mediaCache: Cache + lateinit var mediaCache: dagger.Lazy private var session: MediaSession? = null - private var smartRewindApplied = false + private var artworkJob: kotlinx.coroutines.Job? = null + + private var playbackJob: kotlinx.coroutines.Job? = null + + private var smartRewindApplied = AtomicBoolean(false) private val playerServiceScope = MainScope() @@ -112,26 +118,38 @@ class PlaybackService : MediaSessionService() { } ACTION_PLAY -> { - playerServiceScope - .launch { - checkAndApplySmartRewind() - exoPlayer.prepare() - exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) - exoPlayer.playWhenReady = true - } + playbackJob?.cancel() + playbackJob = + playerServiceScope + .launch { + checkAndApplySmartRewind() + if (exoPlayer.playbackState == ExoPlayer.STATE_IDLE) { + exoPlayer.prepare() + } + exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) + exoPlayer.playWhenReady = true + } return START_STICKY } ACTION_PAUSE -> { - pause() + playbackJob?.cancel() + playbackJob = + playerServiceScope + .launch { + smartRewindApplied.set(false) + exoPlayer.playWhenReady = false + } return START_NOT_STICKY } ACTION_SET_PLAYBACK -> { val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem book?.let { - playerServiceScope - .launch { preparePlayback(it) } + playbackJob?.cancel() + playbackJob = + playerServiceScope + .launch { preparePlayback(it) } } return START_NOT_STICKY } @@ -173,7 +191,8 @@ class PlaybackService : MediaSessionService() { @OptIn(UnstableApi::class) private suspend fun preparePlayback(book: DetailedItem) { exoPlayer.playWhenReady = false - smartRewindApplied = false + smartRewindApplied.set(false) + artworkJob?.cancel() withContext(Dispatchers.IO) { val prepareQueue = @@ -181,7 +200,7 @@ class PlaybackService : MediaSessionService() { val sourceFactory = LissenDataSourceFactory( baseContext = baseContext, - mediaCache = mediaCache, + mediaCache = mediaCache.get(), requestHeadersProvider = requestHeadersProvider, sharedPreferences = sharedPreferences, mediaProvider = mediaProvider, @@ -196,7 +215,7 @@ class PlaybackService : MediaSessionService() { .Builder() .setTitle(file.name) .setArtist(book.title) - .setArtworkUri(fetchCover(book)) + .build() val mediaItem = MediaItem @@ -204,7 +223,7 @@ class PlaybackService : MediaSessionService() { .setMediaId(file.id) .setUri(apply(book.id, file.id)) .setTag(book) - .setMediaMetadata(mediaData.build()) + .setMediaMetadata(mediaData) .build() ProgressiveMediaSource @@ -219,12 +238,8 @@ class PlaybackService : MediaSessionService() { val startPosition = calculateSmartRewindPosition(book) val currentPosition = book.progress?.currentTime ?: 0.0 - if (startPosition < currentPosition) { - Timber.d("Smart rewind applied. Seeking to $startPosition from $currentPosition") - smartRewindApplied = true - } - seek(book.files, startPosition) + smartRewindApplied.set(true) } } @@ -233,6 +248,32 @@ class PlaybackService : MediaSessionService() { playbackSynchronizationService.startPlaybackSynchronization(book) } + // Fire-and-forget: Update cover in background without blocking playback readiness. + artworkJob = + launch { + val artworkUri = fetchCover(book) ?: return@launch + + withContext(Dispatchers.Main) { + for (i in 0 until exoPlayer.mediaItemCount) { + val currentItem = exoPlayer.getMediaItemAt(i) + val updatedMetadata = + currentItem + .mediaMetadata + .buildUpon() + .setArtworkUri(artworkUri) + .build() + + val updatedItem = + currentItem + .buildUpon() + .setMediaMetadata(updatedMetadata) + .build() + + exoPlayer.replaceMediaItem(i, updatedItem) + } + } + } + awaitAll(prepareSession, prepareQueue) val intent = @@ -247,32 +288,23 @@ class PlaybackService : MediaSessionService() { } private suspend fun checkAndApplySmartRewind() { - if (smartRewindApplied) { + if (smartRewindApplied.get()) { return } val item = exoPlayer.currentMediaItem?.localConfiguration?.tag as? DetailedItem ?: return - withContext(Dispatchers.IO) { - val book = - mediaProvider - .fetchBook(item.id) - .fold( - onSuccess = { it }, - onFailure = { item }, - ) + val startPosition = calculateSmartRewindPosition(item) + val currentPosition = item.progress?.currentTime ?: 0.0 + if (startPosition < currentPosition) { withContext(Dispatchers.Main) { - val startPosition = calculateSmartRewindPosition(book) - val currentPosition = book.progress?.currentTime ?: 0.0 - - if (startPosition < currentPosition) { - Timber.d("Smart rewind applied (on resume). Seeking to $startPosition from $currentPosition") - seek(book.files, startPosition) - smartRewindApplied = true - } + Timber.d("Smart rewind applied (on resume). Seeking to $startPosition from $currentPosition") + seek(item.files, startPosition) } } + + smartRewindApplied.set(true) } private fun calculateSmartRewindPosition(book: DetailedItem): Double = @@ -317,16 +349,6 @@ class PlaybackService : MediaSessionService() { Timber.d("Timer canceled.") } - private fun pause() { - smartRewindApplied = false - playerServiceScope - .launch { - exoPlayer.playWhenReady = false - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - } - private fun seek( items: List, position: Double?, diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt index bbd60a40d..0d49fe99d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext import org.grakovne.lissen.channel.common.OperationError import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.lib.domain.DetailedItem @@ -52,6 +53,7 @@ class PlaybackSynchronizationService fun startPlaybackSynchronization(item: DetailedItem) { serviceScope.coroutineContext.cancelChildren() currentItem = item + runSync() } fun cancelSynchronization() { @@ -90,72 +92,84 @@ class PlaybackSynchronizationService } private fun runSync() { - val elapsedMs = exoPlayer.currentPosition - val overallProgress = getProgress(elapsedMs) ?: return - - Timber.d("Trying to sync $overallProgress for ${currentItem?.id}") + serviceScope.launch { + val elapsedMs = exoPlayer.currentPosition + val overallProgress = getProgress(elapsedMs) ?: return@launch + val item = currentItem ?: return@launch + val session = playbackSession + val lastChapterIndex = currentChapterIndex + + Timber.d("Trying to sync $overallProgress for ${item.id}") + + launch(Dispatchers.IO) { + if (syncMutex.tryLock().not()) { + Timber.d("Sync is already running") + return@launch + } - serviceScope.launch(Dispatchers.IO) { - if (syncMutex.tryLock().not()) { - Timber.d("Sync is already running") - return@launch - } + try { + val currentIndex = calculateChapterIndex(item, overallProgress.currentTotalTime) - try { - val currentIndex = - currentItem - ?.let { calculateChapterIndex(it, overallProgress.currentTotalTime) } - ?: return@launch + var activeSession = session + if (activeSession == null || activeSession.itemId != item.id || currentIndex != lastChapterIndex) { + activeSession = openPlaybackSession(item, overallProgress) + withContext(Dispatchers.Main) { + playbackSession = activeSession + currentChapterIndex = currentIndex + } + } - if (playbackSession == null || playbackSession?.itemId != currentItem?.id || currentIndex != currentChapterIndex) { - openPlaybackSession(overallProgress) - currentChapterIndex = currentIndex + mediaChannel.syncLocalProgress(item.id, overallProgress) + activeSession?.let { requestSync(it, item, overallProgress) } + } catch (e: Exception) { + Timber.e(e, "Error during sync") + } finally { + syncMutex.unlock() } - - mediaChannel.syncLocalProgress(currentItem?.id ?: return@launch, overallProgress) - playbackSession?.let { requestSync(it, overallProgress) } - } catch (e: Exception) { - Timber.e(e, "Error during sync") - } finally { - syncMutex.unlock() } } } private suspend fun requestSync( - it: PlaybackSession, + session: PlaybackSession, + item: DetailedItem, overallProgress: PlaybackProgress, - ): Unit? = - mediaChannel - .syncProgress( - sessionId = it.sessionId, - itemId = it.itemId, - progress = overallProgress, - ).foldAsync( - onSuccess = {}, - onFailure = { - when (it.code) { - OperationError.NotFoundError -> openPlaybackSession(overallProgress) - else -> Unit + ) = mediaChannel + .syncProgress( + sessionId = session.sessionId, + itemId = session.itemId, + progress = overallProgress, + ).foldAsync( + onSuccess = {}, + onFailure = { + when (it.code) { + OperationError.NotFoundError -> { + val newSession = openPlaybackSession(item, overallProgress) + withContext(Dispatchers.Main) { + playbackSession = newSession + } } - }, - ) + else -> Unit + } + }, + ) - private suspend fun openPlaybackSession(overallProgress: PlaybackProgress) = - currentItem - ?.let { item -> - val chapterIndex = calculateChapterIndex(item, overallProgress.currentTotalTime) - mediaChannel - .startPlayback( - itemId = item.id, - deviceId = sharedPreferences.getDeviceId(), - supportedMimeTypes = MimeTypeProvider.getSupportedMimeTypes(), - chapterId = item.chapters[chapterIndex].id, - ).fold( - onSuccess = { playbackSession = it }, - onFailure = {}, - ) - } + private suspend fun openPlaybackSession( + item: DetailedItem, + overallProgress: PlaybackProgress, + ): PlaybackSession? { + val chapterIndex = calculateChapterIndex(item, overallProgress.currentTotalTime) + return mediaChannel + .startPlayback( + itemId = item.id, + deviceId = sharedPreferences.getDeviceId(), + supportedMimeTypes = MimeTypeProvider.getSupportedMimeTypes(), + chapterId = item.chapters[chapterIndex].id, + ).fold( + onSuccess = { it }, + onFailure = { null }, + ) + } private fun getProgress(currentElapsedMs: Long): PlaybackProgress? { val currentItem = diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/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/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..1c550a56c --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt @@ -0,0 +1,133 @@ +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.SheetValue +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, + confirmValueChange = { it != SheetValue.Hidden }, + ) + + 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/AsyncShimmeringImage.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt index 96a3bcf1c..ceea479cd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt @@ -3,16 +3,21 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.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 +26,7 @@ import com.valentinilk.shimmer.shimmer @Composable fun AsyncShimmeringImage( imageRequest: ImageRequest, + thumbnailRequest: ImageRequest? = null, imageLoader: ImageLoader, contentDescription: String, contentScale: ContentScale, @@ -28,23 +34,45 @@ fun AsyncShimmeringImage( error: Painter, onLoadingStateChanged: (Boolean) -> Unit = {}, ) { - var isLoading by remember { mutableStateOf(true) } - onLoadingStateChanged(isLoading) + var isMainLoading by remember(imageRequest) { mutableStateOf(true) } + var isThumbnailLoaded by remember(thumbnailRequest) { mutableStateOf(false) } + + LaunchedEffect(imageRequest) { + isMainLoading = true + onLoadingStateChanged(true) + } Box( modifier = modifier, contentAlignment = Alignment.Center, ) { - 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 +80,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/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index 3c5a4b9e5..5130ba5bb 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -1,16 +1,34 @@ package org.grakovne.lissen.ui.components +import androidx.compose.animation.core.EaseOutExpo +import androidx.compose.animation.core.EaseOutQuart +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.contentDescription @@ -25,35 +43,151 @@ import org.grakovne.lissen.lib.domain.CacheStatus @Composable fun DownloadProgressIcon( cacheState: CacheState, + isFullyDownloaded: Boolean, size: Dp = 24.dp, color: Color = LocalContentColor.current, + showShine: Boolean = false, ) { - 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) - - 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, - ) + val isDark = isSystemInDarkTheme() + val successColor = + if (isDark) { + org.grakovne.lissen.ui.theme.DownloadSuccessDark + } else { + org.grakovne.lissen.ui.theme.DownloadSuccessLight + } + + var triggerShine by remember { mutableStateOf(false) } + var wasCaching by remember { mutableStateOf(false) } + + LaunchedEffect(cacheState.status) { + if (cacheState.status is CacheStatus.Caching) { + wasCaching = true + } + + if (wasCaching && (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle) && isFullyDownloaded) { + triggerShine = true + wasCaching = false + } + } + + val shineAlpha by animateFloatAsState( + targetValue = if (showShine && triggerShine) 1f else 0f, + animationSpec = tween(durationMillis = 1500, easing = EaseOutExpo), + label = "shineAlpha", + finishedListener = { + if (it == 1f) triggerShine = false + }, + ) + + val shineScale by animateFloatAsState( + targetValue = if (showShine && triggerShine) 3.5f else 1f, + animationSpec = tween(durationMillis = 1500, easing = EaseOutExpo), + label = "shineScale", + ) + + Box(contentAlignment = Alignment.Center) { + if (showShine && triggerShine && shineAlpha > 0f) { + Box( + modifier = + Modifier + .size(size) + .graphicsLayer( + scaleX = shineScale, + scaleY = shineScale, + alpha = shineAlpha * (1f - (shineScale - 1f) / 2.5f).coerceIn(0f, 1f), + clip = false, + ).background( + color = successColor.copy(alpha = 0.6f), + shape = CircleShape, + ), + ) + } + + when (cacheState.status) { + is CacheStatus.Queued -> { + val queuedDescription = stringResource(R.string.accessibility_id_download_queued) + + CircularProgressIndicator( + modifier = + Modifier + .semantics { contentDescription = queuedDescription } + .size(size - 4.dp), + strokeWidth = 2.dp, + color = colorScheme.primary, + trackColor = color.copy(alpha = 0.1f), + strokeCap = StrokeCap.Round, + ) + } + + is CacheStatus.Caching -> { + val targetProgress = cacheState.progress.coerceIn(0.0, 1.0).toFloat() + val animatedProgress by animateFloatAsState( + targetValue = targetProgress, + animationSpec = + tween( + durationMillis = 800, + easing = EaseOutQuart, + ), + 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 = stringResource(R.string.accessibility_id_download_complete), + modifier = Modifier.size(size), + tint = successColor, + ) + } else { + Icon( + imageVector = Icons.Outlined.CloudDownload, + contentDescription = stringResource(R.string.accessibility_id_download_available), + modifier = Modifier.size(size).alpha(0.8f), + tint = color, + ) + } + } + + else -> { + if (isFullyDownloaded) { + Icon( + imageVector = Icons.Filled.CloudDone, + contentDescription = stringResource(R.string.accessibility_id_download_complete), + modifier = Modifier.size(size), + tint = successColor, + ) + } else { + Icon( + imageVector = Icons.Outlined.CloudDownload, + 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/components/VerticalScrollbar.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt index 186238fa8..49465019d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt @@ -44,16 +44,17 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow -import org.acra.ACRA import timber.log.Timber fun Modifier.withScrollbar( state: LazyListState, - color: Color, + color: () -> Color, totalItems: Int?, - ignoreItems: List = emptyList(), + ignoreItems: Set = emptySet(), ): Modifier { try { return baseScrollbar { atEnd -> @@ -67,6 +68,10 @@ fun Modifier.withScrollbar( key is String && ignoreItems.contains(key) } + if (items.isEmpty()) { + return@baseScrollbar + } + val itemsSize = items.sumOf { it.size } val count = totalItems ?: layoutInfo.totalItemsCount @@ -87,12 +92,12 @@ fun Modifier.withScrollbar( ?.let { (itemSize * it.index - it.offset) / totalSize * canvasSize } ?: 0f - drawScrollbarThumb(atEnd, thumbSize, startOffset, color) + drawScrollbarThumb(atEnd, thumbSize, startOffset, color()) } } } catch (ex: Exception) { Timber.w("Unable to apply scrollbar due to ${ex.message}") - ACRA.errorReporter.handleSilentException(ex) + Firebase.crashlytics.recordException(ex) return this } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt index 6e40bdb54..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 @@ -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 }, @@ -110,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/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/common/DownloadOptionFormat.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt index c1700cc65..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,6 +17,10 @@ fun DownloadOption?.makeText( when (this) { null -> context.getString(R.string.downloads_menu_download_option_disable) + is SpecificFilesDownloadOption -> { + context.getString(R.string.downloads_menu_download_option_selected_volume) + } + CurrentItemDownloadOption -> { when (libraryType) { LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_current_chapter) 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..7837cc13e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -8,6 +8,10 @@ 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.animateFloat +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 @@ -34,9 +38,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AvTimer +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 @@ -81,6 +88,8 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.buildAnnotatedString @@ -93,6 +102,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 @@ -105,6 +115,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 @@ -135,13 +146,21 @@ fun BookDetailScreen( val isOnline by playerViewModel.isOnline.collectAsState(initial = false) val preparingBookId by playerViewModel.preparingBookId.observeAsState(null) val currentChapterIndex by playerViewModel.currentChapterIndex.observeAsState(-1) + val isFetchingDetails by playerViewModel.isFetchingDetails.observeAsState(false) val totalPosition by playerViewModel.totalPosition.observeAsState(0.0) val cacheProgress: CacheState by cachingModelView.getProgress(bookId).collectAsState(initial = CacheState(CacheStatus.Idle)) - val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(bookId).observeAsState(false) 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") } @@ -221,7 +240,9 @@ fun BookDetailScreen( IconButton(onClick = { downloadsExpanded = true }) { DownloadProgressIcon( cacheState = cacheProgress, + isFullyDownloaded = isFullyDownloaded, color = colorScheme.onSurface, + showShine = true, ) } }, @@ -229,12 +250,14 @@ fun BookDetailScreen( }, modifier = Modifier.systemBarsPadding(), ) { innerPadding -> - if (bookDetail == null) { + if (bookDetail == null || (isFetchingDetails && bookDetail?.chapters?.isEmpty() == true)) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } else { val book = bookDetail!! + val storageType = remember(book.id) { cachingModelView.getBookStorageType(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 @@ -257,6 +280,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 @@ -268,6 +303,7 @@ fun BookDetailScreen( ) { AsyncShimmeringImage( imageRequest = imageRequest, + thumbnailRequest = thumbnailRequest, imageLoader = imageLoader, contentDescription = "${book.title} cover", contentScale = ContentScale.Crop, @@ -282,7 +318,12 @@ fun BookDetailScreen( style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), color = colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 24.dp), + modifier = + Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + maxLines = 3, + overflow = TextOverflow.Ellipsis, ) book.author?.takeIf { it.isNotBlank() }?.let { @@ -545,39 +586,35 @@ fun BookDetailScreen( } } - if (downloadsExpanded) { + if (downloadsExpanded && bookDetail != null) { + val book = bookDetail!! + val storageType = remember(book.id) { cachingModelView.getBookStorageType(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 }, 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..c960fdf65 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt @@ -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,9 +103,9 @@ 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) val searchRequested by libraryViewModel.searchRequested.observeAsState(false) val preparingError by playerViewModel.preparingError.observeAsState(false) @@ -144,8 +145,7 @@ fun LibraryScreen( withMinimumTime(minimumTime) { listOf( async { settingsViewModel.fetchLibraries() }, - async { libraryViewModel.refreshLibrary() }, - async { libraryViewModel.fetchRecentListening() }, + async { libraryViewModel.refreshLibrary(forceRefresh = showPullRefreshing) }, ).awaitAll() } @@ -160,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 } @@ -181,11 +181,18 @@ fun LibraryScreen( ) val titleTextStyle = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - val titleHeightDp = with(LocalDensity.current) { titleTextStyle.lineHeight.toPx().toDp() } + val density = LocalDensity.current + val titleHeightDp = + remember(titleTextStyle.lineHeight, density) { + with(density) { titleTextStyle.lineHeight.toPx().toDp() } + } val playingBook by playerViewModel.book.observeAsState() + val onBackgroundColor = MaterialTheme.colorScheme.onBackground val context = LocalContext.current + val scrollbarIgnoreItems = remember { setOf("recent_books", "library_title") } + fun isRecentVisible(): Boolean { val hasContent = recentBooks.isEmpty().not() @@ -224,8 +231,11 @@ fun LibraryScreen( } LaunchedEffect(recentBooks.isNotEmpty()) { + val isScrolling = libraryListState.isScrollInProgress + val isSearching = libraryViewModel.searchRequested.value == true val needsScroll = libraryListState.firstVisibleItemIndex <= 1 - if (recentBooks.isNotEmpty() && needsScroll) { + + if (recentBooks.isNotEmpty() && needsScroll && !isScrolling && !isSearching) { libraryListState.animateScrollToItem(0) } } @@ -345,11 +355,16 @@ fun LibraryScreen( .imePadding() .withScrollbar( state = libraryListState, - color = colorScheme.onBackground.copy(alpha = scrollbarAlpha), + color = { onBackgroundColor.copy(alpha = scrollbarAlpha) }, totalItems = libraryCount, - ignoreItems = listOf("recent_books", "library_title"), + ignoreItems = scrollbarIgnoreItems, ), - contentPadding = PaddingValues(horizontal = Spacing.md), + contentPadding = + PaddingValues( + start = Spacing.md, + end = Spacing.md, + bottom = Spacing.md, + ), ) { item(key = "recent_books") { val showRecent = isRecentVisible() @@ -443,8 +458,11 @@ fun LibraryScreen( } else -> - items(count = library.itemCount, key = { "library_item_$it" }) { - val book = library[it] ?: return@items + items( + count = library.itemCount, + key = { index -> library.peek(index)?.id ?: "library_item_$index" }, + ) { index -> + val book = library[index] ?: return@items BookComposable( book = book, @@ -479,4 +497,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/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/library/composables/MiniPlayerComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt index 58ad17fca..0c3b6713a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme @@ -71,6 +73,9 @@ fun MiniPlayerComposable( val view: View = LocalView.current val isPlaying: Boolean by playerViewModel.isPlaying.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 +265,30 @@ 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) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = colorScheme.onSurface, + strokeWidth = 3.dp, + ) + } else if (isError) { + Icon( + imageVector = Icons.Rounded.Error, + contentDescription = "Error", + tint = colorScheme.error, + modifier = Modifier.size(32.dp), + ) + } else { + Icon( + imageVector = if (isPlaying) AppIcons.PauseCircleNegative else AppIcons.PlayCircleNegative, + 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/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/migration/MigrationScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt new file mode 100644 index 000000000..fc6384ad6 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt @@ -0,0 +1,115 @@ +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.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 +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) + .padding(bottom = Spacing.xl), + ) { + 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)) + + 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)) + + // Dismiss button is intentionally hidden on error to prevent bypassing failed migration. + // Users must retry or clear app data (via system settings) if issues persist. + } + + else -> { + 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/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/player/GlobalPlayerBottomSheet.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt index d264f6c3f..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, + ) } } @@ -449,36 +447,38 @@ 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) - 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..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,44 +432,38 @@ 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) - - 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..810342e3b --- /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 = subtextColor.alpha * contentAlpha), + maxLines = 2, + 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..9ae84caff 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt @@ -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,27 +39,33 @@ 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.content.cache.common.findRelatedFilesByStartTimes +import org.grakovne.lissen.content.cache.persistent.calculateRequestedChapters import org.grakovne.lissen.lib.domain.AllItemsDownloadOption +import org.grakovne.lissen.lib.domain.BookStorageType +import org.grakovne.lissen.lib.domain.BookVolume import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption +import org.grakovne.lissen.lib.domain.DetailedItem import org.grakovne.lissen.lib.domain.DownloadOption import org.grakovne.lissen.lib.domain.LibraryType import org.grakovne.lissen.lib.domain.NumberItemDownloadOption import org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption +import org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption +import org.grakovne.lissen.playback.service.calculateChapterIndex import org.grakovne.lissen.ui.effects.WindowBlurEffect import org.grakovne.lissen.ui.screens.common.makeText @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadsComposable( + book: DetailedItem, + storageType: BookStorageType, + volumes: List, isOnline: Boolean, - libraryType: LibraryType, - hasCachedEpisodes: Boolean, cachingInProgress: Boolean, onRequestedDownload: (DownloadOption) -> Unit, onRequestedStop: () -> Unit, @@ -53,6 +76,27 @@ fun DownloadsComposable( val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val libraryType = book.libraryType ?: LibraryType.UNKNOWN + val hasCachedContent = volumes.any { it.isDownloaded } + val isFullBookDownloaded = volumes.all { it.isDownloaded } + + val currentChapterIndex = + 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 +104,7 @@ fun DownloadsComposable( containerColor = colorScheme.background, scrimColor = colorScheme.scrim.copy(alpha = 0.65f), onDismissRequest = onDismissRequest, + dragHandle = { DragHandle() }, content = { Column( modifier = @@ -69,154 +114,331 @@ fun DownloadsComposable( .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + val title = + 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) + } + 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() - } - } - - if (cachingInProgress) { - item { - HorizontalDivider() - - ListItem( - headlineContent = { - Row { - Text( - text = stringResource(R.string.downloads_menu_download_option_stop_downloads), - color = colorScheme.error, - style = typography.bodyMedium, + .border( + width = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.1f), + shape = RoundedCornerShape(16.dp), + ), + ) { + Column { + // Scenario A: Monolith + if (storageType == 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(AllItemsDownloadOption) + onDismissRequest() + }, ) } - }, - 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) - } + // Scenario B: Segmented + if (storageType == BookStorageType.SEGMENTED) { + if (currentVolume != null && !currentVolume.isDownloaded) { + val startIdx = book.chapters.indexOfFirst { it.id == currentVolume.chapters.firstOrNull()?.id } + val startChapter = if (startIdx >= 0) startIdx + 1 else null + + val endIdx = book.chapters.indexOfFirst { it.id == currentVolume.chapters.lastOrNull()?.id } + val endChapter = if (endIdx >= 0) endIdx + 1 else null - 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)) + val range = + when { + startChapter != null && endChapter != null -> + if (startChapter == + endChapter + ) { + "$startChapter" } else { - append(fullText) + "$startChapter-$endChapter" } - }, - color = colorScheme.error, - style = typography.bodyMedium, + else -> "?" + } + + 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( + SpecificFilesDownloadOption(listOf(currentVolume.id)), + ) + onDismissRequest() + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + + 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( + 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 + .filter { !it.isDownloaded } + .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(AllItemsDownloadOption) + onDismissRequest() + }, + ) + } + } + + // Scenario C: Atomic + if (storageType == 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 CurrentItemDownloadOption -> Icons.Default.MusicNote + is NumberItemDownloadOption -> Icons.Default.Layers + is RemainingItemsDownloadOption -> Icons.AutoMirrored.Filled.QueueMusic + is AllItemsDownloadOption -> Icons.Default.Folder + else -> Icons.Default.Folder + } + + 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 == BookStorageType.ATOMIC && + book.chapters.any { it.available && (book.progress?.currentTime ?: 0.0) >= it.end } + ) + + if (showClearCompleted) { + ActionRow( + title = + 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, + ) + 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 == BookStorageType.MONOLITH) { + stringResource(R.string.download_modal_remove_book) + } else { + when (libraryType) { + LibraryType.LIBRARY -> + stringResource( + R.string.downloads_menu_download_option_clear_chapters, + ) + 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,7 +447,18 @@ 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), 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/ui/screens/player/composable/PlaybackButtonsComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt index 0abc30dff..587689c67 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt @@ -41,6 +41,8 @@ fun PlaybackButtonsComposable( val book by viewModel.book.observeAsState() val chapters = book?.chapters ?: emptyList() + val preparingBookId by viewModel.preparingBookId.observeAsState(null) + val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) val showNavButtons by settingsViewModel.showPlayerNavButtons.observeAsState(true) @@ -94,14 +96,24 @@ 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 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), + ) + } } } } 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/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/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/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt index 6856078a7..09c6b3b7f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt @@ -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 @@ -13,11 +22,13 @@ 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 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,23 +36,34 @@ 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.Checkbox +import androidx.compose.material3.CircularProgressIndicator 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.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 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 @@ -49,6 +71,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 @@ -56,6 +79,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 @@ -71,26 +95,37 @@ 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 import org.grakovne.lissen.viewmodel.PlayerViewModel +data class VolumeIdentifier( + val bookId: String, + val fileId: String, +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun CachedItemsSettingsScreen( onBack: () -> Unit, + onNavigateToLibrary: () -> Unit, imageLoader: ImageLoader, viewModel: CachingModelView = hiltViewModel(), playerViewModel: PlayerViewModel = hiltViewModel(), ) { val view: View = LocalView.current val scope = rememberCoroutineScope() + val context = LocalContext.current var pullRefreshing by remember { mutableStateOf(false) } + var isDeleting by remember { mutableStateOf(false) } + val cachedItems = viewModel.libraryPager.collectAsLazyPagingItems() + var selectionMode by remember { mutableStateOf(false) } + val selectedVolumes = remember { mutableStateListOf() } + fun refreshContent(showPullRefreshing: Boolean) { scope.launch { if (showPullRefreshing) { @@ -106,6 +141,8 @@ fun CachedItemsSettingsScreen( withMinimumTime(minimumTime) { listOf( async { viewModel.fetchCachedItems() }, + async { viewModel.refreshStorageStats() }, + async { viewModel.refreshMetadata() }, ).awaitAll() } @@ -132,7 +169,7 @@ fun CachedItemsSettingsScreen( ) }, navigationIcon = { - IconButton(onClick = { onBack() }) { + IconButton(onClick = { if (selectionMode) selectionMode = false else onBack() }) { Icon( imageVector = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back", @@ -140,8 +177,89 @@ fun CachedItemsSettingsScreen( ) } }, + actions = { + if (cachedItems.itemCount > 0) { + TextButton(onClick = { + selectionMode = !selectionMode + if (!selectionMode) selectedVolumes.clear() + }) { + Text( + 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, + ) + } + } + }, ) }, + bottomBar = { + if (selectionMode && selectedVolumes.isNotEmpty()) { + val totalSizeToReclaim = + remember(selectedVolumes.toList(), cachedItems.itemCount) { + calculateReclaimSize(selectedVolumes, cachedItems, viewModel) + } + val formattedSize = Formatter.formatFileSize(context, totalSizeToReclaim) + val playingBook by playerViewModel.book.observeAsState() + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(Color.Transparent) + .navigationBarsPadding() + .padding(bottom = if (playingBook != null) 16.dp else 0.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Button( + onClick = { + withHaptic(view) { + scope.launch { + isDeleting = true + deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) + selectionMode = false + selectedVolumes.clear() + refreshContent(false) + isDeleting = false + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isDeleting, + colors = + ButtonDefaults.buttonColors( + containerColor = colorScheme.error, + contentColor = colorScheme.onError, + disabledContainerColor = colorScheme.error.copy(alpha = 0.5f), + disabledContentColor = colorScheme.onError.copy(alpha = 0.5f), + ), + ) { + if (isDeleting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = colorScheme.onError, + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.manage_downloads_free_up, formattedSize)) + } + } + } + } + } + }, ) { innerPadding -> Box( modifier = @@ -151,16 +269,22 @@ 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(onNavigateToLibrary) + false -> + CachedItemsComposable( + cachedItems = cachedItems, + imageLoader = imageLoader, + viewModel = viewModel, + playerViewModel = playerViewModel, + selectionMode = selectionMode, + selectedVolumes = selectedVolumes, + onItemRemoved = { refreshContent(showPullRefreshing = false) }, + ) + } } PullRefreshIndicator( @@ -173,12 +297,123 @@ 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, imageLoader: ImageLoader, viewModel: CachingModelView, playerViewModel: PlayerViewModel, + selectionMode: Boolean, + selectedVolumes: MutableList, onItemRemoved: () -> Unit, ) { val state = rememberLazyListState() @@ -196,13 +431,15 @@ private fun CachedItemsComposable( animationSpec = tween(durationMillis = 300), ) + val onBackground = colorScheme.onBackground + LazyColumn( state = state, modifier = Modifier .withScrollbar( state = state, - color = colorScheme.onBackground.copy(alpha = scrollbarAlpha), + color = { onBackground.copy(alpha = scrollbarAlpha) }, totalItems = itemsCount, ).fillMaxSize(), ) { @@ -213,6 +450,8 @@ private fun CachedItemsComposable( imageLoader = imageLoader, viewModel = viewModel, playerViewModel = playerViewModel, + selectionMode = selectionMode, + selectedVolumes = selectedVolumes, onItemRemoved = onItemRemoved, ) } @@ -225,18 +464,25 @@ private fun CachedItemComposable( imageLoader: ImageLoader, viewModel: CachingModelView, playerViewModel: PlayerViewModel, + selectionMode: Boolean, + selectedVolumes: MutableList, onItemRemoved: () -> Unit, ) { val scope = rememberCoroutineScope() val context = LocalContext.current + val view = LocalView.current var expanded by remember { mutableStateOf(false) } + val bookSize = + remember(book) { + Formatter.formatFileSize(context, viewModel.getBookSize(book)) + } + val imageRequest = remember(book.id) { ImageRequest .Builder(context) .data(book.id) - .apply { extras[LocalOnlyKey] = true } .build() } @@ -244,59 +490,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), - ) + 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), + ) - Spacer(Modifier.width(spacing)) + Spacer(Modifier.width(16.dp)) - 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(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,13 +559,40 @@ 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(8.dp)) - Spacer(Modifier.width(spacing)) + 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 = { - scope - .launch { + withHaptic(view) { + scope.launch { dropCache( item = book, cachingModelView = viewModel, @@ -321,96 +601,188 @@ private fun CachedItemComposable( onItemRemoved() } + } }) { Icon( imageVector = Icons.Outlined.Delete, contentDescription = null, - tint = colorScheme.onSurface, + 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), + ) { + 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, + selectionMode: Boolean, + selectedVolumes: MutableList, ) { val scope = rememberCoroutineScope() + val context = LocalContext.current + val view = LocalView.current - Spacer(modifier = Modifier.height(spacing)) + val volumes = remember(item) { viewModel.getVolumes(item).filter { it.isDownloaded } } - val availableChapters = - item - .chapters - .filter { it.available } + volumes.forEachIndexed { index, volume -> + val volumeSize = remember(volume) { Formatter.formatFileSize(context, volume.size) } + val isSelected = selectedVolumes.contains(VolumeIdentifier(item.id, volume.id)) - availableChapters.forEachIndexed { index, chapter -> - key(chapter.id) { + key(volume.id) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = spacing / 2) - .padding(start = chapterIndent), + .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, ) { Column(modifier = Modifier.weight(1f)) { - Text(text = chapter.title, style = typography.bodyMedium) + Text(text = volume.name, style = typography.bodyMedium) + Text( + text = volumeSize, + 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( + 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) { + scope.launch { + playerViewModel.book.value?.let { playingBook -> + if (playingBook.id == item.id) { + playerViewModel.clearPlayingBook() + } + } + volume.chapters.forEach { chapter -> + viewModel.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), - ) + } // dropCache by chapter handles file deletion + 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) { + if (index < volumes.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), ) } } } } +private fun calculateReclaimSize( + selectedIds: List, + cachedItems: LazyPagingItems, + viewModel: CachingModelView, +): Long { + if (selectedIds.isEmpty()) return 0L + + val selectedByBook = selectedIds.groupBy { it.bookId } + var total = 0L + + 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 +} + +private suspend fun deleteSelectedVolumes( + selectedIds: List, + cachedItems: LazyPagingItems, + viewModel: CachingModelView, + playerViewModel: PlayerViewModel, +) { + val bookMap = + (0 until cachedItems.itemCount) + .mapNotNull { cachedItems[it] } + .associateBy { it.id } + + val selectionsByBook = selectedIds.groupBy { it.bookId } + + selectionsByBook.forEach { (bookId, selections) -> + val book = bookMap[bookId] ?: return@forEach + val playingBook = playerViewModel.book.value + val volumes = viewModel.getVolumes(book) + + if (playingBook?.id == book.id) { + playerViewModel.clearPlayingBook() + } + + selections.forEach { selection -> + val volume = volumes.find { v -> v.id == selection.fileId } + volume?.chapters?.forEach { chapter -> + viewModel.dropCache(book, chapter) + } + } + } +} + private suspend fun dropCache( item: DetailedItem, chapter: PlayingChapter, @@ -449,7 +821,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/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/ui/theme/Color.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt index 8e01ea36a..a02ae9f4e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/theme/Color.kt @@ -11,3 +11,6 @@ val MediumBackground = Color(0xFFDADADA) val SurfaceContainerLight = Color(0xFFEEEEEE) val TertiaryContainerDark = Color(0xFF1A1A1A) + +val DownloadSuccessLight = Color(0xFF475569) +val DownloadSuccessDark = Color(0xFF94A3B8) 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..2cd53b8d8 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -16,6 +16,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.grakovne.lissen.content.cache.persistent.CacheState @@ -49,6 +50,18 @@ class CachingModelView val totalCount: LiveData = _totalCount private val _bookCachingProgress = mutableMapOf>() + 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, + val totalBytes: Long, + ) + private val pageConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -77,10 +90,57 @@ class CachingModelView MutableStateFlow(progress) } flow.value = progress + + if (progress.status is CacheStatus.Completed || progress.status is CacheStatus.Error) { + _cacheVersion.update { it + 1 } + } + } + } + + 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 calculateChapterSize( + bookId: String, + 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) { type, index -> + when (type) { + org.grakovne.lissen.content.cache.persistent.VolumeLabelType.FULL_ARCHIVE -> + context.getString( + org.grakovne.lissen.R.string.download_volume_full_archive, + ) + org.grakovne.lissen.content.cache.persistent.VolumeLabelType.VOLUME -> + context.getString( + org.grakovne.lissen.R.string.download_volume_name, + index, + ) + org.grakovne.lissen.content.cache.persistent.VolumeLabelType.PART -> + context.getString( + org.grakovne.lissen.R.string.download_volume_part, + index, + ) + } + } + suspend fun clearShortTermCache() { withContext(Dispatchers.IO) { cachedCoverProvider.clearCache() @@ -113,10 +173,14 @@ class CachingModelView suspend fun dropCache(bookId: String) { contentCachingManager.dropCache(bookId) + _cacheVersion.update { it + 1 } + refreshStorageStats() } suspend fun dropCompletedChapters(item: DetailedItem) { contentCachingManager.dropCompletedChapters(item) + _cacheVersion.update { it + 1 } + refreshStorageStats() } fun stopCaching(item: DetailedItem) { @@ -134,6 +198,8 @@ class CachingModelView chapter: PlayingChapter, ) { contentCachingManager.dropCache(item, chapter) + _cacheVersion.update { it + 1 } + refreshStorageStats() } fun toggleCacheForce() { @@ -145,15 +211,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) { @@ -162,9 +224,40 @@ class CachingModelView } } + fun refreshMetadata() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + var page = 0 + var hasMore = true + + while (hasMore) { + val items = + localCacheRepository + .fetchDetailedItems(BATCH_SIZE, page) + .fold( + onSuccess = { it.items }, + onFailure = { emptyList() }, + ) + + items.forEach { localCacheRepository.cacheBookMetadata(it) } + + if (items.size < BATCH_SIZE) { + hasMore = false + } else { + page++ + } + } + + _cacheVersion.update { it + 1 } + refreshStorageStats() + } + } + } + suspend fun fetchLatestUpdate(libraryId: String) = localCacheRepository.fetchLatestUpdate(libraryId) companion object { private const val PAGE_SIZE = 20 + private const val BATCH_SIZE = 50 } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt index 2a3efa097..910c65f61 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,13 +45,11 @@ 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 - private val _recentBookUpdating = MutableLiveData(false) - val recentBookUpdating: LiveData = _recentBookUpdating - private val _searchRequested = MutableLiveData(false) val searchRequested: LiveData = _searchRequested @@ -74,7 +73,11 @@ class LibraryViewModel networkService.isServerAvailable, preferences.forceCacheFlow, ) { isServerAvailable, isForceCache -> - !isServerAvailable || isForceCache + val downloadedOnly = !isServerAvailable || isForceCache + Timber.d( + "Library State Calculation: isServerAvailable=$isServerAvailable, isForceCache=$isForceCache -> downloadedOnly=$downloadedOnly", + ) + downloadedOnly } private var currentLibraryId = "" @@ -92,7 +95,6 @@ class LibraryViewModel val localCacheUpdated = latestLocalUpdate?.let { it > localCacheUpdatedAt } ?: true if (emptyContent || libraryChanged || orderingChanged || (isLocalCacheUsing && localCacheUpdated)) { - refreshRecentListening() refreshLibrary() currentLibraryId = preferences.getPreferredLibrary()?.id ?: "" @@ -102,10 +104,6 @@ class LibraryViewModel } init { - viewModelScope.launch { - downloadedOnlyFlow.collect { refreshRecentListening() } - } - viewModelScope.launch { combine( preferences.preferredLibraryIdFlow, @@ -131,10 +129,11 @@ class LibraryViewModel .isServerAvailable .collect { isAvailable -> if (isAvailable) { - Timber.d("Server is reachable. Triggering repository sync.") + Timber.i("Server Availability Event: Server is now REACHABLE. Triggering repository sync.") bookRepository.syncRepositories() - refreshRecentListening() refreshLibrary() + } else { + Timber.i("Server Availability Event: Server is now UNREACHABLE.") } } } @@ -199,8 +198,7 @@ class LibraryViewModel private fun syncLibrary(libraryId: String) { viewModelScope.launch(Dispatchers.IO) { - bookRepository.syncRepositories() - refreshRecentListening() + bookRepository.syncRepositories(overrideLibraryId = libraryId) defaultPagingSource.value?.invalidate() } } @@ -215,7 +213,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? = @@ -229,17 +230,32 @@ class LibraryViewModel ?.type ?: LibraryType.UNKNOWN - fun refreshRecentListening() { + fun refreshLibrary(forceRefresh: Boolean = false) { viewModelScope.launch { withContext(Dispatchers.IO) { - fetchRecentListening() - } - } - } + val isAvailable = + if (forceRefresh) { + networkService.refreshServerAvailabilitySync() + } else { + networkService.isServerAvailable.value + } + + val shouldSync = (forceRefresh || isAvailable) && !preferences.isForceCache() + + if (shouldSync) { + val libraryId = preferences.getPreferredLibrary()?.id + + if (libraryId != null) { + bookRepository.syncLibraryPage( + libraryId = libraryId, + pageSize = PAGE_SIZE, + pageNumber = 0, + ) + } + + bookRepository.syncRepositories() + } - fun refreshLibrary() { - viewModelScope.launch { - withContext(Dispatchers.IO) { when (searchRequested.value) { true -> searchPagingSource?.invalidate() else -> defaultPagingSource.value?.invalidate() @@ -248,21 +264,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 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..9c4db1fde --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt @@ -0,0 +1,82 @@ +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() { + if (_migrationState.value != MigrationState.Idle) { + return + } + + executeMigration() + } + + fun retryMigration() { + if (_migrationState.value == MigrationState.Running) { + return + } + + 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 + bookDao.countCachedBooks(null, "", "") + } + + preferences.setDatabaseVersion(CURRENT_DATABASE_VERSION) + _migrationState.value = MigrationState.Completed + } catch (e: Exception) { + 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 = 19 + } + } + +sealed class MigrationState { + data object Idle : 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/PlayerViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt index 3a4f860ae..747a4a485 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt @@ -56,6 +56,9 @@ class PlayerViewModel val preparingError: LiveData = mediaRepository.mediaPreparingError val preparingBookId: LiveData = mediaRepository.preparingBookId + private val _isFetchingDetails = MutableLiveData(false) + val isFetchingDetails: LiveData = _isFetchingDetails + private val _searchRequested = MutableLiveData(false) val searchRequested: LiveData = _searchRequested @@ -154,7 +157,12 @@ class PlayerViewModel fun fetchBook(bookId: String) { viewModelScope.launch { - mediaRepository.fetchBook(bookId) + try { + _isFetchingDetails.postValue(true) + mediaRepository.fetchBook(bookId) + } finally { + _isFetchingDetails.postValue(false) + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt index cd5655b87..edc01eaed 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,8 @@ 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 import org.grakovne.lissen.common.ColorScheme @@ -33,6 +35,10 @@ 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, + private val clarityTracker: ClarityTracker, + private val clarityComponent: ClarityComponent, ) : ViewModel() { private val _host: MutableLiveData = MutableLiveData(preferences.getHost()?.let { Host.external(it) }) val host = _host @@ -75,7 +81,7 @@ class SettingsViewModel val seekTime = preferences.seekTimeFlow.asLiveData() - private val _crashReporting = MutableLiveData(preferences.getAcraEnabled()) + private val _crashReporting = MutableLiveData(preferences.getCrashReportingEnabled()) val crashReporting = _crashReporting private val _bypassSsl = MutableLiveData(preferences.getSslBypass()) @@ -94,9 +100,12 @@ 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) + preferences.saveCrashReportingEnabled(value) } fun preferBypassSsl(value: Boolean) { @@ -137,6 +146,12 @@ class SettingsViewModel preferences.clearPreferences() } + fun updateAnalyticsConsent(accepted: Boolean) { + _analyticsConsent.postValue(accepted) + preferences.saveAnalyticsConsentState(accepted) + clarityComponent.updateConsent(accepted) + } + fun refreshConnectionInfo() { fetchConnectionHost() @@ -184,6 +199,7 @@ class SettingsViewModel fun preferLibrary(library: Library) { _preferredLibrary.postValue(library) preferences.savePreferredLibrary(library) + clarityTracker.trackEvent("library_switched", library.id) } fun preferAutoDownloadNetworkType(type: NetworkTypeAutoCache) { @@ -289,4 +305,26 @@ class SettingsViewModel host?.let { _host.postValue(it) } } + + fun clearMetadataCache(onComplete: () -> Unit) { + viewModelScope.launch { + try { + try { + bookRepository.clearMetadataCache() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + timber.log.Timber.e(e, "Failed to clear book metadata cache") + } + + try { + cachedCoverProvider.clearCache() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + timber.log.Timber.e(e, "Failed to clear cover cache") + } + } finally { + onComplete() + } + } + } } diff --git a/app/src/main/res/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-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 82c7f2ea1..696ad2664 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -102,7 +102,7 @@ لم يتم حفظ أي كتب بعد خادم Audiobookshelf مطلوب لا يوجد اتصال بالإنترنت - عذراً على تعطل التطبيق\nتم إرسال السجلات للتحليل + عذراً على تعطل التطبيق\nتم إرسال السجلات للتحليل البحث حسب العنوان، المؤلف، أو السلسلة يتم الاستماع الآن رؤوس مخصصة @@ -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..d6c7ea8fc 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -37,7 +37,7 @@ Ingen hentede bøger Audiobookshelf-server er påkrævet Ingen internetforbindelse - Beklager appnedbruddet\nLogfiler er sendt til analyse + Beklager appnedbruddet\nLogfiler er sendt til analyse Afspilningshastighed System Lys @@ -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..92927fac5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -24,7 +24,7 @@ Die Bibliothek ist leer Audiobookshelf Server ist notwendig Keine Internetverbindung - Entschuldigung für den Absturz der Anwendung\nDie Protokolle wurden zur Analyse gesendet + Entschuldigung für den Absturz der Anwendung\nDie Protokolle wurden zur Analyse gesendet Farbschema Suche nach Titel, Autor oder Serie Aktuelle Wiedergabe @@ -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-es/strings.xml b/app/src/main/res/values-es/strings.xml index ee3944ac9..61148c06a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -26,7 +26,7 @@ Error de conexión La biblioteca esta vacía Aún no hay libros guardados - Lamentamos el fallo de la aplicación\nLos registros se han enviado para su análisis + Lamentamos el fallo de la aplicación\nLos registros se han enviado para su análisis Seguir escuchando Capítulo %1$d de %2$s Artículo %1$d de %2$s diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index b3ef731fd..85902b209 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -87,7 +87,7 @@ Mukautetut otsakkeet Lataa kirja Koko podcast - Pahoittelut sovelluksen kaatumisesta.\nVirheraportti on lähetetty tutkittavaksi. + Pahoittelut sovelluksen kaatumisesta.\nVirheraportti on lähetetty tutkittavaksi. Mukautetut otsakkeet Poista ajastin käytöstä Paina avataksesi sovelluksen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 812c5e075..5a9ed7a8f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -33,7 +33,7 @@ Aucun livre enregistré pour l\'instant Un serveur Audiobookshelf est requis Pas de connexion internet - Désolé pour le crash de l\'application\nLes journaux ont été transmis pour analyse + Désolé pour le crash de l\'application\nLes journaux ont été transmis pour analyse Vitesse de lecture Système Clair @@ -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..063f8acce 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -34,7 +34,7 @@ Nema preuzetih knjiga Potreban je Audiobookshelf poslužitelj Nema internetske veze - Ispričavamo se zbog rušenja aplikacije\nDnevnici su poslani na analizu + Ispričavamo se zbog rušenja aplikacije\nDnevnici su poslani na analizu Brzina reprodukcije Sustav Svijetla @@ -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..d29986bf2 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -49,7 +49,7 @@ Kiszolgáló verziója Amikor a rész véget ér Amikor a fejezet véget ér - Elnézést kérünk az alkalmazás összeomlásáért\nA naplókat elküldtük kivizsgálásra + Elnézést kérünk az alkalmazás összeomlásáért\nA naplókat elküldtük kivizsgálásra %1$d perc Időzítő kikapcsolása Elalvás időzítő @@ -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..efce3fa2d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -38,7 +38,7 @@ Belum ada buku tersimpan Server Audiobookshelf diperlukan Tidak tersambung ke internet - Kerusakan aplikasi tercatat\nLog telah dikirim untuk dianalisis + Kerusakan aplikasi tercatat\nLog telah dikirim untuk dianalisis Kecepatan pemutaran Sistem Terang @@ -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..b6feaed1e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -30,7 +30,7 @@ La libreria è vuota Nessun libro ancora salvato È necessario il server Audiobookshelf - Ci scusiamo per il blocco dell\'applicazione\nI log sono stati inviati per l\'analisi + Ci scusiamo per il blocco dell\'applicazione\nI log sono stati inviati per l\'analisi Velocità di riproduzione Sistema Chiaro @@ -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-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 28e8d918c..08856fdc8 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -33,7 +33,7 @@ შენახული წიგნები ჯერ-ჯერობით არ არის საჭიროა Audiobookshelf სერვერი ინტერნეტთან კავშირი არ არის - ბოდიშს გიხდით აპლიკაციის ხარვეზისთვის\nჟურნალები ანალიზისთვის გაგზავნილია დამმუშავებელთან + ბოდიშს გიხდით აპლიკაციის ხარვეზისთვის\nჟურნალები ანალიზისთვის გაგზავნილია დამმუშავებელთან მოსმენის სიჩქარე სისტემის მიხედვით ღია diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 43cf3bd40..cdde4d17b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -30,7 +30,7 @@ 저장된 책이 없습니다 Audiobookshelf 서버가 필요합니다 인터넷 연결이 없습니다 - 애플리케이션 오류가 발생했습니다\n관련 로그가 전달되었습니다 + 애플리케이션 오류가 발생했습니다\n관련 로그가 전달되었습니다 재생 속도 시스템 라이트 @@ -149,9 +149,6 @@ WiFi 네트워크 이름 앱이 WiFi 이름을 감지하려면 위치 접근 권한이 필요합니다. 허용 - 썸네일 캐시 삭제 - 공간 확보를 위해 썸네일 캐시 삭제 - 썸네일 캐시 삭제됨 자동 다운로드 지연 다운로드를 시작하기 전에 잠시 대기 SSL 인증 비활성화 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7073c44af..b3a42e8f7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -28,7 +28,7 @@ Gebruikersnaam ontbreekt Voorkeuren Geen internetverbinding - Excuses voor de crash van de applicatie\nLogboeken zijn verzonden voor analyse + Excuses voor de crash van de applicatie\nLogboeken zijn verzonden voor analyse Hele boek Timer Alle inhoud diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9c4664b7f..d3f80c4f7 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -38,7 +38,7 @@ Brak pobranych książek Serwer Audiobookshelf jest wymagany Brak połączenia internetowego - Przykro nam ale aplikacja zamknęła się z powodu błędu\nLogi zostały wysłane do dalszej analizy + Przykro nam ale aplikacja zamknęła się z powodu błędu\nLogi zostały wysłane do dalszej analizy Prędkość odtwarzania System Jasny @@ -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..da4b86df2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -31,7 +31,7 @@ Загруженных книг нет Для работы приложения необходим сервер Audiobookshelf Нет подключения к интернету - Извините за ошибку \nДанные уже отправлены для анализа + Извините за ошибку \nДанные уже отправлены для анализа Скорость воспроизведения Системная Светлая @@ -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..72693a313 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -33,7 +33,7 @@ Ni še shranjenih knjig Potreben je strežnik Audiobookshelf Ni internetne povezave - Se opravičujemo za zrušitev aplikacije\nDnevniki so bili poslani v analizo + Se opravičujemo za zrušitev aplikacije\nDnevniki so bili poslani v analizo Hitrost predvajanja Sistem Svetlo @@ -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-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 26c9d4690..5977efa37 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -28,7 +28,7 @@ Innehåll %1$d av %2$s Lösenord saknas Ingen internetanslutning - Ursäkta oss att applikationen kraschade\nLoggar har skickats för analys + Ursäkta oss att applikationen kraschade\nLoggar har skickats för analys Ljus Mörk Färgschema diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 2261633d2..54e54c795 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -34,7 +34,7 @@ சேமித்த புத்தகங்கள் இதுவரை இல்லை ஆடியோபுக்செல்ஃப் சேவையகம் தேவை இணைய இணைப்பு இல்லை - விண்ணப்ப விபத்துக்கு மன்னிக்கவும்\n பதிவுகள் பகுப்பாய்விற்கு அனுப்பப்பட்டுள்ளன + விண்ணப்ப விபத்துக்கு மன்னிக்கவும்\n பதிவுகள் பகுப்பாய்விற்கு அனுப்பப்பட்டுள்ளன பின்னணி விரைவு மண்டலம் ஒளி diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1ea7937ea..1909bafb0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -34,7 +34,7 @@ Henüz kaydedilmiş kitap yok Audiobookshelf sunucusu gerekli İnternet bağlantısı yok - Uygulama çökmesi için özür dileriz\nGünlükler analiz için gönderildi + Uygulama çökmesi için özür dileriz\nGünlükler analiz için gönderildi Oynatma hızı Sistem Açık diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6fbb0dd2b..cfec2aaae 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -56,7 +56,7 @@ 查看已下载内容 空媒体库 需要 Audiobookshelf 服务器 - 应用意外退出,我们深表歉意\n日志已发送,用于问题分析 + 应用意外退出,我们深表歉意\n日志已发送,用于问题分析 浅色 深色 颜色主题 @@ -145,9 +145,6 @@ Wi-Fi 网络名称 该应用需要访问位置权限以检测 Wi-Fi 名称 允许 - 清除缩略图缓存 - 清除缓存的缩略图以释放空间。 - 缩略图缓存已清除 停止下载 延迟自动下载 开始下载前等待一小段时间 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6b8e7099..81c6d868f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,7 @@ No saved books yet Audiobookshelf server is required No internet connection - Sorry for the application crash\nLogs have been sent for analysis + Sorry for the application crash\nLogs have been sent for analysis Playback speed System Light @@ -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 @@ -224,13 +221,66 @@ 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. + 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 + + + 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 + Part %1$d + Chapters %1$s + + Free up %1$s? + Select + 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 + + App Updates + Notifications about new versions + Kahani %1$s Available + A new version is available. Tap to download. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4ac84b6cf..79565bc1a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,6 @@ plugins { id("com.google.devtools.ksp") version "2.2.21-2.0.4" apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.android.library) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index a07bc6f99..8ae26beb5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,5 +3,5 @@ android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true -appVersionName=1.0.0 -appVersionCode=10000 +appVersionName=1.1.0 +appVersionCode=10100 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index baae4d8a8..60d6775e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,4 @@ [versions] -acraCore = "5.13.1" agp = "8.13.2" browser = "1.9.0" coilCompose = "3.3.0" @@ -32,11 +31,12 @@ coreKtx = "1.17.0" timber = "5.0.1" moshi = "1.15.2" converterMoshi = "3.0.0" +clarity = "3.8.0" +googleServices = "4.4.2" +firebaseCrashlyticsPlugin = "3.0.3" +firebaseBom = "33.9.0" [libraries] -acra-core = { module = "ch.acra:acra-core", version.ref = "acraCore" } -acra-http = { module = "ch.acra:acra-http", version.ref = "acraCore" } -acra-toast = { module = "ch.acra:acra-toast", version.ref = "acraCore" } androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } androidx-glance = { module = "androidx.glance:glance", version.ref = "glance" } androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } @@ -84,9 +84,15 @@ 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" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } diff --git a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt index da1249f3d..cda19f770 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt @@ -11,4 +11,6 @@ data class Book( val author: String?, val duration: Double = 0.0, val libraryId: String = "", + val addedAt: Long = 0, + val updatedAt: Long = 0, ) 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/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() 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..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,61 +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 localProvided: Boolean, - val createdAt: Long, - val updatedAt: Long, + 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 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 008f149ec..4d57d68d6 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt @@ -3,11 +3,17 @@ package org.grakovne.lissen.lib.domain import androidx.annotation.Keep import java.io.Serializable +import java.util.Base64 + @Keep 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 +23,50 @@ 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 -> { + val payload = fileIds + .map { Base64.getUrlEncoder().withoutPadding().encodeToString(it.toByteArray()) } + .joinToString(",") + "specific_files_$payload" + } + + 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_") -> { + try { + NumberItemDownloadOption(substringAfter("number_items_").toInt()) + } catch (e: NumberFormatException) { + null + } + } + + startsWith("specific_files_") -> { + try { + val payload = substringAfter("specific_files_") + + val fileIds = when { + payload.isEmpty() -> emptyList() + else -> payload + .split(",") + .map { String(Base64.getUrlDecoder().decode(it)) } + } + + SpecificFilesDownloadOption(fileIds) + } catch (e: IllegalArgumentException) { + null + } + } + + else -> null }