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..d86dc46b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,15 +44,38 @@ jobs: RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + - name: Build Changelog + id: build_changelog + uses: mikepenz/release-changelog-builder-action@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + configuration: | + { + "categories": [ + { + "title": "## 🚀 Features", + "labels": ["feature", "enhancement"] + }, + { + "title": "## 🐛 Bug Fixes", + "labels": ["fix", "bug"] + }, + { + "title": "## 📦 Dependencies & Maintenance", + "labels": ["chore", "deps", "dependency", "refactor"] + } + ], + "template": "#{{CHANGELOG}}" + } + - name: Create Release uses: softprops/action-gh-release@v1 with: files: app/build/outputs/apk/release/app-release.apk tag_name: v${{ steps.get_version.outputs.version }} name: Kahani v${{ steps.get_version.outputs.version }} - body: | - Automated Release for Kahani. - Commit: ${{ github.sha }} + body: ${{ steps.build_changelog.outputs.changelog }} draft: false prerelease: false env: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7cbac8275..d871bdfe2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,8 @@ android { buildConfigField("String", "ACRA_REPORT_LOGIN", "\"$acraReportLogin\"") buildConfigField("String", "ACRA_REPORT_PASSWORD", "\"$acraReportPassword\"") + buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"") + signingConfigs { create("release") { val envKeyStore = System.getenv("RELEASE_STORE_FILE") @@ -194,6 +196,8 @@ dependencies { implementation(libs.moshi) implementation(libs.moshi.kotlin) + implementation(libs.microsoft.clarity) + debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index cbbb0eeb9..e919402af 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,4 +17,8 @@ # Hilt and Dagger rules (usually bundled, but good to ensure) -keep class dagger.hilt.android.internal.** { *; } -keep class *__HiltBindingModule { *; } --keep class org.grakovne.lissen.**_HiltComponents$* { *; } \ No newline at end of file +-keep class org.grakovne.lissen.**_HiltComponents$* { *; } + +# Microsoft Clarity +-keep class com.microsoft.clarity.** { *; } +-keep interface com.microsoft.clarity.** { *; } \ No newline at end of file diff --git a/app/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/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..9d711bb90 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt @@ -0,0 +1,91 @@ +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 identifier = "$username@$host" + clarityTracker.setUser(identifier) + } else { + // Fallback to device ID if not logged in + clarityTracker.setUser(preferences.getDeviceId()) + } + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt b/app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt new file mode 100644 index 000000000..e9c57d0b5 --- /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: $value") + Timber.d("Clarity Event tracked: $eventName with value: $value") + } catch (e: Exception) { + Timber.e(e, "Failed to track Clarity event: $eventName") + } + } + } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt index 82041671a..ab0ae19e6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt @@ -77,6 +77,7 @@ class BookResponseConverter .audioFiles ?.sortedBy { it.index } ?.map { + timber.log.Timber.d("Mapping file ${it.ino} with size ${it.metadata.size}") BookFile( id = it.ino, name = @@ -84,6 +85,7 @@ class BookResponseConverter ?.tagTitle ?: (it.metadata.filename.removeSuffix(it.metadata.ext)), duration = it.duration, + size = it.metadata.size ?: 0L, mimeType = it.mimeType, ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt index c7bc40efd..00a9eb14e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt @@ -66,7 +66,7 @@ data class BookAudioFileResponse( data class AudioFileMetadata( val filename: String, val ext: String, - val size: Long, + val size: Long?, ) @Keep diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt index 89d4fc878..d8e072204 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt @@ -85,6 +85,7 @@ class PodcastResponseConverter id = it.audioFile.ino, name = it.title, duration = it.audioFile.duration, + size = it.audioFile.size ?: it.size ?: 0L, mimeType = it.audioFile.mimeType, ) } diff --git a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt index 532371698..ba7d69b0e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt @@ -38,6 +38,7 @@ data class PodcastEpisodeResponse( val episode: String?, val pubDate: String?, val title: String, + val size: Long?, val audioFile: PodcastAudioFileResponse, ) @@ -46,5 +47,6 @@ data class PodcastEpisodeResponse( data class PodcastAudioFileResponse( val ino: String, val duration: Double, + val size: Long?, val mimeType: String, ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt b/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt new file mode 100644 index 000000000..7a34e10ab --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt @@ -0,0 +1,12 @@ +package org.grakovne.lissen.common + +import java.security.MessageDigest + +fun sha256(input: String): String { + val bytes = + MessageDigest + .getInstance("SHA-256") + .digest(input.toByteArray()) + + return bytes.joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt index e4d7c66c5..be7f1168f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt @@ -7,12 +7,26 @@ import com.squareup.moshi.JsonWriter import com.squareup.moshi.Moshi import com.squareup.moshi.ToJson import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import timber.log.Timber +import java.lang.reflect.Type import java.util.UUID val moshi: Moshi = Moshi .Builder() .add( + object : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: Set, + moshi: Moshi, + ): JsonAdapter<*>? { + val adapter = moshi.nextAdapter(this, type, annotations) + Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}") + return adapter + } + }, + ).add( UUID::class.java, object : JsonAdapter() { @FromJson @@ -26,4 +40,5 @@ val moshi: Moshi = writer.value(value?.toString()) } }, - ).build() + ).addLast(KotlinJsonAdapterFactory()) + .build() diff --git a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt index c56cc90d5..7fb83043a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt @@ -54,37 +54,37 @@ class NetworkService val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - checkServerAvailability() + refreshServerAvailability() } override fun onLost(network: Network) { if (cachedNetworkHandle == network.getNetworkHandle()) { cachedSsid = null } - checkServerAvailability() + refreshServerAvailability() } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities, ) { - checkServerAvailability() + refreshServerAvailability() } } connectivityManager.registerNetworkCallback(networkRequest, networkCallback) - checkServerAvailability() + refreshServerAvailability() scope.launch { preferences.hostFlow.collect { - checkServerAvailability() + refreshServerAvailability() } } } private var checkJob: Job? = null - private fun checkServerAvailability() { + fun refreshServerAvailability() { checkJob?.cancel() checkJob = scope.launch { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt index a3d7359cc..e8ecebfe8 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,12 @@ class AuthRepository refreshToken = account.refreshToken, ) + clarityTracker.setUser( + org.grakovne.lissen.common + .sha256("${account.username}@$host"), + ) + clarityTracker.trackEvent("login_success") + // Trigger library fetch bookRepository .fetchLibraries() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt index 89146ce1a..0ba8df5d3 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt @@ -1,8 +1,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, @@ -162,7 +180,10 @@ class BookRepository pageNumber = pageNumber, ).also { it.foldAsync( - onSuccess = { result -> localCacheRepository.cacheBooks(result.items) }, + onSuccess = { result -> + localCacheRepository.cacheBooks(result.items) + backgroundScope.launch { prefetchCovers(result.items) } + }, onFailure = {}, ) } @@ -324,7 +345,10 @@ class BookRepository when { remoteTime > localTime -> { providePreferredChannel().fetchBook(id).foldAsync( - onSuccess = { localCacheRepository.cacheBookMetadata(it) }, + onSuccess = { + localCacheRepository.cacheBookMetadata(it) + backgroundScope.launch { prefetchCovers(listOf(it.toBook())) } + }, onFailure = {}, ) } @@ -355,6 +379,34 @@ class BookRepository } } + private suspend fun prefetchCovers(books: List) { + if (!networkService.isNetworkAvailable() || preferences.isForceCache()) { + return + } + + yield() + delay(2000) // Initial delay to prioritize core metadata and thumbnails + + withContext(Dispatchers.IO) { + books.forEach { book -> + fetchBookCover(book.id, null) + delay(100) + yield() + } + } + } + + private fun DetailedItem.toBook() = + Book( + id = this.id, + subtitle = this.subtitle, + series = this.series.joinToString { it.name }, + title = this.title, + author = this.author, + duration = this.chapters.sumOf { it.duration }, + libraryId = this.libraryId ?: "", + ) + private fun OperationResult.getOrNull(): T? = when (this) { is OperationResult.Success -> data diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt index 0a1670f94..6c9666692 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt @@ -237,7 +237,7 @@ class LissenMediaProvider val localResult = localCacheRepository.fetchBook(bookId) val isDetailed = localResult - ?.let { it.chapters.isNotEmpty() || it.files.isNotEmpty() } + ?.let { (it.chapters.isNotEmpty() || it.files.isNotEmpty()) && (it.files.isEmpty() || it.files.all { f -> f.size > 0 }) } ?: false if (localResult != null && isDetailed) { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt index cf0f0db8e..5fca2aa25 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt @@ -7,15 +7,21 @@ fun findRelatedFiles( chapter: PlayingChapter, files: List, ): List { - val chapterStartRounded = chapter.start.round() - val chapterEndRounded = chapter.end.round() - val startTimes = files .runningFold(0.0) { acc, file -> acc + file.duration } .dropLast(1) val fileStartTimes = files.zip(startTimes) + return findRelatedFilesByStartTimes(chapter, fileStartTimes) +} + +fun findRelatedFilesByStartTimes( + chapter: PlayingChapter, + fileStartTimes: List>, +): List { + val chapterStartRounded = chapter.start.round() + val chapterEndRounded = chapter.end.round() return fileStartTimes .filter { (file, fileStartTime) -> diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt index fc2bce5e5..51a89cb10 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt @@ -1,6 +1,7 @@ package org.grakovne.lissen.content.cache.persistent import org.grakovne.lissen.lib.domain.AllItemsDownloadOption +import org.grakovne.lissen.lib.domain.BookFile import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption import org.grakovne.lissen.lib.domain.DetailedItem import org.grakovne.lissen.lib.domain.DownloadOption @@ -13,6 +14,21 @@ fun calculateRequestedChapters( book: DetailedItem, option: DownloadOption, currentTotalPosition: Double, +): List { + val startTimes = + book.files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) + + val fileStartTimes = book.files.zip(startTimes) + return calculateRequestedChapters(book, option, currentTotalPosition, fileStartTimes) +} + +fun calculateRequestedChapters( + book: DetailedItem, + option: DownloadOption, + currentTotalPosition: Double, + fileStartTimes: List>, ): List { val chapterIndex = calculateChapterIndex(book, currentTotalPosition) @@ -24,10 +40,18 @@ fun calculateRequestedChapters( chapterIndex.coerceAtLeast(0), (chapterIndex + option.itemsNumber).coerceIn(chapterIndex..book.chapters.size), ) + RemainingItemsDownloadOption -> book.chapters.subList( chapterIndex.coerceIn(0, book.chapters.size), book.chapters.size, ) + + is org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption -> + book.chapters.filter { chapter -> + org.grakovne.lissen.content.cache.common + .findRelatedFilesByStartTimes(chapter, fileStartTimes) + .any { it.id in option.fileIds } + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt index c98d7e848..eaa3ef925 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt @@ -3,13 +3,16 @@ package org.grakovne.lissen.content.cache.persistent import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withContext import okhttp3.Request +import org.grakovne.lissen.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 +39,100 @@ class ContentCachingManager private val properties: OfflineBookStorageProperties, private val requestHeadersProvider: RequestHeadersProvider, private val preferences: LissenSharedPreferences, + private val clarityTracker: ClarityTracker, ) { 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 } -> { + cacheBookInfo(mediaItem, requestedChapters) + 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 +147,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 +210,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 +224,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 +244,31 @@ class ContentCachingManager try { dest.outputStream().use { output -> body.byteStream().use { input -> - input.copyTo(output) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead = 0L + val contentLength = body.contentLength().takeIf { it > 0 } ?: file.size + + var lastReportedProgress = -1.0 + val reportThreshold = 0.01 // 1% + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + val fileProgress = 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) { return@withContext CacheState(CacheStatus.Error) } - - onProgress(files.size.takeIf { it != 0 }?.let { index / it.toDouble() } ?: 0.0) } CacheState(CacheStatus.Completed) diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/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..983957100 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt @@ -37,6 +37,9 @@ class ContentCachingService : LifecycleService() { private val executionStatuses = mutableMapOf() private val executingCaching = mutableMapOf() + private var lastNotificationUpdate = 0L + private val notificationUpdateThrottle = 500L + override fun onStartCommand( intent: Intent?, flags: Int, @@ -103,23 +106,29 @@ class ContentCachingService : LifecycleService() { executor .run(mediaProvider.providePreferredChannel()) .onCompletion { - if (executionStatuses.isEmpty()) { + if (executionStatuses.isEmpty() || !inProgress()) { finish() } }.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 shouldUpdateNotification = isTerminalState || (currentTime - lastNotificationUpdate >= notificationUpdateThrottle) + + if (shouldUpdateNotification && inProgress() && hasErrors().not()) { + executionStatuses + .entries + .map { (item, status) -> item to status } + .let { notificationService.updateCachingNotification(it) } - when (inProgress() && hasErrors().not()) { - true -> - executionStatuses - .entries - .map { (item, status) -> item to status } - .let { notificationService.updateCachingNotification(it) } + lastNotificationUpdate = currentTime + } - false -> finish() + if (!inProgress()) { + 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..d9144cad9 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt @@ -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..a7ce938bd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt @@ -15,6 +15,7 @@ import org.grakovne.lissen.lib.domain.Library import org.grakovne.lissen.lib.domain.MediaProgress import org.grakovne.lissen.lib.domain.PagedItems import org.grakovne.lissen.lib.domain.PlaybackProgress +import org.grakovne.lissen.lib.domain.PlayingChapter import org.grakovne.lissen.lib.domain.RecentBook import org.grakovne.lissen.playback.service.calculateChapterIndex import timber.log.Timber @@ -148,6 +149,148 @@ 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): List { + val storageType = getBookStorageType(book) + + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + val file = book.files.firstOrNull() ?: return emptyList() + return listOf( + org.grakovne.lissen.lib.domain.BookVolume( + id = file.id, + name = "Full Archive", + size = file.size, + chapters = book.chapters, + isDownloaded = storageProperties.provideMediaCachePath(book.id, file.id).exists(), + ), + ) + } + + val mediaMap = calculateMediaMap(book) + + return book.files.mapIndexed { index, file -> + val relatedChapters = mediaMap[file.id] ?: emptyList() + + val name = + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.SEGMENTED) { + "Volume ${index + 1}" + } else { + relatedChapters.firstOrNull()?.title ?: "Part ${index + 1}" + } + + org.grakovne.lissen.lib.domain.BookVolume( + id = file.id, + name = name, + size = file.size, + chapters = relatedChapters, + isDownloaded = storageProperties.provideMediaCachePath(book.id, file.id).exists(), + ) + } + } + + private fun calculateMediaMap(book: DetailedItem): Map> { + val result = mutableMapOf>() + val fileStartTimes = calculateFileStartTimes(book.files) + + var chapterIdx = 0 + for ((file, fileStart) in fileStartTimes) { + val fileEnd = fileStart + file.duration + + // Skip chapters that end before this file starts + while (chapterIdx < book.chapters.size && book.chapters[chapterIdx].end.round() <= fileStart.round()) { + chapterIdx++ + } + + var currentChapterIdx = chapterIdx + while (currentChapterIdx < book.chapters.size && book.chapters[currentChapterIdx].start.round() < fileEnd.round()) { + result.getOrPut(file.id) { mutableListOf() }.add(book.chapters[currentChapterIdx]) + currentChapterIdx++ + } + } + return result + } + + private fun calculateFileStartTimes( + files: List, + ): List> { + val startTimes = + files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) + + return files.zip(startTimes) + } + + private val PRECISION = 0.01 + + private fun Double.round(): Double = kotlin.math.round(this / PRECISION) * PRECISION + + fun calculateTotalCacheSize(): Long { + val mediaFolder = storageProperties.baseFolder() + return calculateFolderSize(mediaFolder) + } + + 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 +337,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..97a2def86 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt @@ -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) @@ -103,6 +118,8 @@ class CachedBookRepository libraryType = null, createdAt = 0, updatedAt = 0, + 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..126e356b5 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt @@ -10,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,20 +69,13 @@ class FetchRequestBuilder { else -> "ASC" } - val joinClause = - when (downloadedOnly) { - true -> "INNER JOIN book_chapters ON detailed_books.id = book_chapters.bookId AND book_chapters.isCached = 1" - false -> "" - } - args.add(pageSize) args.add(pageNumber * pageSize) val sql = """ - SELECT DISTINCT detailed_books.* FROM detailed_books - $joinClause - WHERE $whereClause + SELECT detailed_books.* FROM detailed_books + WHERE $libraryClause AND $isolationClause ORDER BY $field $direction LIMIT ? OFFSET ? """.trimIndent() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt index eb05aecb6..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..5c4ead5cc 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt @@ -3,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 ? OR author LIKE ? OR seriesNames LIKE ?)" + val pattern = "%$searchQuery%" + 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,7 +66,7 @@ class SearchRequestBuilder { val sql = """ SELECT * FROM detailed_books - WHERE $whereClause + WHERE $libraryClause AND $searchClause AND $isolationClause ORDER BY $field $direction """.trimIndent() diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/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..08bb31726 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt @@ -1,6 +1,7 @@ package org.grakovne.lissen.content.cache.persistent.entity import androidx.annotation.Keep +import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey @@ -51,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 @@ -120,6 +124,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..72b206bd8 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt @@ -0,0 +1,146 @@ +package org.grakovne.lissen.domain.update + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.grakovne.lissen.BuildConfig +import org.grakovne.lissen.R +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpdateCheckerService + @Inject + constructor( + @ApplicationContext private 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() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.e("Update check failed: ${response.code}") + return@withContext + } + + val json = response.body?.string() ?: return@withContext + val releaseNode = JSONObject(json) + val tagName = releaseNode.optString("tag_name", "") + val htmlUrl = releaseNode.optString("html_url", "") + + if (tagName.isNotEmpty() && isNewerVersion(tagName, BuildConfig.VERSION_NAME)) { + showUpdateNotification(tagName, htmlUrl) + } + } catch (e: Exception) { + Timber.e(e, "Failed to check for updates") + } + } + } + + private fun isNewerVersion( + remoteTag: String, + localVersion: String, + ): Boolean { + val remote = remoteTag.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } + val local = localVersion.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } + + val length = maxOf(remote.size, local.size) + for (i in 0 until length) { + val r = remote.getOrElse(i) { 0 } + val l = local.getOrElse(i) { 0 } + if (r > l) return true + if (r < l) return false + } + return false + } + + private fun showUpdateNotification( + version: String, + url: String, + ) { + val channelId = "app_updates" + val notificationId = 1001 + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = context.getString(R.string.notification_channel_updates_name) + val descriptionText = context.getString(R.string.notification_channel_updates_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel(channelId, name, importance).apply { + description = descriptionText + } + val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val builder = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(R.drawable.ic_notification_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..13ae67cfd 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -110,6 +110,10 @@ class LissenSharedPreferences } } + fun getDatabaseVersion() = sharedPreferences.getInt(KEY_DATABASE_VERSION, 0) + + fun setDatabaseVersion(version: Int) = sharedPreferences.edit().putInt(KEY_DATABASE_VERSION, version).apply() + fun getSslBypass() = sharedPreferences.getBoolean(KEY_BYPASS_SSL, false) fun saveSslBypass(enabled: Boolean) { @@ -383,7 +387,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 +482,21 @@ class LissenSharedPreferences putBoolean(KEY_SMART_REWIND_ENABLED, enabled) } + fun getAnalyticsConsentState(): Boolean? { + if (!sharedPreferences.contains(KEY_ANALYTICS_CONSENT)) return null + return sharedPreferences.getBoolean(KEY_ANALYTICS_CONSENT, false) + } + + fun saveAnalyticsConsentState(accepted: Boolean?) { + sharedPreferences.edit { + if (accepted == null) { + remove(KEY_ANALYTICS_CONSENT) + } else { + putBoolean(KEY_ANALYTICS_CONSENT, accepted) + } + } + } + fun getSmartRewindEnabled(): Boolean = sharedPreferences.getBoolean(KEY_SMART_REWIND_ENABLED, false) fun saveSmartRewindThreshold(threshold: SmartRewindInactivityThreshold) = @@ -639,6 +662,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,6 +687,7 @@ class LissenSharedPreferences private const val KEY_SHOW_PLAYER_NAV_BUTTONS = "show_player_nav_buttons" private const val KEY_SHAKE_TO_RESET_TIMER = "shake_to_reset_timer" private const val KEY_SKIP_SILENCE_ENABLED = "skip_silence_enabled" + private const val KEY_ANALYTICS_CONSENT = "analytics_consent" private const val KEY_PLAYING_BOOK = "playing_book" private const val KEY_VOLUME_BOOST = "volume_boost" diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt index d109317fe..fab598e7c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.grakovne.lissen.analytics.ClarityTracker import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.lib.domain.CurrentEpisodeTimerOption import org.grakovne.lissen.lib.domain.DetailedItem @@ -64,6 +65,7 @@ class MediaRepository private val preferences: LissenSharedPreferences, private val mediaChannel: LissenMediaProvider, private val shakeDetector: ShakeDetector, + private val clarityTracker: ClarityTracker, ) { fun getBookFlow(bookId: String): Flow = mediaChannel.fetchBookFlow(bookId) @@ -160,6 +162,7 @@ class MediaRepository updateShakeDetectorState(preferences.getShakeToResetTimer(), isPlaying) if (isPlaying) { + _preparingBookId.postValue(null) _playingBook.value?.let { startUpdatingProgress(it) } } else { handler.removeCallbacksAndMessages(null) @@ -181,6 +184,7 @@ class MediaRepository override fun onFailure(t: Throwable) { Timber.e("Unable to add callback to player") + clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error") } }, MoreExecutors.directExecutor(), @@ -212,7 +216,6 @@ class MediaRepository preferences.savePlayingBook(it) _isPlaybackReady.postValue(true) - _preparingBookId.postValue(null) pendingChapterIndex?.let { index -> setChapter(index) @@ -222,6 +225,8 @@ class MediaRepository if (_playAfterPrepare.value == true) { _playAfterPrepare.postValue(false) play() + } else { + _preparingBookId.postValue(null) } } } @@ -263,6 +268,9 @@ class MediaRepository position: Double? = null, ) { _timerOption.postValue(timerOption) + if (timerOption != null) { + clarityTracker.trackEvent("sleep_timer_set") + } when (timerOption) { is DurationTimerOption -> scheduleServiceTimer(timerOption.duration * 60.0, timerOption) @@ -564,6 +572,7 @@ class MediaRepository } context.startForegroundService(intent) + clarityTracker.trackEvent("playback_start") } private fun pause() { @@ -573,6 +582,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..871868e4d 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt @@ -12,6 +12,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope @@ -60,7 +61,7 @@ class PlaybackService : MediaSessionService() { lateinit var playbackTimer: PlaybackTimer @Inject - lateinit var mediaCache: Cache + lateinit var mediaCache: dagger.Lazy private var session: MediaSession? = null @@ -181,7 +182,7 @@ class PlaybackService : MediaSessionService() { val sourceFactory = LissenDataSourceFactory( baseContext = baseContext, - mediaCache = mediaCache, + mediaCache = mediaCache.get(), requestHeadersProvider = requestHeadersProvider, sharedPreferences = sharedPreferences, mediaProvider = mediaProvider, @@ -196,7 +197,7 @@ class PlaybackService : MediaSessionService() { .Builder() .setTitle(file.name) .setArtist(book.title) - .setArtworkUri(fetchCover(book)) + .build() val mediaItem = MediaItem @@ -204,7 +205,7 @@ class PlaybackService : MediaSessionService() { .setMediaId(file.id) .setUri(apply(book.id, file.id)) .setTag(book) - .setMediaMetadata(mediaData.build()) + .setMediaMetadata(mediaData) .build() ProgressiveMediaSource @@ -233,6 +234,31 @@ class PlaybackService : MediaSessionService() { playbackSynchronizationService.startPlaybackSynchronization(book) } + // Fire-and-forget: Update cover in background without blocking playback readiness. + launch { + val artworkUri = fetchCover(book) ?: return@launch + + withContext(Dispatchers.Main) { + for (i in 0 until exoPlayer.mediaItemCount) { + val currentItem = exoPlayer.getMediaItemAt(i) + val updatedMetadata = + currentItem + .mediaMetadata + .buildUpon() + .setArtworkUri(artworkUri) + .build() + + val updatedItem = + currentItem + .buildUpon() + .setMediaMetadata(updatedMetadata) + .build() + + exoPlayer.replaceMediaItem(i, updatedItem) + } + } + } + awaitAll(prepareSession, prepareQueue) val intent = 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..878de2ec5 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt @@ -0,0 +1,128 @@ +package org.grakovne.lissen.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.grakovne.lissen.R +import org.grakovne.lissen.ui.theme.FoxOrange +import org.grakovne.lissen.ui.theme.Spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnalyticsConsentBottomSheet( + onAccept: () -> Unit, + onDecline: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = { /* User must make a choice */ }, + sheetState = sheetState, + containerColor = colorScheme.background, + dragHandle = null, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .padding(top = 48.dp, bottom = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.analytics_consent_title), + style = + typography.headlineSmall.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.5).sp, + lineHeight = 28.sp, + fontSize = 22.sp, + ), + textAlign = TextAlign.Center, + color = colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(Spacing.md)) + + Text( + text = stringResource(R.string.analytics_consent_message), + style = + typography.bodyLarge.copy( + lineHeight = 24.sp, + letterSpacing = 0.2.sp, + ), + textAlign = TextAlign.Center, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + ) + + Spacer(modifier = Modifier.height(48.dp)) + + Button( + onClick = onAccept, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = CircleShape, + colors = + ButtonDefaults.buttonColors( + containerColor = FoxOrange, + contentColor = Color.White, + ), + elevation = ButtonDefaults.buttonElevation(0.dp), + ) { + Text( + text = stringResource(R.string.analytics_consent_accept), + style = typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + + // Increased gap to prevent accidental clicks + Spacer(modifier = Modifier.height(Spacing.md)) + + TextButton( + onClick = onDecline, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp) + .background( + color = colorScheme.onSurface.copy(alpha = 0.05f), + shape = CircleShape, + ), + shape = CircleShape, + ) { + Text( + text = stringResource(R.string.analytics_consent_decline), + style = + typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + ), + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt index 96a3bcf1c..0b6f5a006 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt @@ -3,6 +3,9 @@ package org.grakovne.lissen.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -10,9 +13,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp import coil3.ImageLoader import coil3.compose.AsyncImage import coil3.request.ImageRequest @@ -21,6 +25,7 @@ import com.valentinilk.shimmer.shimmer @Composable fun AsyncShimmeringImage( imageRequest: ImageRequest, + thumbnailRequest: ImageRequest? = null, imageLoader: ImageLoader, contentDescription: String, contentScale: ContentScale, @@ -28,23 +33,40 @@ fun AsyncShimmeringImage( error: Painter, onLoadingStateChanged: (Boolean) -> Unit = {}, ) { - var isLoading by remember { mutableStateOf(true) } - onLoadingStateChanged(isLoading) + var isMainLoading by remember(imageRequest) { mutableStateOf(true) } + var isThumbnailLoaded by remember(thumbnailRequest) { mutableStateOf(false) } Box( modifier = modifier, contentAlignment = Alignment.Center, ) { - if (isLoading) { + // Thumbnail / Preview layer + if (thumbnailRequest != null && isMainLoading) { + AsyncImage( + model = thumbnailRequest, + imageLoader = imageLoader, + contentDescription = null, + contentScale = contentScale, + modifier = + Modifier + .fillMaxSize() + .blur(if (isThumbnailLoaded) 20.dp else 0.dp), + onSuccess = { isThumbnailLoaded = true }, + ) + } + + // Shimmer fallback if no thumbnail is loaded yet + if (isMainLoading && !isThumbnailLoaded) { Box( modifier = Modifier .fillMaxSize() .shimmer() - .background(androidx.compose.material3.MaterialTheme.colorScheme.surfaceVariant), + .background(MaterialTheme.colorScheme.surfaceVariant), ) } + // Main Image layer AsyncImage( model = imageRequest, imageLoader = imageLoader, @@ -52,14 +74,23 @@ fun AsyncShimmeringImage( contentScale = contentScale, modifier = Modifier.fillMaxSize(), onSuccess = { - isLoading = false + isMainLoading = false onLoadingStateChanged(false) }, onError = { - isLoading = false + isMainLoading = false onLoadingStateChanged(false) }, error = error, ) + + // Center Loader (Instagram style) + if (isMainLoading && isThumbnailLoaded) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + strokeWidth = 4.dp, + ) + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt index 3c5a4b9e5..18354e356 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt @@ -1,14 +1,22 @@ package org.grakovne.lissen.ui.components +import androidx.compose.animation.core.EaseOutQuart +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.stringResource @@ -25,35 +33,96 @@ import org.grakovne.lissen.lib.domain.CacheStatus @Composable fun DownloadProgressIcon( cacheState: CacheState, + isFullyDownloaded: Boolean, size: Dp = 24.dp, color: Color = LocalContentColor.current, ) { - if (cacheState.status is CacheStatus.Caching) { - val progress = cacheState.progress.coerceIn(0.0, 1.0).toFloat() - val progressPercent = (progress * 100).toInt() - val progressDescription = stringResource(R.string.download_progress_description, progressPercent) - - 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, - ) + Box(contentAlignment = Alignment.Center) { + 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 = colorScheme.primary, + ) + } 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 = colorScheme.primary, + ) + } 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/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..92dca6084 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt @@ -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 @@ -37,6 +41,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AvTimer +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -93,6 +98,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.text.HtmlCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import coil3.Extras import coil3.ImageLoader import coil3.compose.AsyncImage import coil3.request.ImageRequest @@ -105,6 +111,7 @@ import org.grakovne.lissen.lib.domain.CacheStatus import org.grakovne.lissen.lib.domain.DetailedItem import org.grakovne.lissen.lib.domain.LibraryType import org.grakovne.lissen.ui.components.AsyncShimmeringImage +import org.grakovne.lissen.ui.components.BookCoverFetcher import org.grakovne.lissen.ui.components.DownloadProgressIcon import org.grakovne.lissen.ui.extensions.formatTime import org.grakovne.lissen.ui.navigation.AppNavigationService @@ -138,10 +145,17 @@ fun BookDetailScreen( val totalPosition by playerViewModel.totalPosition.observeAsState(0.0) val cacheProgress: CacheState by cachingModelView.getProgress(bookId).collectAsState(initial = CacheState(CacheStatus.Idle)) - val hasDownloadedChapters by cachingModelView.hasDownloadedChapters(bookId).observeAsState(false) var downloadsExpanded by remember { mutableStateOf(false) } val scope = androidx.compose.runtime.rememberCoroutineScope() + 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,6 +235,7 @@ fun BookDetailScreen( IconButton(onClick = { downloadsExpanded = true }) { DownloadProgressIcon( cacheState = cacheProgress, + isFullyDownloaded = isFullyDownloaded, color = colorScheme.onSurface, ) } @@ -235,6 +250,8 @@ fun BookDetailScreen( } } 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 +274,18 @@ fun BookDetailScreen( .build() } + val thumbnailRequest: ImageRequest = + remember(book.id) { + ImageRequest + .Builder(context) + .data(book.id) + .size(200, 200) + .memoryCacheKey("${book.id}_thumbnail") + .diskCacheKey("${book.id}_thumbnail") + .apply { extras[BookCoverFetcher.LocalOnlyKey] = true } + .build() + } + Box( modifier = Modifier @@ -268,6 +297,7 @@ fun BookDetailScreen( ) { AsyncShimmeringImage( imageRequest = imageRequest, + thumbnailRequest = thumbnailRequest, imageLoader = imageLoader, contentDescription = "${book.title} cover", contentScale = ContentScale.Crop, @@ -277,13 +307,31 @@ fun BookDetailScreen( } // Title & Author - Text( - text = book.title, - style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), - color = colorScheme.onSurface, - textAlign = TextAlign.Center, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, modifier = Modifier.padding(horizontal = 24.dp), - ) + ) { + Text( + text = book.title, + style = typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f, fill = false), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (isFullyDownloaded) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = stringResource(R.string.accessibility_id_fully_downloaded), + tint = colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } book.author?.takeIf { it.isNotBlank() }?.let { Text( @@ -545,39 +593,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..8e79d7f7f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt @@ -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(true) var pullRefreshing by remember { mutableStateOf(false) } - val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false) val searchRequested by libraryViewModel.searchRequested.observeAsState(false) val preparingError by playerViewModel.preparingError.observeAsState(false) @@ -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 } @@ -479,4 +479,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..2aa0b4080 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt @@ -71,6 +71,10 @@ fun MiniPlayerComposable( val view: View = LocalView.current val isPlaying: Boolean by playerViewModel.isPlaying.observeAsState(false) + val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false) + val preparingBookId by playerViewModel.preparingBookId.observeAsState(null) + val preparingError by playerViewModel.preparingError.observeAsState(false) + var backgroundVisible by remember { mutableStateOf(true) } val dismissState = @@ -260,12 +264,23 @@ fun MiniPlayerComposable( IconButton( onClick = { withHaptic(view) { playerViewModel.togglePlayPause() } }, ) { - Icon( - imageVector = if (isPlaying) AppIcons.PauseCircleNegative else AppIcons.PlayCircleNegative, - contentDescription = if (isPlaying) "Pause" else "Play", - tint = colorScheme.onSurface, - modifier = Modifier.size(38.dp), - ) + val isError = preparingError + val isLoading = preparingBookId != null + + if (isLoading) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = colorScheme.onSurface, + strokeWidth = 3.dp, + ) + } else { + Icon( + imageVector = if (isPlaying) AppIcons.PauseCircleNegative else AppIcons.PlayCircleNegative, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = colorScheme.onSurface, + modifier = Modifier.size(38.dp), + ) + } } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/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..d4b4601e9 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt @@ -0,0 +1,122 @@ +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)) + + TextButton( + onClick = { onMigrationComplete() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.migration_screen_dismiss_button), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp, + ) + + Spacer(modifier = Modifier.height(Spacing.xl)) + + Text( + text = stringResource(R.string.migration_screen_message), + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/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..1ef04fa8e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt @@ -1,20 +1,37 @@ package org.grakovne.lissen.ui.screens.player.composable -import androidx.compose.foundation.clickable +import android.text.format.Formatter +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.AutoAwesomeMotion +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.StopCircle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -22,29 +39,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.grakovne.lissen.R -import org.grakovne.lissen.lib.domain.AllItemsDownloadOption -import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption -import org.grakovne.lissen.lib.domain.DownloadOption -import org.grakovne.lissen.lib.domain.LibraryType -import org.grakovne.lissen.lib.domain.NumberItemDownloadOption -import org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption +import org.grakovne.lissen.content.cache.common.findRelatedFilesByStartTimes +import org.grakovne.lissen.content.cache.persistent.calculateRequestedChapters import org.grakovne.lissen.ui.effects.WindowBlurEffect import org.grakovne.lissen.ui.screens.common.makeText @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadsComposable( + book: org.grakovne.lissen.lib.domain.DetailedItem, + storageType: org.grakovne.lissen.lib.domain.BookStorageType, + volumes: List, isOnline: Boolean, - libraryType: LibraryType, - hasCachedEpisodes: Boolean, cachingInProgress: Boolean, - onRequestedDownload: (DownloadOption) -> Unit, + onRequestedDownload: (org.grakovne.lissen.lib.domain.DownloadOption) -> Unit, onRequestedStop: () -> Unit, onRequestedDrop: () -> Unit, onRequestedDropCompleted: () -> Unit, @@ -53,6 +65,28 @@ fun DownloadsComposable( val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val libraryType = book.libraryType ?: org.grakovne.lissen.lib.domain.LibraryType.UNKNOWN + val hasCachedContent = volumes.any { it.isDownloaded } + val isFullBookDownloaded = volumes.all { it.isDownloaded } + + val currentChapterIndex = + org.grakovne.lissen.playback.service + .calculateChapterIndex(book, book.progress?.currentTime ?: 0.0) + val currentVolume = + volumes.find { volume -> + volume.chapters.any { it.id == book.chapters.getOrNull(currentChapterIndex)?.id } + } + + val remainingVolumes = + if (currentVolume != null) { + val currentIndex = volumes.indexOf(currentVolume) + volumes.drop(currentIndex + 1).filter { !it.isDownloaded } + } else { + emptyList() + } + + val completedVolumes = volumes.filter { it.isDownloaded && it.chapters.all { ch -> (book.progress?.currentTime ?: 0.0) >= ch.end } } + WindowBlurEffect() ModalBottomSheet( @@ -60,6 +94,7 @@ fun DownloadsComposable( containerColor = colorScheme.background, scrimColor = colorScheme.scrim.copy(alpha = 0.65f), onDismissRequest = onDismissRequest, + dragHandle = { DragHandle() }, content = { Column( modifier = @@ -69,154 +104,333 @@ fun DownloadsComposable( .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + val title = + when (libraryType) { + org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) + org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) + org.grakovne.lissen.lib.domain.LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) + } + Text( - text = - when (libraryType) { - LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) - LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) - LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) - }, - style = typography.bodyLarge, + text = title, + style = + typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + letterSpacing = 0.8.sp, + fontSize = 14.sp, + ), ) - Spacer(modifier = Modifier.height(8.dp)) - - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(DownloadOptions) { index, item -> - ListItem( - headlineContent = { - Row { - Text( - text = item.makeText(context, libraryType), - style = typography.bodyMedium, - color = - when (isOnline) { - true -> colorScheme.onBackground - false -> colorScheme.onBackground.copy(alpha = 0.4f) - }, - ) - } - }, + Spacer(modifier = Modifier.height(12.dp)) + + val fileStartTimes = + androidx.compose.runtime.remember(book.id) { + book.files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) + .let { book.files.zip(it) } + } + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Surface( + color = colorScheme.surfaceContainerLow.copy(alpha = 0.5f), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() - .clickable { - if (isOnline) { - onRequestedDownload(item) - onDismissRequest() - } - }, - ) - if (index < DownloadOptions.size - 1) { - HorizontalDivider() - } - } - - 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 == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + val monolithVolume = volumes.firstOrNull() + if (monolithVolume != null && !monolithVolume.isDownloaded) { + ActionRow( + title = + stringResource( + R.string.download_modal_monolith_title, + Formatter.formatFileSize(context, monolithVolume.size), + ), + subtitle = stringResource(R.string.download_modal_monolith_description), + icon = Icons.Default.LibraryMusic, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + isSuggested = true, + onClick = { + onRequestedDownload(org.grakovne.lissen.lib.domain.AllItemsDownloadOption) + onDismissRequest() + }, ) } - }, - 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 == org.grakovne.lissen.lib.domain.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( + org.grakovne.lissen.lib.domain + .SpecificFilesDownloadOption(listOf(currentVolume.id)), + ) + onDismissRequest() + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + + if (remainingVolumes.isNotEmpty()) { + val totalSize = remainingVolumes.sumOf { it.size } + val startPart = volumes.indexOf(remainingVolumes.first()) + 1 + val endPart = volumes.indexOf(remainingVolumes.last()) + 1 + val partRange = if (startPart == endPart) "$startPart" else "$startPart-$endPart" + + ActionRow( + title = + stringResource( + R.string.download_modal_segmented_remaining, + Formatter.formatFileSize(context, totalSize), + ), + subtitle = stringResource(R.string.download_modal_segmented_remaining_subtext, partRange), + icon = Icons.Default.AutoAwesomeMotion, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + isSuggested = currentVolume?.isDownloaded == true, + onClick = { + onRequestedDownload( + org.grakovne.lissen.lib.domain + .SpecificFilesDownloadOption(remainingVolumes.map { it.id }), + ) + onDismissRequest() + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), ) } - }, + + if (!isFullBookDownloaded) { + val totalSize = + volumes + .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(org.grakovne.lissen.lib.domain.AllItemsDownloadOption) + onDismissRequest() + }, + ) + } + } + + // Scenario C: Atomic + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC) { + AtomicOptions.forEachIndexed { index, option -> + val requestedChapters = + calculateRequestedChapters( + book = book, + option = option, + currentTotalPosition = book.progress?.currentTime ?: 0.0, + fileStartTimes = fileStartTimes, + ) + + val totalSize = + requestedChapters + .flatMap { chapter -> findRelatedFilesByStartTimes(chapter, fileStartTimes) } + .distinctBy { it.id } + .sumOf { it.size } + + val icon = + when (option) { + is org.grakovne.lissen.lib.domain.CurrentItemDownloadOption -> Icons.Default.MusicNote + is org.grakovne.lissen.lib.domain.NumberItemDownloadOption -> Icons.Default.Layers + is org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption -> Icons.AutoMirrored.Filled.QueueMusic + is org.grakovne.lissen.lib.domain.AllItemsDownloadOption -> Icons.Default.Folder + else -> Icons.Default.Folder + } + + ActionRow( + title = option.makeText(context, libraryType), + subtitle = Formatter.formatFileSize(context, totalSize), + icon = icon, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + enabled = isOnline, + isSuggested = index == 0, + onClick = { + onRequestedDownload(option) + onDismissRequest() + }, + ) + + if (index < AtomicOptions.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + } + } + } + } + } + + if (cachingInProgress || hasCachedContent) { + item { Spacer(modifier = Modifier.height(16.dp)) } + + item { + Surface( + color = colorScheme.surfaceContainerLow.copy(alpha = 0.5f), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() - .clickable { - onRequestedDropCompleted() - onDismissRequest() - }, - ) - - HorizontalDivider() - - ListItem( - headlineContent = { - Row { - Text( - text = - when (libraryType) { - LibraryType.LIBRARY -> - stringResource( - R.string.downloads_menu_download_option_clear_chapters, - ) - - LibraryType.PODCAST -> - stringResource( - R.string.downloads_menu_download_option_clear_episodes, - ) - - LibraryType.UNKNOWN -> - stringResource( - R.string.downloads_menu_download_option_clear_items, - ) + .border( + width = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.1f), + shape = RoundedCornerShape(16.dp), + ), + ) { + Column { + if (cachingInProgress) { + ActionRow( + title = stringResource(R.string.downloads_menu_download_option_stop_downloads), + icon = Icons.Default.StopCircle, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + isDanger = true, + onClick = { + onRequestedStop() + onDismissRequest() + }, + ) + + if (hasCachedContent) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + } + + if (hasCachedContent) { + val showClearCompleted = + completedVolumes.isNotEmpty() || + ( + storageType == org.grakovne.lissen.lib.domain.BookStorageType.ATOMIC && + book.chapters.any { it.available && (book.progress?.currentTime ?: 0.0) >= it.end } + ) + + if (showClearCompleted) { + ActionRow( + title = + when (libraryType) { + org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> + stringResource( + R.string.downloads_menu_download_option_clear_completed_chapters, + ) + org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> + stringResource( + R.string.downloads_menu_download_option_clear_completed_episodes, + ) + else -> stringResource(R.string.downloads_menu_download_option_clear_completed_items) + }, + icon = Icons.Default.DownloadDone, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + isDanger = true, + onClick = { + onRequestedDropCompleted() + onDismissRequest() + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = colorScheme.onSurface.copy(alpha = 0.05f), + ) + } + + ActionRow( + title = + if (storageType == org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH) { + stringResource(R.string.download_modal_remove_book) + } else { + when (libraryType) { + org.grakovne.lissen.lib.domain.LibraryType.LIBRARY -> + stringResource( + R.string.downloads_menu_download_option_clear_chapters, + ) + org.grakovne.lissen.lib.domain.LibraryType.PODCAST -> + stringResource( + R.string.downloads_menu_download_option_clear_episodes, + ) + else -> stringResource(R.string.downloads_menu_download_option_clear_items) + } }, - color = colorScheme.error, - style = typography.bodyMedium, + icon = Icons.Default.DeleteSweep, + trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + isDanger = true, + onClick = { + onRequestedDrop() + onDismissRequest() + }, ) } - }, - modifier = - Modifier - .fillMaxWidth() - .clickable { - onRequestedDrop() - onDismissRequest() - }, - ) + } + } } } } @@ -225,11 +439,24 @@ fun DownloadsComposable( ) } -private val DownloadOptions = +@Composable +private fun DragHandle() { + Box( + modifier = + Modifier + .padding(vertical = 8.dp) + .size(width = 32.dp, height = 4.dp) + .background(color = colorScheme.onSurfaceVariant.copy(alpha = 0.4f), shape = RoundedCornerShape(2.dp)), + ) +} + +private val AtomicOptions = listOf( - CurrentItemDownloadOption, - NumberItemDownloadOption(5), - NumberItemDownloadOption(10), - RemainingItemsDownloadOption, - AllItemsDownloadOption, + org.grakovne.lissen.lib.domain.CurrentItemDownloadOption, + org.grakovne.lissen.lib.domain + .NumberItemDownloadOption(5), + org.grakovne.lissen.lib.domain + .NumberItemDownloadOption(10), + org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption, + org.grakovne.lissen.lib.domain.AllItemsDownloadOption, ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/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..8bb23e0c6 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt @@ -41,6 +41,10 @@ fun PlaybackButtonsComposable( val book by viewModel.book.observeAsState() val chapters = book?.chapters ?: emptyList() + val isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false) + val preparingBookId by viewModel.preparingBookId.observeAsState(null) + val preparingError by viewModel.preparingError.observeAsState(false) + val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) val showNavButtons by settingsViewModel.showPlayerNavButtons.observeAsState(true) @@ -94,14 +98,25 @@ fun PlaybackButtonsComposable( shadowElevation = 4.dp, ) { Box(contentAlignment = Alignment.Center) { - GlowIcon( - imageVector = if (isPlaying) Icons.Filled.Pause else AppIcons.PlayFilled, - contentDescription = null, - tint = colorScheme.onPrimary, - glowColor = colorScheme.onPrimary.copy(alpha = 0.5f), - glowRadius = 8.dp, - modifier = Modifier.size(42.dp), - ) + val isError = preparingError + val isLoading = preparingBookId != null + + if (isLoading) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = colorScheme.onPrimary, + strokeWidth = 3.dp, + ) + } else { + GlowIcon( + imageVector = if (isPlaying) Icons.Filled.Pause else AppIcons.PlayFilled, + contentDescription = null, + tint = colorScheme.onPrimary, + glowColor = colorScheme.onPrimary.copy(alpha = 0.5f), + glowRadius = 8.dp, + modifier = Modifier.size(42.dp), + ) + } } } } 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..57a27b04f 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt @@ -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,33 @@ 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.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 +70,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -56,6 +78,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 +94,35 @@ 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) } 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 +138,8 @@ fun CachedItemsSettingsScreen( withMinimumTime(minimumTime) { listOf( async { viewModel.fetchCachedItems() }, + async { viewModel.refreshStorageStats() }, + async { viewModel.refreshMetadata() }, ).awaitAll() } @@ -132,7 +166,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 +174,76 @@ 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 { + deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel) + selectionMode = false + selectedVolumes.clear() + refreshContent(false) + } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorScheme.error, + contentColor = colorScheme.onError, + ), + ) { + Text(stringResource(R.string.manage_downloads_free_up, formattedSize)) + } + } + } + } + }, ) { innerPadding -> Box( modifier = @@ -151,16 +253,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 +281,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() @@ -213,6 +432,8 @@ private fun CachedItemsComposable( imageLoader = imageLoader, viewModel = viewModel, playerViewModel = playerViewModel, + selectionMode = selectionMode, + selectedVolumes = selectedVolumes, onItemRemoved = onItemRemoved, ) } @@ -225,18 +446,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 +472,66 @@ private fun CachedItemComposable( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .background( + color = if (expanded) colorScheme.surfaceVariant.copy(alpha = 0.2f) else colorScheme.surface, + shape = RoundedCornerShape(12.dp), + ).animateContentSize() .clickable { expanded = expanded.not() } - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(8.dp), ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - AsyncShimmeringImage( - imageRequest = imageRequest, - imageLoader = imageLoader, - contentDescription = "${book.title} cover", - contentScale = ContentScale.FillBounds, - modifier = - Modifier - .size(64.dp) - .aspectRatio(1f) - .clip(RoundedCornerShape(4.dp)), - error = painterResource(R.drawable.cover_fallback), - ) - - Spacer(Modifier.width(spacing)) + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncShimmeringImage( + imageRequest = imageRequest, + imageLoader = imageLoader, + contentDescription = "${book.title} cover", + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .size(64.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)), + error = painterResource(R.drawable.cover_fallback), + ) - Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = book.title, - style = - typography.bodyMedium.copy( - fontWeight = FontWeight.SemiBold, - color = colorScheme.onBackground, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) + Spacer(Modifier.width(16.dp)) - Spacer(Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = book.title, + style = + typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onBackground, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) - Icon( - imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = colorScheme.onBackground, - ) - } + Text( + text = bookSize, + style = + typography.labelLarge.copy( + color = colorScheme.primary, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.padding(start = 8.dp), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { book .author ?.takeIf { it.isNotBlank() } ?.let { Text( - modifier = Modifier.padding(vertical = 2.dp), + modifier = Modifier.weight(1f), text = it, style = typography.bodyMedium.copy( @@ -306,13 +541,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 +583,175 @@ 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( - item = item, - chapter = chapter, - cachingModelView = viewModel, - playerViewModel = playerViewModel, - ) - onItemRemoved() - } - }, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - tint = colorScheme.onSurface, - modifier = Modifier.size(24.dp), + if (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() + } + } + viewModel.dropCache(item, volume.chapters.first()) // 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, +) { + selectedIds.forEach { selection -> + val book = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }.find { it.id == selection.bookId } + book?.let { + val volumes = viewModel.getVolumes(it) + val volume = volumes.find { v -> v.id == selection.fileId } + volume?.chapters?.firstOrNull()?.let { chapter -> + playerViewModel.book.value?.let { playingBook -> + if (playingBook.id == it.id) { + playerViewModel.clearPlayingBook() + } + } + viewModel.dropCache(it, chapter) + } + } + } +} + private suspend fun dropCache( item: DetailedItem, chapter: PlayingChapter, @@ -449,7 +790,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/viewmodel/CachingModelView.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt index 7f051daab..e4e336834 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -49,6 +49,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 +89,39 @@ class CachingModelView MutableStateFlow(progress) } flow.value = progress + + if (progress.status is CacheStatus.Completed || progress.status is CacheStatus.Error) { + _cacheVersion.value += 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) + suspend fun clearShortTermCache() { withContext(Dispatchers.IO) { cachedCoverProvider.clearCache() @@ -113,10 +154,14 @@ class CachingModelView suspend fun dropCache(bookId: String) { contentCachingManager.dropCache(bookId) + _cacheVersion.value += 1 + refreshStorageStats() } suspend fun dropCompletedChapters(item: DetailedItem) { contentCachingManager.dropCompletedChapters(item) + _cacheVersion.value += 1 + refreshStorageStats() } fun stopCaching(item: DetailedItem) { @@ -134,6 +179,8 @@ class CachingModelView chapter: PlayingChapter, ) { contentCachingManager.dropCache(item, chapter) + _cacheVersion.value += 1 + refreshStorageStats() } fun toggleCacheForce() { @@ -145,15 +192,11 @@ class CachingModelView fun localCacheUsing() = preferences.isForceCache() - fun provideCacheState(bookId: String): LiveData = contentCachingManager.hasMetadataCached(bookId) - fun provideCacheState( bookId: String, chapterId: String, ): LiveData = contentCachingManager.hasMetadataCached(bookId, chapterId) - fun hasDownloadedChapters(bookId: String): LiveData = contentCachingManager.hasDownloadedChapters(bookId) - fun fetchCachedItems() { viewModelScope.launch { withContext(Dispatchers.IO) { @@ -162,6 +205,22 @@ class CachingModelView } } + fun refreshMetadata() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + localCacheRepository + .fetchDetailedItems(Int.MAX_VALUE, 0) + .fold( + onSuccess = { it.items }, + onFailure = { emptyList() }, + ).forEach { localCacheRepository.cacheBookMetadata(it) } + + _cacheVersion.value += 1 + refreshStorageStats() + } + } + } + suspend fun fetchLatestUpdate(libraryId: String) = localCacheRepository.fetchLatestUpdate(libraryId) companion object { diff --git a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt index 2a3efa097..1c72dd934 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 @@ -92,7 +91,6 @@ class LibraryViewModel val localCacheUpdated = latestLocalUpdate?.let { it > localCacheUpdatedAt } ?: true if (emptyContent || libraryChanged || orderingChanged || (isLocalCacheUsing && localCacheUpdated)) { - refreshRecentListening() refreshLibrary() currentLibraryId = preferences.getPreferredLibrary()?.id ?: "" @@ -102,10 +100,6 @@ class LibraryViewModel } init { - viewModelScope.launch { - downloadedOnlyFlow.collect { refreshRecentListening() } - } - viewModelScope.launch { combine( preferences.preferredLibraryIdFlow, @@ -133,7 +127,6 @@ class LibraryViewModel if (isAvailable) { Timber.d("Server is reachable. Triggering repository sync.") bookRepository.syncRepositories() - refreshRecentListening() refreshLibrary() } } @@ -200,7 +193,6 @@ class LibraryViewModel private fun syncLibrary(libraryId: String) { viewModelScope.launch(Dispatchers.IO) { bookRepository.syncRepositories() - refreshRecentListening() defaultPagingSource.value?.invalidate() } } @@ -215,7 +207,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 +224,29 @@ class LibraryViewModel ?.type ?: LibraryType.UNKNOWN - fun refreshRecentListening() { + fun refreshLibrary(forceRefresh: Boolean = false) { viewModelScope.launch { withContext(Dispatchers.IO) { - fetchRecentListening() - } - } - } + if (forceRefresh) { + networkService.refreshServerAvailability() + } + + val shouldSync = (forceRefresh || networkService.isServerAvailable.value) && !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 +255,6 @@ class LibraryViewModel } } - fun fetchRecentListening() { - _recentBookUpdating.postValue(true) - - val preferredLibrary = - preferences.getPreferredLibrary()?.id ?: run { - _recentBookUpdating.postValue(false) - return - } - - viewModelScope.launch { - bookRepository.fetchRecentListenedBooks(preferredLibrary) - _recentBookUpdating.postValue(false) - } - } - companion object { private const val EMPTY_SEARCH = "" private const val PAGE_SIZE = 20 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/SettingsViewModel.kt b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt index cd5655b87..d7e4354b0 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -7,6 +7,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 @@ -94,6 +100,9 @@ class SettingsViewModel val smartRewindThreshold = preferences.smartRewindThresholdFlow.asLiveData() val smartRewindDuration = preferences.smartRewindDurationFlow.asLiveData() + private val _analyticsConsent = MutableLiveData(preferences.getAnalyticsConsentState()) + val analyticsConsent: LiveData = _analyticsConsent + fun preferCrashReporting(value: Boolean) { _crashReporting.postValue(value) preferences.saveAcraEnabled(value) @@ -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,21 @@ class SettingsViewModel host?.let { _host.postValue(it) } } + + fun clearMetadataCache(onComplete: () -> Unit) { + viewModelScope.launch { + try { + bookRepository.clearMetadataCache() + cachedCoverProvider.clearCache() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) { + throw e + } + + timber.log.Timber.e(e, "Failed to clear metadata cache") + } finally { + onComplete() + } + } + } } diff --git a/app/src/main/res/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..3e8516089 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -144,9 +144,6 @@ اسم شبكة WiFi التطبيق يحتاج صلاحيات الموقع للتعرف على اسم شبكة الـWiFi السماح - مسح ذاكرة التخزين المؤقتة للصور المصغرة - إزالة الصور المصغّرة المخزنة لتحرير المساحة. - تم مسح ذاكرة الصور المصغّرة تأخير التنزيل التلقائي انتظر وقتاً قصيراً قبل البدء بالتنزيل تعطيل التحقق من SSL diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f9eed1bbf..c62e90e18 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -148,9 +148,6 @@ Wi-Fi-netværksnavn Appen behøver placeringsadgang for at kunne identificere Wi-Fi-netværket Tillad - Ryd billedmellemlager for forsidebillede - Ryd billedmellemlager for forsidebilleder for at frigøre lagerplads. - Forsidebilledermellemlager ryddet Udskyd automatisk hentning Vent et kort stykke tid, før hentningen påbegyndes Serveren er utilgængelig diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c9caaffdc..639f06f74 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -140,14 +140,11 @@ Bücher Podcasts Unbekannt - Vorschaubild-Cache gelöscht Lokale Netzwerkserveradresse Serverzugriff für Heim-WLAN definieren WLAN Netzwerk Name Die App benötigt Zugriff auf den Standort, um den WLAN-Namen zu erkennen Erlauben - Vorschaubild-Cache löschen - Vorschaubild-Cache löschen, um Speicherplatz freizugeben. Verzögere automatisches Herunterladen Warte eine kurze Zeit bevor der Download startet Verbindungstyp diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 812c5e075..9e3d50bde 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -149,9 +149,6 @@ Nom du réseau WiFi L\'application nécessite l\'accès à la localisation pour détecter le nom du Wi-Fi Autoriser - Nettoyer le cache des miniatures - Supprimer le cache des miniatures pour libérer de l\'espace. - Cache des miniatures nettoyé Reporter le téléchargement automatique Attendre un peu avant que le téléchargement ne commence Maximum diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 1a9fa1bc7..ef0fdbecb 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -144,9 +144,6 @@ Aplikaciji je potreban pristup lokaciji kako bi otkrila naziv Wi-Fi mreže Naziv WiFi mreže Dozvoli - Izbriši predmemoriju sličica - Uklonite predmemorirane sličice kako biste oslobodili prostor. - Predmemorija sličica izbrisana Odgodi automatsko preuzimanje Kratko pričekaj prije početka preuzimanja Onemogući SSL provjeru diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 1a91af9cf..83e2b9fad 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -149,9 +149,6 @@ Helyi hálózat Távoli hálózat Letöltés leállítása - Miniatűrök gyorsítótárának törlése - Távolítsa el a gyorsítótárban tárolt miniatűröket, hogy helyet szabadítson fel. - Miniatűrök gyorsítótára törölve Automatikus letöltés késleltetése Várjon egy kicsit, mielőtt elkezdené a letöltést SSL-ellenőrzés letiltása diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 3f8fa2607..9df87babd 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -150,9 +150,6 @@ Nama jaringan Wi-Fi Akses lokasi diperlukan agar dapat mendeteksi nama Wi-Fi Izinkan - Hapus cache thumbnail - Hapus cache thumbnail untuk membebaskan ruang. - Cache thumbnail berhasil dibersihkan Tunda unduhan otomatis Jeda singkat sebelum memulai unduhan Nonaktifkan verifikasi SSL diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3aa6e0e1d..5fefe8cf0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -144,9 +144,6 @@ Nome rete WiFi L\'app necessita dell\'accesso alla posizione per rilevare il nome del Wi-Fi Consenti - Pulisci cache delle miniature - Rimuovi le miniature in cache per liberare spazio. - Cache delle miniature pulita Tipo di connessione Rete locale Rete esterna diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 43cf3bd40..b1d42fe51 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -149,9 +149,6 @@ WiFi 네트워크 이름 앱이 WiFi 이름을 감지하려면 위치 접근 권한이 필요합니다. 허용 - 썸네일 캐시 삭제 - 공간 확보를 위해 썸네일 캐시 삭제 - 썸네일 캐시 삭제됨 자동 다운로드 지연 다운로드를 시작하기 전에 잠시 대기 SSL 인증 비활성화 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9c4664b7f..87816738e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -150,9 +150,6 @@ Nazwa sieci WiFi Aplikacja potrzebuje dostępu do lokalizacji aby wykryć nazwę WiFi Zezwól - Usuń pamięć podręczną miniatur - Usuń pamięć podręczną miniatur aby zwolnić miejsce. - Usunięto pamięć podręczną miniatur Opóźnij automatyczne pobieranie Zaczekaj krótki czas przed pobraniem Wyłącz weryfikację certyfikatu SSL diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c1a34daa7..3b733a79d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -147,9 +147,6 @@ Имя WiFi сети Для определения текущей Wi‑Fi сети приложению нужен доступ к местоположению Разрешить - Очистить кеш обложек - Удалить загруженные с сервера обложки - Кеш обложек очищен Откладывать автоматическую загрузку Ждать перед началом скачивания Тип подключения diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 126453287..f93b526bd 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -150,9 +150,6 @@ Ime omrežja Wi-Fi Aplikacija potrebuje dostop do lokacije za zaznavanje imena omrežja Wi-Fi Dovoli - Počisti predpomnilnik sličic - Odstranite predpomnjene sličice, da sprostite prostor. - Predpomnilnik sličic je bil izbrisan Zakasnitev samodejnega prenosa Preden začnete s prenosom, počakajte nekaj časa Onemogoči preverjanje SSL diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6fbb0dd2b..cf9af6d3a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -145,9 +145,6 @@ Wi-Fi 网络名称 该应用需要访问位置权限以检测 Wi-Fi 名称 允许 - 清除缩略图缓存 - 清除缓存的缩略图以释放空间。 - 缩略图缓存已清除 停止下载 延迟自动下载 开始下载前等待一小段时间 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6b8e7099..10fe5f6d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,9 +153,6 @@ WiFi network name The app needs location access to detect Wi‑Fi name Allow - Clear thumbnail cache - Remove cached thumbnails to free up space. - Thumbnail cache cleared Delay automatic download Wait a short time before starting download Disable SSL verification @@ -224,13 +221,65 @@ 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 + 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index baae4d8a8..b1c368314 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ coreKtx = "1.17.0" timber = "5.0.1" moshi = "1.15.2" converterMoshi = "3.0.0" +clarity = "3.8.0" [libraries] acra-core = { module = "ch.acra:acra-core", version.ref = "acraCore" } @@ -84,6 +85,7 @@ converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.r moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } +microsoft-clarity = { module = "com.microsoft.clarity:clarity-compose", version.ref = "clarity" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 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..ba1167aa8 100644 --- a/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt +++ b/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt @@ -3,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,39 @@ 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_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt()) + startsWith("specific_files_") -> { + val payload = substringAfter("specific_files_") + + val fileIds = when { + payload.isEmpty() -> emptyList() + else -> payload + .split(",") + .map { String(Base64.getUrlDecoder().decode(it)) } + } + + SpecificFilesDownloadOption(fileIds) + } + + else -> null }