Skip to content

Feat: Download enhancements#7

Closed
SurjitSahoo wants to merge 22 commits intomainfrom
feat/download-enhancements
Closed

Feat: Download enhancements#7
SurjitSahoo wants to merge 22 commits intomainfrom
feat/download-enhancements

Conversation

@SurjitSahoo
Copy link
Owner

@SurjitSahoo SurjitSahoo commented Feb 5, 2026

Summary by CodeRabbit

  • New Features

    • Migration screen for major DB upgrades, analytics consent prompt, telemetry (Microsoft Clarity), and automatic update checks/notifications.
    • Enhanced download UI: storage-type classification, per-volume and specific-file selection, thumbnail staging, and storage stats.
  • Bug Fixes

    • Better file-size handling, added Queued cache status, and notification update throttling.
  • Improvements

    • Metadata cache clearing action, richer progress visuals and animations, improved cache management, and batch reclaim/delete for cached volumes.

Surjit Kumar Sahoo added 8 commits February 3, 2026 22:33
… enhance cache isolation by host and user, and update cache clearing functionality.
…ook/file sizes, download progress, individual file deletion, and a polished empty state.
…ted and atomic content caching, including UI and database schema updates.
…d badge with a checkmark icon, and ensure reactive UI updates for caching progress.
…, improved animation, and robust progress calculation.
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Bumps Room schema to v19, adds per-host/user scoping and file size, implements migrations, computes disk/volume mappings, rewrites caching to channelFlow with buffered copy and progress, integrates Microsoft Clarity telemetry and consent UI, and updates DAOs, repositories, viewmodels, and many composables.

Changes

Cohort / File(s) Summary
Schemas & Migrations
app/schemas/.../LocalCacheStorage/16.json, .../17.json, .../18.json, .../19.json, app/src/main/kotlin/.../LocalCacheStorage.kt, app/src/main/kotlin/.../Migrations.kt, app/src/main/kotlin/.../LocalCacheModule.kt
Added schema snapshots through v19; bumped DB version; added produceMigration15_16 and MIGRATION_16_17/17_18/18_19; dynamic migration wired via preferences; introduced host/username columns and file size.
Entities & DAOs (multi-tenant + size)
app/src/main/kotlin/.../entity/CachedBookEntity.kt, .../entity/CachedLibraryEntity.kt, .../dao/CachedBookDao.kt, .../dao/CachedLibraryDao.kt
Added host/username fields, added size to BookFileEntity; updated DAO queries/signatures for per-host/user scoping; added batch safe-upsert APIs and non-downloaded-books deletion.
Local cache logic & repo
app/src/main/kotlin/.../LocalCacheRepository.kt, .../OfflineBookStorageProperties.kt, .../common/FindRelatedFiles.kt, .../CalculateRequestedChapters.kt
New disk/size utilities and disk-space queries; storage-type inference (MONOLITH/SEGMENTED/ATOMIC); mapChaptersToVolumes and media-map/file-start-time helpers; calculateRequestedChapters overload for specific-file options; baseFolder made public.
Content caching core
app/src/main/kotlin/.../ContentCachingManager.kt, .../ContentCachingService.kt, .../ContentCachingNotificationService.kt
Replaced flow with channelFlow, added buffered file copy with per-file & overall progress reporting, concurrent caching tasks, refined dropCache logic, notification throttling, CacheStatus.Queued, eased progress animation, and Clarity tracking hooks.
Request builders & repository wiring
app/src/main/kotlin/.../api/*.kt, .../CachedBookRepository.kt, .../CachedLibraryRepository.kt
Fetch/Recent/Search builders accept/propagate host & username and use EXISTS-based downloaded checks; repositories read preferences and pass host/username to DAOs; exposed deleteNonDownloadedBooks and adjusted upsert flows.
Domain model & download options
lib/src/main/kotlin/org/grakovne/lissen/lib/domain/*
Added BookStorageType and BookVolume, BookFile.size and default ctor values, SpecificFilesDownloadOption (serialisation/id), and CacheStatus.Queued.
UI — Downloads, Player, Settings, Migration
app/src/main/kotlin/.../DownloadsComposable.kt, .../DownloadProgressIcon.kt, .../ActionRow.kt, .../BookDetailScreen.kt, .../PlayerScreen.kt, .../GlobalPlayerBottomSheet.kt, .../CachedItemsSettingsScreen.kt, .../CacheSettingsScreen.kt, .../MigrationScreen.kt, .../AppNavHost.kt, .../Route.kt
Downloads UI reworked to be book-centric with volumes/storageType, new ActionRow, animated download icon with queued state, selection/batch delete in cached items, migration screen and nav gating, and settings item to clear metadata cache; several composable signatures changed.
ViewModels
app/src/main/kotlin/.../CachingModelView.kt, .../MigrationViewModel.kt, .../SettingsViewModel.kt
Added StorageStats and cacheVersion flows, exposed size/volume/storage helpers delegating to LocalCacheRepository, MigrationViewModel orchestrates migration and persists DB version, SettingsViewModel adds analytics consent and clearMetadataCache.
Analytics (Microsoft Clarity)
app/src/main/kotlin/.../analytics/*, app/build.gradle.kts, gradle/libs.versions.toml, app/proguard-rules.pro
Added ClarityComponent and ClarityTracker with DI binding, BuildConfig CLARITY_PROJECT_ID and dependency, ProGuard keep rules, telemetry calls in repos/viewmodels, and clarityMask usage for sensitive inputs.
Converters, responses & models
app/src/main/kotlin/.../BookResponse*.kt, .../PodcastResponse*.kt, .../CachedBookEntityDetailedConverter.kt
Propagated file size into domain models/converters; made some response metadata size fields nullable and provided defaulting in converters.
UI components & utilities
app/src/main/kotlin/.../AnalyticsConsentBottomSheet.kt, .../DownloadProgressIcon.kt, .../ActionRow.kt, app/src/main/kotlin/.../HashUtils.kt, .../Moshi.kt, .../AsyncShimmeringImage.kt
Added analytics consent sheet, animated DownloadProgressIcon accepts isFullyDownloaded, ActionRow composable, sha256 helper, Moshi adapter logging + KotlinJsonAdapterFactory, and AsyncShimmeringImage thumbnail-first loading (new param).
Resources & localization
app/src/main/res/values/strings.xml, app/src/main/res/values-*/strings.xml, app/src/main/res/drawable/available_offline_filled.xml
Added migration, metadata/volume, and analytics consent strings; removed thumbnail-cache strings across locales; simplified an icon drawable path.
Build & proguard
app/build.gradle.kts, app/proguard-rules.pro, gradle/libs.versions.toml
Added Microsoft Clarity dependency/version and BuildConfig field; added ProGuard keep rules for Clarity.
Small UX/behavior tweaks
assorted files (Login, Search, ServerSettings, CustomHeader, LocalUrl inputs)
Applied clarityMask to multiple text inputs and exposed InfoRow modifier parameter.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Downloads UI
    participant CCM as ContentCachingManager
    participant Repo as LocalCacheRepository
    participant DB as RoomDB
    participant FS as FileSystem
    participant Tracker as ClarityTracker

    UI->>CCM: request cache(bookId, downloadOption)
    CCM->>Repo: compute fileStartTimes & requested chapters
    CCM->>Tracker: trackEvent("download_start")
    CCM->>FS: copy file (buffered) with progress callbacks
    FS-->>CCM: per-file progress updates
    CCM->>DB: persist media progress / metadata
    CCM->>UI: emit progress events (per-file / overall)
    CCM->>Tracker: trackEvent("download_finished")
    alt error
      CCM->>UI: emit Error
      CCM->>Tracker: trackEvent("download_error", "err")
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through schemas, volumes, and size,

I nudged migrations and watched progress rise.
Chapters grouped, covers fetched with care,
Telemetry hummed — consent filled the air.
A rabbit cheers: caches tidy and fair!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat: Download enhancements' accurately summarizes the main objective of the pull request, which introduces substantial improvements to the download and caching functionality throughout the app.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/download-enhancements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt (1)

15-24: ⚠️ Potential issue | 🟡 Minor

Ensure baseFolder() only returns a writable directory.

takeIf { it.exists() || it.mkdirs() && it.canWrite() } returns an existing but non-writable directory, which can cause downstream write failures now that this API is public. Consider requiring canWrite() for both existing and newly created directories, and otherwise fall back to cacheDir.

Suggested fix
-    fun baseFolder(): File =
-      context
-        .getExternalFilesDir(MEDIA_CACHE_FOLDER)
-        ?.takeIf { it.exists() || it.mkdirs() && it.canWrite() }
+    fun baseFolder(): File =
+      context
+        .getExternalFilesDir(MEDIA_CACHE_FOLDER)
+        ?.takeIf { (it.exists() || it.mkdirs()) && it.canWrite() }
         ?: context
           .cacheDir
           .resolve(MEDIA_CACHE_FOLDER)
           .apply {
             if (exists().not()) {
               mkdirs()
             }
           }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt (1)

81-118: ⚠️ Potential issue | 🔴 Critical

Filter fetchBooks() results by current host/username to prevent cross-account data collision.

BookEntity has id as sole primary key; host and username are regular fields. When cacheBooks() calls fetchBooks(bookIds) without filtering by the current user's host/username, it can retrieve and then overwrite another account's rows via upsertBooks() with OnConflictStrategy.REPLACE. The method should extract host/username and filter existing books before attempting updates.

app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt (1)

175-193: ⚠️ Potential issue | 🟠 Major

Add host/username filtering to cached-items queries.

The fetchCachedItems and fetchCachedItemsCount methods return cached items across all users/hosts, breaking per-account isolation. Other methods like countCachedBooks and deleteNonDownloadedBooks correctly apply host/username filtering; these should follow the same pattern.

Suggested change
`@Query`(
  """
  SELECT * FROM detailed_books
  WHERE host = :host
    AND username = :username
    AND 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)
  """,
)
suspend fun fetchCachedItems(
  pageSize: Int,
  pageNumber: Int,
+ host: String,
+ username: String,
): List<CachedBookEntity>

`@Query`(
  """
  SELECT COUNT(*) FROM detailed_books 
  WHERE host = :host
    AND username = :username
    AND EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)
  """,
)
suspend fun fetchCachedItemsCount(
+ host: String,
+ username: String,
): Int

Then update CachedBookRepository.fetchCachedItems and fetchCachedItems to retrieve and pass the current user's host/username.

🤖 Fix all issues with AI agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedLibraryRepository.kt`:
- Around line 18-31: Both cacheLibraries and fetchLibraries currently use
empty-string fallbacks for missing credentials which can merge data across
sessions; change cacheLibraries and fetchLibraries to validate credentials from
preferences.getHost() and preferences.getUsername() and stop when either is
null/blank (e.g., return early from fetchLibraries with an empty List and skip
dao.updateLibraries in cacheLibraries, or throw a clear exception if you prefer
explicit failure). Update the logic around dao.updateLibraries(libraries, host,
username) and dao.fetchLibraries(host, username) to only call DAO methods when
both host and username are non-blank, and keep converter.apply only in the
successful fetch path.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt`:
- Around line 10-21: The query built by RecentRequestBuilder bypasses
host/username isolation: when downloadedOnly is true host/username filters are
omitted and when false the OR EXISTS clause allows other-host/user books to leak
in; update the SQL construction in RecentRequestBuilder to always apply host and
username filters (use the builder fields host and username) and remove the OR
EXISTS bypass so cached-chapter checks are only applied in conjunction with
matching host/username; ensure this change touches the downloadedOnly-related
branch and the branch using the OR EXISTS clause so both enforce per-host/user
filtering.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt`:
- Around line 116-131: The current logic skips calling finish() when work is no
longer in progress but errors remain; update the condition around finish() so
finish() is invoked whenever inProgress() returns false (regardless of
hasErrors()), and ensure error notification logic still runs (e.g., call
notificationService.updateCachingNotification(...) for executionStatuses when
errors exist before or after calling finish()). Locate the block using
inProgress(), hasErrors(), finish(), executionStatuses and
notificationService.updateCachingNotification and move or add the finish() call
out of the "!hasErrors()" guard so the service always calls finish() once no
items are in progress.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt`:
- Around line 35-43: The fetchLibrary function currently queries only by ID and
must be scoped to tenant context; update suspend fun fetchLibrary(libraryId:
String): CachedLibraryEntity? to accept host: String and username: String
parameters and change its `@Query` to "SELECT * FROM libraries WHERE id =
:libraryId AND host = :host AND username = :username"; do the same for the other
similar method around lines 48-53 (add host and username params and include them
in the WHERE clause), and then update any DAO call sites to pass host/username
so the DAO enforces multi-tenant scoping (refer to fetchLibraries and
deleteLibrariesExcept for the pattern).

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt`:
- Around line 48-53: produceMigration15_16 currently injects
preferences.getHost() and preferences.getUsername() directly into SQL DEFAULT
clauses (e.g., DEFAULT '$host'), which risks SQL injection and is fragile since
ALTER TABLE doesn’t accept bind parameters; change the migration to avoid
embedding user values into DDL: create the new/altered tables with neutral
defaults (no interpolated strings) inside produceMigration15_16, then after the
schema change run a separate parameterized INSERT/UPDATE statement (using bound
parameters) to persist host/username into the appropriate rows, using
preferences.getHost()/getUsername() as parameters; update any callers of
produceMigration15_16 and ensure MIGRATION_16_17 is applied after this safe
population step.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt`:
- Around line 267-281: The migration in produceMigration15_16 interpolates host
and username directly into SQL, risking syntax errors or injection when values
contain single quotes; update produceMigration15_16 (inside its migrate
override) to escape single quotes in host and username (e.g., replace ' with '')
before building the ALTER TABLE ... ADD COLUMN ... DEFAULT 'value' strings and
then use those escaped literals for the three tables (detailed_books,
media_progress, libraries) so the DEFAULT clauses are valid SQLite string
literals.

In `@app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt`:
- Around line 238-241: The current isDetailed predicate treats any zero-sized
file as not detailed even when chapters exist; change the logic in the
isDetailed computation (the val isDetailed using localResult) to short-circuit
on chapters: if localResult?.chapters.isNotEmpty() then isDetailed = true;
otherwise, when chapters are empty, require files.isNotEmpty() and files.all { f
-> f.size > 0 } to mark detailed. Update the expression around localResult?.let
{ ... } (referencing localResult, chapters, files) so file-size checks only
apply when chapters are absent.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt`:
- Around line 19-22: The first is-check for
org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption duplicates a later
identical branch and uses hard-coded labels; remove the duplicate branch and
replace both with a single SpecificFilesDownloadOption handling that inspects
the instance's fileIds.size to choose between the plural/singular localized
resources (e.g., use context.getString(R.string.download_selected_volume) vs
context.getString(R.string.download_multiple_files) or appropriate
string/plurals), and add the referenced string resources to strings.xml instead
of embedding "Selected Volume"/"Multiple Files".

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt`:
- Around line 582-586: The local remembers in the downloads sheet use
remember(book.id) so cachingModelView.getVolumes(book) and
getBookStorageType(book) won't update when cacheVersion changes; update the
remember keys to include cacheVersion (e.g., remember(book.id, cacheVersion)) or
simply remove the local volumes and storageType and reference the top-level
volumes (which is already keyed by cacheVersion) and the appropriate storage
lookup so the downloads sheet reflects cache updates; adjust the remember calls
around getBookStorageType/getVolumes or replace them with the existing top-level
volumes variable.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt`:
- Around line 33-43: The code currently calls viewModel.startMigration()
unconditionally in LaunchedEffect(Unit), causing re-execution after
configuration changes; change the effects to a single LaunchedEffect keyed on
the observed state (state from viewModel.migrationState.observeAsState) and only
invoke viewModel.startMigration() when the state is the initial/idle state
(e.g., MigrationState.Idle or not MigrationState.Completed), while still calling
onMigrationComplete() when state is MigrationState.Completed; locate usages of
viewModel.startMigration(), MigrationState.Completed, and the state observed via
observeAsState to implement this guard.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt`:
- Around line 65-72: The subtextColor is built with a base alpha (alpha = 0.6f)
but later overwritten by copy(alpha = contentAlpha), so change the later copy to
combine alphas instead of replacing them: compute combinedAlpha = 0.6f *
contentAlpha (or read the base alpha from the initial color if extracted) and
use copy(alpha = combinedAlpha) when updating subtextColor; update both
occurrences (the subtextColor initialization block and the later copy at the
other location) so the intended 0.6 opacity is preserved and scaled by
contentAlpha (refer to subtextColor and contentAlpha in ActionRow).

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt`:
- Around line 127-133: The cached fileStartTimes computed with remember(book.id)
is stale when book.files changes; update the remember call to include book.files
as a key (e.g., remember(book.id, book.files)) so the block that computes
fileStartTimes (using book.files.runningFold(...).dropLast(1).let {
book.files.zip(it) }) recomputes whenever the file list or its metadata change.

In `@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt`:
- Line 443: The UI treats only CacheStatus.Caching as an active cache, so queued
downloads don't surface stop/clear actions; update the expression that sets
cachingInProgress (currently using cacheProgress.status) to consider
CacheStatus.Queued as in-progress as well (e.g., check cacheProgress.status is
CacheStatus.Caching || cacheProgress.status is CacheStatus.Queued) so both
queued and caching states enable the stop/clear UI.
- Around line 440-442: The calls to cachingModelView.getBookStorageType(book)
and cachingModelView.getVolumes(book) in PlayerScreen are performing synchronous
filesystem I/O on every recomposition; wrap both calls in remember(book) to
memoize results (as done in BookDetailScreen and NavigationBarComposable) so
getBookStorageType and getVolumes are only computed once per book and avoid
duplicated work (note getVolumes internally calls getBookStorageType, so
memoizing both prevents repeated I/O). Ensure you replace the direct calls with
remember(book) { cachingModelView.getBookStorageType(book) } and remember(book)
{ cachingModelView.getVolumes(book) } usages in the PlayerScreen composable.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt`:
- Around line 177-181: The Text composable in CachedItemsSettingsScreen.kt uses
hardcoded "Cancel" and "Edit" strings (tied to selectionMode) which bypass
localization; add two string resources (e.g., R.string.cached_items_cancel and
R.string.cached_items_edit) to strings.xml with the appropriate values, change
the Text call to use stringResource(...) instead of raw literals, and ensure the
file imports androidx.compose.ui.res.stringResource so the composition uses
localized strings.
- Line 638: Replace the unsafe volume.chapters.first() call with a defensive
check: use volume.chapters.firstOrNull(), and if it returns null skip calling
viewModel.dropCache (or call an appropriate overload that doesn't require a
chapter) to avoid NoSuchElementException; update the call site where
viewModel.dropCache(item, ...) is invoked in CachedItemsSettingsScreen (same
area that uses deleteSelectedVolumes) to handle the null chapter case safely.
- Around line 199-208: The haptic call is currently executed after
deleteSelectedVolumes finishes because withHaptic(view) is inside the coroutine;
move the withHaptic invocation to run immediately when the Button is pressed
(before launching the deletion coroutine) so feedback is instantaneous.
Specifically, call withHaptic(view) synchronously at the start of the Button
onClick (or right before scope.launch) and keep selectionMode = false,
selectedVolumes.clear(), and refreshContent(false) inside the coroutine after
deleteSelectedVolumes(viewModel, playerViewModel, cachedItems, selectedVolumes)
as appropriate; update the call sites referencing deleteSelectedVolumes,
withHaptic, scope.launch, selectionMode, selectedVolumes.clear(), and
refreshContent to reflect this change.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CacheSettingsScreen.kt`:
- Around line 106-118: Wrap the cache-clear invocation in a try-catch and
surface failures to the user and logs: inside the
AdvancedSettingsNavigationItemComposable onclick lambda, call
viewModel.clearMetadataCache within a try block and on success show the existing
Toast; in the catch block log the exception and show a failure Toast (use
stringResource(R.string.settings_screen_clear_metadata_cache_failure) or a new
failure string), ensuring any exceptions from clearMetadataCache or
cachedCoverProvider.clearCache are handled instead of failing silently;
alternatively, extend viewModel.clearMetadataCache to accept an onError callback
and invoke that when cachedCoverProvider.clearCache throws, then show the
failure Toast from the onclick handler.

In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt`:
- Around line 161-164: The dropCompletedChapters path updates _cacheVersion but
never refreshes storage stats, so call the ViewModel's storage stats refresh
routine immediately after updating the cache version; specifically, in suspend
fun dropCompletedChapters(item: DetailedItem) (which calls
contentCachingManager.dropCompletedChapters(item) and increments
_cacheVersion.value) invoke the existing method that refreshes storage totals
(e.g., refreshStorageStats() or
loadStorageStats()/storageStatsManager.refreshStats()) right after the
_cacheVersion update so UI totals are updated.

In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt`:
- Around line 48-50: The CURRENT_DATABASE_VERSION constant is out of sync with
the schema/migrations (schema at 17 and MIGRATION_16_17 exists); update
companion object constant CURRENT_DATABASE_VERSION from 16 to 17 so
preferences.setDatabaseVersion(...) writes the correct version and the app's
migration checks match 17.json and MIGRATION_16_17.
🧹 Nitpick comments (10)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt (3)

429-429: Unused variable isSingleFileBook.

This variable is computed but never referenced anywhere in the composable. It should be removed to avoid confusion.

Suggested fix
-  val isSingleFileBook = remember(book) { book.files.size <= 1 }
-
   val bookSize =

609-618: Import Checkbox instead of using fully qualified name.

Using the fully qualified androidx.compose.material3.Checkbox is inconsistent with other Material3 components in this file that are imported. Add the import at the top of the file.

Suggested fix

Add to imports:

import androidx.compose.material3.Checkbox

Then simplify the usage:

         if (selectionMode) {
-          androidx.compose.material3.Checkbox(
+          Checkbox(
             checked = isSelected,
             onCheckedChange = {

666-681: Consider optimizing lookup performance.

The current implementation iterates through all cached items for each selected volume (O(n×m) complexity). With many cached items and selections, this could cause UI jank since calculateReclaimSize is called during composition.

Suggested optimization
 private fun calculateReclaimSize(
   selectedIds: List<VolumeIdentifier>,
   cachedItems: LazyPagingItems<DetailedItem>,
   viewModel: CachingModelView,
 ): Long {
+  val booksById = (0 until cachedItems.itemCount)
+    .mapNotNull { cachedItems[it] }
+    .associateBy { it.id }
+
   var total = 0L
   selectedIds.forEach { selection ->
-    val book = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }.find { it.id == selection.bookId }
+    val book = booksById[selection.bookId]
     book?.let {
       val volumes = viewModel.getVolumes(it)
       val volume = volumes.find { v -> v.id == selection.fileId }
       total += volume?.size ?: 0L
     }
   }
   return total
 }
app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt (1)

390-394: Log the swallowed exception for debugging purposes.

The exception is caught and silently swallowed. While returning null is a reasonable fallback for corrupted/outdated JSON data, logging the exception would aid debugging without affecting user experience.

♻️ Proposed fix
           val adapter = moshi.adapter(DetailedItem::class.java)
           try {
             adapter.fromJson(json)
           } catch (e: Exception) {
+            android.util.Log.w("LissenSharedPreferences", "Failed to parse playing book", e)
             null
           }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt (1)

240-259: Ensure progress reaches 100% when content length is unknown.

If contentLength <= 0, overallProgress never advances to 1.0, so UI can stay below 100% until completion. Consider emitting a final per‑file update after the loop.

♻️ Suggested tweak
                 while (input.read(buffer).also { bytesRead = it } != -1) {
                   output.write(buffer, 0, bytesRead)
                   totalBytesRead += bytesRead
@@
                   if (overallProgress - lastReportedProgress >= reportThreshold || overallProgress >= 1.0) {
                     onProgress(overallProgress)
                     lastReportedProgress = overallProgress
                   }
                 }
+                val finalOverallProgress = (index + 1).toDouble() / files.size.toDouble()
+                if (finalOverallProgress > lastReportedProgress) {
+                  onProgress(finalOverallProgress)
+                }
app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt (1)

80-89: Guard per‑file Timber logging to avoid production noise.

String interpolation runs even when logs are disabled; for large libraries this adds overhead. Consider gating behind a debug flag or removing.

♻️ Example guard
-              timber.log.Timber.d("Mapping file ${it.ino} with size ${it.metadata.size}")
+              if (BuildConfig.DEBUG) {
+                timber.log.Timber.d("Mapping file ${it.ino} with size ${it.metadata.size}")
+              }
app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt (1)

36-37: Consider using standard import declarations.

The fully qualified names are used inline instead of import statements at the top of the file. This is inconsistent with the other imports in this class.

♻️ Suggested refactor

Add these imports at the top of the file with the other imports:

import org.grakovne.lissen.content.BookRepository
import org.grakovne.lissen.content.cache.temporary.CachedCoverProvider

Then simplify the constructor parameters:

-    private val bookRepository: org.grakovne.lissen.content.BookRepository,
-    private val cachedCoverProvider: org.grakovne.lissen.content.cache.temporary.CachedCoverProvider,
+    private val bookRepository: BookRepository,
+    private val cachedCoverProvider: CachedCoverProvider,
app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt (1)

41-44: Consider logging the migration failure.

The catch block silently proceeds to Completed state without logging the exception. While avoiding "bricking" is reasonable, logging the error would help with debugging production issues.

♻️ Suggested improvement
+import timber.log.Timber
...
         } catch (e: Exception) {
-          // In a real app, we might want to handle this more gracefully
+          Timber.e(e, "Database migration failed, proceeding anyway")
           _migrationState.value = MigrationState.Completed // Proceed anyway to avoid bricking
         }
app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt (1)

40-119: Deduplicate the identical icon rendering branches.
CacheStatus.Completed and else render the same CloudDone/CloudDownload blocks; consolidating reduces drift and keeps accessibility consistent.

♻️ Possible consolidation
-      is CacheStatus.Completed -> {
-        if (isFullyDownloaded) {
-          Icon(
-            imageVector = Icons.Filled.CloudDone,
-            contentDescription = null,
-            modifier = Modifier.size(size),
-            tint = colorScheme.primary,
-          )
-        } else {
-          Icon(
-            imageVector = Icons.Outlined.CloudDownload,
-            contentDescription = stringResource(R.string.player_screen_downloads_navigation),
-            modifier = Modifier.size(size).alpha(0.8f),
-            tint = color,
-          )
-        }
-      }
-
-      else -> {
-        if (isFullyDownloaded) {
-          Icon(
-            imageVector = Icons.Filled.CloudDone,
-            contentDescription = null,
-            modifier = Modifier.size(size),
-            tint = colorScheme.primary,
-          )
-        } else {
-          Icon(
-            imageVector = Icons.Outlined.CloudDownload,
-            contentDescription = stringResource(R.string.player_screen_downloads_navigation),
-            modifier = Modifier.size(size).alpha(0.8f),
-            tint = color,
-          )
-        }
-      }
+      is CacheStatus.Completed, else -> {
+        val isDone = isFullyDownloaded
+        val icon = if (isDone) Icons.Filled.CloudDone else Icons.Outlined.CloudDownload
+        Icon(
+          imageVector = icon,
+          contentDescription = if (isDone) null else stringResource(R.string.player_screen_downloads_navigation),
+          modifier = Modifier.size(size).then(if (isDone) Modifier else Modifier.alpha(0.8f)),
+          tint = if (isDone) colorScheme.primary else color,
+        )
+      }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt (1)

166-222: Avoid recalculating mediaMap twice for segmented/atomic

getBookStorageType may already compute mediaMap, and mapChaptersToVolumes computes it again. Consider a shared helper that returns both storageType and mediaMap to avoid duplicate work.

Comment on lines 35 to 43
@Query("SELECT * FROM libraries WHERE id = :libraryId")
suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity?

@Transaction
@Query("SELECT * FROM libraries")
suspend fun fetchLibraries(): List<CachedLibraryEntity>
@Query("SELECT * FROM libraries WHERE host = :host AND username = :username")
suspend fun fetchLibraries(
host: String,
username: String,
): List<CachedLibraryEntity>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and read the CachedLibraryDao.kt file
find . -name "CachedLibraryDao.kt" -type f

Repository: SurjitSahoo/kahani-android

Length of output: 160


🏁 Script executed:

# Get the size to determine how to read it
wc -l app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt

Repository: SurjitSahoo/kahani-android

Length of output: 161


🏁 Script executed:

# Read the entire file to see the context
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt

Repository: SurjitSahoo/kahani-android

Length of output: 1924


🏁 Script executed:

# Search for all calls to fetchLibrary in the codebase
rg "fetchLibrary" --type kotlin -A 2 -B 2

Repository: SurjitSahoo/kahani-android

Length of output: 7563


🏁 Script executed:

# Check the CachedLibraryEntity structure to understand if host/username are part of the entity
find . -name "*CachedLibraryEntity*" -type f

Repository: SurjitSahoo/kahani-android

Length of output: 275


🏁 Script executed:

# Read the CachedLibraryEntity to confirm it has host and username fields
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt

Repository: SurjitSahoo/kahani-android

Length of output: 751


🏁 Script executed:

# Search for actual call sites of fetchLibrary more carefully - look for calls to the DAO method
rg "\.fetchLibrary\(" --type kotlin -B 3 -A 3

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Also check if there's a repository that wraps this DAO
find . -name "*LibraryRepository*" -o -name "*LibraryDataSource*" | grep -E "\.(kt|java)$"

Repository: SurjitSahoo/kahani-android

Length of output: 167


🏁 Script executed:

# Read the CachedLibraryRepository to see how fetchLibrary is used
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedLibraryRepository.kt

Repository: SurjitSahoo/kahani-android

Length of output: 1394


🏁 Script executed:

# Also search for any usage of the DAO method in other files - maybe it's being used indirectly
rg "fetchLibrary" --type kotlin -B 5 -A 5 | grep -A 5 -B 5 "CachedLibraryDao\|libraryDao"

Repository: SurjitSahoo/kahani-android

Length of output: 2319


🏁 Script executed:

# Double-check: search for any references to fetchLibrary in the entire codebase including comments and strings
rg "fetchLibrary" --type kotlin --no-heading

Repository: SurjitSahoo/kahani-android

Length of output: 1869


🏁 Script executed:

# Also check if this method might be called through reflection or if there are any imports
rg "CachedLibraryDao" --type kotlin -A 10 | head -50

Repository: SurjitSahoo/kahani-android

Length of output: 5630


fetchLibrary lacks multi-tenant scoping and risks exposing data across hosts/users.

While other DAO queries filter by host and username, fetchLibrary(libraryId) retrieves by ID alone. This creates a latent cross-tenant data leak risk if the DAO is directly injected. Add host and username parameters to match the scoping pattern of fetchLibraries and deleteLibrariesExcept.

Suggested fix
-  `@Query`("SELECT * FROM libraries WHERE id = :libraryId")
-  suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity?
+  `@Query`("SELECT * FROM libraries WHERE id = :libraryId AND host = :host AND username = :username")
+  suspend fun fetchLibrary(
+    libraryId: String,
+    host: String,
+    username: String,
+  ): CachedLibraryEntity?

Also applies to: 48-53

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt`
around lines 35 - 43, The fetchLibrary function currently queries only by ID and
must be scoped to tenant context; update suspend fun fetchLibrary(libraryId:
String): CachedLibraryEntity? to accept host: String and username: String
parameters and change its `@Query` to "SELECT * FROM libraries WHERE id =
:libraryId AND host = :host AND username = :username"; do the same for the other
similar method around lines 48-53 (add host and username params and include them
in the WHERE clause), and then update any DAO call sites to pass host/username
so the DAO enforces multi-tenant scoping (refer to fetchLibraries and
deleteLibrariesExcept for the pattern).

Comment on lines 48 to 53
.addMigrations(
produceMigration15_16(
host = preferences.getHost() ?: "",
username = preferences.getUsername() ?: "",
),
).addMigrations(MIGRATION_16_17)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the produceMigration15_16 function to understand what it does
rg -n "produceMigration15_16" --type kotlin -A 30

Repository: SurjitSahoo/kahani-android

Length of output: 4697


🏁 Script executed:

#!/bin/bash
# Look at the LocalCacheModule.kt file to see the context
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt | head -60

Repository: SurjitSahoo/kahani-android

Length of output: 2374


🏁 Script executed:

#!/bin/bash
# Find DAO files and check how host/username columns are used
fd -e "\.kt$" -path "*/dao/*" app/src/main/kotlin/

Repository: SurjitSahoo/kahani-android

Length of output: 238


🏁 Script executed:

#!/bin/bash
# Find DAO files correctly
find app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent -name "*Dao*.kt"

Repository: SurjitSahoo/kahani-android

Length of output: 244


🏁 Script executed:

#!/bin/bash
# Search for host and username usage in query methods
rg -n "host|username" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ --type kotlin -B 2 -A 2

Repository: SurjitSahoo/kahani-android

Length of output: 30749


🏁 Script executed:

#!/bin/bash
# Check if there are any safeguards against SQL injection in preferences or migration
rg -n "getHost|getUsername" --type kotlin app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/ -B 2 -A 2

Repository: SurjitSahoo/kahani-android

Length of output: 3500


🏁 Script executed:

#!/bin/bash
# Check if there's any validation or escaping of host/username values
rg -n "class LissenSharedPreferences|fun getHost|fun getUsername" --type kotlin -A 10

Repository: SurjitSahoo/kahani-android

Length of output: 4264


Use parameterized queries instead of string interpolation to prevent SQL injection.

The migration uses string interpolation directly in SQL statements (DEFAULT '$host', DEFAULT '$username'), which is a security vulnerability. Although the values come from app-controlled SharedPreferences, best practice requires parameterized or bound queries. Since ALTER TABLE commands don't support bind parameters in SQLite, consider storing these values in the database after table creation instead of embedding them in the schema migration.

The concern about empty-string defaults orphaning data is not valid—all subsequent queries use the same preferences.getHost() ?: "" fallback, so cache scoping remains consistent. This is intentional multi-tenant behavior: when credentials change, the cache scope changes accordingly.

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt`
around lines 48 - 53, produceMigration15_16 currently injects
preferences.getHost() and preferences.getUsername() directly into SQL DEFAULT
clauses (e.g., DEFAULT '$host'), which risks SQL injection and is fragile since
ALTER TABLE doesn’t accept bind parameters; change the migration to avoid
embedding user values into DDL: create the new/altered tables with neutral
defaults (no interpolated strings) inside produceMigration15_16, then after the
schema change run a separate parameterized INSERT/UPDATE statement (using bound
parameters) to persist host/username into the appropriate rows, using
preferences.getHost()/getUsername() as parameters; update any callers of
produceMigration15_16 and ensure MIGRATION_16_17 is applied after this safe
population step.

tint = colorScheme.onSurface,
modifier = Modifier.size(24.dp),
)
viewModel.dropCache(item, volume.chapters.first()) // dropCache by chapter handles file deletion
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential crash: first() on possibly empty collection.

volume.chapters.first() will throw NoSuchElementException if chapters is empty. While unlikely for downloaded volumes, this should be handled defensively. Note that deleteSelectedVolumes at line 694 correctly uses firstOrNull() for the same operation.

Suggested fix
-                  viewModel.dropCache(item, volume.chapters.first()) // dropCache by chapter handles file deletion
-                  onItemRemoved()
+                  volume.chapters.firstOrNull()?.let { chapter ->
+                    viewModel.dropCache(item, chapter)
+                    onItemRemoved()
+                  }
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt`
at line 638, Replace the unsafe volume.chapters.first() call with a defensive
check: use volume.chapters.firstOrNull(), and if it returns null skip calling
viewModel.dropCache (or call an appropriate overload that doesn't require a
chapter) to avoid NoSuchElementException; update the call site where
viewModel.dropCache(item, ...) is invoked in CachedItemsSettingsScreen (same
area that uses deleteSelectedVolumes) to handle the null chapter case safely.

Comment on lines +106 to +118
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()
}
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find clearMetadataCache implementation
rg -n "clearMetadataCache" --type kotlin

Repository: SurjitSahoo/kahani-android

Length of output: 794


🏁 Script executed:

# Get file context and look for the viewModel definition to understand the ViewModel class
fd -e kt -path "*/settings/advanced/*" | head -20

Repository: SurjitSahoo/kahani-android

Length of output: 238


🏁 Script executed:

# Get the clearMetadataCache implementation with context
sed -n '290,310p' app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt

Repository: SurjitSahoo/kahani-android

Length of output: 334


🏁 Script executed:

# Look for the SettingsViewModel class definition and coroutine scope setup
sed -n '1,50p' app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt

Repository: SurjitSahoo/kahani-android

Length of output: 2305


Add error handling and user feedback for cache clear failures.

The callback is safely dispatched on the main thread via viewModelScope.launch, so the Toast won't crash. However, there's no error handling—if either clearMetadataCache() or cachedCoverProvider.clearCache() throws an exception, the coroutine fails silently and the user receives no feedback. Add a try-catch block to handle and communicate failures to the user.

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CacheSettingsScreen.kt`
around lines 106 - 118, Wrap the cache-clear invocation in a try-catch and
surface failures to the user and logs: inside the
AdvancedSettingsNavigationItemComposable onclick lambda, call
viewModel.clearMetadataCache within a try block and on success show the existing
Toast; in the catch block log the exception and show a failure Toast (use
stringResource(R.string.settings_screen_clear_metadata_cache_failure) or a new
failure string), ensuring any exceptions from clearMetadataCache or
cachedCoverProvider.clearCache are handled instead of failing silently;
alternatively, extend viewModel.clearMetadataCache to accept an onError callback
and invoke that when cachedCoverProvider.clearCache throws, then show the
failure Toast from the onclick handler.

Comment on lines 48 to 50
companion object {
const val CURRENT_DATABASE_VERSION = 16
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Version mismatch: CURRENT_DATABASE_VERSION is 16 but schema is at version 17.

The database schema (17.json) and migrations (MIGRATION_16_17) indicate the current version is 17, but this constant is set to 16. This will cause preferences.setDatabaseVersion(16) to store an incorrect version, potentially triggering unnecessary migration checks or causing version-tracking inconsistencies.

🐛 Proposed fix
   companion object {
-    const val CURRENT_DATABASE_VERSION = 16
+    const val CURRENT_DATABASE_VERSION = 17
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
companion object {
const val CURRENT_DATABASE_VERSION = 16
}
companion object {
const val CURRENT_DATABASE_VERSION = 17
}
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt`
around lines 48 - 50, The CURRENT_DATABASE_VERSION constant is out of sync with
the schema/migrations (schema at 17 and MIGRATION_16_17 exists); update
companion object constant CURRENT_DATABASE_VERSION from 16 to 17 so
preferences.setDatabaseVersion(...) writes the correct version and the app's
migration checks match 17.json and MIGRATION_16_17.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt (1)

230-238: ⚠️ Potential issue | 🟡 Minor

Potential resource leak: OkHttp response body not explicitly closed on all paths.

The response.body is consumed inside the try block, but if an exception occurs after getting the response but before the body is fully read (e.g., during dest.parentFile?.mkdirs()), the response body may not be closed. While OkHttp typically handles this via GC finalization, explicit closure is safer.

🛡️ Proposed fix to ensure response body closure
          val request = requestBuilder.build()
          val response = client.newCall(request).execute()

          if (!response.isSuccessful) {
            Timber.e("Unable to cache media content: $response")
+           response.close()
            return@withContext CacheState(CacheStatus.Error)
          }

          val body = response.body
          val dest = properties.provideMediaCachePath(bookId, file.id)
          dest.parentFile?.mkdirs()

          try {
-           dest.outputStream().use { output ->
-             body.byteStream().use { input ->
+           response.use {
+             dest.outputStream().use { output ->
+               body.byteStream().use { input ->

Alternatively, wrap the entire file processing in response.use { }.

app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt (1)

49-64: ⚠️ Potential issue | 🟠 Major

Same privacy concern applies here for user identifier.

The "${account.username}@$host" identifier is sent to Microsoft Clarity. As noted in the ClarityComponent review, consider hashing this identifier to protect user privacy.

The placement of analytics calls after persistCredentials is correct - ensuring credentials are saved before tracking the login event.

🔒 Proposed fix to hash the identifier
-      clarityTracker.setUser("${account.username}@$host")
+      clarityTracker.setUser("${account.username}@$host".hashCode().toString())
       clarityTracker.trackEvent("login_success")
🤖 Fix all issues with AI agents
In `@app/build.gradle.kts`:
- Around line 69-70: The CLARITY_PROJECT_ID is hardcoded via
buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"") — change it
to read from a build-time property or environment variable (mirroring the
signing credentials pattern used around the other buildConfigField entries) so
each flavor/environment can supply its own project ID; update the
buildConfigField invocation for CLARITY_PROJECT_ID to use project property
lookup (falling back to an env var and then a sensible default) and document the
expected property name so CI/build scripts can set per-environment values.

In `@app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt`:
- Around line 62-74: The reidentifyUser currently sends a plaintext identifier
("$username@$host") to Clarity via clarityTracker.setUser; update reidentifyUser
to first check user consent (e.g., via preferences.hasConsentedToAnalytics or
similar) before calling clarityTracker.setUser, and if consented, compute a
non-reversible hash (use a secure hash like SHA-256 with optional salt/pepper
stored securely) of the combined identifier and pass the hashed string to
clarityTracker.setUser instead of the raw "$username@$host"; when no credentials
exist still respect consent and send only a hashed device id (hash
preferences.getDeviceId()) or skip if no consent.

In `@app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt`:
- Around line 13-40: The success logs in setUser, trackEvent(String) and
trackEvent(String, String) currently print raw userId and value (Timber.d calls)
which can leak PII; change those logging statements to avoid raw sensitive data
by either omitting the userId/value or logging a redacted/hashed form (e.g.,
mask only prefix/suffix or a stable hash), and ensure error logs (Timber.e) also
do not include raw PII—update the Timber.d/Timber.e calls inside setUser,
trackEvent(eventName) and trackEvent(eventName, value) to reference the
redacted/hash variable or only the non-sensitive eventName.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt`:
- Around line 266-268: The catch block inside the withContext block in
ContentCachingManager currently swallows exceptions and returns
CacheState(CacheStatus.Error); modify it to log the exception before returning
by calling the class logger (e.g., logger.error or similar) with a clear message
and the exception object (for example: logger.error("Failed to ... while
updating cache", ex)), so that the error and stacktrace are recorded; ensure you
use the existing logging instance on ContentCachingManager and keep the return
CacheState(CacheStatus.Error) afterwards.
- Line 124: The code is calling the suspend function dropCache(mediaItem, it)
inside a non-suspending .map { ... } which does not await the suspend calls;
replace the .map usage with a suspending iteration such as
cachingChapters.forEach { dropCache(mediaItem, it) } (or use a for (chapter in
cachingChapters) { dropCache(mediaItem, chapter) } if you need to handle
exceptions/ordering) so each dropCache call is properly awaited; ensure this
change is made in the method in ContentCachingManager where cachingChapters and
mediaItem are used.

In `@app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt`:
- Around line 184-187: The onFailure handler in MediaRepository currently sends
raw t.message to clarityTracker.trackEvent which may leak sensitive data;
instead sanitize or categorize the error before sending by mapping known
Throwable types or message patterns to safe error categories (e.g.,
"network_error", "io_error", "authentication_error", "unknown_error") and send
only the category and a non-sensitive code/flag via clarityTracker.trackEvent in
onFailure, while still logging the full throwable locally with Timber.e for
debugging; update the onFailure implementation that calls
clarityTracker.trackEvent and Timber.e to perform this
classification/sanitization.
🧹 Nitpick comments (3)
app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt (1)

237-244: Analytics event fires before operation completes.

The library_refresh event is tracked before the actual refresh operation starts. If you want to track successful refreshes, consider moving the tracking inside the coroutine after fetchRecentListening() completes. However, if the intent is to track user intent rather than success, the current placement is acceptable.

app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt (2)

45-58: Empty lifecycle callbacks are required by the interface.

The detekt warnings about empty function blocks are false positives here since Application.ActivityLifecycleCallbacks requires implementing all methods. You can suppress the warnings or use a more explicit style to indicate intentional no-ops.

💡 Option to suppress detekt warnings
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityStarted(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityResumed(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityPaused(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityStopped(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivitySaveInstanceState(
     activity: Activity,
     outState: Bundle,
   ) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityDestroyed(activity: Activity) {}

26-28: Null-safe cast could silently fail.

If context is not an Application instance (unlikely but possible in edge cases), the callback registration will silently fail and Clarity won't initialize. Consider logging when this occurs.

💡 Proposed fix to add logging
   override fun onCreate() {
-    (context as? Application)?.registerActivityLifecycleCallbacks(this)
+    (context as? Application)?.registerActivityLifecycleCallbacks(this)
+      ?: Timber.w("ClarityComponent: Context is not an Application, lifecycle callbacks not registered")
   }

Comment on lines +69 to +70
buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"")

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd app && head -250 build.gradle.kts | tail -200

Repository: SurjitSahoo/kahani-android

Length of output: 5795


🌐 Web query:

Microsoft Clarity Android SDK project ID configuration best practices

💡 Result:

Best practices for configuring the Microsoft Clarity Android SDK Project ID

  • Treat the Project ID as an environment-specific value (dev/stage/prod). The projectId is the routing key that sends sessions to the correct Clarity project, so use separate Clarity projects (and IDs) per environment to avoid mixing data. [1]
  • Don’t hardcode the Project ID in Activities/fragments. Keep it in build-time configuration (e.g., Gradle buildConfigField, productFlavors, or CI-injected values) and reference that constant when creating ClarityConfig(projectId = ...). This makes it easy to swap IDs per build variant while keeping initialization code identical. [1]
  • Initialize Clarity exactly once, as early as practical. Microsoft recommends calling Clarity.initialize(...) from the startup activity onCreate(); if you have multiple startup activities, initialize in a custom Application class or duplicate the call in each startup activity. [1]
  • Use Verbose logging only for debugging. Set logLevel = Verbose temporarily to validate the integration, then disable (default is None) for production builds. [1]
  • Know where to get the correct Project ID. Copy it from your Clarity project’s Settings/Overview in the Clarity dashboard. [1][2]
  • If you use a custom WorkManager initializer, initialize Clarity after it. Otherwise Clarity may not function properly. [1]

Reference initialization (Kotlin):

val config = ClarityConfig(
  projectId = BuildConfig.CLARITY_PROJECT_ID,
  logLevel = LogLevel.Verbose
)
Clarity.initialize(applicationContext, config)

(Initialization pattern per Microsoft docs. [1])

Sources: Android SDK installation docs [1], Mobile SDK getting started / Project settings overview [2].

[1] (learn.microsoft.com)
[2] (learn.microsoft.com)

Citations:


🏁 Script executed:

# Check if localProperties is defined/used in build.gradle.kts
grep -n "localProperties" app/build.gradle.kts | head -20

Repository: SurjitSahoo/kahani-android

Length of output: 546


🏁 Script executed:

# Examine the specific lines mentioned
sed -n '60,80p' app/build.gradle.kts

Repository: SurjitSahoo/kahani-android

Length of output: 841


🏁 Script executed:

# Check what's at lines 199-200
sed -n '195,205p' app/build.gradle.kts

Repository: SurjitSahoo/kahani-android

Length of output: 324


Make CLARITY_PROJECT_ID configurable to support per-environment project IDs.

Microsoft Clarity best practices recommend storing the project ID in build-time configuration (not hardcoding), with support for environment variables and build flavors. This allows separate Clarity projects for different build variants while keeping initialization code identical.

The codebase already uses this pattern for signing credentials (lines 74, 82–84); apply the same approach here:

♻️ Suggested refactor
-    buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"")
+    val clarityProjectId =
+      System.getenv("CLARITY_PROJECT_ID")
+        ?: localProperties.getProperty("CLARITY_PROJECT_ID")
+        ?: "vc8bgk8nk9"
+    buildConfigField("String", "CLARITY_PROJECT_ID", "\"$clarityProjectId\"")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"")
val clarityProjectId =
System.getenv("CLARITY_PROJECT_ID")
?: localProperties.getProperty("CLARITY_PROJECT_ID")
?: "vc8bgk8nk9"
buildConfigField("String", "CLARITY_PROJECT_ID", "\"$clarityProjectId\"")
🤖 Prompt for AI Agents
In `@app/build.gradle.kts` around lines 69 - 70, The CLARITY_PROJECT_ID is
hardcoded via buildConfigField("String", "CLARITY_PROJECT_ID", "\"vc8bgk8nk9\"")
— change it to read from a build-time property or environment variable
(mirroring the signing credentials pattern used around the other
buildConfigField entries) so each flavor/environment can supply its own project
ID; update the buildConfigField invocation for CLARITY_PROJECT_ID to use project
property lookup (falling back to an env var and then a sensible default) and
document the expected property name so CI/build scripts can set per-environment
values.

Comment on lines +62 to +74
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())
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Privacy consideration: User identifier sent to third-party analytics.

The identifier "$username@$host" is sent to Microsoft Clarity. Depending on your privacy policy and user consent mechanisms, this could be a compliance concern (GDPR/CCPA). Consider:

  1. Hashing the identifier before sending: identifier.hashCode().toString() or a proper hash
  2. Ensuring user consent is obtained before analytics initialization
  3. Documenting this in your privacy policy
🔒 Proposed fix to hash the identifier
     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"
+        val identifier = "$username@$host".hashCode().toString()
         clarityTracker.setUser(identifier)
       } else {
         // Fallback to device ID if not logged in
-        clarityTracker.setUser(preferences.getDeviceId())
+        clarityTracker.setUser(preferences.getDeviceId().hashCode().toString())
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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())
}
}
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".hashCode().toString()
clarityTracker.setUser(identifier)
} else {
// Fallback to device ID if not logged in
clarityTracker.setUser(preferences.getDeviceId().hashCode().toString())
}
}
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt` around
lines 62 - 74, The reidentifyUser currently sends a plaintext identifier
("$username@$host") to Clarity via clarityTracker.setUser; update reidentifyUser
to first check user consent (e.g., via preferences.hasConsentedToAnalytics or
similar) before calling clarityTracker.setUser, and if consented, compute a
non-reversible hash (use a secure hash like SHA-256 with optional salt/pepper
stored securely) of the combined identifier and pass the hashed string to
clarityTracker.setUser instead of the raw "$username@$host"; when no credentials
exist still respect consent and send only a hashed device id (hash
preferences.getDeviceId()) or skip if no consent.

Comment on lines 266 to 268
} catch (ex: Exception) {
return@withContext CacheState(CacheStatus.Error)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Exception swallowed without logging.

The caught exception is discarded silently, making it difficult to diagnose caching failures. Consider logging the exception for debugging purposes.

🔧 Proposed fix
          } catch (ex: Exception) {
+           Timber.e(ex, "Failed to cache media file: ${file.id}")
            return@withContext CacheState(CacheStatus.Error)
          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (ex: Exception) {
return@withContext CacheState(CacheStatus.Error)
}
} catch (ex: Exception) {
Timber.e(ex, "Failed to cache media file: ${file.id}")
return@withContext CacheState(CacheStatus.Error)
}
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt`
around lines 266 - 268, The catch block inside the withContext block in
ContentCachingManager currently swallows exceptions and returns
CacheState(CacheStatus.Error); modify it to log the exception before returning
by calling the class logger (e.g., logger.error or similar) with a clear message
and the exception object (for example: logger.error("Failed to ... while
updating cache", ex)), so that the error and stacktrace are recorded; ensure you
use the existing logging instance on ContentCachingManager and keep the return
CacheState(CacheStatus.Error) afterwards.

Comment on lines 184 to 187
override fun onFailure(t: Throwable) {
Timber.e("Unable to add callback to player")
clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error message may contain sensitive information.

The error message t.message is passed directly to analytics. Depending on the error source, this could potentially contain sensitive information (file paths, user data, etc.). Consider sanitizing or categorizing errors instead of sending raw messages.

💡 Proposed fix to categorize errors
         override fun onFailure(t: Throwable) {
           Timber.e("Unable to add callback to player")
-          clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error")
+          clarityTracker.trackEvent("playback_error", t.javaClass.simpleName)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override fun onFailure(t: Throwable) {
Timber.e("Unable to add callback to player")
clarityTracker.trackEvent("playback_error", t.message ?: "Unknown error")
}
override fun onFailure(t: Throwable) {
Timber.e("Unable to add callback to player")
clarityTracker.trackEvent("playback_error", t.javaClass.simpleName)
}
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt` around
lines 184 - 187, The onFailure handler in MediaRepository currently sends raw
t.message to clarityTracker.trackEvent which may leak sensitive data; instead
sanitize or categorize the error before sending by mapping known Throwable types
or message patterns to safe error categories (e.g., "network_error", "io_error",
"authentication_error", "unknown_error") and send only the category and a
non-sensitive code/flag via clarityTracker.trackEvent in onFailure, while still
logging the full throwable locally with Timber.e for debugging; update the
onFailure implementation that calls clarityTracker.trackEvent and Timber.e to
perform this classification/sanitization.

Surjit Kumar Sahoo added 2 commits February 6, 2026 21:17
…atabase migrations, and update the migration screen UI.
…n the cache is empty, providing a new `onNavigateToLibrary` callback.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt (1)

296-302: ⚠️ Potential issue | 🟡 Minor

cacheBookCover returns Completed even when cover fetch fails.

The onFailure callback (line 297-298) is empty, so execution falls through to line 301 which always returns CacheState(CacheStatus.Completed). This means a cover fetch failure is masked and the overall caching result in cacheMediaItem (line 117) will consider cover caching as successful. If the cover is non-critical, consider at least logging the failure.

🔧 Proposed fix
            onFailure = {
+             Timber.w("Failed to fetch book cover for ${book.id}: $it")
            },
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt (1)

172-193: ⚠️ Potential issue | 🟠 Major

fetchCachedItems and fetchCachedItemsCount leak data across all hosts and users.

These queries return cached items and counts without any host/username filtering. The countCachedBooks method demonstrates the established pattern for multi-tenant scoping; both methods must apply similar filtering to prevent data leakage across sessions.

🤖 Fix all issues with AI agents
In `@app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt`:
- Around line 62-66: The calls to clarityTracker.setUser and
clarityTracker.trackEvent inside onPostLogin can throw and currently can abort
the rest of the login flow; wrap those analytics calls in a try/catch so any
exception is swallowed (or logged) and does not propagate, making them
fire-and-forget; locate the calls to clarityTracker.setUser(...) and
clarityTracker.trackEvent("login_success") and enclose them in a small try { ...
} catch (e: Exception) { /* log.debug or ignore */ } block so the subsequent
library-fetch logic in onPostLogin always runs.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt`:
- Around line 44-58: The isolationClause in FetchRequestBuilder.kt weakens
tenant isolation by adding "OR EXISTS(...)" when host and username are present;
change the online branch so that when host and username are non-empty you only
add host and username to args and return "host = ? AND username = ?" (no OR
EXISTS), and keep the downloadedOnly=true branch as-is only if offline should
ignore host/username; update the variable isolationClause construction and any
callers expecting the previous behavior to enforce strict per-tenant scoping
when host/username are supplied.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt`:
- Around line 35-39: The current code builds the SQL LIKE pattern from
searchQuery without escaping '%' and '_' so user input can act as wildcards;
update the logic that constructs pattern (used where searchClause is defined) to
escape '%' and '_' (e.g., replace "%" -> "\\%" and "_" -> "\\_") before wrapping
with "%" for the surrounding contains match, and update searchClause to include
"ESCAPE '\\'" so the backslash escapes are honored; ensure args.add(pattern)
still receives the escaped pattern for title, author, and seriesNames checks.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt`:
- Around line 121-128: The current guard prevents any notification updates when
any task has errored because of hasErrors().not(); remove that global error
check and instead call notificationService.updateCachingNotification with a
filtered view of executionStatuses so only errored entries are excluded (e.g.,
executionStatuses.entries.filter { (_, status) -> /* status not in error state
*/ } .map { (item, status) -> item to status }). Keep the
shouldUpdateNotification and inProgress() checks and still update
lastNotificationUpdate after calling updateCachingNotification.
- Around line 106-133: The finish() method can be called twice because both the
collect block and the onCompletion handler call finish() when inProgress()
becomes false; modify the flow so finish() is only invoked once—either remove
the finish() call from onCompletion inside executor.run(...).onCompletion or add
a guarded boolean/atomic flag (e.g., a local/field like finishCalled) that
finish() checks/sets to ensure it only runs its stop/notification side-effects
once; update references around executor.run(...).onCompletion, collect { ... },
inProgress(), and
lastNotificationUpdate/notificationService.updateCachingNotification to rely on
the single guarded finish invocation.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt`:
- Around line 251-266: The two DAO methods deleteNonDownloadedBooks and
fetchNonDownloadedBookIds operate globally; change both to accept host and
username params (e.g., host: String, username: String) and add tenant predicates
to their SQL (restrict detailed_books and the subquery on book_chapters with
WHERE host = :host AND username = :username), then update all call sites to pass
the current host/username from preferences (same pattern used by
cacheBook/fetchCachedItems/fetchRecentBooks) so deletions and fetches are scoped
per tenant.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt`:
- Around line 13-31: The upsert currently uses a single-column PK (id) which
allows cross-tenant overwrites; change the CachedLibraryEntity primary key to a
composite of id, host, and username (e.g., `@PrimaryKey`(value =
["id","host","username"])) and update any DAO upsert logic (upsertLibraries) so
conflict resolution operates on that composite key; keep
deleteLibrariesExcept(host, username) as-is but ensure updateLibraries continues
to build entities with host and username before calling upsertLibraries so each
(id,host,username) tuple is treated uniquely and cannot overwrite other tenants'
rows.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt`:
- Around line 296-407: MIGRATION_17_18 currently recreates detailed_books,
libraries, media_progress, and book_files but omits book_chapters; add explicit
handling for book_chapters by renaming book_chapters to book_chapters_old,
creating the new book_chapters table with the original columns and a FOREIGN
KEY(bookId) REFERENCES detailed_books(id) ON UPDATE NO ACTION ON DELETE CASCADE
(matching media_progress/book_files style), INSERT ... SELECT to copy rows from
book_chapters_old into book_chapters, DROP TABLE book_chapters_old, and recreate
any needed indexes (e.g., index on bookId) so the migration consistently updates
dependent tables alongside detailed_books.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt`:
- Around line 38-44: The bottom sheet currently still allows swipe-to-dismiss
because the ModalBottomSheet's state (sheetState created by
rememberModalBottomSheetState) has no confirmValueChange guard; update the sheet
state creation to pass confirmValueChange = { newValue -> newValue !=
ModalBottomSheetValue.Hidden } (or simply { false } for stricter behavior) when
calling rememberModalBottomSheetState so drags cannot transition the state to
Hidden while retaining programmatic control used by ModalBottomSheet and its
onDismissRequest.

In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt`:
- Around line 34-36: retryMigration() currently calls executeMigration()
unconditionally, allowing concurrent migrations if tapped multiple times; add
the same guard used in startMigration() to prevent re-entry by checking the
migration-running flag or job before invoking executeMigration() (e.g., early
return if a boolean like isRunning/_isMigrating or an active migration Job
exists), mirror the exact condition and behavior used in startMigration() so
retryMigration() becomes a no-op while a migration is in progress.

In `@app/src/main/res/values/strings.xml`:
- Around line 276-279: The analytics consent text incorrectly claims data is
anonymous while ClarityComponent.reidentifyUser() sends plaintext username@host
to Microsoft Clarity; either stop sending PII or update the copy. Fix option A:
in ClarityComponent.reidentifyUser() (ClarityComponent.kt) hash or redact the
username@host before calling the Clarity SDK so no plaintext identifiers are
transmitted. Fix option B: update the strings (analytics_consent_message and
analytics_consent_accept) to remove claims of anonymity/privacy and explicitly
state that non-personal telemetry and device identifiers are collected; ensure
the UI copy matches the actual behavior.
🧹 Nitpick comments (24)
app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt (1)

18-28: Debug-only adapter factory: use lazy formatting or guard against release builds.

The "Moshi Breadcrumb" label suggests this is a diagnostic aid. Two concerns:

  1. Eager string allocation: Kotlin string templates are evaluated before Timber.d is called, so the interpolation cost is paid even when no Timber.Tree is planted (i.e., release builds). Prefer Timber's format-string overload to defer formatting:
Suggested change
-          Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}")
+          Timber.d("Moshi Breadcrumb: Created adapter for type: %s. Adapter: %s", type, adapter.javaClass.simpleName)
  1. Production residue: Since Moshi caches adapters the runtime impact is limited, but wrapping every type lookup for breadcrumb logging is unusual in production code. Consider removing this factory before release, or at minimum guarding it behind BuildConfig.DEBUG.
gradle/libs.versions.toml (1)

88-88: Consider privacy/compliance implications of Microsoft Clarity integration.

Microsoft Clarity is a session replay and behavioral analytics tool that captures user interactions (taps, scrolls, navigation). Ensure that:

  1. Appropriate user consent is obtained before initializing Clarity (GDPR/CCPA).
  2. Sensitive screens (e.g., login, payment) are masked or excluded from session recording.
  3. The Clarity project ID is not hardcoded but injected via build config or remote config.

This is broader than this file, but worth validating across the ClarityTracker, ClarityComponent, and AnalyticsModule wiring mentioned in the PR.

app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt (1)

91-125: Completed and else branches are identical — consider merging.

The bodies of CacheStatus.Completed (lines 91–107) and else (lines 109–125) are copy-pasted. You can collapse them into a single branch to reduce duplication:

♻️ Suggested refactor
-      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,
-          )
-        }
-      }
+      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,
+          )
+        }
+      }

If Completed is intentionally separated to allow future divergence, a short comment explaining that would be helpful.

app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt (2)

57-60: translationY uses raw pixels — consider density-aware conversion.

floatY is -2f raw pixels applied via translationY. This will appear differently across screen densities. For a consistent look, convert from dp to px using LocalDensity:

♻️ Suggested approach
+  val density = LocalDensity.current
+
   val floatY by animateFloatAsState(
-    targetValue = if (isSuggested && !isPressed) -2f else 0f,
+    targetValue = if (isSuggested && !isPressed) with(density) { -2.dp.toPx() } else 0f,
     label = "float",
   )

Given the value is only 2 pixels, the visual inconsistency is subtle, so this is purely a polish item.

Also applies to: 78-82


86-90: Redundant if (enabled) guard inside onClick.

The clickable modifier already receives enabled = enabled, which prevents the callback from firing when disabled. The inner guard is unnecessary.

♻️ Suggested simplification
           onClick = {
-            if (enabled) {
-              haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
-              onClick()
-            }
+            haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+            onClick()
           },
app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt (1)

5-11: Specify UTF-8 charset explicitly in toByteArray().

toByteArray() without arguments uses the platform default charset, which can vary. For consistent hashing across devices, specify Charsets.UTF_8.

♻️ Proposed fix
-      .digest(input.toByteArray())
+      .digest(input.toByteArray(Charsets.UTF_8))
lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt (1)

15-17: Consider making SpecificFilesDownloadOption a data class.

NumberItemDownloadOption follows the same pattern (plain class), but since SpecificFilesDownloadOption wraps a List<String>, value-based equality could be useful for comparisons and deduplication. This is a consistency note — if plain class is intentional across the hierarchy, feel free to ignore.

app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt (1)

63-64: Use an import instead of fully qualified name.

org.grakovne.lissen.common.sha256(...) can be simplified by adding an import for sha256 at the top of the file.

♻️ Proposed fix

Add at the top with other imports:

import org.grakovne.lissen.common.sha256

Then simplify the call:

       clarityTracker.setUser(
-        org.grakovne.lissen.common
-          .sha256("${account.username}@$host"),
+        sha256("${account.username}@$host"),
       )
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/RecentRequestBuilder.kt (1)

52-62: LIMIT interpolated directly instead of being parameterized.

Line 61 uses LIMIT $limit via string interpolation. While safe (it's always an Int), FetchRequestBuilder parameterizes LIMIT ? — applying the same approach here would be more consistent and defensive.

app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/18.json (1)

1-388: Schemas 18 and 19 are byte-for-byte identical (same identity hash).

Both 18.json and 19.json share identity hash 58717f77244fed8e0b015049d0164838, and MIGRATION_18_19 has an empty migrate() body. This appears intentional for schema realignment, but should be documented (e.g., in the migration comment or a changelog) so future maintainers understand why two consecutive versions produce the same schema.

app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt (1)

410-415: MIGRATION_18_19 is a no-op — add a brief code comment explaining the reason.

The empty migrate body with just // Schema realignment to fix identity hash mismatch is terse. A follow-up sentence explaining what caused the mismatch (e.g., "MIGRATION_17_18 recreated tables to match Room's expected schema; this version bump aligns the stored version number") would help future maintainers avoid accidentally removing this migration.

app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt (1)

115-119: Unnecessary fully-qualified names for PlayingChapter and BookFile.

PlayingChapter is already imported at line 31. BookFile could be added to imports as well. Using FQN inline reduces readability.

♻️ Proposed fix

Add import at the top:

import org.grakovne.lissen.lib.domain.BookFile

Then simplify:

     fun calculateChapterSize(
       bookId: String,
-      chapter: org.grakovne.lissen.lib.domain.PlayingChapter,
-      files: List<org.grakovne.lissen.lib.domain.BookFile>,
+      chapter: PlayingChapter,
+      files: List<BookFile>,
     ) = localCacheRepository.calculateChapterSize(bookId, chapter, files)
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt (1)

61-71: TOCTOU gap between file deletion and DAO deletion in deleteNonDownloadedBooks.

fetchNonDownloadedBookIds() and deleteNonDownloadedBooks() are two separate queries. If a book becomes "downloaded" between the two calls, its cache directory may still be deleted (Line 64-68 uses the snapshot), but the DAO call on Line 70 won't delete it (uses current state). Conversely, deleteRecursively() silently swallows failures—if a file can't be removed, the DB record is still cleared, orphaning nothing but leaving stale files on disk.

For a cache-cleanup path this is probably acceptable, but consider using the fetched nonDownloadedIds list for the DAO deletion too, to keep both operations consistent:

Suggested fix
   suspend fun deleteNonDownloadedBooks() {
     val nonDownloadedIds = bookDao.fetchNonDownloadedBookIds()

     nonDownloadedIds.forEach { bookId ->
       properties
         .provideBookCache(bookId)
         .deleteRecursively()
     }

-    bookDao.deleteNonDownloadedBooks()
+    bookDao.deleteBooksByIds(nonDownloadedIds)
   }

This would require a deleteBooksByIds(ids: List<String>) DAO method. If that's too much churn, the current approach is functionally safe for cache cleanup.

app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt (1)

113-116: Minor style inconsistency in setDatabaseVersion.

This uses the raw edit().putInt().apply() chain, while every other setter in this class uses Kotlin's edit { ... } DSL extension. Consider aligning for consistency:

Suggested fix
-  fun setDatabaseVersion(version: Int) = sharedPreferences.edit().putInt(KEY_DATABASE_VERSION, version).apply()
+  fun setDatabaseVersion(version: Int) {
+    sharedPreferences.edit {
+      putInt(KEY_DATABASE_VERSION, version)
+    }
+  }
app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt (1)

448-451: Prefer imports over fully qualified class references.

org.grakovne.lissen.content.cache.persistent.CacheState and org.grakovne.lissen.lib.domain.CacheStatus are used inline with their full package paths. Adding them to the import block would improve readability.

Suggested fix

Add to imports:

import org.grakovne.lissen.content.cache.persistent.CacheState
import org.grakovne.lissen.lib.domain.CacheStatus

Then simplify usage:

              .collectAsState(
                initial =
-                  org.grakovne.lissen.content.cache.persistent
-                    .CacheState(org.grakovne.lissen.lib.domain.CacheStatus.Idle),
+                  CacheState(CacheStatus.Idle),
              )
...
-                cachingInProgress = cacheProgress.status is org.grakovne.lissen.lib.domain.CacheStatus.Caching,
+                cachingInProgress = cacheProgress.status is CacheStatus.Caching,

Also applies to: 459-459

app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt (1)

57-57: Use an import for Timber instead of the fully qualified reference.

Suggested fix

Add to imports:

import timber.log.Timber

Then:

-          timber.log.Timber.e(e, "Migration failed")
+          Timber.e(e, "Migration failed")
app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt (1)

61-74: Empty lifecycle callbacks are interface-mandated — suppress detekt warnings.

These empty blocks are required by Application.ActivityLifecycleCallbacks. Add a @Suppress("EmptyFunctionBlock") annotation on the class or individual overrides to silence detekt.

app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt (1)

38-41: Use imports instead of fully-qualified class names.

Lines 38–39 use FQNs (org.grakovne.lissen.content.BookRepository, org.grakovne.lissen.content.cache.temporary.CachedCoverProvider) while the rest of the file uses imports. Same issue on line 315 (kotlinx.coroutines.CancellationException) and line 319 (timber.log.Timber). Add proper imports for consistency.

app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt (2)

72-88: Derived state computed during composition — consider remember.

currentChapterIndex, currentVolume, remainingVolumes, and completedVolumes are recomputed on every recomposition. Wrap them in remember(book, volumes) to avoid redundant work, especially the nested iteration in completedVolumes.

Also, line 88 is extremely long — consider breaking it up for readability.

♻️ Suggested refactor
-  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 } }
+  val currentChapterIndex = remember(book, volumes) {
+    org.grakovne.lissen.playback.service
+      .calculateChapterIndex(book, book.progress?.currentTime ?: 0.0)
+  }
+
+  val currentVolume = remember(book, volumes) {
+    volumes.find { volume ->
+      volume.chapters.any { it.id == book.chapters.getOrNull(currentChapterIndex)?.id }
+    }
+  }
+
+  val remainingVolumes = remember(currentVolume, volumes) {
+    if (currentVolume != null) {
+      val currentIndex = volumes.indexOf(currentVolume)
+      volumes.drop(currentIndex + 1).filter { !it.isDownloaded }
+    } else {
+      emptyList()
+    }
+  }
+
+  val currentTime = book.progress?.currentTime ?: 0.0
+  val completedVolumes = remember(book, volumes) {
+    volumes.filter { vol ->
+      vol.isDownloaded && vol.chapters.all { ch -> currentTime >= ch.end }
+    }
+  }

279-324: ATOMIC option size calculations are not memoized.

calculateRequestedChapters and findRelatedFilesByStartTimes are called for each of the 5 AtomicOptions on every recomposition. While the data set is small, wrapping the entire block in a remember(book, fileStartTimes) that precomputes the sizes would be cleaner and avoid redundant work.

app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt (4)

699-720: deleteSelectedVolumes iterates all cached items per selection — O(n×m).

For each selection, it scans all cachedItems (line 706) and calls getVolumes. With small data sets this is fine, but consider grouping by bookId first (as calculateReclaimSize already does) to reduce redundant lookups.

♻️ Suggested improvement
 private suspend fun deleteSelectedVolumes(
   selectedIds: List<VolumeIdentifier>,
   cachedItems: LazyPagingItems<DetailedItem>,
   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()
+  val books = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }
+  val selectionsByBook = selectedIds.groupBy { it.bookId }
+
+  selectionsByBook.forEach { (bookId, selections) ->
+    val book = books.find { it.id == bookId } ?: return@forEach
+    val volumes = viewModel.getVolumes(book)
+
+    playerViewModel.book.value?.let { playingBook ->
+      if (playingBook.id == bookId) {
+        playerViewModel.clearPlayingBook()
+      }
+    }
+
+    selections.forEach { selection ->
+      volumes.find { it.id == selection.fileId }
+        ?.chapters?.firstOrNull()?.let { chapter ->
+          viewModel.dropCache(book, chapter)
-          }
         }
-        viewModel.dropCache(it, chapter)
-      }
     }
   }
 }

196-200: Reclaim size recalculation on every selection change may be sluggish.

remember(selectedVolumes.toList(), cachedItems.itemCount) triggers calculateReclaimSize — which calls getVolumes() per book — on every checkbox toggle. For a smoother UX, consider precomputing and caching volume sizes when the list loads, so the reclaim total can be summed from a map without I/O.


98-101: Consider placing VolumeIdentifier in a shared domain/model package.

It's currently a top-level data class in a UI screen file, but it's used across multiple functions. If other screens need similar selection tracking, this would need to be moved.


675-697: calculateReclaimSize calls getVolumes(book) which performs filesystem I/O during composition in a remember block.

mapChaptersToVolumes checks file existence via .exists() for every matched book, and this computation recalculates on every selection change (since selectedVolumes.toList() is a remember key). Consider caching the volumes per book or moving this computation off the main thread.

Comment on lines +62 to +66
clarityTracker.setUser(
org.grakovne.lissen.common
.sha256("${account.username}@$host"),
)
clarityTracker.trackEvent("login_success")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Analytics calls should not block the login flow.

If clarityTracker.setUser() or trackEvent() throws an exception, the entire onPostLogin function will fail before the library-fetch logic on lines 69–100 executes. Analytics should be fire-and-forget and never disrupt core functionality.

🛡️ Proposed fix — wrap analytics in try/catch
+    try {
       clarityTracker.setUser(
         org.grakovne.lissen.common
           .sha256("${account.username}@$host"),
       )
       clarityTracker.trackEvent("login_success")
+    } catch (e: Exception) {
+      Timber.w(e, "Failed to send analytics event")
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clarityTracker.setUser(
org.grakovne.lissen.common
.sha256("${account.username}@$host"),
)
clarityTracker.trackEvent("login_success")
try {
clarityTracker.setUser(
org.grakovne.lissen.common
.sha256("${account.username}@$host"),
)
clarityTracker.trackEvent("login_success")
} catch (e: Exception) {
Timber.w(e, "Failed to send analytics event")
}
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/content/AuthRepository.kt` around
lines 62 - 66, The calls to clarityTracker.setUser and clarityTracker.trackEvent
inside onPostLogin can throw and currently can abort the rest of the login flow;
wrap those analytics calls in a try/catch so any exception is swallowed (or
logged) and does not propagate, making them fire-and-forget; locate the calls to
clarityTracker.setUser(...) and clarityTracker.trackEvent("login_success") and
enclose them in a small try { ... } catch (e: Exception) { /* log.debug or
ignore */ } block so the subsequent library-fetch logic in onPostLogin always
runs.

Comment on lines +44 to +58
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)"
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Host/user isolation is weakened by the OR EXISTS fallback in the online path.

When downloadedOnly = false and host/username are provided (lines 50–53), the OR EXISTS(...) clause means books cached by a different host/user will also appear in the current user's list. If this is intentional (to always surface local content regardless of origin), it should be documented. If not, the OR should be removed so that online queries are strictly scoped to the current tenant.

The downloadedOnly = true branch (line 46) similarly ignores host/username — this may be acceptable for offline mode, but should be a deliberate choice.

Suggested tightening for the online branch
          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))"
+            "(host = ? AND username = ?)"
          } else {
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt`
around lines 44 - 58, The isolationClause in FetchRequestBuilder.kt weakens
tenant isolation by adding "OR EXISTS(...)" when host and username are present;
change the online branch so that when host and username are non-empty you only
add host and username to args and return "host = ? AND username = ?" (no OR
EXISTS), and keep the downloadedOnly=true branch as-is only if offline should
ignore host/username; update the variable isolationClause construction and any
callers expecting the previous behavior to enforce strict per-tenant scoping
when host/username are supplied.

Comment on lines +35 to +39
val searchClause = "(title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)"
val pattern = "%$searchQuery%"
args.add(pattern)
args.add(pattern)
args.add(pattern)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

LIKE wildcards in user input are not escaped.

If searchQuery contains % or _, these characters will act as SQL LIKE wildcards, potentially returning broader results than expected. You could escape them with a custom ESCAPE clause, but this is a minor edge case for a local cache search.

♻️ Optional fix — escape LIKE wildcards
-    val pattern = "%$searchQuery%"
+    val escaped = searchQuery.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+    val pattern = "%$escaped%"

And update the SQL clause to include ESCAPE '\\':

-    val searchClause = "(title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)"
+    val searchClause = "(title LIKE ? ESCAPE '\\' OR author LIKE ? ESCAPE '\\' OR seriesNames LIKE ? ESCAPE '\\')"
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt`
around lines 35 - 39, The current code builds the SQL LIKE pattern from
searchQuery without escaping '%' and '_' so user input can act as wildcards;
update the logic that constructs pattern (used where searchClause is defined) to
escape '%' and '_' (e.g., replace "%" -> "\\%" and "_" -> "\\_") before wrapping
with "%" for the surrounding contains match, and update searchClause to include
"ESCAPE '\\'" so the backslash escapes are honored; ensure args.add(pattern)
still receives the escaped pattern for title, author, and seriesNames checks.

Comment on lines 106 to 133
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()
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

finish() can be invoked twice: once from collect and again from onCompletion.

When the last emission makes inProgress() false, finish() fires at line 131. The flow then completes, and onCompletion (line 109) re-evaluates !inProgress() → calls finish() again. While Android tolerates repeated stopSelf()/stopForeground() calls, the double invocation also triggers duplicate updateErrorNotification() or cancel() side-effects.

Consider guarding with a flag or removing the redundant path:

Option: simplify onCompletion
        executor
          .run(mediaProvider.providePreferredChannel())
          .onCompletion {
-            if (executionStatuses.isEmpty() || !inProgress()) {
+            if (executionStatuses.isEmpty()) {
               finish()
             }
           }.collect { progress ->
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt`
around lines 106 - 133, The finish() method can be called twice because both the
collect block and the onCompletion handler call finish() when inProgress()
becomes false; modify the flow so finish() is only invoked once—either remove
the finish() call from onCompletion inside executor.run(...).onCompletion or add
a guarded boolean/atomic flag (e.g., a local/field like finishCalled) that
finish() checks/sets to ensure it only runs its stop/notification side-effects
once; update references around executor.run(...).onCompletion, collect { ... },
inProgress(), and
lastNotificationUpdate/notificationService.updateCachingNotification to rely on
the single guarded finish invocation.

Comment on lines +121 to +128
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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Notification updates suppressed while any task is in error state.

The hasErrors().not() guard at line 121 means that if task A errors out while task B is still caching, progress notifications for B stop entirely. The user only sees the last notification before the error, with no further progress until everything finishes.

If the intent is to suppress noisy notifications, consider only suppressing for the errored item rather than all items.

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt`
around lines 121 - 128, The current guard prevents any notification updates when
any task has errored because of hasErrors().not(); remove that global error
check and instead call notificationService.updateCachingNotification with a
filtered view of executionStatuses so only errored entries are excluded (e.g.,
executionStatuses.entries.filter { (_, status) -> /* status not in error state
*/ } .map { (item, status) -> item to status }). Keep the
shouldUpdateNotification and inProgress() checks and still update
lastNotificationUpdate after calling updateCachingNotification.

Comment on lines 13 to +31
@Transaction
suspend fun updateLibraries(libraries: List<Library>) {
suspend fun updateLibraries(
libraries: List<Library>,
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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check how Library class is defined and how IDs are sourced
rg -n --type kotlin 'data class Library' -C 5
rg -n --type kotlin 'class Library\(' -C 5

Repository: SurjitSahoo/kahani-android

Length of output: 17090


🏁 Script executed:

#!/bin/bash
# Find Library instantiation patterns to understand ID source
rg -n --type kotlin 'Library\(' -C 2 -g '!**/test/**' -g '!**/dao/**' | head -80

Repository: SurjitSahoo/kahani-android

Length of output: 7690


🏁 Script executed:

#!/bin/bash
# Examine the CachedLibraryEntity to see the actual schema and conflict strategy
rg -n --type kotlin 'CachedLibraryEntity' -B 5 -A 15 | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 12030


🏁 Script executed:

#!/bin/bash
# Check the upsertLibraries method implementation to see the OnConflictStrategy
rg -n --type kotlin 'upsertLibraries\|@Insert\|@Upsert' -B 2 -A 2

Repository: SurjitSahoo/kahani-android

Length of output: 52


Composite primary key required to prevent cross-tenant library overwrites.

The @PrimaryKey id: String combined with OnConflictStrategy.REPLACE creates a collision risk in this multi-tenant scenario. If libraries from different Audiobookshelf servers share the same ID, upserting for one tenant silently replaces another tenant's library. While deleteLibrariesExcept is scoped to (host, username), the deletion happens after the unsafe upsert, leaving a window where data from one tenant overwrites another's cached data.

Change the PK to composite @PrimaryKey(value = ["id", "host", "username"]) or include both fields in the conflict resolution, ensuring each (id, host, username) tuple is truly unique.

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt`
around lines 13 - 31, The upsert currently uses a single-column PK (id) which
allows cross-tenant overwrites; change the CachedLibraryEntity primary key to a
composite of id, host, and username (e.g., `@PrimaryKey`(value =
["id","host","username"])) and update any DAO upsert logic (upsertLibraries) so
conflict resolution operates on that composite key; keep
deleteLibrariesExcept(host, username) as-is but ensure updateLibraries continues
to build entities with host and username before calling upsertLibraries so each
(id,host,username) tuple is treated uniquely and cannot overwrite other tenants'
rows.

Comment on lines +296 to +407
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)")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's view the complete migration context and check for book_chapters
cd app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent || echo "Directory not found"
wc -l Migrations.kt

Repository: SurjitSahoo/kahani-android

Length of output: 87


🏁 Script executed:

# Check if book_chapters is mentioned in the migration file
rg -n "book_chapters" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt

Repository: SurjitSahoo/kahani-android

Length of output: 197


🏁 Script executed:

# Look for the database schema definitions to understand table relationships
rg -n "book_chapters\|CREATE TABLE" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ -A 5 -B 1 | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Check for migration testing setup
rg -n "MigrationTestHelper\|@RunWith\|testMigration" --type kotlin -g '!**/build/**'

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Check for foreign key pragma management
rg -n "foreign_key\|PRAGMA" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ -C 2

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Read the full Migrations.kt to understand the complete migration context
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 3337


🏁 Script executed:

# Read around lines 296-407 to see MIGRATION_17_18 in context
sed -n '280,420p' app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt | cat -n

Repository: SurjitSahoo/kahani-android

Length of output: 5800


🏁 Script executed:

# Check if there's a subsequent migration that handles book_chapters
grep -n "MIGRATION_18\|MIGRATION_19\|MIGRATION_20\|book_chapters" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt

Repository: SurjitSahoo/kahani-android

Length of output: 223


🏁 Script executed:

# Look for the database schema/entity definitions to find book_chapters relationship
find app -type f -name "*.kt" | xargs grep -l "book_chapters" | grep -v build

Repository: SurjitSahoo/kahani-android

Length of output: 604


🏁 Script executed:

# Check the Room database configuration
find app -type f -name "*Database.kt" -o -name "*Entities.kt" | head -10

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Find and read the Room database entity definitions to see book_chapters schema
find app -type f -name "*.kt" | xargs grep -l "book_chapters\|BookChapter" | head -10

Repository: SurjitSahoo/kahani-android

Length of output: 984


🏁 Script executed:

# Check the database schema - look for `@Entity` or table definitions
rg -n "book_chapters|BookChapter" --type kotlin -B 5 -A 10 app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 11605


🏁 Script executed:

# Look at the complete migration file to see all migrations after 17_18
tail -100 app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt

Repository: SurjitSahoo/kahani-android

Length of output: 3601


🏁 Script executed:

# Check if there are any Room database initialization or fallback creation scripts
find app -type f -name "*.kt" | xargs grep -l "createFromAsset\|createFromFile\|fallback" | head -10

Repository: SurjitSahoo/kahani-android

Length of output: 998


🏁 Script executed:

# Check if Room or the Database class explicitly manages foreign key constraints during migrations
rg -n "foreign_key\|PRAGMA\|foreignKeys\|enforceForeignKeys" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ -i

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Look for the database class definition and setup
find app -type f -name "*Database.kt" | xargs cat -n

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Check git history or comments about FK handling in migrations
rg -n "foreign\|FK\|constraint" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt -i

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Search for the Room database class definition more broadly
find app -type f -name "*.kt" | xargs grep -l "RoomDatabase\|@Database" | head -10

Repository: SurjitSahoo/kahani-android

Length of output: 155


🏁 Script executed:

# Look for database initialization or configuration
find app -type f -name "*.kt" | xargs grep -l "\.addMigrations\|Migration\(" | head -15

Repository: SurjitSahoo/kahani-android

Length of output: 93


🏁 Script executed:

# Check the LocalCacheStorage file mentioned in earlier results (likely database setup)
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheStorage.kt | head -150

Repository: SurjitSahoo/kahani-android

Length of output: 1275


🏁 Script executed:

# Look for Room database migration with explicit FK deferral
rg -n "allowMainThreadQueries\|databaseBuilder" app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ -B 2 -A 5

Repository: SurjitSahoo/kahani-android

Length of output: 52


🏁 Script executed:

# Check if there's a database setup/builder in the application or dependency injection
find app -type f -name "*.kt" | xargs grep -l "LocalCacheStorage\|databaseBuilder" | head -10

Repository: SurjitSahoo/kahani-android

Length of output: 240


🏁 Script executed:

# Verify that book_chapters has a valid structure matching the FK constraint to detailed_books
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt

Repository: SurjitSahoo/kahani-android

Length of output: 4247


🏁 Script executed:

# Check the LocalCacheModule to see how the database is configured
cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt

Repository: SurjitSahoo/kahani-android

Length of output: 2620


🏁 Script executed:

# Verify if book_chapters is ever recreated or handled in any subsequent migration
tail -100 app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt | cat -n

Repository: SurjitSahoo/kahani-android

Length of output: 4301


book_chapters table is not recreated in MIGRATION_17_18 — consider explicit recreation for schema consistency.

The migration recreates detailed_books, libraries, media_progress, and book_files, but skips book_chapters. Since book_chapters has a foreign key referencing detailed_books(id), and detailed_books is dropped and recreated, this inconsistency is safe in practice because Room defers foreign key enforcement during migrations—the new detailed_books table exists before detailed_books_old is dropped.

However, explicitly recreating book_chapters with its foreign key constraint (like media_progress and book_files) would make the schema transformation clearer and consistent across all dependent tables. The empty MIGRATION_18_19 suggests schema issues required post-migration reconciliation, which could have been avoided with explicit handling.

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt`
around lines 296 - 407, MIGRATION_17_18 currently recreates detailed_books,
libraries, media_progress, and book_files but omits book_chapters; add explicit
handling for book_chapters by renaming book_chapters to book_chapters_old,
creating the new book_chapters table with the original columns and a FOREIGN
KEY(bookId) REFERENCES detailed_books(id) ON UPDATE NO ACTION ON DELETE CASCADE
(matching media_progress/book_files style), INSERT ... SELECT to copy rows from
book_chapters_old into book_chapters, DROP TABLE book_chapters_old, and recreate
any needed indexes (e.g., index on bookId) so the migration consistently updates
dependent tables alongside detailed_books.

Comment on lines +38 to +44
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)

ModalBottomSheet(
onDismissRequest = { /* User must make a choice */ },
sheetState = sheetState,
containerColor = colorScheme.background,
dragHandle = null,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Drag-to-dismiss may bypass the forced consent choice.

onDismissRequest = {} blocks outside-tap and back-button dismissal, but the user can still swipe the sheet down to dismiss it. To truly force a choice, pass confirmValueChange = { false } to the sheet state:

Suggested fix
-  val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+  val sheetState = rememberModalBottomSheetState(
+    skipPartiallyExpanded = true,
+    confirmValueChange = { false },
+  )

This prevents the sheet from transitioning to the Hidden state via drag gesture while still allowing the programmatic callbacks to work.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { /* User must make a choice */ },
sheetState = sheetState,
containerColor = colorScheme.background,
dragHandle = null,
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { false },
)
ModalBottomSheet(
onDismissRequest = { /* User must make a choice */ },
sheetState = sheetState,
containerColor = colorScheme.background,
dragHandle = null,
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt`
around lines 38 - 44, The bottom sheet currently still allows swipe-to-dismiss
because the ModalBottomSheet's state (sheetState created by
rememberModalBottomSheetState) has no confirmValueChange guard; update the sheet
state creation to pass confirmValueChange = { newValue -> newValue !=
ModalBottomSheetValue.Hidden } (or simply { false } for stricter behavior) when
calling rememberModalBottomSheetState so drags cannot transition the state to
Hidden while retaining programmatic control used by ModalBottomSheet and its
onDismissRequest.

Comment on lines +276 to +279
<string name="analytics_consent_title">Help us polish the experience.</string>
<string name="analytics_consent_message">Help us squash bugs by sharing anonymous technical data. Your library and personal info stay 100% private.</string>
<string name="analytics_consent_accept">Help Improve Kahani 🚀</string>
<string name="analytics_consent_decline">Maybe later</string>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Analytics consent message claims "anonymous" data, but identifiable data is sent.

Line 277 states "sharing anonymous technical data" and "personal info stay 100% private," but ClarityComponent.reidentifyUser() sends the plaintext username@host to Microsoft Clarity. Either hash the identifier (as flagged in ClarityComponent.kt) or update this copy to accurately reflect what's collected.

🤖 Prompt for AI Agents
In `@app/src/main/res/values/strings.xml` around lines 276 - 279, The analytics
consent text incorrectly claims data is anonymous while
ClarityComponent.reidentifyUser() sends plaintext username@host to Microsoft
Clarity; either stop sending PII or update the copy. Fix option A: in
ClarityComponent.reidentifyUser() (ClarityComponent.kt) hash or redact the
username@host before calling the Clarity SDK so no plaintext identifiers are
transmitted. Fix option B: update the strings (analytics_consent_message and
analytics_consent_accept) to remove claims of anonymity/privacy and explicitly
state that non-personal telemetry and device identifiers are collected; ensure
the UI copy matches the actual behavior.

…data refresh, and refine cache cleanup logic.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt (1)

277-308: ⚠️ Potential issue | 🟠 Major

cacheBookCover always returns Completed even when the cover fetch fails.

The .fold() result on lines 287–304 is never captured. Both the onFailure path (empty body, Line 302-303) and an exception in onSuccess (returns CacheState(CacheStatus.Error) at Line 299 — but only from the lambda, not the function) lead to the unconditional CacheState(CacheStatus.Completed) at Line 306.

This means a cover caching failure is reported as success, which can suppress the error rollback in cacheMediaItem (Lines 112-127).

🐛 Proposed fix — capture fold result and return it
     return withContext(Dispatchers.IO) {
-      channel
+      val result = channel
         .fetchBookCover(book.id, width = null)
         .fold(
           onSuccess = { cover ->
             try {
               cover
                 .peek()
                 .withBlur(context)
                 .writeToFile(rawFile)

               cover
                 .withBlur(context, width = 300)
                 .writeToFile(thumbFile)
+              CacheState(CacheStatus.Completed)
             } catch (ex: Exception) {
-              return@fold CacheState(CacheStatus.Error)
+              CacheState(CacheStatus.Error)
             }
           },
           onFailure = {
+            CacheState(CacheStatus.Error)
           },
         )

-      CacheState(CacheStatus.Completed)
+      result
     }
🤖 Fix all issues with AI agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt`:
- Around line 128-136: In ContentCachingManager's catch block that currently
checks "if (e !is kotlinx.coroutines.CancellationException)" and calls
send(CacheState(CacheStatus.Error)), remove the unconditional "throw e" so
non-CancellationException errors are signaled via send(...) but not rethrown;
keep rethrowing CancellationException only (i.e., ensure CancellationException
still propagates) so the collector receives the error state without being
crashed by a second thrown exception.

In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt`:
- Around line 113-123: getBookSize and calculateChapterSize perform synchronous
file I/O and must be moved off the main thread: change these two methods
(getBookSize and calculateChapterSize in CachingModelView) to suspend functions
and wrap the calls to localCacheRepository.calculateBookSize(...) and
localCacheRepository.calculateChapterSize(...) inside
withContext(Dispatchers.IO) (or alternatively make the corresponding
localCacheRepository methods suspend and do the withContext there); update
callers (e.g., BookDetailScreen, CachedItemsSettingsScreen, PlayerScreen,
NavigationBarComposable) to call these from a coroutine or document that they
must be invoked from a background context.
🧹 Nitpick comments (6)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt (4)

339-341: Misleading parameter name: onBack actually navigates to library.

At the call site (line 260), onNavigateToLibrary is passed as this parameter. The name onBack is confusing since it doesn't navigate back — it navigates to the library (potentially clearing the back stack).

Suggested rename
-private fun PolishedCachedItemsEmptyState(onBack: () -> Unit) {
+private fun PolishedCachedItemsEmptyState(onNavigateToLibrary: () -> Unit) {

And update the reference at line 376:

-      onClick = onBack,
+      onClick = onNavigateToLibrary,

660-669: Redundant fully-qualified Checkbox reference.

Checkbox from androidx.compose.material3 is already imported at line 46. The FQN at line 662 is unnecessary and inconsistent with the usage at line 561.

Suggested fix
-          androidx.compose.material3.Checkbox(
+          Checkbox(

736-752: Redundant full scan of cachedItems per selection entry.

deleteSelectedVolumes iterates the entire paging list for each selected volume (line 739). For n selections and m items, this is O(n·m). Pre-building a lookup map avoids repeated scans.

Suggested refactor
 private suspend fun deleteSelectedVolumes(
   selectedIds: List<VolumeIdentifier>,
   cachedItems: LazyPagingItems<DetailedItem>,
   viewModel: CachingModelView,
   playerViewModel: PlayerViewModel,
 ) {
+  val bookMap = (0 until cachedItems.itemCount)
+    .mapNotNull { cachedItems[it] }
+    .associateBy { it.id }
+
   selectedIds.forEach { selection ->
-    val book = (0 until cachedItems.itemCount).mapNotNull { cachedItems[it] }.find { it.id == selection.bookId }
+    val book = bookMap[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)
       }
     }
   }
 }

556-572: Duplicated getVolumes + filter call.

viewModel.getVolumes(book).filter { it.isDownloaded } is computed at line 557 (inside CachedItemComposable) and again at line 636 (inside CachedItemVolumeComposable). Since CachedItemVolumeComposable is a direct child, consider passing the already-computed list as a parameter to avoid the redundant call.

app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt (2)

115-119: Redundant fully-qualified name for PlayingChapter.

PlayingChapter is already imported at Line 31. The FQN on Line 117 is unnecessary. BookFile on Line 118 does appear to need qualification if not imported — consider adding an import for it instead.

♻️ Proposed fix
+import org.grakovne.lissen.lib.domain.BookFile
 ...
     fun calculateChapterSize(
       bookId: String,
-      chapter: org.grakovne.lissen.lib.domain.PlayingChapter,
-      files: List<org.grakovne.lissen.lib.domain.BookFile>,
+      chapter: PlayingChapter,
+      files: List<BookFile>,
     ) = localCacheRepository.calculateChapterSize(bookId, chapter, files)

208-222: refreshMetadata() loads all cached items into memory at once.

Using Int.MAX_VALUE as the page size (Line 212) fetches the entire cache in a single query. For users with large libraries this could cause memory pressure and long pauses.

Consider paginating the fetch or processing items in batches.

Comment on lines +128 to 136
} 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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Rethrowing after send(CacheStatus.Error) can crash the collector.

For non-CancellationException cases, Line 131 sends the Error state and then Line 133 rethrows the exception. The collector will receive the error emission and then be hit by the unhandled exception from the flow. Typically you either emit a terminal error state or throw — doing both can cause unexpected behavior in the collecting coroutine.

If the intent is a graceful error signal, remove the rethrow for non-cancellation exceptions. If the intent is to propagate exceptions, remove the send.

🐛 Proposed fix — don't rethrow non-cancellation exceptions
       } catch (e: Exception) {
         if (e !is kotlinx.coroutines.CancellationException) {
           Timber.e(e, "Failed to cache media item")
           send(CacheState(CacheStatus.Error))
-        }
-        throw e
+        } else {
+          throw e
+        }
       } finally {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} 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
}
} catch (e: Exception) {
if (e !is kotlinx.coroutines.CancellationException) {
Timber.e(e, "Failed to cache media item")
send(CacheState(CacheStatus.Error))
} else {
throw e
}
} finally {
// No additional terminal state needed if completed/error already sent
}
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt`
around lines 128 - 136, In ContentCachingManager's catch block that currently
checks "if (e !is kotlinx.coroutines.CancellationException)" and calls
send(CacheState(CacheStatus.Error)), remove the unconditional "throw e" so
non-CancellationException errors are signaled via send(...) but not rethrown;
keep rethrowing CancellationException only (i.e., ensure CancellationException
still propagates) so the collector receives the error state without being
crashed by a second thrown exception.

Comment on lines +113 to +123
fun getBookSize(book: DetailedItem) = localCacheRepository.calculateBookSize(book)

fun calculateChapterSize(
bookId: String,
chapter: org.grakovne.lissen.lib.domain.PlayingChapter,
files: List<org.grakovne.lissen.lib.domain.BookFile>,
) = localCacheRepository.calculateChapterSize(bookId, chapter, files)

fun getBookStorageType(book: DetailedItem) = localCacheRepository.getBookStorageType(book)

fun getVolumes(book: DetailedItem) = localCacheRepository.mapChaptersToVolumes(book)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the localCacheRepository implementation
find . -type f -name "*.kt" | xargs grep -l "localCacheRepository" | head -20

Repository: SurjitSahoo/kahani-android

Length of output: 552


🏁 Script executed:

# Search for the implementation of the methods mentioned
rg -n --type=kt "fun calculateBookSize|fun calculateChapterSize|fun getBookStorageType|fun mapChaptersToVolumes" -g "!*test*"

Repository: SurjitSahoo/kahani-android

Length of output: 96


🏁 Script executed:

# Check the localCacheRepository interface/class definition
rg -n --type=kt "class.*LocalCacheRepository|interface.*LocalCacheRepository" -A 5

Repository: SurjitSahoo/kahani-android

Length of output: 96


🏁 Script executed:

# Search for calculateBookSize and related methods without type specification
rg "fun calculateBookSize|fun calculateChapterSize|fun getBookStorageType|fun mapChaptersToVolumes" --glob "!*test*" -A 5

Repository: SurjitSahoo/kahani-android

Length of output: 4628


🏁 Script executed:

# Find the LocalCacheRepository class definition
rg "class.*LocalCacheRepository|interface.*LocalCacheRepository" -A 20

Repository: SurjitSahoo/kahani-android

Length of output: 2592


🏁 Script executed:

# Check the structure of the repository
find . -path ./node_modules -prune -o -type d -name "cache" -print | head -10

Repository: SurjitSahoo/kahani-android

Length of output: 202


🏁 Script executed:

# List Kotlin files in the cache directory
find . -path "*/cache*" -name "*.kt" | head -20

Repository: SurjitSahoo/kahani-android

Length of output: 1878


🏁 Script executed:

# Find usages of getBookSize, calculateChapterSize, getBookStorageType, getVolumes in the codebase
rg "getBookSize|calculateChapterSize|getBookStorageType|getVolumes" --glob "!*test*" -B 2 -A 2

Repository: SurjitSahoo/kahani-android

Length of output: 11403


🏁 Script executed:

# Check the LocalCacheRepository implementation more closely for calculateChapterSize and calculateBookSize
sed -n '1,100p' app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt | cat -n

Repository: SurjitSahoo/kahani-android

Length of output: 3965


🏁 Script executed:

# Get more context around the calculateBookSize and calculateChapterSize implementations
rg "fun calculateBookSize|fun calculateChapterSize" -A 15

Repository: SurjitSahoo/kahani-android

Length of output: 4585


🏁 Script executed:

# Find calculateMediaMap implementation
rg "fun calculateMediaMap" -A 20

Repository: SurjitSahoo/kahani-android

Length of output: 2827


🏁 Script executed:

# Also check the complete getBookStorageType implementation
rg "fun getBookStorageType" -A 25

Repository: SurjitSahoo/kahani-android

Length of output: 6112


Only getBookSize and calculateChapterSize perform blocking file I/O on the calling thread.

getBookSize (which delegates to calculateBookSize) and calculateChapterSize call .length() on file paths, which performs synchronous disk I/O. These are invoked from composables in BookDetailScreen, CachedItemsSettingsScreen, PlayerScreen, and NavigationBarComposable, blocking the main thread.

getBookStorageType and getVolumes are CPU-bound (iterating collections and comparisons) and do not perform I/O—no action needed for these.

For the two I/O methods, consider wrapping them in suspend functions with withContext(Dispatchers.IO), or document that callers must invoke them from a background context.

🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt` around
lines 113 - 123, getBookSize and calculateChapterSize perform synchronous file
I/O and must be moved off the main thread: change these two methods (getBookSize
and calculateChapterSize in CachingModelView) to suspend functions and wrap the
calls to localCacheRepository.calculateBookSize(...) and
localCacheRepository.calculateChapterSize(...) inside
withContext(Dispatchers.IO) (or alternatively make the corresponding
localCacheRepository methods suspend and do the withContext there); update
callers (e.g., BookDetailScreen, CachedItemsSettingsScreen, PlayerScreen,
NavigationBarComposable) to call these from a coroutine or document that they
must be invoked from a background context.

…d dynamic cover art updates for media items, and enable forced library synchronization on pull-to-refresh.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt (1)

246-269: ⚠️ Potential issue | 🟡 Minor

refreshLibrary is fire-and-forget — callers using async { refreshLibrary(...) } won't await the sync.

refreshLibrary internally launches via viewModelScope.launch and returns Unit immediately. In LibraryScreen.refreshContent, it's wrapped in async { ... }.awaitAll(), which gives the illusion of coordination but the awaitAll completes instantly while the actual sync (syncLibraryPage, syncRepositories) continues in the background. The pull-refresh spinner will stop before the forced sync finishes.

This is a pre-existing pattern for the non-forceRefresh path, but it becomes more impactful now since forceRefresh = true adds potentially long-running network I/O that the user expects to complete before the spinner dismisses.

If this isn't addressed now, consider marking it as a known limitation.

🤖 Fix all issues with AI agents
In `@app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt`:
- Around line 236-261: The race occurs because updateCover (which calls
fetchCover(book) and immediately updates exoPlayer) can run before prepareQueue
finishes setting media sources; instead, start the cover fetch concurrently but
only apply it after the queue is ready: create a Deferred by calling async {
fetchCover(book) } (e.g., artworkDeferred), then await prepareSession and
prepareQueue (awaitAll), then if artworkDeferred.await() is non-null switch to
withContext(Dispatchers.Main) and loop over exoPlayer.mediaItemCount to build
and replace media items (same logic currently in updateCover); reference
updateCover, fetchCover, prepareQueue, exoPlayer and ensure you remove or
replace the original updateCover async so the apply step happens after
prepareQueue completes.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt`:
- Around line 36-37: The state variables isMainLoading and isThumbnailLoaded are
currently remembered without depending on the imageRequest, so they can hold
stale values when AsyncShimmeringImage is reused for a different image; change
their remember calls to key off the imageRequest identity (for example
imageRequest.data or imageRequest.memoryCacheKey / cache key) so the states are
re-initialized when the request changes — update the remember invocations that
create isMainLoading and isThumbnailLoaded inside AsyncShimmeringImage to
remember(imageRequest.data) { mutableStateOf(true) } and
remember(imageRequest.data) { mutableStateOf(false) } (or use the request cache
key) respectively.
🧹 Nitpick comments (7)
app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt (2)

382-397: A single exception aborts the entire prefetch loop; add per-item error handling.

fetchBookCover returns OperationResult, but any unexpected exception (e.g., from the network layer or an uncaught IO error) will terminate the forEach and skip all remaining covers. For a best-effort background task, wrap each iteration in a try-catch.

Also, withContext(Dispatchers.IO) on line 390 is redundant — backgroundScope already dispatches on Dispatchers.IO.

♻️ Proposed fix
     private suspend fun prefetchCovers(books: List<Book>) {
       if (!networkService.isNetworkAvailable() || preferences.isForceCache()) {
         return
       }
 
       yield()
       delay(2000) // Initial delay to prioritize core metadata and thumbnails
 
-      withContext(Dispatchers.IO) {
-        books.forEach { book ->
+      books.forEach { book ->
+        try {
           fetchBookCover(book.id, null)
-          delay(100)
-          yield()
+        } catch (e: Exception) {
+          Timber.w(e, "Failed to prefetch cover for ${book.id}")
         }
+        delay(100)
+        yield()
       }
     }

399-408: libraryId defaults to empty string — fine for current usage, fragile if reused.

this.libraryId ?: "" is safe here since toBook() is only consumed by prefetchCovers (which uses book.id). If this helper is reused for cache or DB lookups keyed on libraryId, the empty-string default will silently produce wrong results. Consider documenting the intended scope or using a more explicit sentinel.

app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt (2)

219-224: Tracking event fires on every keystroke — consider debouncing.

updateSearch is invoked as the user types (via onSearchRequested in LibrarySearchActionComposable). Emitting "search_performed" on every non-empty intermediate value will flood analytics with one event per character. Typically, you'd track this once on search submission or after a debounce window (e.g., 300–500 ms of inactivity).

Sketch: debounce approach

You could collect _searchToken in the init block with a debounce and track there, removing the inline tracking from updateSearch:

// In init block
viewModelScope.launch {
    _searchToken
        .debounce(500)
        .filter { it.isNotEmpty() }
        .collect { clarityTracker.trackEvent("search_performed") }
}
 fun updateSearch(token: String) {
     _searchToken.value = token
-    if (token.isNotEmpty()) {
-        clarityTracker.trackEvent("search_performed")
-    }
 }

237-244: Tracking "library_refresh" on every call is noisy for automatic refreshes.

refreshRecentListening() is called from multiple automatic paths — init collectors on network/cache-state changes, syncLibrary, and checkRefreshNeeded. The tracking event will fire for every one of those background triggers, not just user-initiated refreshes. If the intent is to track user pull-to-refresh actions, consider moving the event to the call-site where the user explicitly triggers the refresh, or introducing a parameter to distinguish user-initiated from background refreshes.

app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt (2)

11-14: Unused animation imports.

animateFloat, infiniteRepeatable, rememberInfiniteTransition, and tween are imported but not used anywhere in this file. They may be leftovers from an earlier iteration. Consider removing them to keep imports clean.

Suggested cleanup
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween

253-254: Duplicate storageType computation — refactor to eliminate code duplication at lines 253 and 598.

storageType is computed identically at line 253 and line 598, both keyed only on book.id. Since getBookStorageType is a pure function depending only on immutable book metadata (files and chapters structure), compute it once at a higher scope and reuse it in both places.

Note: cacheVersion should not be added to the dependency key because getBookStorageType does not depend on cache state—only on the book's file and chapter structure, which are immutable properties of the DetailedItem data class.

Suggested fix — compute once after bookDetail is confirmed non-null

Move the storageType computation to line 253 location and reuse the same value at line 598:

        val book = bookDetail!!
        val storageType = remember(book.id) { cachingModelView.getBookStorageType(book) }

Then remove the duplicate computation at line 598, and pass the single storageType to DownloadsComposable.

app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt (1)

69-85: Main image layer — consider notifying onLoadingStateChanged(true) initially.

The callback is only invoked with false (on success/error). If any caller relies on being notified when loading starts, they won't receive it. The default no-op {} means this is benign today, but it's a subtle API contract change.

Comment on lines 236 to 261
val updateCover =
async {
val artworkUri = fetchCover(book) ?: return@async

withContext(Dispatchers.Main) {
for (i in 0 until exoPlayer.mediaItemCount) {
val currentItem = exoPlayer.getMediaItemAt(i)
val updatedMetadata =
currentItem
.mediaMetadata
.buildUpon()
.setArtworkUri(artworkUri)
.build()

val updatedItem =
currentItem
.buildUpon()
.setMediaMetadata(updatedMetadata)
.build()

exoPlayer.replaceMediaItem(i, updatedItem)
}
}
}

awaitAll(prepareSession, prepareQueue)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition: updateCover may find zero media items if the cover fetch resolves before prepareQueue sets the media sources.

updateCover and prepareQueue run concurrently. Both switch to Dispatchers.Main for ExoPlayer mutations, but there's no ordering guarantee. If fetchCover completes quickly (e.g., cover is cached), the for (i in 0 until exoPlayer.mediaItemCount) loop on Line 241 can execute before setMediaSources on Line 216, resulting in mediaItemCount == 0 — artwork is silently never applied.

A straightforward fix: fetch the cover concurrently with queue preparation, but apply it to the player only after the queue is ready.

Proposed fix — decouple fetch from apply
-      val updateCover =
-        async {
-          val artworkUri = fetchCover(book) ?: return@async
-
-          withContext(Dispatchers.Main) {
-            for (i in 0 until exoPlayer.mediaItemCount) {
-              val currentItem = exoPlayer.getMediaItemAt(i)
-              val updatedMetadata =
-                currentItem
-                  .mediaMetadata
-                  .buildUpon()
-                  .setArtworkUri(artworkUri)
-                  .build()
-
-              val updatedItem =
-                currentItem
-                  .buildUpon()
-                  .setMediaMetadata(updatedMetadata)
-                  .build()
-
-              exoPlayer.replaceMediaItem(i, updatedItem)
-            }
-          }
-        }
-
-      awaitAll(prepareSession, prepareQueue)
+      val updateCover =
+        async {
+          fetchCover(book)
+        }
+
+      awaitAll(prepareSession, prepareQueue)
+
+      // Apply artwork after queue is ready
+      val artworkUri = updateCover.await()
+      if (artworkUri != null) {
+        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)
+          }
+        }
+      }

This keeps the cover fetch concurrent with queue preparation (no added latency) but guarantees the player has media items before artwork is applied.

🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt`
around lines 236 - 261, The race occurs because updateCover (which calls
fetchCover(book) and immediately updates exoPlayer) can run before prepareQueue
finishes setting media sources; instead, start the cover fetch concurrently but
only apply it after the queue is ready: create a Deferred by calling async {
fetchCover(book) } (e.g., artworkDeferred), then await prepareSession and
prepareQueue (awaitAll), then if artworkDeferred.await() is non-null switch to
withContext(Dispatchers.Main) and loop over exoPlayer.mediaItemCount to build
and replace media items (same logic currently in updateCover); reference
updateCover, fetchCover, prepareQueue, exoPlayer and ensure you remove or
replace the original updateCover async so the apply step happens after
prepareQueue completes.

…or when preparation completes without immediate play.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt`:
- Around line 271-273: The "sleep_timer_set" event is being emitted inside
updateTimer which is also invoked by internal flows (adjustTimer,
resetSleepTimer), causing noisy analytics; modify updateTimer(signature) to
accept a userInitiated:Boolean (default false) or similar flag, change calls
from user actions to pass true and internal callers (adjustTimer,
resetSleepTimer) to leave it false, then only call
clarityTracker.trackEvent("sleep_timer_set") when userInitiated is true;
alternatively, remove the tracking from updateTimer and add
clarityTracker.trackEvent("sleep_timer_set") to the explicit user-facing callers
instead (referencing updateTimer, adjustTimer, resetSleepTimer to locate sites).
🧹 Nitpick comments (1)
app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt (1)

578-586: Consider distinguishing timer-expired pauses from user-initiated pauses.

pause() is called both by user action (togglePlayPause) and by the timer expiry receiver (line 248). Both will emit "playback_pause", which may skew analytics if you later want to understand why users pause. This is minor and can be addressed later if needed.

Comment on lines +271 to +273
if (timerOption != null) {
clarityTracker.trackEvent("sleep_timer_set")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"sleep_timer_set" fires on internal recalculations, not just user actions.

updateTimer is also called from adjustTimer (on every seek/speed change while a timer is active) and resetSleepTimer (on shake). This will emit "sleep_timer_set" events that don't represent a deliberate user timer-set, producing noisy analytics.

Consider either moving the tracking call to the caller sites that represent actual user intent, or gating it with a flag:

Proposed fix — add a parameter to distinguish user-initiated calls
   fun updateTimer(
     timerOption: TimerOption?,
     position: Double? = null,
+    userInitiated: Boolean = false,
   ) {
     _timerOption.postValue(timerOption)
-    if (timerOption != null) {
+    if (timerOption != null && userInitiated) {
       clarityTracker.trackEvent("sleep_timer_set")
     }
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt` around
lines 271 - 273, The "sleep_timer_set" event is being emitted inside updateTimer
which is also invoked by internal flows (adjustTimer, resetSleepTimer), causing
noisy analytics; modify updateTimer(signature) to accept a userInitiated:Boolean
(default false) or similar flag, change calls from user actions to pass true and
internal callers (adjustTimer, resetSleepTimer) to leave it false, then only
call clarityTracker.trackEvent("sleep_timer_set") when userInitiated is true;
alternatively, remove the tracking from updateTimer and add
clarityTracker.trackEvent("sleep_timer_set") to the explicit user-facing callers
instead (referencing updateTimer, adjustTimer, resetSleepTimer to locate sites).

…hance migration SQL safety, and refine UI/analytics state management with various fixes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt`:
- Line 106: The analyticsConsent LiveData is nullable but the code uses
observeAsState(true), which masks the null "undecided" state; change the
observeAsState call in LibraryScreen (the line creating analyticsConsent from
settingsViewModel.analyticsConsent) to use a null initial value (e.g.,
settingsViewModel.analyticsConsent.observeAsState(null)) so analyticsConsent can
be null and the existing check that shows AnalyticsConsentBottomSheet
(analyticsConsent == null) will work.
🧹 Nitpick comments (6)
app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt (2)

1-14: Missing imports for Timber and CancellationException.

Lines 57 and 61 use fully-qualified names (kotlinx.coroutines.CancellationException, timber.log.Timber) inline instead of importing them at the top like all other dependencies. Add the imports for consistency.

♻️ Proposed fix
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CancellationException
 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 timber.log.Timber
 import javax.inject.Inject

Then on line 57:

-          if (e is kotlinx.coroutines.CancellationException) {
+          if (e is CancellationException) {

And on line 61:

-          timber.log.Timber.e(e, "Migration failed")
+          Timber.e(e, "Migration failed")

42-65: Consider moving the artificial delay inside try, after the migration query.

Currently the 1.5s delay runs before the migration attempt. If the migration fails immediately, the user still waited 1.5s staring at a spinner for no benefit. Moving the delay after the IO block (or using minOf/parallel timing) would ensure the delay only pads successful fast migrations.

♻️ Proposed reordering
     viewModelScope.launch {
       _migrationState.value = MigrationState.Running
 
-      // Artificial delay for a better UX if migration is target-fast
-      delay(1500)
       try {
+        // Artificial delay for a better UX if migration is fast
+        delay(1500)
+
         withContext(Dispatchers.IO) {
           // Trigger DB initialization and migration by performing a simple query
           bookDao.countCachedBooks(null, "", "")
         }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/Migrations.kt (1)

413-418: Empty migration used as a Room identity-hash workaround — consider a brief explanation.

This is a recognized pattern for fixing Room identity-hash mismatches when the actual on-disk schema is already correct. A slightly more descriptive comment (e.g., noting which table or column ordering caused the mismatch) would help future maintainers understand why this no-op migration exists rather than fixing the schema in 17→18 directly.

app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt (2)

35-49: Consider marking isClarityInitialized as @Volatile.

isClarityInitialized is written on the main thread in onActivityCreated but read in updateConsent, which could be called from any thread. Without @Volatile, a non-main thread could see a stale false and silently skip the consent update.

♻️ Suggested fix
-    private var isClarityInitialized = false
+    `@Volatile`
+    private var isClarityInitialized = false

Also applies to: 76-76


61-74: Suppress detekt warnings for required interface stubs.

These empty bodies are mandated by ActivityLifecycleCallbacks. Suppress the detekt noise with a single annotation or Unit expressions.

♻️ Suggested fix (suppress at class level or inline)
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityStarted(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityResumed(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityPaused(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityStopped(activity: Activity) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivitySaveInstanceState(
     activity: Activity,
     outState: Bundle,
   ) {}
 
+  `@Suppress`("EmptyFunctionBlock")
   override fun onActivityDestroyed(activity: Activity) {}

Alternatively, a single class-level @Suppress("EmptyFunctionBlock") keeps it cleaner.

app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt (1)

44-55: Consider handling thumbnail load failure explicitly.

If the thumbnail request fails, isThumbnailLoaded stays false and the shimmer remains — a reasonable fallback. However, adding onError to set a flag could let you remove the thumbnail AsyncImage from composition entirely on failure, avoiding a redundant composable in the tree.

This is a minor polish; current behavior degrades gracefully.


val activity = LocalActivity.current
val recentBooks: List<RecentBook> by libraryViewModel.recentBooks.observeAsState(emptyList())
val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState(true)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: observeAsState(true) prevents the analytics consent sheet from ever appearing.

analyticsConsent is typed LiveData<Boolean?> where null means "user hasn't decided yet." Passing true as the initial value swallows the null state, so the condition on line 485 (analyticsConsent == null) is never satisfied and the AnalyticsConsentBottomSheet is never shown.

🐛 Proposed fix
-  val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState(true)
+  val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState(true)
val analyticsConsent by settingsViewModel.analyticsConsent.observeAsState()
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt`
at line 106, The analyticsConsent LiveData is nullable but the code uses
observeAsState(true), which masks the null "undecided" state; change the
observeAsState call in LibraryScreen (the line creating analyticsConsent from
settingsViewModel.analyticsConsent) to use a null initial value (e.g.,
settingsViewModel.analyticsConsent.observeAsState(null)) so analyticsConsent can
be null and the existing check that shows AnalyticsConsentBottomSheet
(analyticsConsent == null) will work.

… listening flicker on pull down to refresh
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt (1)

172-193: ⚠️ Potential issue | 🟠 Major

fetchCachedItems and fetchCachedItemsCount lack host/username filtering.

countCachedBooks (line 137–141) applies per-host/per-user filtering, but fetchCachedItems and fetchCachedItemsCount don't accept or filter by host/username. In a multi-tenant setup this returns cached items across all users, which may leak another user's cached library entries.

If this is intentional (e.g., a device-level cache view), a clarifying comment would help. Otherwise, add the same nullable filtering:

Suggested fix
   `@Transaction`
   `@Query`(
     """
     SELECT * FROM detailed_books
     WHERE EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)
+      AND ((:host IS NULL AND host IS NULL) OR (host = :host))
+      AND ((:username IS NULL AND username IS NULL) OR (username = :username))
     ORDER BY title ASC, libraryId ASC
     LIMIT :pageSize
     OFFSET (:pageNumber * :pageSize)
     """,
   )
   suspend fun fetchCachedItems(
     pageSize: Int,
     pageNumber: Int,
+    host: String?,
+    username: String?,
   ): List<CachedBookEntity>

   `@Query`(
     """
     SELECT COUNT(*) FROM detailed_books 
     WHERE EXISTS (SELECT 1 FROM book_chapters WHERE bookId = detailed_books.id AND isCached = 1)
+      AND ((:host IS NULL AND host IS NULL) OR (host = :host))
+      AND ((:username IS NULL AND username IS NULL) OR (username = :username))
     """,
   )
-  suspend fun fetchCachedItemsCount(): Int
+  suspend fun fetchCachedItemsCount(host: String?, username: String?): Int
app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt (1)

227-256: ⚠️ Potential issue | 🔴 Critical

refreshLibrary is fire-and-forget — callers cannot await its completion.

refreshLibrary internally launches a new coroutine via viewModelScope.launch and returns Unit immediately. In LibraryScreen.refreshContent, it's wrapped in async { libraryViewModel.refreshLibrary(...) }.awaitAll(), but since the function isn't suspend and doesn't return the Job, the await resolves instantly. This means pullRefreshing = false (line 152 in LibraryScreen) fires before the sync/invalidation work finishes, causing the pull-to-refresh indicator to dismiss prematurely.

Either make refreshLibrary a suspend function that does the work directly (the caller already provides a coroutine scope), or return the Job so the caller can join on it.

🔧 Option A: make it a suspend function
-    fun refreshLibrary(forceRefresh: Boolean = false) {
-      viewModelScope.launch {
-        withContext(Dispatchers.IO) {
+    suspend fun refreshLibrary(forceRefresh: Boolean = false) {
+      withContext(Dispatchers.IO) {
           if (forceRefresh) {
             networkService.refreshServerAvailability()
           }
           ...
           when (searchRequested.value) {
             true -> searchPagingSource?.invalidate()
             else -> defaultPagingSource.value?.invalidate()
           }
-        }
       }
     }
app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt (1)

145-153: ⚠️ Potential issue | 🔴 Critical

awaitAll() doesn't actually wait for refreshLibrary to finish.

As noted in the LibraryViewModel review, refreshLibrary is fire-and-forget. The async { ... }.awaitAll() + withMinimumTime wrapper here gives the illusion of waiting, but pullRefreshing = false on line 152 fires before the actual refresh work completes. Once refreshLibrary is converted to a suspend function, this call site will work correctly as-is.

🤖 Fix all issues with AI agents
In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt`:
- Around line 209-213: The updateSearch function currently calls
clarityTracker.trackEvent("search_performed") on every keystroke; stop flooding
telemetry by removing that immediate track call and instead either (A) emit the
event from a debounced collector that observes _searchToken (use a
coroutine/Flow with debounce and distinctUntilChanged, then call
clarityTracker.trackEvent("search_performed") only when the debounced value is
collected) or (B) add an explicit public action like searchSubmitted(token:
String) and call clarityTracker.trackEvent("search_performed") only from
searchSubmitted (and wired to the UI submit event); update references to
updateSearch, _searchToken, and clarityTracker.trackEvent("search_performed")
accordingly.
🧹 Nitpick comments (4)
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedBookDao.kt (1)

243-247: upsertBooks unconditionally updates all books after bulk insert.

updateBooks(books) is called on the entire list, including books that were just freshly inserted. This is functionally correct (Room's @Update on a just-inserted row is a no-op in terms of data change), but it doubles the write I/O for new entries. For small batches this is fine; if batch sizes grow, consider filtering to only update rows where insertBooksIgnore returned -1L.

Potential optimization
  `@Transaction`
  suspend fun upsertBooks(books: List<BookEntity>) {
-   insertBooksIgnore(books)
-   updateBooks(books)
+   val insertResults = insertBooksIgnore(books)
+   val toUpdate = books.filterIndexed { index, _ -> insertResults[index] == -1L }
+   if (toUpdate.isNotEmpty()) {
+     updateBooks(toUpdate)
+   }
  }
app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt (1)

123-133: Double syncRepositories call when server becomes available.

When isServerAvailable emits true, line 129 calls bookRepository.syncRepositories() and line 130 calls refreshLibrary(), which internally also calls bookRepository.syncRepositories() (line 247) when shouldSync is true. This results in two concurrent sync calls for the same event.

Remove the explicit syncRepositories() here since refreshLibrary already handles it, or vice versa.

♻️ Suggested fix
       networkService
         .isServerAvailable
         .collect { isAvailable ->
           if (isAvailable) {
             Timber.d("Server is reachable. Triggering repository sync.")
-            bookRepository.syncRepositories()
             refreshLibrary()
           }
         }
app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt (2)

85-121: Race condition on checkJob — consider synchronization.

checkJob is read/written from multiple threads (network callbacks on the main/handler thread, hostFlow collection on Dispatchers.IO, and now any external caller since the method is public). The cancel-and-reassign on lines 88–90 is not atomic, so two concurrent callers can each launch a coroutine while only one reference is retained — the other runs unsupervised.

Practical impact is low (both coroutines emit to a StateFlow, last write wins), but the orphaned coroutine still does unnecessary I/O. A simple fix is to serialize access through the coroutine scope itself:

♻️ Proposed fix — use scope serialization
-    private var checkJob: Job? = null
+    private val checkJobLock = Any()
+    private var checkJob: Job? = null

     fun refreshServerAvailability() {
-      checkJob?.cancel()
-      checkJob =
-        scope.launch {
+      synchronized(checkJobLock) {
+        checkJob?.cancel()
+        checkJob = scope.launch {
           delay(500)
           // ... rest unchanged
         }
+      }
     }

Alternatively, confine all calls to a single-threaded dispatcher (e.g., Dispatchers.Main.immediate or a dedicated single-thread context) so the mutable state is only touched from one thread.


106-119: DNS resolution not covered by the connect timeout.

InetSocketAddress(url.host, port) on line 109 eagerly resolves the hostname. If DNS is slow or hanging, this blocks the Dispatchers.IO thread outside the 2000 ms socket.connect() timeout. Use createUnresolved so resolution happens inside the guarded connect call:

♻️ Proposed fix
-            val address = java.net.InetSocketAddress(url.host, port)
+            val address = java.net.InetSocketAddress.createUnresolved(url.host, port)

Comment on lines 209 to +213
fun updateSearch(token: String) {
viewModelScope.launch { _searchToken.emit(token) }
_searchToken.value = token
if (token.isNotEmpty()) {
clarityTracker.trackEvent("search_performed")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Analytics event fires on every keystroke.

updateSearch is called on each character typed. Emitting "search_performed" for every non-empty intermediate value will flood telemetry. Consider debouncing or only tracking when the user submits/finishes typing (e.g., via a debounced flow collector or an explicit "search submitted" action).

🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt` around
lines 209 - 213, The updateSearch function currently calls
clarityTracker.trackEvent("search_performed") on every keystroke; stop flooding
telemetry by removing that immediate track call and instead either (A) emit the
event from a debounced collector that observes _searchToken (use a
coroutine/Flow with debounce and distinctUntilChanged, then call
clarityTracker.trackEvent("search_performed") only when the debounced value is
collected) or (B) add an explicit public action like searchSubmitted(token:
String) and call clarityTracker.trackEvent("search_performed") only from
searchSubmitted (and wired to the UI submit event); update references to
updateSearch, _searchToken, and clarityTracker.trackEvent("search_performed")
accordingly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt (1)

90-92: ⚠️ Potential issue | 🟡 Minor

Play/pause button remains clickable during loading state.

When isLoading is true, the spinner is shown but the IconButton on line 90 still invokes viewModel.togglePlayPause() on click. If the player isn't ready yet, this could trigger unintended behavior or a no-op that confuses users (haptic fires but nothing happens). Consider disabling the button while loading:

Proposed fix
     IconButton(
       onClick = { withHaptic(view) { viewModel.togglePlayPause() } },
+      enabled = preparingBookId == null,
       modifier = Modifier.size(72.dp),
     ) {

Also applies to: 104-109

🤖 Fix all issues with AI agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt`:
- Around line 264-283: preparingBookId is global so the spinner may show for the
wrong item; change the loading guard to check that preparingBookId ==
currentBook.id (or the composable's bookId) before showing
CircularProgressIndicator, remove the unused isError variable (or use it to
switch the icon to an error indicator), and prevent clicks during loading by
disabling the IconButton (enabled = preparingBookId == currentBook.id &&
preparingBookId == null) or make onClick a no-op while that book is preparing;
also replace the fully-qualified
androidx.compose.material3.CircularProgressIndicator with an import at the top
of the file.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt`:
- Around line 44-46: The composable is observing isPlaybackReady and
preparingError but not using them, causing unnecessary recompositions and
leaving preparation failures invisible; either remove the unused observer val
isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false) (and the
local isError) if playback readiness/error are handled elsewhere, or wire
preparingError into the UI by using preparingError (and preparingBookId) to show
error feedback (e.g., replace the spinner with a retry icon/button or trigger a
Snackbar when preparingError is true) and ensure the spinner dismissal logic
respects preparingError vs preparingBookId so users see the failure state and
can retry; update the code paths around preparingBookId, preparingError and the
local isError to reflect the chosen approach.
- Around line 101-102: The local variable isError (val isError = preparingError)
in PlaybackButtonsComposable is dead code and should be removed or used; either
delete that assignment or wire it into the UI branch (e.g., replace places where
preparingError should control UI state with isError or add an error state branch
to show an error icon when preparingError is true). Update the composable logic
that reads preparingBookId/isLoading (preparingBookId != null) to also handle
preparingError (or remove isError and refer directly to preparingError) so the
error state is not unused; adjust any conditional rendering in the
PlaybackButtonsComposable accordingly.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt`:
- Around line 454-455: The calls to cachingModelView.getBookStorageType(book)
and cachingModelView.getVolumes(book) allocate new objects on every composition
and should be memoized to avoid unnecessary recompositions of
DownloadsComposable; wrap each call in remember(book) (e.g., create local vals
like storageType = remember(book) { cachingModelView.getBookStorageType(book) }
and volumes = remember(book) { cachingModelView.getVolumes(book) }) and then
pass those vals into DownloadsComposable instead of calling the methods inline.
🧹 Nitpick comments (5)
app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt (1)

63-64: Nit: redundant fully-qualified name for dagger.Lazy.

dagger.Lazy is already imported on Line 15, so the FQN on Line 64 is unnecessary. You can simplify to Lazy<Cache>.

Suggested fix
   `@Inject`
-  lateinit var mediaCache: dagger.Lazy<Cache>
+  lateinit var mediaCache: Lazy<Cache>
app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt (1)

105-105: Use an import instead of the fully-qualified class name.

androidx.compose.material3.CircularProgressIndicator is used inline with its FQN. Adding it to the import list keeps the composable body consistent with the rest of the file.

Proposed fix

Add the import at the top of the file:

import androidx.compose.material3.CircularProgressIndicator

Then simplify the usage:

-          androidx.compose.material3.CircularProgressIndicator(
+          CircularProgressIndicator(
             modifier = Modifier.size(32.dp),
             color = colorScheme.onPrimary,
             strokeWidth = 3.dp,
           )
app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt (2)

74-76: Unused observed states trigger unnecessary recompositions.

isPlaybackReady (line 74) is never referenced in this composable. preparingError (line 76) is only assigned to the unused isError local on line 267. Both observations subscribe to LiveData and will cause recompositions for no visible effect. Remove them or wire them into the UI (e.g., show an error state).


279-279: Hardcoded content description strings.

"Pause" and "Play" should use stringResource(…) for localization, consistent with the stringResource(R.string.mini_player_action_close) pattern used elsewhere in this file (line 305).

app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt (1)

451-481: Redundant .let — name the outer let parameter instead.

it on line 451 is already the non-null book from the playingBook?.let { on line 405. Wrapping it in another .let { book -> } just to rename it adds unnecessary nesting. Name the parameter at line 405 directly:

♻️ Suggested cleanup

At line 405, name the parameter:

-      playingBook?.let {
+      playingBook?.let { book ->

Then at line 451, drop the redundant let and un-indent:

-            it.let { book ->
-              DownloadsComposable(
-                book = book,
-                ...
-              )
-            }
+            DownloadsComposable(
+              book = book,
+              ...
+            )

This also removes the inconsistency where it is used directly on lines 406–438 but renamed to book only here.

Comment on lines 264 to +283
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),
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Loading indicator is not scoped to the current book — may show a spinner for the wrong item.

preparingBookId is a global ViewModel state. When any book is being prepared, this mini player (which displays the currently playing book) will show a spinner even if the preparation is for a different book. Compare against the current book's ID:

-                  val isLoading = preparingBookId != null
+                  val isLoading = preparingBookId == book.id

Additional observations in this block:

  1. isError is dead code — assigned on line 267 but never read. Either remove it or use it (e.g., swap the icon to an error indicator).
  2. Button remains clickable during loading — the IconButton onClick still calls togglePlayPause() while the spinner is shown. Consider guarding it (enabled = !isLoading) or making onClick a no-op during preparation to prevent double-taps.
  3. FQN instead of importandroidx.compose.material3.CircularProgressIndicator should be imported at the top of the file for consistency with the other Material 3 imports already present.
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt`
around lines 264 - 283, preparingBookId is global so the spinner may show for
the wrong item; change the loading guard to check that preparingBookId ==
currentBook.id (or the composable's bookId) before showing
CircularProgressIndicator, remove the unused isError variable (or use it to
switch the icon to an error indicator), and prevent clicks during loading by
disabling the IconButton (enabled = preparingBookId == currentBook.id &&
preparingBookId == null) or make onClick a no-op while that book is preparing;
also replace the fully-qualified
androidx.compose.material3.CircularProgressIndicator with an import at the top
of the file.

Comment on lines +44 to +46
val isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false)
val preparingBookId by viewModel.preparingBookId.observeAsState(null)
val preparingError by viewModel.preparingError.observeAsState(false)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

isPlaybackReady is observed but never used; preparingError is observed but the error state is not surfaced.

  • isPlaybackReady (line 44) is subscribed via observeAsState but never referenced in the composable, causing unnecessary recompositions on every value change. Remove it if not needed here.
  • preparingError (line 46) is observed and assigned to isError on line 101, but that local is also unused — the user gets no visual feedback when preparation fails (e.g., the spinner would have already been dismissed since preparingBookId would be null). Consider either removing these if error handling belongs elsewhere, or wiring the error state into the UI (e.g., show a retry icon or snackbar).
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt`
around lines 44 - 46, The composable is observing isPlaybackReady and
preparingError but not using them, causing unnecessary recompositions and
leaving preparation failures invisible; either remove the unused observer val
isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false) (and the
local isError) if playback readiness/error are handled elsewhere, or wire
preparingError into the UI by using preparingError (and preparingBookId) to show
error feedback (e.g., replace the spinner with a retry icon/button or trigger a
Snackbar when preparingError is true) and ensure the spinner dismissal logic
respects preparingError vs preparingBookId so users see the failure state and
can retry; update the code paths around preparingBookId, preparingError and the
local isError to reflect the chosen approach.

Comment on lines +101 to +102
val isError = preparingError
val isLoading = preparingBookId != null
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

isError is assigned but never read — dead code.

val isError = preparingError on line 101 is unused. Either remove it or implement the error-state branch (e.g., show an error icon when isError is true).

Proposed fix (remove dead variable)
         Box(contentAlignment = Alignment.Center) {
-          val isError = preparingError
           val isLoading = preparingBookId != null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val isError = preparingError
val isLoading = preparingBookId != null
Box(contentAlignment = Alignment.Center) {
val isLoading = preparingBookId != null
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt`
around lines 101 - 102, The local variable isError (val isError =
preparingError) in PlaybackButtonsComposable is dead code and should be removed
or used; either delete that assignment or wire it into the UI branch (e.g.,
replace places where preparingError should control UI state with isError or add
an error state branch to show an error icon when preparingError is true). Update
the composable logic that reads preparingBookId/isLoading (preparingBookId !=
null) to also handle preparingError (or remove isError and refer directly to
preparingError) so the error state is not unused; adjust any conditional
rendering in the PlaybackButtonsComposable accordingly.

Comment on lines +454 to +455
storageType = cachingModelView.getBookStorageType(book),
volumes = cachingModelView.getVolumes(book),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*CachingModelView*" | head -20

Repository: SurjitSahoo/kahani-android

Length of output: 141


🏁 Script executed:

rg "class CachingModelView" --type kotlin

Repository: SurjitSahoo/kahani-android

Length of output: 162


🏁 Script executed:

cat -n app/src/main/kotlin/org/grakovne/lissen/viewmodel/CachingModelView.kt

Repository: SurjitSahoo/kahani-android

Length of output: 9118


🏁 Script executed:

find . -type f -name "*LocalCacheRepository*" | head -20

Repository: SurjitSahoo/kahani-android

Length of output: 160


🏁 Script executed:

rg "class LocalCacheRepository|interface LocalCacheRepository" --type kotlin

Repository: SurjitSahoo/kahani-android

Length of output: 185


🏁 Script executed:

wc -l app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt

Repository: SurjitSahoo/kahani-android

Length of output: 162


🏁 Script executed:

cat -n app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt

Repository: SurjitSahoo/kahani-android

Length of output: 13871


🏁 Script executed:

sed -n '440,470p' app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt

Repository: SurjitSahoo/kahani-android

Length of output: 1334


Wrap getBookStorageType and getVolumes calls in remember(book) to prevent unnecessary recompositions.

These methods allocate new objects on every invocation. mapChaptersToVolumes creates a new List and new BookVolume objects each time, while getBookStorageType recalculates by creating a new Map in calculateMediaMap. Calling them directly as composition parameters will cause DownloadsComposable to recompose unnecessarily even when the underlying book data hasn't changed.

Suggested fix
val storageType = remember(book) { cachingModelView.getBookStorageType(book) }
val volumes = remember(book) { cachingModelView.getVolumes(book) }

DownloadsComposable(
  book = book,
  storageType = storageType,
  volumes = volumes,
  // ... rest of parameters
)
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/GlobalPlayerBottomSheet.kt`
around lines 454 - 455, The calls to cachingModelView.getBookStorageType(book)
and cachingModelView.getVolumes(book) allocate new objects on every composition
and should be memoized to avoid unnecessary recompositions of
DownloadsComposable; wrap each call in remember(book) (e.g., create local vals
like storageType = remember(book) { cachingModelView.getBookStorageType(book) }
and volumes = remember(book) { cachingModelView.getVolumes(book) }) and then
pass those vals into DownloadsComposable instead of calling the methods inline.

@SurjitSahoo SurjitSahoo closed this Feb 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant