Skip to content

Download Enhancements, Integrate Clarity analytics, database migrations, and cache redesign#8

Merged
SurjitSahoo merged 37 commits intomainfrom
feat/download-enhancements
Feb 7, 2026
Merged

Download Enhancements, Integrate Clarity analytics, database migrations, and cache redesign#8
SurjitSahoo merged 37 commits intomainfrom
feat/download-enhancements

Conversation

@SurjitSahoo
Copy link
Owner

@SurjitSahoo SurjitSahoo commented Feb 6, 2026

Summary by CodeRabbit

  • New Features

    • Analytics consent UI and opt-in/out with tracking; Microsoft Clarity + analytics events added
    • App update checker with user notification
    • Download manager: storage-type aware downloads, volumes, per-book controls, queued state
  • Improvements

    • Storage statistics, disk-space info, metadata cache clearing, and selection-based cache removal
    • Thumbnail preview for faster cover loading; richer download progress visuals and animations
    • Enhanced playback flows, error reporting, and more robust server reachability checks
  • Chores

    • Firebase/Crashlytics and project config added; database schema/version and migration flow updated

Surjit Kumar Sahoo added 22 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.
…atabase migrations, and update the migration screen UI.
…n the cache is empty, providing a new `onNavigateToLibrary` callback.
…data refresh, and refine cache cleanup logic.
…d dynamic cover art updates for media items, and enable forced library synchronization on pull-to-refresh.
…or when preparation completes without immediate play.
…hance migration SQL safety, and refine UI/analytics state management with various fixes.
… listening flicker on pull down to refresh
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Integrates Microsoft Clarity analytics and Firebase Crashlytics, adds Google Services config, extensive Room schema migrations (→19) with host/username/size plumbing, refactors caching/downloads to be volume-aware, adds migration UI and analytics consent flows, and updates many DAOs, repositories, viewmodels, and UI components.

Changes

Cohort / File(s) Summary
Build & CI
\.coderabbit.yaml, build.gradle.kts, app/build.gradle.kts, gradle/libs.versions.toml, .gitignore, app/google-services.json, .github/workflows/release.yml
Add CodeRabbit config, Google Services & Firebase plugins, Microsoft Clarity and Firebase entries in version catalog, include google-services.json, and add changelog step + release action update.
Analytics core
app/src/main/kotlin/.../AnalyticsModule.kt, ClarityComponent.kt, ClarityTracker.kt
New Hilt module and singleton to initialize Microsoft Clarity, apply stored consent, re-identify users, and provide tracking APIs.
Crash handling & app init
CrashHandler.kt, LissenApplication.kt, many common/* usages
Remove ACRA init, add CrashHandler/uncaught-exception handling, configure Firebase Crashlytics collection from preferences, and record errors to Crashlytics across code paths.
Room schemas & migrations
app/schemas/.../LocalCacheStorage/{16,17,18,19}.json, LocalCacheStorage.kt, Migrations.kt, LocalCacheModule.kt
Add schema files versions 16–19, bump DB version to 19, add produceMigration15_16 and migrations 16→17→18→19, include host/username and size columns and data-preserving table rebuilds.
DAO & entities
dao/*CachedBookDao.kt, CachedLibraryDao.kt, entity/CachedBookEntity.kt, entity/CachedLibraryEntity.kt
Add host/username to entities/queries, add BookFile.size with default, introduce batch upsert/ignore helpers, add deleteNonDownloadedBooks helpers, and remove hasDownloadedChapters API.
Local cache & manager
ContentCachingManager.kt, ContentCachingService.kt, LocalCacheRepository.kt, OfflineBookStorageProperties.kt, CalculateRequestedChapters.kt, FindRelatedFiles.kt
Refactor caching to channelFlow with queued state, parallelize cover/media/library caching, compute per-file start times, integrate fileStartTimes into requested chapters, add storage/size APIs and volume mapping, and add progress/crashlytics reporting.
Repository / builders / converters
CachedBookRepository.kt, CachedLibraryRepository.kt, api/*RequestBuilder.kt, converter/*CachedBookEntityDetailedConverter.kt
Thread host/username through builders and repos, replace join logic with isolationClause, propagate new size/addedAt/updatedAt fields through converters and repository upserts.
Domain & download options
lib/.../BookStorage.kt, CacheStatus.kt, DownloadOption.kt, DetailedItem.kt, Book.kt
Add BookStorageType/BookVolume, add Queued cache state, introduce SpecificFilesDownloadOption (encoded id payload), add addedAt/updatedAt to Book, and make DetailedItem nested types default-constructible.
Caching model & migrations UI
CachingModelView.kt, LocalCacheRepository.kt, MigrationViewModel.kt, SettingsViewModel.kt, LibraryViewModel.kt, PlayerViewModel.kt
Expose storageStats/cacheVersion and getVolumes/getBookSize APIs, implement MigrationViewModel and migration state, add analytics consent LiveData and persistence, and adjust library refresh/search telemetry.
Content flows & analytics
BookRepository.kt, AuthRepository.kt, AudioBookshelfRepository.kt, channel implementations`
Add clarityTracker deps and events (login_success, sync_progress, search_performed), add fetchLibraryMinified/fetchPodcastMinified, and background cover prefetching.
Network & utilities
NetworkService.kt, Moshi.kt, HashUtils.kt
Promote check→refreshServerAvailability with synchronous retry logic, add Moshi adapter factory logging and KotlinJsonAdapterFactory, and add sha256 utility.
Playback & synchronization
PlaybackService.kt, PlaybackSynchronizationService.kt, MediaRepository.kt
Change mediaCache injection to dagger.Lazy, async artwork fetching and metadata updates, robust playback session sync with mutex/coroutines, and track playback events/errors to Clarity/Crashlytics.
Downloads UI & components
DownloadsComposable.kt, ActionRow.kt, DownloadProgressIcon.kt, AsyncShimmeringImage.kt, BookDetailScreen.kt, PlayerScreen.kt, GlobalPlayerBottomSheet.kt, NavigationBarComposable.kt, PlaybackButtonsComposable.kt
Redesign downloads UI to be book/storage-type/volume-aware, add ActionRow, queued state and animated progress, thumbnail-preview loading, pass volumes/isFullyDownloaded through UI, and update many call sites.
Settings, consent & screens
AnalyticsConsentBottomSheet.kt, CacheSettingsScreen.kt, CachedItemsSettingsScreen.kt, AppActivity.kt, LibraryScreen.kt, MigrationScreen.kt, resources`
Add analytics consent bottom sheet, metadata cache clear action, selection-driven cached-items UI with volume deletion and storage stats, migration screen/flow and routing, and wire update checker startup call.
Theme, resources & assets
ui/theme/Color.kt, drawable/available_offline_filled.xml, values/strings.xml, values-*/strings.xml
Add download success colors, simplify drawable to filled path, add many migration/download/consent strings, rename typo app_crach_toast→app_crash_toast, and remove thumbnail-cache strings in translations.
Other changes
many small UI modifiers and maskings (clarityMask) across inputs, plus miscellaneous refactors
Apply clarityMask modifiers to sensitive text fields, adjust scrollbars and throttled notifications, and other targeted UI/logic tweaks across files.

Sequence Diagram(s)

[skipped]

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped from fifteen up to nineteen,
Clarity in my pocket, tidy and clean.
Volumes queued, thumbnails shimmer and glow,
Consent signed, migrations steady and slow.
A rabbit's small cheer — caches trimmed, onward we go.

🚥 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 accurately describes the main changes in the PR: download enhancements, analytics integration (Clarity), database migrations, and cache redesign are all prominent themes throughout the changeset.

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

✨ Finishing touches
🧪 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.

@SurjitSahoo
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai bot changed the title @coderabbit title Integrate Clarity analytics, database migrations, and cache redesign Feb 7, 2026
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: 9

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (5)
app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt (1)

266-273: ⚠️ Potential issue | 🟡 Minor

sleep_timer_set fires on every timer update, including resets and adjustments.

updateTimer is also called from resetSleepTimer() (shake-to-reset) and adjustTimer() (seek adjustments for episode-based timers). This means sleep_timer_set will fire repeatedly during normal usage, inflating the metric beyond actual user-initiated timer sets.

Consider moving the tracking to the call sites where the user explicitly sets the timer, or adding a flag to distinguish user-initiated from programmatic calls.

app/src/main/res/values/strings.xml (1)

97-97: ⚠️ Potential issue | 🟡 Minor

Pre-existing typo: "No podcast has no episodes".

This string wasn't changed in this PR, but it reads as a double negative. The intended text is likely "This podcast has no episodes" or "No episodes available."

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

277-308: ⚠️ Potential issue | 🟠 Major

cacheBookCover always returns Completed, even when cover fetching or writing fails.

The fold return value is discarded (not assigned), and the function always falls through to CacheState(CacheStatus.Completed) on Line 306. This means cover-caching failures are invisible to the caller at Lines 108-127, and the overall when block will never see a cover error.

🐛 Proposed fix to propagate the fold result
     return withContext(Dispatchers.IO) {
-      channel
+      val coverResult = channel
         .fetchBookCover(book.id, width = null)
         .fold(
           onSuccess = { cover ->
             try {
               cover
                 .peek()
                 .withBlur(context)
                 .writeToFile(rawFile)
 
               cover
                 .withBlur(context, width = 300) // Trigger thumbnail transformation
                 .writeToFile(thumbFile)
+              CacheState(CacheStatus.Completed)
             } catch (ex: Exception) {
-              return@fold CacheState(CacheStatus.Error)
+              Timber.e(ex, "Failed to write book cover for ${book.id}")
+              CacheState(CacheStatus.Error)
             }
           },
           onFailure = {
+            Timber.w("Failed to fetch book cover for ${book.id}")
+            CacheState(CacheStatus.Error)
           },
         )
 
-      CacheState(CacheStatus.Completed)
+      coverResult
     }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt (2)

96-129: ⚠️ Potential issue | 🟡 Minor

Existing books are not updated with the current host/username during cacheBooks.

When an existing BookEntity is found (Line 100), the copy() call omits host and username. Since the @Update DAO method updates all fields of the entity, these fields retain stale values from the previous caching session. If a user switches servers or accounts, the book entry keeps the old host/username and becomes invisible to host/username-filtered queries (Lines 163-164), effectively orphaning it.

Update the copy() call to include current host/username values from preferences:

existing?.copy(
  title = book.title,
  author = book.author,
  subtitle = book.subtitle,
  seriesNames = book.series,
  duration = book.duration.toInt(),
  libraryType = null,
  host = preferences.getHost(),
  username = preferences.getUsername(),
)

199-221: ⚠️ Potential issue | 🔴 Critical

fetchMediaProgress at line 216 lacks host/username filtering, creating potential data isolation issues.

The request includes host and username from preferences (lines 207-208), but fetchMediaProgress(it) filters only by bookId. Since MediaProgressEntity has bookId as the sole @PrimaryKey, it stores only one progress record per book across all users/hosts. If progress data contains host/username values that don't match the current request context, the wrong user's progress could be returned.

🤖 Fix all issues with AI agents
In @.github/workflows/release.yml:
- Line 73: The workflow uses the deprecated softprops/action-gh-release@v1 which
relies on an unsupported Node runtime; update the action reference in the
release workflow by changing the uses entry from softprops/action-gh-release@v1
to softprops/action-gh-release@v2 so the job uses the supported v2 release
action; ensure the rest of the job (inputs/outputs, environment variables)
remain compatible after the upgrade and run the workflow to verify no other
adjustments are required.

In `@app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt`:
- Around line 78-90: The reidentifyUser method is sending raw PII
("$username@$host") to Clarity; change it to compute a strong hash (e.g.,
SHA-256) of the combined identifier and pass the hex/base64 digest to
clarityTracker.setUser instead of the plain string; update the branch that
currently builds identifier in reidentifyUser (using preferences.getUsername()
and preferences.getHost()) to produce and use the hashed identifier, while
leaving the fallback to preferences.getDeviceId() unchanged if device ID is
non-PII or already acceptable.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt`:
- Around line 127-128: The username field (CachedBookEntity.username) is being
sent and logged in plaintext; update the code that calls Clarity.setCustomUserId
(ClarityComponent) and the Timber log in LissenMediaProvider to send only an
irreversible pseudonymized identifier (e.g., SHA-256/HMAC of "$username@$host"
or a session/device ID) instead of the raw username, and stop logging the
plaintext username (log only masked or the hashed ID); ensure the hashing is
applied before any call to Clarity.setCustomUserId and replace the Timber
statement that prints username with one that prints the hashed/masked value, and
update privacy statement strings to reflect the new behavior.

In
`@app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt`:
- Around line 48-54: In UpdateCheckerService (the method that calls
client.newCall(request).execute()), ensure the OkHttp Response is always closed
to avoid resource leaks: wrap the response handling in response.use { ... } (or
at minimum response.body?.use { ... }) and move the call to
response.body?.string() and subsequent processing (including the isSuccessful
check) inside that block so the Response/ResponseBody are closed even if
string() throws or processing fails; update references to response and json
accordingly.

In `@app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt`:
- Around line 237-260: The launched coroutine that updates artwork can overwrite
metadata after a new preparePlayback run; to fix, add a cancellable Job field
(e.g., artworkUpdateJob: Job?) on PlaybackService, cancel() and null it at the
start of preparePlayback, then assign artworkUpdateJob = launch { ... } when
starting the background update (the existing launch block containing fetchCover
and exoPlayer.replaceMediaItem). Additionally, capture the current book
identifier (e.g., book.id) into a local val before calling fetchCover and,
inside the withContext(Dispatchers.Main) loop, verify the queue still belongs to
that book by checking a media item tag/metadata value (e.g., mediaMetadata.tag
or a custom identifier you set when building MediaItems) before calling
exoPlayer.replaceMediaItem, to prevent applying stale artwork.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/components/AnalyticsConsentBottomSheet.kt`:
- Around line 38-41: The ModalBottomSheet can still be dismissed via swipe
because rememberModalBottomSheetState(skipPartiallyExpanded = true) alone
doesn't block transitions to Hidden; update the sheet state creation
(rememberModalBottomSheetState) to supply confirmValueChange = { it !=
SheetValue.Hidden } so the sheet cannot transition to Hidden by drag, and add
the import for androidx.compose.material3.SheetValue; keep the existing
onDismissRequest no-op as-is and reference rememberModalBottomSheetState,
ModalBottomSheet, and SheetValue.Hidden when making this change.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt`:
- Around line 93-101: The dismiss TextButton in MigrationScreen (the TextButton
whose onClick calls onMigrationComplete and label
stringResource(R.string.migration_screen_dismiss_button)) allows users to bypass
a failed migration and can cause runtime crashes; update the flow so the dismiss
action is not allowed on migration failure: replace or gate the TextButton
behavior to require a successful migration or explicit destructive fallback
(e.g., show a Retry migration action that calls the migration retry helper and a
Clear App Data confirmation that wipes DB and then calls onMigrationComplete),
disable or hide the dismiss button when migration state indicates failure, and
surface clear retry UI with explicit confirmation instead of immediately calling
onMigrationComplete. Ensure the UI hooks reference the migration state checks
used by MigrationScreen and call the existing migration retry / clear methods
rather than directly proceeding.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt`:
- Around line 54-59: The file uses fully-qualified domain type names everywhere
(e.g., org.grakovne.lissen.lib.domain.DetailedItem, BookStorageType, BookVolume,
DownloadOption) which hurts readability; update DownloadsComposable (and any
other top-level composables in DownloadsComposable.kt) to import the domain
types at the top of the file and replace all fully-qualified references with the
short type names (DetailedItem, BookStorageType, BookVolume, DownloadOption,
etc.) in the function signature and body, keeping the exact parameter names and
lambda signatures (e.g., onRequestedDownload: (DownloadOption) -> Unit)
unchanged.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt`:
- Around line 732-753: deleteSelectedVolumes currently calls
viewModel.dropCache(it, chapter) only for volume.chapters.firstOrNull(), so
SEGMENTED/MONOLITH files shared by other chapters are not removed; change the
logic in deleteSelectedVolumes to iterate all chapters in the found volume
(e.g., volume.chapters.forEach { chapter -> viewModel.dropCache(book, chapter)
}) so each chapter reference is dropped, and apply the same fix in
CachedItemVolumeComposable where volume.chapters.firstOrNull() is used (replace
with iterating all chapters and calling dropCache for each) to ensure underlying
files are actually deleted.
🟡 Minor comments (12)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt-267-283 (1)

267-283: ⚠️ Potential issue | 🟡 Minor

isError is assigned but never used; no error state is surfaced to the user.

Line 267 assigns preparingError to isError but nothing reads it. If preparation fails, the user sees neither the spinner nor an error indication — they just get the normal play button with no feedback.

Additionally, the loading state checks preparingBookId != null globally rather than preparingBookId == book.id. If a different book is being prepared, this mini player will incorrectly show a spinner.

Proposed fix
-                  val isError = preparingError
-                  val isLoading = preparingBookId != null
+                  val isLoading = preparingBookId == book.id
 
                   if (isLoading) {

For the error case, consider showing an error icon or snackbar when preparingError == true, or remove the unused observer entirely to avoid confusion.

app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt-68-83 (1)

68-83: ⚠️ Potential issue | 🟡 Minor

mapNotNull silently drops non-numeric version segments, which can corrupt the comparison.

If a tag contains pre-release suffixes (e.g., 1.2.3-rc.1), "3-rc".toIntOrNull() returns null and is dropped, collapsing the version to [1, 2, 1]. This misrepresents the actual version. Consider splitting on - first to strip pre-release suffixes, or at minimum using substringBefore("-") before parsing.

Proposed fix
-      val remote = remoteTag.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() }
-      val local = localVersion.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() }
+      val remote = remoteTag.removePrefix("v").substringBefore("-").split(".").mapNotNull { it.toIntOrNull() }
+      val local = localVersion.removePrefix("v").substringBefore("-").split(".").mapNotNull { it.toIntOrNull() }
app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt-44-46 (1)

44-46: ⚠️ Potential issue | 🟡 Minor

isPlaybackReady and isError are observed/assigned but never used.

isPlaybackReady (line 44) is subscribed as a LiveData observer but not referenced anywhere in this composable — it creates an unnecessary recomposition trigger. isError (line 101) is assigned from preparingError but also unused. Either wire these into the UI (e.g., disable buttons when not ready, show error state) or remove them to avoid unnecessary recompositions.

Proposed cleanup if these are not needed yet
-  val isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false)
   val preparingBookId by viewModel.preparingBookId.observeAsState(null)
-  val preparingError by viewModel.preparingError.observeAsState(false)

...

-          val isError = preparingError
           val isLoading = preparingBookId != null

Also applies to: 101-102

lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt-40-60 (1)

40-60: ⚠️ Potential issue | 🟡 Minor

makeDownloadOption() can throw uncaught exceptions on malformed input.

substringAfter("number_items_").toInt() (line 45) throws NumberFormatException on non-numeric input, and Base64.getUrlDecoder().decode(it) (line 53) throws IllegalArgumentException on invalid Base64. Since this parses persisted/serialized strings, corrupted data could crash the app.

Consider wrapping in runCatching or toIntOrNull() with a fallback to null.

Proposed defensive parsing
-  startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt())
+  startsWith("number_items_") -> substringAfter("number_items_").toIntOrNull()?.let { NumberItemDownloadOption(it) }
   startsWith("specific_files_") -> {
     val payload = substringAfter("specific_files_")
 
-    val fileIds = when {
-      payload.isEmpty() -> emptyList()
-      else -> payload
-        .split(",")
-        .map { String(Base64.getUrlDecoder().decode(it)) }
+    val fileIds = runCatching {
+      when {
+        payload.isEmpty() -> emptyList()
+        else -> payload
+          .split(",")
+          .map { String(Base64.getUrlDecoder().decode(it), Charsets.UTF_8) }
+      }
+    }.getOrNull() ?: return null
     }
 
     SpecificFilesDownloadOption(fileIds)
   }
app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt-185-188 (1)

185-188: ⚠️ Potential issue | 🟡 Minor

Error messages sent to analytics may contain internal details.

t.message (e.g., from network errors) could include server URLs, file paths, or other internal info being sent to Clarity. Consider sanitizing or categorizing the error type instead of forwarding the raw message.

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

67-69: ⚠️ Potential issue | 🟡 Minor

Hardcoded CURRENT_DATABASE_VERSION risks going out of sync with Room's schema version.

This constant at line 68 must always match the version parameter in @Database(version = 19) on LocalCacheStorage. Currently both are 19, but they are two separate hardcoded values with no automated verification. If one is updated without the other, migrations may silently malfunction or the migration screen may not appear when needed. Consider deriving this from a single source of truth, or at minimum add a test linking these values.

app/schemas/org.grakovne.lissen.content.cache.persistent.LocalCacheStorage/16.json-91-102 (1)

91-102: ⚠️ Potential issue | 🟡 Minor

Schema 16 declares host/username as NOT NULL, but migration 15→16 adds them as nullable.

In produceMigration15_16, the columns are added via ALTER TABLE … ADD COLUMN host TEXT and ALTER TABLE … ADD COLUMN username TEXT—both nullable, with optional DEFAULT values. However, schema 16.json declares these fields as "notNull": true for detailed_books, media_progress, and libraries. This mismatch means the exported v16 schema does not reflect what the migration produces, which would cause schema validation issues in migration tests.

This is corrected in migration 17→18 (which recreates tables with nullable columns) and schema 18.json (which correctly omits notNull), so runtime behavior is unaffected for users upgrading through all versions. However, the v16 snapshot is inaccurate for intermediate validation.

app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt-85-87 (1)

85-87: ⚠️ Potential issue | 🟡 Minor

trackEvent("sync_progress") fires on every progress sync — could be very noisy.

During playback, syncProgress is called frequently (typically every few seconds). Emitting an analytics event on every call may flood the analytics backend and skew event counts. Consider throttling or sampling this event, or tracking it only at meaningful boundaries (e.g., chapter completion).

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

451-481: ⚠️ Potential issue | 🟡 Minor

getBookStorageType / getVolumes perform file-existence checks on the main thread during composition.

Both delegates ultimately call storageProperties.provideMediaCachePath(...).exists() (visible in LocalCacheRepository.mapChaptersToVolumes). Since this composable runs on the main thread, these synchronous I/O calls could cause frame drops, especially for books with many files.

Consider hoisting these into a LaunchedEffect or produceState with a background dispatcher.

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

113-123: ⚠️ Potential issue | 🟡 Minor

getBookSize, calculateChapterSize, getBookStorageType, and getVolumes perform blocking I/O but are exposed as non-suspend functions.

These delegate to LocalCacheRepository methods that call File.length() and File.exists(). Callers (composables) invoke them on the main thread. Making them suspend (or computing results in the background and exposing via StateFlow) would prevent potential jank.

This is the root cause of the main-thread I/O concern noted in GlobalPlayerBottomSheet.kt and CachedItemsSettingsScreen.kt.

app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt-61-71 (1)

61-71: ⚠️ Potential issue | 🟡 Minor

deleteNonDownloadedBooks has no error handling for file deletion failures.

deleteRecursively() at Line 67 can return false or throw on I/O errors. If file deletion fails but the DB records are deleted at Line 70, the orphaned files will never be cleaned up. Consider logging failures or continuing with best-effort cleanup.

Proposed improvement
     nonDownloadedIds.forEach { bookId ->
-      properties
-        .provideBookCache(bookId)
-        .deleteRecursively()
+      val deleted = properties
+        .provideBookCache(bookId)
+        .deleteRecursively()
+      if (!deleted) {
+        Timber.w("Failed to fully delete cache for book $bookId")
+      }
     }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt-269-271 (1)

269-271: ⚠️ Potential issue | 🟡 Minor

Exception is caught and swallowed without logging.

The catch returns CacheState(CacheStatus.Error) but discards ex, making download failures hard to diagnose. This aligns with the detekt SwallowedException warning.

Proposed fix
         } catch (ex: Exception) {
+          Timber.e(ex, "Failed to write cached media file for $bookId")
           return@withContext CacheState(CacheStatus.Error)
         }
🧹 Nitpick comments (48)
app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt (1)

238-241: Consider extracting the isDetailed predicate for readability.

The compound boolean on line 240 is dense and uses nested lambdas (it shadowing within it.files.all { f -> … }). Extracting it into a small named helper or local variables would make the intent clearer at a glance.

♻️ Optional: break the condition into named parts
       val isDetailed =
         localResult
-          ?.let { (it.chapters.isNotEmpty() || it.files.isNotEmpty()) && (it.files.isEmpty() || it.files.all { f -> f.size > 0 }) }
+          ?.let { book ->
+            val hasContent = book.chapters.isNotEmpty() || book.files.isNotEmpty()
+            val allFilesComplete = book.files.isEmpty() || book.files.all { it.size > 0 }
+            hasContent && allFilesComplete
+          }
           ?: false
app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt (1)

18-28: Debug logging factory likely shouldn't ship in production.

This JsonAdapter.Factory exists solely to log adapter creation. Even when no Timber debug tree is planted in release builds, the string interpolation on line 25 ("$type", "${adapter.javaClass.simpleName}") still executes for every unique type Moshi resolves. Consider removing this before merging, or wrapping it behind a BuildConfig.DEBUG check if you want to keep it for development.

♻️ Suggested removal
 val moshi: Moshi =
   Moshi
     .Builder()
-    .add(
-      object : JsonAdapter.Factory {
-        override fun create(
-          type: Type,
-          annotations: Set<Annotation>,
-          moshi: Moshi,
-        ): JsonAdapter<*>? {
-          val adapter = moshi.nextAdapter<Any>(this, type, annotations)
-          Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}")
-          return adapter
-        }
-      },
-    ).add(
+    .add(
app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt (1)

85-121: Minor thread-safety note on checkJob now that the method is public.

checkJob is read/written from network-callback threads, the hostFlow collector, and now any external caller. The cancel-and-reassign sequence on lines 88-89 isn't atomic, so two concurrent invocations could each launch a coroutine. The practical impact is just a redundant server check (the StateFlow emits are safe), so this is benign today, but worth noting.

If you ever want to tighten this up, wrapping the cancel+launch in a synchronized block or using Mutex would eliminate the window.

app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt (2)

74-76: isPlaybackReady is observed but never used in this composable.

This creates an unnecessary recomposition trigger. Either use it (e.g., to disable the play/pause button when playback isn't ready) or remove the observer.


271-275: Use a short import instead of the fully-qualified androidx.compose.material3.CircularProgressIndicator.

Other Material 3 components (e.g., Icon, IconButton, Text) are already imported at the top of the file. Add an import for CircularProgressIndicator and use the short name here.

Proposed fix

Add to imports:

import androidx.compose.material3.CircularProgressIndicator

Then simplify:

-                    androidx.compose.material3.CircularProgressIndicator(
+                    CircularProgressIndicator(
                       modifier = Modifier.size(32.dp),
                       color = colorScheme.onSurface,
                       strokeWidth = 3.dp,
                     )
app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt (1)

126-130: Use top-level imports instead of fully-qualified names.

Lines 126–130 use FQN references (android.os.Build, androidx.core.content.ContextCompat, android.Manifest, android.content.pm.PackageManager) instead of importing them at the top of the file, unlike the rest of the code.

app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt (1)

64-64: Redundant fully-qualified name: use Lazy<Cache> since dagger.Lazy is already imported on Line 15.

-  lateinit var mediaCache: dagger.Lazy<Cache>
+  lateinit var mediaCache: Lazy<Cache>
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingNotificationService.kt (1)

29-44: Consider using when or a Set instead of creating a list for each status check.

Lines 31 and 87 both create a new listOf(...) on each invocation just for a membership test. A when expression (or a pre-allocated Set) is more idiomatic and avoids unnecessary allocations.

♻️ Suggested refactor
      val cachingItems =
        items
-          .filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed, CacheStatus.Queued).contains(state.status) }
+          .filter { (_, state) ->
+            state.status is CacheStatus.Caching ||
+              state.status is CacheStatus.Completed ||
+              state.status is CacheStatus.Queued
+          }

Same pattern applies to line 87.

gradle/libs.versions.toml (1)

35-35: Consider upgrading to Microsoft Clarity Compose version 3.8.1 (released January 26, 2026), which fixes a Compose rendering edge case. Version 3.8.0 is valid and published, but 3.8.1 is the current release.

app/build.gradle.kts (1)

69-69: Clarity project ID is hardcoded in the build file.

This is comparable to a Google Analytics tracking ID — not a secret, but hardcoding it here means different environments (debug/release/staging) share the same project. Consider whether you want separate Clarity projects per build type. If so, you could define different values per buildType block.

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

105-109: Use an import instead of the fully qualified androidx.compose.material3.CircularProgressIndicator.

There's already a mix of Material3 imports at the top of the file. Add CircularProgressIndicator to the imports and use the short name here.

Proposed fix

Add to imports:

import androidx.compose.material3.CircularProgressIndicator
-          androidx.compose.material3.CircularProgressIndicator(
+          CircularProgressIndicator(
             modifier = Modifier.size(32.dp),
             color = colorScheme.onPrimary,
             strokeWidth = 3.dp,
           )
app/src/main/kotlin/org/grakovne/lissen/common/HashUtils.kt (1)

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

input.toByteArray() uses the platform default charset, which can vary. For deterministic hashing, use Charsets.UTF_8 explicitly.

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

3-5: Unused import: com.squareup.moshi.JsonClass is still imported but no longer referenced.

The @JsonClass(generateAdapter = true) annotations were removed from all classes, but the import on line 4 remains. This is a minor leftover.

🧹 Remove unused import
 import androidx.annotation.Keep
-import com.squareup.moshi.JsonClass
 import java.io.Serializable
app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/ActionRow.kt (1)

82-91: Redundant enabled guard inside onClick.

The clickable modifier already uses enabled = enabled, so the onClick lambda will never be invoked when enabled is false. The inner if (enabled) check on line 87 is redundant.

Simplify
          onClick = {
-            if (enabled) {
-              haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
-              onClick()
-            }
+            haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+            onClick()
          },
app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt (1)

41-54: Event name concatenation loses structured data and risks exceeding Clarity's event name length limit.

"$eventName: $value" merges the event name and payload into one string. This makes it harder to filter/aggregate in the Clarity dashboard and could silently truncate if Clarity imposes a character limit on event names.

If Clarity's sendCustomEvent only accepts a single string, consider at minimum truncating or sanitizing the combined value to a safe max length.

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

35-50: When downloadedOnly = false but host/username are not set, the query behaves identically to downloadedOnly = true.

Lines 46–48: the fallback clause for missing host/username is the same EXISTS subquery used for downloadedOnly = true (line 37). This means callers that set downloadedOnly(false) without providing host/username will silently get download-filtered results. If this is intentional as a safety measure for cache isolation, a code comment would help clarify the intent.

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

91-125: Duplicated rendering logic between Completed and else branches.

Lines 91–107 (Completed) and 109–125 (else) are identical. Combining them would eliminate the duplication.

♻️ Merge the two branches
-      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 -> {
+      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,
           )
         }
       }
app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CacheSettingsScreen.kt (1)

106-118: Use proper imports instead of fully-qualified class names.

androidx.compose.ui.platform.LocalContext (line 106) and android.widget.Toast (line 114) should be imported at the top of the file, consistent with the rest of the file's style.

♻️ Add imports and simplify usage

Add to the import block at the top of the file:

import android.widget.Toast
import androidx.compose.ui.platform.LocalContext

Then simplify the inline usages:

-          val context = androidx.compose.ui.platform.LocalContext.current
+          val context = 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)
+                Toast
+                  .makeText(context, successMessage, Toast.LENGTH_SHORT)
                   .show()
               }
             },
           )
app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt (1)

80-80: Leftover debug log — consider removing or using parameterized format.

This Timber.d fires for every audio file on every book fetch, which is noisy. If it's still needed, import Timber and use format specifiers (Timber.d("Mapping file %s with size %s", it.ino, it.metadata.size)) to avoid string allocation when debug logging is disabled.

♻️ Remove or clean up the debug log

If the log should be removed:

-              timber.log.Timber.d("Mapping file ${it.ino} with size ${it.metadata.size}")

If keeping it, add import timber.log.Timber to the imports and use:

-              timber.log.Timber.d("Mapping file ${it.ino} with size ${it.metadata.size}")
+              Timber.d("Mapping file %s with size %s", it.ino, it.metadata.size)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt (1)

42-50: LaunchedEffect ordering: startMigration() should be invoked before observing for Completed.

Currently, the LaunchedEffect(state) block (line 42) that checks for Completed is declared before the LaunchedEffect(Unit) block (line 48) that starts the migration. While this works because LaunchedEffect(state) re-triggers on state changes, swapping the order would make the intent clearer: start first, then react.

Suggested reorder
-  LaunchedEffect(state) {
-    if (state is MigrationState.Completed) {
-      onMigrationComplete()
-    }
-  }
-
   LaunchedEffect(Unit) {
     viewModel.startMigration()
   }
+
+  LaunchedEffect(state) {
+    if (state is MigrationState.Completed) {
+      onMigrationComplete()
+    }
+  }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt (1)

53-53: Nit: inconsistent chain formatting.

Line 53 chains .addMigrations(MIGRATION_16_17) directly after the closing paren of the previous call, breaking the one-call-per-line pattern used elsewhere.

Suggested format
       .addMigrations(
         produceMigration15_16(
           host = preferences.getHost(),
           username = preferences.getUsername(),
         ),
-      ).addMigrations(MIGRATION_16_17)
+      )
+      .addMigrations(MIGRATION_16_17)
       .addMigrations(MIGRATION_17_18)
       .addMigrations(MIGRATION_18_19)
app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavHost.kt (1)

107-117: Consider importing MigrationScreen and MigrationViewModel instead of using fully qualified names.

Lines 58 and 112 use fully qualified references (org.grakovne.lissen.viewmodel.MigrationViewModel and org.grakovne.lissen.ui.screens.migration.MigrationScreen) while all other composables in this file are imported at the top. This is a minor inconsistency.

Add imports
 import org.grakovne.lissen.ui.screens.settings.advanced.cache.CachedItemsSettingsScreen
 import org.grakovne.lissen.ui.screens.settings.playback.PlaybackSettingsScreen
+import org.grakovne.lissen.ui.screens.migration.MigrationScreen
+import org.grakovne.lissen.viewmodel.MigrationViewModel

Then replace FQN usages with short names.

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

390-394: Swallowed exception loses diagnostic information.

The Throwable is caught and discarded. While returning null for corrupted JSON is the right fallback, silently swallowing the exception makes it harder to diagnose data corruption issues. Consider logging it.

Log the exception
           try {
             adapter.fromJson(json)
           } catch (e: Throwable) {
+            timber.log.Timber.w(e, "Failed to deserialize playing book")
             null
           }

113-115: Minor style inconsistency in setDatabaseVersion.

This method uses sharedPreferences.edit().putInt(...).apply() while most other setters in this class use the edit { } Kotlin extension (e.g., line 101, 121, etc.). Consider aligning for consistency.

Use edit block
-    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/content/cache/persistent/CalculateRequestedChapters.kt (1)

50-55: Prefer imports over fully-qualified names.

SpecificFilesDownloadOption and findRelatedFilesByStartTimes are referenced with full package paths inline, reducing readability. Add them as imports at the top of the file.

♻️ Proposed refactor

Add imports:

+import org.grakovne.lissen.content.cache.common.findRelatedFilesByStartTimes
+import org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption

Then simplify the branch:

-    is org.grakovne.lissen.lib.domain.SpecificFilesDownloadOption ->
-      book.chapters.filter { chapter ->
-        org.grakovne.lissen.content.cache.common
-          .findRelatedFilesByStartTimes(chapter, fileStartTimes)
-          .any { it.id in option.fileIds }
-      }
+    is SpecificFilesDownloadOption ->
+      book.chapters.filter { chapter ->
+        findRelatedFilesByStartTimes(chapter, fileStartTimes)
+          .any { it.id in option.fileIds }
+      }
app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt (1)

209-213: Analytics event fired on every keystroke.

updateSearch is called on each character the user types in the search field. Since there's no debounce, trackEvent("search_performed") will fire for every non-empty intermediate input (e.g., typing "Harry" triggers 5 events). Consider debouncing or tracking only on search submission to reduce noise.

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

413-418: Empty migration for identity hash realignment.

MIGRATION_18_19 has no SQL statements. While the comment explains the intent, this suggests the v18 schema's identity hash didn't match Room's expected hash, requiring a version bump to resync. This is a known Room workaround, but worth a brief doc comment to future maintainers explaining why the hash drifted (e.g., nullable vs NOT NULL mismatch in earlier versions).

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

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

BookRepository and CachedCoverProvider are referenced by their fully-qualified names in the constructor signature. This is atypical for Kotlin and reduces readability. Add proper imports instead.

♻️ Proposed refactor

Add imports:

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

Then update the constructor:

-    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,

309-324: clearMetadataCache: CancellationException handling and FQN usage.

The CancellationException rethrow pattern is correct for structured concurrency. However, kotlinx.coroutines.CancellationException and timber.log.Timber are referenced by FQN inline — add them as imports for consistency.

Also, the onComplete() callback in finally ensures the UI is always notified regardless of success/failure, which is a good pattern.

♻️ Proposed refactor for cleaner imports

Add imports at the top:

+import kotlinx.coroutines.CancellationException
+import timber.log.Timber

Then simplify the catch block:

-        } catch (e: Exception) {
-          if (e is kotlinx.coroutines.CancellationException) {
-            throw e
-          }
-
-          timber.log.Timber.e(e, "Failed to clear metadata cache")
+        } catch (e: Exception) {
+          if (e is CancellationException) throw e
+          Timber.e(e, "Failed to clear metadata cache")
app/src/main/kotlin/org/grakovne/lissen/viewmodel/MigrationViewModel.kt (2)

34-40: retryMigration allows re-execution from Completed state.

The guard only blocks Running, so calling retryMigration() after a successful migration would re-execute it (re-running the DB query and re-setting the version). While not harmful, this is likely unintended. Consider also blocking the Completed state.

♻️ Proposed fix
     fun retryMigration() {
-      if (_migrationState.value == MigrationState.Running) {
+      if (_migrationState.value == MigrationState.Running || _migrationState.value == MigrationState.Completed) {
         return
       }
 
       executeMigration()
     }

56-62: Use imports for CancellationException and Timber instead of FQNs.

Same FQN pattern seen in SettingsViewModel. For consistency across the codebase, add proper imports.

♻️ Proposed refactor

Add imports:

+import kotlinx.coroutines.CancellationException
+import timber.log.Timber

Then simplify:

-        } catch (e: Exception) {
-          if (e is kotlinx.coroutines.CancellationException) {
-            throw e
-          }
-
-          timber.log.Timber.e(e, "Migration failed")
+        } catch (e: Exception) {
+          if (e is CancellationException) throw e
+          Timber.e(e, "Migration failed")
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt (1)

128-136: CancellationException handling and empty finally look correct but the finally is dead weight.

The CancellationException check and re-throw is the right pattern. The empty finally block with just a comment can be removed for clarity.

Proposed cleanup
       } 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
       }
app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt (2)

382-397: withContext(Dispatchers.IO) is redundant inside a backgroundScope already on Dispatchers.IO.

backgroundScope at Line 47 is defined with Dispatchers.IO. The withContext(Dispatchers.IO) at Line 390 adds unnecessary context switching overhead.

Proposed simplification
   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 ->
-        fetchBookCover(book.id, null)
-        delay(100)
-        yield()
-      }
+    books.forEach { book ->
+      fetchBookCover(book.id, null)
+      delay(100)
+      yield()
     }
   }

47-47: Unscoped CoroutineScope in a @Singleton — acceptable but worth documenting.

Since BookRepository is a @Singleton, backgroundScope lives for the entire process lifetime and is never cancelled. This is a well-known pattern for singletons, but a brief comment noting this intentional design would help future maintainers.

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

208-222: refreshMetadata fetches all cached items with Int.MAX_VALUE page size.

For users with large caches, this loads every DetailedItem into memory at once and re-caches each sequentially. Consider paginating or batching this work to limit memory pressure.

app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt (3)

284-292: File.freeSpace may overstate available space; prefer File.usableSpace.

freeSpace includes blocks reserved for the OS/root, which aren't usable by the app. usableSpace gives a more accurate number on Android devices.

Proposed fix
   fun getAvailableDiskSpace(): Long {
     val mediaFolder = storageProperties.baseFolder()
-    return mediaFolder.freeSpace
+    return mediaFolder.usableSpace
   }

292-292: StorageHeader (in CachedItemsSettingsScreen.kt) uses usedBytes / (usedBytes + freeBytes) for the progress bar.

Since usedBytes is only the app's cache size (not total disk usage by all apps), the bar will appear nearly empty even when the disk is almost full from other apps. If the intent is to show the disk usage proportion, consider using (totalBytes - freeBytes) for overall used and showing the cache portion within that.


166-184: Storage-type heuristic is reasonable but the files.size == chapters.size shortcut could misclassify.

If a book has N files and N chapters but they don't have a 1:1 mapping (e.g., a chapter spans two files while two short chapters share another file), this would incorrectly return ATOMIC. This is acknowledged as a heuristic, but worth a comment in code.

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

339-391: Empty state composable is clean and provides a clear call-to-action.

The PolishedCachedItemsEmptyState with navigation to the library is well-designed. Minor note: the parameter is named onBack but it navigates to the library, not back.

Suggested rename for clarity
-private fun PolishedCachedItemsEmptyState(onBack: () -> Unit) {
+private fun PolishedCachedItemsEmptyState(onNavigateToLibrary: () -> Unit) {
   ...
-      onClick = onBack,
+      onClick = onNavigateToLibrary,

107-148: refreshContent calls viewModel.refreshMetadata() on every pull-to-refresh, which re-caches all items.

As noted in CachingModelView.kt, refreshMetadata fetches every cached item (Int.MAX_VALUE) and re-processes them. On pull-to-refresh, this could cause noticeable delay for large caches. Consider whether a full metadata refresh is needed on every pull or only on explicit user action.

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

436-466: storageType and volumes are not wrapped in remember, unlike in BookDetailScreen.

In BookDetailScreen.kt (lines 152–156, 253), both getVolumes(book) and getBookStorageType(book) are wrapped in remember(...) to avoid recomputation on every recomposition. Here they're called directly, which may cause unnecessary work on each recompose.

♻️ Suggested fix
       playingBook?.let { book ->
+        val storageType = remember(book.id) { cachingModelView.getBookStorageType(book) }
+        val bookVolumes = remember(book, cacheProgress.status) { cachingModelView.getVolumes(book) }
+
         DownloadsComposable(
           book = book,
-          storageType = cachingModelView.getBookStorageType(book),
-          volumes = cachingModelView.getVolumes(book),
+          storageType = storageType,
+          volumes = bookVolumes,
           isOnline = isOnline,

426-434: cacheProgress Flow is subscribed even when playingBook is null.

When downloadsExpanded is true but playingBook is null, playingBook?.id.orEmpty() resolves to "", creating an unnecessary Flow subscription for an empty book ID. Consider moving this collection inside the playingBook?.let block to avoid wasted work.

♻️ Suggested restructuring
     if (downloadsExpanded) {
-      val cacheProgress by cachingModelView
-        .getProgress(
-          playingBook?.id.orEmpty(),
-        ).collectAsState(
-          initial =
-            org.grakovne.lissen.content.cache.persistent
-              .CacheState(org.grakovne.lissen.lib.domain.CacheStatus.Idle),
-        )
-
       playingBook?.let { book ->
+        val cacheProgress by cachingModelView
+          .getProgress(book.id)
+          .collectAsState(
+            initial = org.grakovne.lissen.content.cache.persistent
+              .CacheState(org.grakovne.lissen.lib.domain.CacheStatus.Idle),
+          )
+
         DownloadsComposable(
app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt (2)

61-74: Consider suppressing detekt's EmptyFunctionBlock for required interface stubs.

These empty bodies are required by the Application.ActivityLifecycleCallbacks interface. You can silence the detekt warnings with a targeted suppression or by adding Unit as the body expression.

♻️ Example using Unit bodies
-  override fun onActivityStarted(activity: Activity) {}
+  override fun onActivityStarted(activity: Activity) = Unit
 
-  override fun onActivityResumed(activity: Activity) {}
+  override fun onActivityResumed(activity: Activity) = Unit
 
-  override fun onActivityPaused(activity: Activity) {}
+  override fun onActivityPaused(activity: Activity) = Unit
 
-  override fun onActivityStopped(activity: Activity) {}
+  override fun onActivityStopped(activity: Activity) = Unit
 
   override fun onActivitySaveInstanceState(
     activity: Activity,
     outState: Bundle,
-  ) {}
+  ) = Unit
 
-  override fun onActivityDestroyed(activity: Activity) {}
+  override fun onActivityDestroyed(activity: Activity) = Unit

26-33: Silent failure if context is not an Application instance.

The as? safe cast at line 32 silently skips registration if context isn't an Application. Since @ApplicationContext should always inject Application, this is defensive — but a log warning would help diagnose if it ever fails.

♻️ Suggested improvement
   override fun onCreate() {
     if (BuildConfig.DEBUG) {
       Timber.d("Skip Microsoft Clarity initialization for debug build")
       return
     }
 
-    (context as? Application)?.registerActivityLifecycleCallbacks(this)
+    val app = context as? Application
+    if (app == null) {
+      Timber.w("ClarityComponent: context is not Application, skipping lifecycle registration")
+      return
+    }
+    app.registerActivityLifecycleCallbacks(this)
   }
app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt (1)

88-88: Dense one-liner for completedVolumes — consider extracting for clarity.

This line combines volume filtering, nested chapter iteration, and progress comparison in a single expression. Extracting it to a local helper or splitting across multiple lines would improve readability and make the completion logic easier to verify.

♻️ Suggested extraction
-  val completedVolumes = volumes.filter { it.isDownloaded && it.chapters.all { ch -> (book.progress?.currentTime ?: 0.0) >= ch.end } }
+  val currentTime = book.progress?.currentTime ?: 0.0
+  val completedVolumes = volumes.filter { volume ->
+    volume.isDownloaded && volume.chapters.all { ch -> currentTime >= ch.end }
+  }
app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt (1)

596-629: Redundant storageType computation and shadowed book variable.

storageType is already computed at line 253 and book is already assigned at line 252. Both are in scope here, so re-declaring them at lines 597–598 is redundant. The shadowed book variable is identical to the outer one (bookDetail!!), and storageType uses the same remember(book.id) key.

♻️ Suggested simplification
-    if (downloadsExpanded && bookDetail != null) {
-      val book = bookDetail!!
-      val storageType = remember(book.id) { cachingModelView.getBookStorageType(book) }
-
+    if (downloadsExpanded && bookDetail != null) {
       DownloadsComposable(
-        book = book,
+        book = bookDetail!!,
         storageType = storageType,
         volumes = volumes,

Or even simpler, since both book and storageType from the outer scope (line 252–253) are already available when bookDetail != null.

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

246-250: upsertBooks always calls updateBooks on the full list, including rows just inserted.

Unlike safeUpsertBook (which conditionally updates only when insert fails), this batch version blindly updates all rows after inserting. For newly inserted rows, the subsequent UPDATE is a no-op but wastes I/O. Consider filtering to only update rows whose insert returned -1L.

♻️ Suggested optimization
   `@Transaction`
   suspend fun upsertBooks(books: List<BookEntity>) {
-    insertBooksIgnore(books)
-    updateBooks(books)
+    val insertResults = insertBooksIgnore(books)
+    val toUpdate = books.zip(insertResults)
+      .filter { (_, result) -> result == -1L }
+      .map { (book, _) -> book }
+    if (toUpdate.isNotEmpty()) {
+      updateBooks(toUpdate)
+    }
   }

44-44: Duration truncated from Double to Int.

book.chapters.sumOf { it.duration }.toInt() drops fractional seconds. If chapter durations have meaningful sub-second precision (e.g., 12.5 seconds), the total will accumulate truncation error. If the BookEntity.duration field is intentionally integer seconds, this is fine — but worth confirming.

Comment on lines +127 to +128
val host: String? = null,
val username: String? = 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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's explore the file structure and understand the context
find . -name "CachedBookEntity.kt" -type f

Repository: SurjitSahoo/kahani-android

Length of output: 163


🏁 Script executed:

#!/bin/bash
# Check how username is populated and whether it's logged anywhere
rg -n --type=kt 'username' -C2 -g '!*.json' | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 96


🏁 Script executed:

#!/bin/bash
# Look for CachedBookEntity definition and its full structure
fd CachedBookEntity.kt -x cat -n {}

Repository: SurjitSahoo/kahani-android

Length of output: 4247


🏁 Script executed:

#!/bin/bash
# Search for where username is assigned or populated in Kotlin files
rg -n 'username\s*=' --type-list | head -5

Repository: SurjitSahoo/kahani-android

Length of output: 459


🏁 Script executed:

#!/bin/bash
# Use a different approach - search for username assignments
rg -n 'username\s*=' -A2 -B2 2>/dev/null | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 10886


🏁 Script executed:

#!/bin/bash
# Search for logging patterns that might include username
rg -n '(Log\.|log|Timber|println).*username' -i 2>/dev/null | head -50

Repository: SurjitSahoo/kahani-android

Length of output: 3345


🏁 Script executed:

#!/bin/bash
# Search for API calls or network operations involving these entities
rg -n 'BookEntity|MediaProgressEntity' -A3 -B1 | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 11489


🏁 Script executed:

#!/bin/bash
# Look for host and username usage context
rg -n 'host.*username|username.*host' -B3 -A3 2>/dev/null | head -80

Repository: SurjitSahoo/kahani-android

Length of output: 10197


🏁 Script executed:

#!/bin/bash
# Search for ClarityComponent to see how username is used in analytics
cat -n app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityComponent.kt | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 3486


🏁 Script executed:

#!/bin/bash
# Check how BookEntity and MediaProgressEntity are serialized/exposed
rg -n '@JsonClass|toJson|fromJson' -B2 -A2 | grep -A5 -B5 'BookEntity\|MediaProgressEntity' | head -50

Repository: SurjitSahoo/kahani-android

Length of output: 5373


🏁 Script executed:

#!/bin/bash
# Look for data deletion or cleanup routines
rg -n 'clearData|deleteData|logout|resetUser' -B2 -A4 | head -80

Repository: SurjitSahoo/kahani-android

Length of output: 1581


🏁 Script executed:

#!/bin/bash
# Check if there are any data retention policies or comments about PII
rg -n 'GDPR|PII|sensitive|credential|personal' -B1 -A1 | head -60

Repository: SurjitSahoo/kahani-android

Length of output: 6165


🏁 Script executed:

#!/bin/bash
# Check the ClarityTracker implementation to see if username is hashed/masked
fd ClarityTracker.kt -x cat -n {}

Repository: SurjitSahoo/kahani-android

Length of output: 1944


Username is being sent unmasked to Microsoft Clarity analytics, contradicting privacy claims.

The username field is indeed PII and requires special handling. While cascade delete is properly configured and credentials are cleared on logout, there's a more critical issue: the username (combined as $username@$host) is being sent unmasked to Microsoft Clarity's analytics service (ClarityComponent.kt:84-85). Although logs mask the identifier for display, the actual Clarity.setCustomUserId() call receives the plaintext username. This contradicts the analytics consent message stating "Your library and personal info stay 100% private." Additionally, username is logged plaintext via Timber (LissenMediaProvider.kt:292).

To address privacy and compliance concerns:

  • Hash or anonymize the username before sending to Clarity, or use only device/session IDs
  • Remove plaintext username logging
  • Update privacy statements to accurately reflect what data is shared with analytics providers
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedBookEntity.kt`
around lines 127 - 128, The username field (CachedBookEntity.username) is being
sent and logged in plaintext; update the code that calls Clarity.setCustomUserId
(ClarityComponent) and the Timber log in LissenMediaProvider to send only an
irreversible pseudonymized identifier (e.g., SHA-256/HMAC of "$username@$host"
or a session/device ID) instead of the raw username, and stop logging the
plaintext username (log only masked or the hashed ID); ensure the hashing is
applied before any call to Clarity.setCustomUserId and replace the Timber
statement that prints username with one that prints the hashed/masked value, and
update privacy statement strings to reflect the new behavior.

Surjit Kumar Sahoo added 2 commits February 7, 2026 12:45
…efactor download option type references in player downloads.
…fine smart rewind logic in `PlaybackService`, and adjust coroutine execution in `PlaybackSynchronizationService`.
@SurjitSahoo SurjitSahoo changed the title Integrate Clarity analytics, database migrations, and cache redesign Download Enhancements, Integrate Clarity analytics, database migrations, and cache redesign Feb 7, 2026
Surjit Kumar Sahoo added 13 commits February 7, 2026 15:30
…dProgressIcon` and remove the redundant inline checkmark from `BookDetailScreen`.
…a `Set` of ignored items, improve `LazyColumn` keying for better performance, refine scroll behavior, and update project context documentation.
…ils are being fetched by introducing an `isFetchingDetails` state in PlayerViewModel.
…d remove the now redundant `cacheBookInfo` function.
…th varying delays and enhance related logging for server status and library state.
…imestamps and minified book fetches to efficiently update local book data, and add `addedAt`/`updatedAt` fields to the `Book` model.
…fety, correct UI text, and refine mini-player state management.**
@SurjitSahoo SurjitSahoo merged commit d0ec47e into main Feb 7, 2026
1 check passed
@SurjitSahoo SurjitSahoo deleted the feat/download-enhancements branch February 7, 2026 19:56
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: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

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

88-119: ⚠️ Potential issue | 🟡 Minor

Play button remains clickable during the loading state.

When preparingBookId != null, the CircularProgressIndicator is shown, but the IconButton (Line 88) is still enabled and its onClick calls togglePlayPause. Consider disabling the button while loading to prevent the user from toggling playback mid-preparation.

Also, CircularProgressIndicator on Line 102 uses a fully-qualified name instead of an import — add a top-level import for consistency with the rest of the file.

Suggested fix

Add import at the top of the file:

import androidx.compose.material3.CircularProgressIndicator
     IconButton(
       onClick = { withHaptic(view) { viewModel.togglePlayPause() } },
       modifier = Modifier.size(72.dp),
+      enabled = preparingBookId == null,
     ) {
       Surface(
         ...
         Box(contentAlignment = Alignment.Center) {
           val isLoading = preparingBookId != null

           if (isLoading) {
-            androidx.compose.material3.CircularProgressIndicator(
+            CircularProgressIndicator(
               modifier = Modifier.size(32.dp),
               color = colorScheme.onPrimary,
               strokeWidth = 3.dp,
             )
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedBookRepository.kt (1)

223-243: ⚠️ Potential issue | 🟡 Minor

fetchRecentBooksFlow captures host/username eagerly at flow-creation time.

Since this is a non-suspend function returning a Flow, preferences.getHost() and preferences.getUsername() are evaluated once when the flow is built (line 231–232), not when it's collected. If the user's session context changes while the flow is active, the query will use stale credentials.

The other fetchRecentBooks (suspend, line 199) has the same pattern but runs once per call, so it's fine. For the flow variant, consider building the request inside the map or using a flow { } builder that re-evaluates on each collection.

app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt (1)

574-592: ⚠️ Potential issue | 🟡 Minor

Analytics calls in play()/pause() lack error handling, unlike AuthRepository.

In AuthRepository.onPostLogin, analytics calls are wrapped in try-catch to prevent analytics failures from disrupting core functionality. Here, clarityTracker.trackEvent(...) is called without protection. If ClarityTracker throws (e.g., Clarity not initialized in debug builds), it could prevent the playback intent from being dispatched or cause an unhandled exception.

🛡️ Suggested fix for play()
     context.startForegroundService(intent)
-    clarityTracker.trackEvent("playback_start")
+    try { clarityTracker.trackEvent("playback_start") } catch (_: Exception) {}

Apply the same pattern for pause() (line 591) and updateTimer() (line 275).

#!/bin/bash
# Check if ClarityTracker methods have internal error handling
ast-grep --pattern 'class ClarityTracker {
  $$$
}'
app/src/main/res/values/strings.xml (1)

97-97: ⚠️ Potential issue | 🟡 Minor

Pre-existing grammatical error: "No podcast has no episodes" (double negative).

This is likely intended to say something like "This podcast has no episodes" or "No episodes available."

✏️ Suggested fix
-    <string name="episodes_list_empty">No podcast has no episodes</string>
+    <string name="episodes_list_empty">This podcast has no episodes</string>
app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt (1)

127-139: ⚠️ Potential issue | 🟡 Minor

Multiple init-time collectors can trigger concurrent overlapping syncs.

This isServerAvailable collector (Line 130) triggers syncRepositories() + refreshLibrary() when the server becomes reachable. Meanwhile, the libraryPager flow (Line 178-181) also calls syncLibrary(libraryId) when downloadedOnly changes (which depends on isServerAvailable). Both fire near-simultaneously on server availability change, leading to redundant concurrent sync operations.

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

281-313: ⚠️ Potential issue | 🔴 Critical

cacheBookCover always returns Completed, even when cover fetch fails.

The onFailure block on Line 307-308 is empty, and execution falls through to Line 311 which unconditionally returns CacheState(CacheStatus.Completed). This means:

  1. If fetchBookCover returns an error → Completed is returned (bug).
  2. If the fold.onSuccess block throws and catches via Line 302-304 → CacheState(CacheStatus.Error) is returned from fold, but it's discarded because the fold call's return value isn't used as the function's return value — Line 311 still returns Completed.

The fold result is never assigned or returned.

🐛 Proposed fix
     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) {
               Firebase.crashlytics.recordException(ex)
               return@fold CacheState(CacheStatus.Error)
             }
           },
           onFailure = {
+            CacheState(CacheStatus.Error)
           },
         )

-      CacheState(CacheStatus.Completed)
+      result
     }
🤖 Fix all issues with AI agents
In `@app/google-services.json`:
- Around line 16-19: The committed Firebase/GCP API key is exposed in
google-services.json under the "api_key" -> "current_key" field; restrict that
key in the Google Cloud Console (Credentials) by applying Android app
restrictions (package name + SHA-1) and API restrictions to only needed Firebase
APIs, and if this repo is public remove or stop committing google-services.json
(or serve it via CI secrets) and consider enabling Firebase App Check for
additional protection; also review .gitignore to prevent accidental commits of
google-services.json going forward.

In `@app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt`:
- Around line 41-54: The trackEvent function accepts a value parameter but never
uses it and appends a redacted suffix to the event name, which misleads callers
and pollutes Clarity event names; fix by removing the unused value parameter
from the signature of org.grakovne.lissen.analytics.ClarityTracker.trackEvent
and implement a single-argument overload that preserves existing behavior (i.e.,
call Clarity.sendCustomEvent(eventName) and Timber.d with eventName) and update
all callers to use the single-arg overload (or provide a two-arg overload that
delegates to the single-arg one if you need backward compatibility);
alternatively, if you intend to keep the parameter, explicitly use or sanitize
value before sending it via Clarity.sendCustomEvent and add a KDoc or
`@Suppress`("UNUSED_PARAMETER") if it must remain unused.

In `@app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt`:
- Around line 20-22: The current CrashHandler use of
FirebaseCrashlytics.getInstance().recordException(ex) records the throwable as a
non-fatal event and can duplicate the same uncaught crash that Crashlytics
already captures; update the CrashHandler (the code in CrashHandler.kt where
recordException is called) to either remove the recordException call and rely on
Crashlytics' default uncaught exception handler, or replace it with
FirebaseCrashlytics.getInstance().sendUnsentReports() (or another appropriate
send/flush method) to ensure delivery without creating a duplicate non-fatal
record, and keep any existing toast/delay logic intact so the crash is not
double-reported.

In `@app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt`:
- Around line 96-135: The refreshServerAvailabilitySync() retry logic wrongly
re-applies the 500ms debounce on each recursive retry and uses an
instance-scoped initialRetryCount that is never reset, causing compounding
delays and one-shot retries; replace the recursion with a local-loop retry
inside refreshServerAvailabilitySync(): perform the initial
delay/isNetworkAvailable()/host null check once at the start, then attempt the
socket connect in a for/while loop that uses a local retry counter (reset on
each invocation) and delays 300ms between attempts, emit _isServerAvailable and
_networkStatus appropriately on success/failure, and remove the instance field
initialRetryCount entirely so each call gets its own retry budget.

In `@app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt`:
- Around line 221-227: The current use of Int.MAX_VALUE in
localCacheRepository.fetchBooks (the knownBooks construction) can OOM for large
libraries; replace the single massive call with a paginated or streaming
approach: implement a bounded PAGE_SIZE constant and loop calling
localCacheRepository.fetchBooks(libraryId, PAGE_SIZE, offset, false) (or add/use
a dedicated repository method like
fetchAllBookIdsWithUpdatedAt/fetchBookIdsPage) accumulating items until no more
pages, then build the associateBy { it.id } map; ensure the loop/stream handles
failures (fold onFailure -> emptyList or propagate) and avoids loading all items
at once.
- Around line 177-192: The current syncLibraryPage function ignores its
parameters and always calls syncAllLibraries; change it to perform a targeted
page sync using the provided libraryId, pageSize, and pageNumber instead of
calling syncAllLibraries. Replace the backgroundScope.launch block to call a
suspend worker like syncLibraryPageTask(libraryId, pageSize, pageNumber) (or
create such a helper) and handle exceptions with Timber.e(e, "...") as before;
keep returning OperationResult.Success(Unit) immediately if you want
fire-and-forget semantics, or await the task and return a success/failure based
on the result if you want synchronous behavior. Ensure you reference the
existing function name syncLibraryPage and the launched coroutine
(backgroundScope.launch) when making the change so callers like
LibraryViewModel.refreshLibrary receive the intended behavior.

In
`@app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt`:
- Around line 48-55: The migration call is passing empty-string fallbacks which
defeat the migration's null-handling; change the arguments to
produceMigration15_16 to pass the nullable results from preferences.getHost()
and preferences.getUsername() (i.e., remove the ?: "" fallbacks) so
produceMigration15_16 receives null when the user is logged out and can emit
NULL defaults as intended.

In `@app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt`:
- Around line 69-73: The artworkJob field is written on the IO dispatcher inside
withContext(Dispatchers.IO) but read/cancelled on the Main thread, risking a
visibility race; fix by making the field volatile or by ensuring assignment
happens on the Main dispatcher: either annotate artworkJob with `@Volatile` var
artworkJob: kotlinx.coroutines.Job? = null, or move the assignment out of the IO
block so you set artworkJob on Dispatchers.Main (or wrap the assignment in
withContext(Dispatchers.Main)) before entering withContext(Dispatchers.IO);
update references to artworkJob (the cancel/read site and the assignment site
inside withContext(Dispatchers.IO)) accordingly.
- Around line 251-277: The artwork coroutine launched inside the
withContext(Dispatchers.IO) is still a structured-child and blocks
preparePlayback/playsbackJob; instead, run the fire-and-forget task on the
service-wide scope: call playerServiceScope.launch { val artworkUri =
withContext(Dispatchers.IO) { fetchCover(book) } ?: return@launch;
withContext(Dispatchers.Main) { /* update exoPlayer metadata loop */ } } and
assign that to artworkJob so you keep explicit cancellation, replacing the
current launch that is nested under withContext and keeping UI updates in
withContext(Dispatchers.Main); ensure you reference artworkJob,
fetchCover(book), playerServiceScope, and the existing loop that updates
exoPlayer.getMediaItemAt(i)/replaceMediaItem.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt`:
- Around line 37-43: The LaunchedEffect that sets isMainLoading and calls
onLoadingStateChanged races with Coil’s onSuccess; remove the LaunchedEffect and
keep isMainLoading initialized by remember(imageRequest) only, then add a
SideEffect (or snapshotFlow observing isMainLoading) that invokes
onLoadingStateChanged(true) only when isMainLoading transitions to true so a
later onSuccess handler (in onSuccess) can reliably set isMainLoading = false
and call onLoadingStateChanged(false) without being overwritten; reference the
variables and callbacks isMainLoading, remember(imageRequest), LaunchedEffect,
SideEffect/snapshotFlow, and the onSuccess block to locate and update the code.

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt`:
- Around line 76-77: The loading/error states are not gated to the current book;
compute local flags using the observed values so the spinner/error show only for
this book: derive val isLoading = preparingBookId == book.id and val isError =
preparingError && preparingBookId == book.id (observed from
playerViewModel.preparingBookId and preparingError) and replace usages that
currently check preparingBookId or preparingError directly with isLoading and
isError inside MiniPlayerComposable (where the spinner and error icon rendering
occur).

In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt`:
- Around line 741-749: calculateReclaimSize and deleteSelectedVolumes currently
iterate 0 until cachedItems.itemCount and skip null entries, which silently
ignores selections whose pages aren't loaded; fix by maintaining a persistent
lookup (e.g., MutableMap<String, DetailedItem> renderedItemCache) that is
populated when items are bound/rendered (update in the composable or adapter
where items are accessed) and then use renderedItemCache.lookup(book.id) and
renderedItemCache.lookup(volumeId) inside calculateReclaimSize and
deleteSelectedVolumes (fall back to loading from viewModel.getVolumes(book) only
if the cache misses), or alternatively trigger loading of the necessary paging
pages before computing/deleting to guarantee presence — update the code paths
that reference cachedItems[i], calculateReclaimSize, and deleteSelectedVolumes
to use this cache/fallback so selections aren't skipped.

In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt`:
- Around line 233-257: refreshLibrary currently triggers duplicate work by
calling bookRepository.syncLibraryPage(...) and then
bookRepository.syncRepositories(), which results in syncFullLibrary running
twice for the preferred library (via syncAllLibraries inside syncLibraryPage and
directly in syncRepositories). Fix by ensuring only one path performs a full
sync for the preferred library: either remove the call to
bookRepository.syncRepositories() when preferences.getPreferredLibrary() is
non-null (i.e., when syncLibraryPage is invoked for that library), or change
BookRepository.syncRepositories() to skip libraries already synced by
syncLibraryPage (e.g., accept an optional excludedLibraryId and avoid calling
syncFullLibrary for that id). Update refreshLibrary (function refreshLibrary)
and/or BookRepository.syncRepositories/syncAllLibraries/syncFullLibrary
accordingly so the preferred library is not synced twice.

In `@app/src/main/res/values/strings.xml`:
- Around line 277-278: The consent copy in resources (strings
analytics_consent_title and analytics_consent_message) incorrectly states
"anonymous" while ClarityComponent sends a SHA-256 hash of username@host
(pseudonymous identifier); update the strings to accurately reflect pseudonymous
or non‑personally‑identifiable data (e.g., replace "anonymous" with
"pseudonymous" or "non-personally-identifiable") and ensure messaging aligns
with ClarityComponent's hashing behavior and any privacy docs.
🟡 Minor comments (18)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt-203-261 (1)

203-261: ⚠️ Potential issue | 🟡 Minor

Bottom bar reclaim-size calculation is eagerly remembered — verify reactivity with paging.

totalSizeToReclaim at line 206 is wrapped in remember(selectedVolumes.toList(), cachedItems.itemCount). Since calculateReclaimSize reads from cachedItems[i] (subject to the paging window concern noted above), the remembered value could be stale or incorrect if paging state changes without itemCount changing. This is a downstream consequence of the paging-iteration pattern.

Also, Formatter.formatFileSize at line 209 will display "0 B" for any volumes that couldn't be resolved, which may confuse users into thinking nothing will be freed.

app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt-574-589 (1)

574-589: ⚠️ Potential issue | 🟡 Minor

Book-level checkbox: isFullySelected is vacuously true when a book has zero downloaded volumes.

If downloadedVolumes is empty, bookVolumes is empty, and selectedVolumes.containsAll(emptyList()) always returns true — rendering the checkbox as checked. This is a cosmetic edge case since the volumes section would also be empty, but it's worth a guard:

-        val isFullySelected = selectedVolumes.containsAll(bookVolumes)
+        val isFullySelected = bookVolumes.isNotEmpty() && selectedVolumes.containsAll(bookVolumes)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt-680-687 (1)

680-687: ⚠️ Potential issue | 🟡 Minor

Fully-qualified Checkbox is redundant; missing duplicate guard on add.

Two small issues here:

  1. androidx.compose.material3.Checkbox is already imported at line 46, so the FQN is unnecessary.
  2. selectedVolumes.add(identifier) on line 684 does not check for duplicates before adding, unlike the book-level checkbox at line 583 which does if (!selectedVolumes.contains(it)) selectedVolumes.add(it). Rapid or repeated onCheckedChange(true) calls could insert duplicate entries, inflating the reclaim-size calculation.
Proposed fix
-        if (selectionMode) {
-          androidx.compose.material3.Checkbox(
-            checked = isSelected,
-            onCheckedChange = {
-              val identifier = VolumeIdentifier(item.id, volume.id)
-              if (it) selectedVolumes.add(identifier) else selectedVolumes.remove(identifier)
-            },
+        if (selectionMode) {
+          Checkbox(
+            checked = isSelected,
+            onCheckedChange = {
+              val identifier = VolumeIdentifier(item.id, volume.id)
+              if (it) {
+                if (!selectedVolumes.contains(identifier)) selectedVolumes.add(identifier)
+              } else {
+                selectedVolumes.remove(identifier)
+              }
+            },
app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt-226-237 (1)

226-237: ⚠️ Potential issue | 🟡 Minor

Delete button onClick launches a coroutine but doesn't guard against double-tap.

isDeleting prevents a second press via enabled = !isDeleting, but isDeleting is set inside the launched coroutine (line 230). Between the click and the coroutine dispatching, a rapid second tap could launch a duplicate deletion. Consider setting isDeleting = true before scope.launch or using a LaunchedEffect-style guard.

Proposed fix
              onClick = {
                withHaptic(view) {
+                 if (isDeleting) return@withHaptic
+                 isDeleting = true
                  scope.launch {
-                   isDeleting = true
                    deleteSelectedVolumes(selectedVolumes, cachedItems, viewModel, playerViewModel)
                    selectionMode = false
                    selectedVolumes.clear()
                    refreshContent(false)
                    isDeleting = false
                  }
                }
              },
app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt-94-131 (1)

94-131: ⚠️ Potential issue | 🟡 Minor

Stale state captured outside mutex can cause redundant session creation.

Lines 98–100 snapshot currentItem, playbackSession, and currentChapterIndex on the Main thread, but the mutex is only acquired later on the IO coroutine (line 105). Between the snapshot and the lock acquisition, another runSync invocation may have already opened a new session and updated playbackSession. The second invocation still sees the stale null snapshot and opens a duplicate session.

Consider re-reading playbackSession and currentChapterIndex inside the mutex-guarded section (e.g., via withContext(Dispatchers.Main)) or marking them @Volatile so the IO coroutine sees the latest value:

Suggested approach: re-read state after acquiring lock
         try {
             val currentIndex = calculateChapterIndex(item, overallProgress.currentTotalTime)

-            var activeSession = session
-            if (activeSession == null || activeSession.itemId != item.id || currentIndex != lastChapterIndex) {
+            var activeSession = withContext(Dispatchers.Main) { playbackSession }
+            val lastIdx = withContext(Dispatchers.Main) { currentChapterIndex }
+            if (activeSession == null || activeSession.itemId != item.id || currentIndex != lastIdx) {
               activeSession = openPlaybackSession(item, overallProgress)
               withContext(Dispatchers.Main) {
                 playbackSession = activeSession
                 currentChapterIndex = currentIndex
               }
             }
app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt-18-44 (1)

18-44: ⚠️ Potential issue | 🟡 Minor

Timing race: toast may not be visible before the process is killed.

Thread.sleep(TOAST_TIMEOUT) sleeps 1000ms, then finally delegates to defaultHandler which typically terminates the process. Meanwhile, the toast thread's looper is scheduled to quit after 1500ms. The toast may barely flash before the process dies.

Consider increasing TOAST_TIMEOUT to at least match the toast visibility window (e.g., 2000–2500ms), or switch to Toast.LENGTH_SHORT whose actual duration (~2s) is closer to the sleep budget.

app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt-265-292 (1)

265-292: ⚠️ Potential issue | 🟡 Minor

Button remains interactive during loading and error states; hardcoded content description.

  1. The IconButton onClick still fires togglePlayPause while the spinner or error icon is displayed. During loading, tapping should be a no-op (or disabled). During the error state, you may want a different action (e.g., retry) rather than togglePlayPause.

  2. Line 280: "Error" is a hardcoded English string. Use a string resource for accessibility and localization.

Suggested fix
 IconButton(
-  onClick = { withHaptic(view) { playerViewModel.togglePlayPause() } },
+  onClick = {
+    if (preparingBookId == null && !preparingError) {
+      withHaptic(view) { playerViewModel.togglePlayPause() }
+    }
+  },
+  enabled = preparingBookId == null && !preparingError,
 ) {
   Icon(
     imageVector = Icons.Rounded.Error,
-    contentDescription = "Error",
+    contentDescription = stringResource(R.string.playback_error),
     tint = colorScheme.error,
     modifier = Modifier.size(32.dp),
   )
app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/PlaybackButtonsComposable.kt-44-45 (1)

44-45: ⚠️ Potential issue | 🟡 Minor

Compare preparingBookId against book?.id to scope the loading state to the current book.

The loading indicator should only display when preparing the current book shown in this composable. Since book is already observed (line 41), use preparingBookId == book?.id instead of preparingBookId != null. This matches how BookDetailScreen.kt correctly handles the same state, showing the spinner only when preparing that specific book.

gradle/libs.versions.toml-34-37 (2)

34-37: ⚠️ Potential issue | 🟡 Minor

Update Firebase BOM and Crashlytics plugin to latest versions.

  • firebaseBom should be 34.9.0 (current: 33.9.0, released February 5, 2026)
  • firebaseCrashlyticsPlugin should be 3.0.6 (current: 3.0.3)

34-37: ⚠️ Potential issue | 🟡 Minor

Update dependency versions to latest available releases.

All specified versions are published and valid, but each has a newer version available:

  • clarity: 3.8.0 → 3.8.1 (Jan 26, 2026)
  • googleServices: 4.4.2 → 4.4.4 (Oct 9, 2025)
  • firebaseCrashlyticsPlugin: 3.0.3 → 3.0.6
  • firebaseBom: 33.9.0 → 34.7.0

Update to these current versions to gain latest bug fixes and feature support.

app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfRepository.kt-88-98 (1)

88-98: ⚠️ Potential issue | 🟡 Minor

Hardcoded pageSize = 10000 will silently truncate results for large libraries.

If a library contains more than 10,000 items, the excess will be silently dropped. Consider either paginating until all results are fetched, or at minimum extracting this into a named constant and documenting the assumption. The same applies to fetchPodcastMinified on Line 123.

Suggested constant extraction
+  companion object {
+    /** Upper bound for minified single-page fetches. Libraries exceeding this size will be truncated. */
+    private const val MAX_MINIFIED_PAGE_SIZE = 10_000
+  }
+
   suspend fun fetchLibraryMinified(libraryId: String): OperationResult<LibraryItemsResponse> =
     audioBookShelfApiService.makeRequest {
       it.fetchLibraryItems(
         libraryId = libraryId,
-        pageSize = 10000,
+        pageSize = MAX_MINIFIED_PAGE_SIZE,
         pageNumber = 0,
         sort = "addedAt",
         desc = "1",
         minified = "1",
       )
     }
app/src/main/kotlin/org/grakovne/lissen/ui/components/DownloadProgressIcon.kt-62-71 (1)

62-71: ⚠️ Potential issue | 🟡 Minor

wasCaching is never reset when the download is completed but isFullyDownloaded is false.

If the status transitions from CachingCompleted while isFullyDownloaded is false (partial download), wasCaching stays true indefinitely. If isFullyDownloaded later flips to true without passing through Caching again, the LaunchedEffect won't re-fire (it keys on cacheState.status, not isFullyDownloaded), so the shine won't trigger. This is likely harmless but means wasCaching leaks state across download cycles.

Consider resetting wasCaching in the Completed/Idle path regardless of isFullyDownloaded:

     if (wasCaching && (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle) && isFullyDownloaded) {
       triggerShine = true
-      wasCaching = false
     }
+    if (wasCaching && (cacheState.status is CacheStatus.Completed || cacheState.status is CacheStatus.Idle)) {
+      wasCaching = false
+    }
app/src/main/kotlin/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt-392-396 (1)

392-396: ⚠️ Potential issue | 🟡 Minor

Swallowed exception — consider logging or reporting the deserialization failure.

The catch (e: Throwable) silently discards the error. Since decrypt() already reports exceptions to Crashlytics, applying the same pattern here would help diagnose corrupted preference data in production.

🐛 Suggested fix
           try {
             adapter.fromJson(json)
           } catch (e: Throwable) {
+            Firebase.crashlytics.recordException(e)
             null
           }
app/src/main/kotlin/org/grakovne/lissen/LissenApplication.kt-44-54 (1)

44-54: ⚠️ Potential issue | 🟡 Minor

Component error handler may fail silently if core services initialization failed.

If the try-catch on lines 23–42 catches an exception (e.g., Crashlytics not available), then the error handling in lines 49–52 relies on both Timber (which has no tree planted in release, and none at all if DEBUG init failed) and FirebaseCrashlytics.getInstance() (which could also throw). Consider wrapping lines 50–52 in their own try-catch to avoid masking component failures.

🛡️ Suggested defensive fix
       try {
         it.onCreate()
       } catch (ex: Exception) {
         Timber.e(ex, "Unable to register Running component: ${ex.message}")
-        com.google.firebase.crashlytics.FirebaseCrashlytics
-          .getInstance()
-          .recordException(ex)
+        try {
+          com.google.firebase.crashlytics.FirebaseCrashlytics
+            .getInstance()
+            .recordException(ex)
+        } catch (crashEx: Exception) {
+          android.util.Log.e("LissenApplication", "Failed to record exception to Crashlytics", crashEx)
+        }
       }
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt-121-128 (1)

121-128: ⚠️ Potential issue | 🟡 Minor

Notification updates are suppressed while any prior item has errored, even if other items are still caching.

When one item errors and another is still in progress, hasErrors() returns true, making the guard inProgress() && hasErrors().not() evaluate to false. Non-terminal progress emissions will skip the notification update, leaving the user with a stale notification until the remaining items reach a terminal state.

If the intent is to keep updating the notification for actively-caching items regardless of prior errors, drop the hasErrors() guard from the in-progress branch:

🔧 Proposed fix
-            if (shouldUpdateNotification && (isTerminalState || (inProgress() && hasErrors().not()))) {
+            if (shouldUpdateNotification && (isTerminalState || inProgress())) {
app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt-245-270 (1)

245-270: ⚠️ Potential issue | 🟡 Minor

Unbounded fire-and-forget cover prefetch inside chunked loop.

Each chunk spawns a backgroundScope.launch { prefetchCovers(...) } that is completely detached. For a library with many updated books, this creates multiple concurrent cover-prefetch tasks that all independently delay 2 seconds and then fetch sequentially. There is no concurrency limit, no cancellation propagation from the parent sync, and no backpressure. If syncFullLibrary is called again before the previous prefetches finish, they pile up.

Consider collecting all books needing covers and launching a single prefetch at the end of syncFullLibrary, or use a bounded dispatcher/semaphore.

app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingManager.kt-115-130 (1)

115-130: ⚠️ Potential issue | 🟡 Minor

All-or-nothing rollback on partial failure may discard successfully downloaded media.

If the cover or library cache fails (e.g., transient network issue for a small cover image), the entire media download is rolled back (Line 127), even though cacheBookMedia may have successfully downloaded large audio files. Consider treating cover/library failures as non-fatal — the media is the primary artifact users care about.

app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt-471-480 (1)

471-480: ⚠️ Potential issue | 🟡 Minor

toBook() omits addedAt and updatedAt fields from the mapped Book.

The Book model reportedly has addedAt/updatedAt fields (per commit messages and the incremental sync logic on Line 235), but this mapper doesn't set them. This means books created via this mapper will have default values for these timestamp fields, which could cause issues if they're subsequently used in comparisons like remote.updatedAt > local.updatedAt.

#!/bin/bash
# Check the Book data class to see if addedAt/updatedAt have defaults
ast-grep --pattern $'data class Book(
  $$$
)'
🧹 Nitpick comments (33)
app/src/main/kotlin/org/grakovne/lissen/viewmodel/PlayerViewModel.kt (1)

158-167: Consider catching exceptions from fetchBook to avoid unhandled coroutine failures.

The finally block correctly resets the loading flag, which is good. However, if mediaRepository.fetchBook(bookId) throws, the exception propagates unhandled in viewModelScope.launch, which could surface as a crash. This applies to other launch calls in this file too, but since this one drives a user-visible loading state, handling the error (e.g., exposing a fetch-error state) would improve UX.

♻️ Optional: add error handling
     fun fetchBook(bookId: String) {
       viewModelScope.launch {
         try {
           _isFetchingDetails.postValue(true)
           mediaRepository.fetchBook(bookId)
+        } catch (e: Exception) {
+          // Consider exposing an error state to the UI
+          timber.e(e, "Failed to fetch book details")
         } finally {
           _isFetchingDetails.postValue(false)
         }
       }
     }
app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt (1)

31-42: Debug logging factory should not ship in production code.

This JsonAdapter.Factory is a diagnostic/breadcrumb tool that intercepts every adapter creation. Two concerns:

  1. String template is always evaluated"Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}" performs toString() and reflection even when no Timber tree is planted in release builds.
  2. Debug artifact in the adapter chain — it adds unnecessary complexity to the Moshi builder for no functional benefit.

If this was added to diagnose a specific issue, consider removing it now that the issue is resolved, or at minimum guard it behind a BuildConfig.DEBUG check.

Suggested fix: remove the debug factory
-    ).add(
-      object : JsonAdapter.Factory {
-        override fun create(
-          type: Type,
-          annotations: Set<Annotation>,
-          moshi: Moshi,
-        ): JsonAdapter<*>? {
-          val adapter = moshi.nextAdapter<Any>(this, type, annotations)
-          Timber.d("Moshi Breadcrumb: Created adapter for type: $type. Adapter: ${adapter.javaClass.simpleName}")
-          return adapter
-        }
-      },
-    ).addLast(KotlinJsonAdapterFactory())
+    ).addLast(KotlinJsonAdapterFactory())

Also remove the unused imports if this factory is dropped:

-import timber.log.Timber
-import java.lang.reflect.Type
app/src/main/kotlin/org/grakovne/lissen/ui/components/VerticalScrollbar.kt (1)

98-102: The try-catch doesn't protect the draw-phase lambda.

baseScrollbar returns a Modifier via composed { ... drawWithContent { ... } }. The lambda body (lines 61–96) executes during the draw phase, well after withScrollbar returns — so this try-catch never catches exceptions from the actual scrollbar computation. The Crashlytics call on line 100 will only fire for errors during Modifier chain assembly, which is unlikely to fail.

This is pre-existing, but worth noting: if you want crash reports from the draw-time logic, the try-catch (and recordException) should be moved inside the onDraw lambda passed to baseScrollbar.

♻️ Suggested: move error handling inside the draw lambda
 fun Modifier.withScrollbar(
   state: LazyListState,
   color: () -> Color,
   totalItems: Int?,
   ignoreItems: Set<String> = emptySet(),
 ): Modifier {
-  try {
     return baseScrollbar { atEnd ->
+    try {
       val layoutInfo = state.layoutInfo
       ...
       
       drawScrollbarThumb(atEnd, thumbSize, startOffset, color())
+    } catch (ex: Exception) {
+      Timber.w("Unable to draw scrollbar due to ${ex.message}")
+      Firebase.crashlytics.recordException(ex)
     }
-  } catch (ex: Exception) {
-    Timber.w("Unable to apply scrollbar due to ${ex.message}")
-    Firebase.crashlytics.recordException(ex)
-    return this
   }
 }
app/src/main/kotlin/org/grakovne/lissen/domain/update/UpdateCheckerService.kt (2)

31-36: Consider injecting a shared OkHttpClient instead of creating a dedicated instance.

If OkHttp is already used elsewhere in the app (e.g., for API calls to the Audiobookshelf server), each standalone OkHttpClient maintains its own connection pool and dispatcher threads. Injecting a shared instance via Hilt would reduce resource overhead.


136-141: Nit: Use imports instead of fully-qualified names for consistency.

Build is already imported on Line 9, yet Line 136 uses android.os.Build. Similarly, ContextCompat, Manifest, and PackageManager could be imported at the top of the file to reduce visual noise here.

Proposed fix

Add to imports:

import android.Manifest
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat

Then simplify the check:

-          if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
-            if (androidx.core.content.ContextCompat.checkSelfPermission(
-                context,
-                android.Manifest.permission.POST_NOTIFICATIONS,
-              ) == android.content.pm.PackageManager.PERMISSION_GRANTED
-            ) {
+          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            if (ContextCompat.checkSelfPermission(
+                context,
+                Manifest.permission.POST_NOTIFICATIONS,
+              ) == PackageManager.PERMISSION_GRANTED
+            ) {
app/src/main/kotlin/org/grakovne/lissen/ui/screens/migration/MigrationScreen.kt (1)

97-111: Add BackHandler to prevent back-press interruption during migration.

The migration screen currently lacks back-press handling. While the dismiss button was intentionally removed to prevent bypassing migration (lines 93-94), a user can still press the system back button during the Idle, Running, or Completed states to escape the screen prematurely. The clearHistory = true call in AppNavHost (line 114) only takes effect after migration completes, offering no protection during the process.

Other screens in the codebase already use BackHandler (PlayerScreen, GlobalPlayerBottomSheet, LibraryScreen), establishing the pattern. Add it here to suppress back navigation until migration completes:

Suggested implementation
+import androidx.activity.compose.BackHandler
+
 `@Composable`
 fun MigrationScreen(
   onMigrationComplete: () -> Unit,
   viewModel: MigrationViewModel = hiltViewModel(),
 ) {
   val state by viewModel.migrationState.observeAsState(MigrationState.Idle)
+
+  BackHandler(enabled = state !is MigrationState.Completed) {
+    // Intentionally no-op: prevent navigating away during migration
+  }
app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt (1)

88-94: refreshServerAvailabilitySync() is public but not safe for concurrent callers.

refreshServerAvailability() cancels the previous checkJob to ensure single-flight execution. But since refreshServerAvailabilitySync() is also public, an external caller can invoke it concurrently, causing two coroutines to race on _isServerAvailable emissions (and initialRetryCount if it remains an instance field).

If external callers need a synchronous check, consider either:

  • Making refreshServerAvailabilitySync() internal/private and having the public path go through refreshServerAvailability(), or
  • Using a Mutex to serialize concurrent executions.

Also applies to: 96-96

app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt (2)

143-154: currentChapterIndex not updated on NotFoundError session recovery.

When a NotFoundError triggers session re-creation (line 147–150), playbackSession is updated on Main but currentChapterIndex is not, unlike the session-creation path in runSync (lines 116–119). While this doesn't cause a functional bug (the next runSync will reconcile), it is inconsistent and could cause one redundant session re-open on the following sync tick if the chapter index has shifted.

Suggested fix for consistency
             OperationError.NotFoundError -> {
               val newSession = openPlaybackSession(item, overallProgress)
               withContext(Dispatchers.Main) {
                 playbackSession = newSession
+                currentChapterIndex = calculateChapterIndex(item, overallProgress.currentTotalTime)
               }
             }

94-108: Nested launch(Dispatchers.IO) inside serviceScope.launch: clarify intent with a comment.

The two-layer coroutine structure — outer launch on Main (to safely read ExoPlayer state) and inner launch on IO (for network/sync) — is correct but non-obvious to future readers. A brief inline comment on the outer launch (e.g., "// Capture player state on Main, then sync on IO") would help readability.

app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt (1)

64-65: Nit: Redundant fully-qualified type on mediaCache declaration.

Line 15 already imports dagger.Lazy, so the field can use Lazy<Cache> directly.

-  lateinit var mediaCache: dagger.Lazy<Cache>
+  lateinit var mediaCache: Lazy<Cache>
app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt (1)

40-41: Swallowed exception in crash handler (detekt: SwallowedException).

While silencing exceptions here is defensively reasonable, consider logging to Timber or Log.e so crash-handler failures are diagnosable from logcat during development. This won't affect production safety.

Suggested fix
     } catch (e: Exception) {
-      // Ignore errors in the crash handler itself to avoid infinite loops
+      // Log but don't rethrow to avoid infinite loops
+      android.util.Log.e("CrashHandler", "Error in crash handler", e)
     } finally {
lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt (1)

15-17: SpecificFilesDownloadOption is a plain class — no structural equality.

This is consistent with NumberItemDownloadOption, but worth noting: two instances with the same fileIds won't be ==. If this type is ever compared, stored in a set, or used as a map key, consider making it a data class. Not blocking if the current usage doesn't require equality.

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

108-128: @Keep annotation removed from MediaProgressEntity but retained on all other entities.

All other entity classes (BookEntity, BookFileEntity, BookChapterEntity, BookSeriesDto, and the wrapper CachedBookEntity) still carry @Keep. While @JsonClass(generateAdapter = true) uses codegen rather than reflection (so @Keep isn't strictly required), the inconsistency could cause confusion or subtle issues if ProGuard/R8 rules change. Consider either removing @Keep from all entities or restoring it here for consistency.

app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt (1)

4-4: Unused import: ClarityConfig.

ClarityConfig is imported but never referenced in this file.

app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheRepository.kt (2)

158-231: Excessive fully-qualified names hurt readability throughout the new methods.

Types like BookStorageType, BookVolume, BookFile, and the already-imported findRelatedFiles (Line 9) are referenced with their full package paths repeatedly (e.g., Lines 168, 172–190, 195–231, 256–257). Adding the missing imports at the top of the file would make this code much cleaner.

Add imports and simplify references

Add these imports at the top:

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

Then replace all fully-qualified references, e.g.:

-    fun getBookStorageType(book: DetailedItem): org.grakovne.lissen.lib.domain.BookStorageType {
-      if (book.files.size <= 1) return org.grakovne.lissen.lib.domain.BookStorageType.MONOLITH
+    fun getBookStorageType(book: DetailedItem): BookStorageType {
+      if (book.files.size <= 1) return BookStorageType.MONOLITH

And on Line 168, use the already-imported findRelatedFiles without FQN.


293-301: File.freeSpace/totalSpace may be inaccurate on modern Android with scoped storage.

On API 26+, StorageStatsManager or StatFs provide more reliable disk space metrics that account for storage quotas. File.freeSpace can report misleading values on some devices. Worth noting if accuracy matters for UX decisions.

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

156-190: Completed and else branches are identical — extract to a shared helper.

The icon-rendering logic for CacheStatus.Completed (lines 156–172) and the else branch (lines 174–190) are exact duplicates. Consider extracting a local composable or inline lambda to avoid the 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 = successColor,
-          )
-        } else {
-          Icon(
-            imageVector = Icons.Outlined.CloudDownload,
-            contentDescription = stringResource(R.string.accessibility_id_download_available),
-            modifier = Modifier.size(size).alpha(0.8f),
-            tint = color,
-          )
-        }
-      }
-
-      else -> {
-        if (isFullyDownloaded) {
-          Icon(
-            imageVector = Icons.Filled.CloudDone,
-            contentDescription = stringResource(R.string.accessibility_id_download_complete),
-            modifier = Modifier.size(size),
-            tint = successColor,
-          )
-        } else {
-          Icon(
-            imageVector = Icons.Outlined.CloudDownload,
-            contentDescription = stringResource(R.string.accessibility_id_download_available),
-            modifier = Modifier.size(size).alpha(0.8f),
-            tint = color,
-          )
-        }
-      }
+      is CacheStatus.Completed, else -> {
+        if (isFullyDownloaded) {
+          Icon(
+            imageVector = Icons.Filled.CloudDone,
+            contentDescription = stringResource(R.string.accessibility_id_download_complete),
+            modifier = Modifier.size(size),
+            tint = successColor,
+          )
+        } else {
+          Icon(
+            imageVector = Icons.Outlined.CloudDownload,
+            contentDescription = stringResource(R.string.accessibility_id_download_available),
+            modifier = Modifier.size(size).alpha(0.8f),
+            tint = color,
+          )
+        }
+      }
app/build.gradle.kts (1)

65-65: Consider externalizing the Clarity project ID.

The Clarity project ID "vc8bgk8nk9" is hardcoded directly in the build file. While analytics project IDs aren't secret, consider moving it to local.properties or gradle.properties for easier configuration across environments (e.g., separate IDs for debug/release builds or different flavors).

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

61-71: Consider wrapping deleteNonDownloadedBooks in a transaction.

The file deletion (lines 64–68) and DB deletion (line 70) are not atomic. If the process is interrupted between them, orphaned DB records (pointing to deleted files) could remain. Wrapping the DAO call and file I/O, or at minimum the two DAO operations, in a coordinated block would improve resilience.

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

94-98: Duplicate SHA-256 hashing logic — reuse HashUtils.sha256.

AuthRepository already uses org.grakovne.lissen.common.sha256(...) for the same purpose (hashing username@host). This local hashIdentifier method duplicates that utility.

♻️ Suggested fix
-    private fun reidentifyUser() {
-      if (preferences.hasCredentials()) {
-        val username = preferences.getUsername() ?: return
-        val host = preferences.getHost() ?: return
-
-        // Combine host and username for a unique identifier across servers
-        val rawIdentifier = "$username@$host"
-        val hashedIdentifier = hashIdentifier(rawIdentifier)
-
-        clarityTracker.setUser(hashedIdentifier)
+    private fun reidentifyUser() {
+      if (preferences.hasCredentials()) {
+        val username = preferences.getUsername() ?: return
+        val host = preferences.getHost() ?: return
+
+        val hashedIdentifier = org.grakovne.lissen.common.sha256("$username@$host")
+        clarityTracker.setUser(hashedIdentifier)
       } else {
         // Fallback to device ID if not logged in
         clarityTracker.setUser(preferences.getDeviceId())
       }
     }
-
-    private fun hashIdentifier(input: String): String {
-      val digest = java.security.MessageDigest.getInstance("SHA-256")
-      val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
-      return hashBytes.joinToString("") { "%02x".format(it) }
-    }
#!/bin/bash
# Verify HashUtils.sha256 exists and its signature
ast-grep --pattern 'fun sha256($$$) : String {
  $$$
}'

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

These no-op overrides are required by Application.ActivityLifecycleCallbacks. Consider suppressing the detekt warning at the class level to avoid noise.

♻️ Optional suppression

Add above the class declaration:

`@Suppress`("EmptyFunctionBlock")
app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt (1)

238-241: Dense predicate — consider extracting to a named function for readability.

The compound condition on line 240 correctly ensures files (if present) all have a populated size, but it's hard to parse at a glance. A small helper like DetailedItem.hasCompleteMetadata() would make the intent self-documenting.

♻️ Suggested extraction
+    private fun DetailedItem.hasCompleteMetadata(): Boolean {
+      val hasContent = chapters.isNotEmpty() || files.isNotEmpty()
+      val filesValid = files.isEmpty() || files.all { it.size > 0 }
+      return hasContent && filesValid
+    }
+
     suspend fun fetchBook(bookId: String): OperationResult<DetailedItem> {
       Timber.d("Fetching Detailed book info for $bookId")
 
       val localResult = localCacheRepository.fetchBook(bookId)
-      val isDetailed =
-        localResult
-          ?.let { (it.chapters.isNotEmpty() || it.files.isNotEmpty()) && (it.files.isEmpty() || it.files.all { f -> f.size > 0 }) }
-          ?: false
+      val isDetailed = localResult?.hasCompleteMetadata() ?: false
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingService.kt (1)

37-38: Prefer top-level imports over fully qualified inline references.

java.util.concurrent.ConcurrentHashMap and java.util.concurrent.atomic.AtomicLong are used inline. Importing them at the top keeps the code consistent with the rest of the file's style.

♻️ Suggested cleanup

Add to the import block:

import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong

Then simplify the declarations:

-  private val executionStatuses = java.util.concurrent.ConcurrentHashMap<DetailedItem, CacheState>()
-  private val executingCaching = java.util.concurrent.ConcurrentHashMap<DetailedItem, Job>()
-
-  private val lastNotificationUpdate =
-    java.util.concurrent.atomic
-      .AtomicLong(0L)
+  private val executionStatuses = ConcurrentHashMap<DetailedItem, CacheState>()
+  private val executingCaching = ConcurrentHashMap<DetailedItem, Job>()
+  private val lastNotificationUpdate = AtomicLong(0L)
app/src/main/kotlin/org/grakovne/lissen/viewmodel/SettingsViewModel.kt (2)

38-39: Use imports instead of fully qualified names in constructor parameters.

org.grakovne.lissen.content.BookRepository and org.grakovne.lissen.content.cache.temporary.CachedCoverProvider are written fully qualified in the constructor while every other dependency uses a short name via imports.

♻️ Suggested cleanup

Add to the import block:

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

Then simplify:

-    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,

309-329: FQN references to timber.log.Timber and kotlinx.coroutines.CancellationException — use imports for consistency.

The rest of the codebase (and this file's sibling LissenMediaProvider) uses imported Timber. Same applies to kotlinx.coroutines.CancellationException. The CancellationException re-throw logic itself is correct and important for structured concurrency.

♻️ Suggested cleanup

Add to the import block:

import kotlinx.coroutines.CancellationException
import timber.log.Timber

Then simplify inline references:

           } catch (e: Exception) {
-            if (e is kotlinx.coroutines.CancellationException) throw e
-            timber.log.Timber.e(e, "Failed to clear book metadata cache")
+            if (e is CancellationException) throw e
+            Timber.e(e, "Failed to clear book metadata cache")
           }
 
           try {
             cachedCoverProvider.clearCache()
           } catch (e: Exception) {
-            if (e is kotlinx.coroutines.CancellationException) throw e
-            timber.log.Timber.e(e, "Failed to clear cover cache")
+            if (e is CancellationException) throw e
+            Timber.e(e, "Failed to clear cover cache")
           }
app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt (2)

47-47: backgroundScope is never cancelled.

Since BookRepository is a @Singleton, this scope lives for the entire app lifetime, which is generally acceptable on Android. However, it has no structured relationship with any lifecycle owner, so leaked coroutines from syncAllLibraries or prefetchCovers will silently run until completion or app death. Consider whether a ProcessLifecycleOwner-scoped approach or at least documenting this design choice would be beneficial.


454-469: prefetchCovers uses yield() and delay() but runs inside backgroundScope which ignores structured cancellation.

When called from backgroundScope.launch (Lines 265, 422), yield() and delay() are cancellation points — but nothing ever cancels these jobs. The 2-second initial delay and per-book 100ms delay are reasonable throttling, but the withContext(Dispatchers.IO) on Line 462 is redundant since backgroundScope already uses Dispatchers.IO.

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

215-219: Analytics event fires on every keystroke during search.

updateSearch is called on each text change. The isNotEmpty() guard prevents tracking the initial empty state, but every subsequent character typed fires a search_performed event. Consider debouncing or only tracking on search submission.

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

137-143: fileStartTimes is computed unconditionally but only used in the ATOMIC branch.

The fileStartTimes computation runs for all storage types, including MONOLITH and SEGMENTED where it's never referenced. Consider moving it inside the ATOMIC block or wrapping it in a lazy computation.


83-98: Derived state computations in the composable body are not wrapped in remember/derivedStateOf.

currentChapterIndex, currentVolume, remainingVolumes, and completedVolumes are recomputed on every recomposition. Since these depend on book and volumes, consider wrapping them in remember with appropriate keys to avoid redundant computation, especially calculateChapterIndex which iterates through chapters.

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

116-142: Fully-qualified type names hurt readability.

Several parameter types and references use full package paths instead of imports (e.g., org.grakovne.lissen.lib.domain.PlayingChapter, org.grakovne.lissen.lib.domain.BookFile, org.grakovne.lissen.content.cache.persistent.VolumeLabelType, org.grakovne.lissen.R.string.*). Add imports and use short names.

♻️ Add imports at the top of the file
+import org.grakovne.lissen.R
+import org.grakovne.lissen.lib.domain.BookFile
+import org.grakovne.lissen.content.cache.persistent.VolumeLabelType

Then use short names throughout.


227-255: refreshMetadata has no cancellation check or progress feedback in its loop.

The while (hasMore) loop re-fetches and re-caches every detailed item across all pages. For large libraries, this could run for an extended period with no way for the user to observe progress or cancel. Consider adding yield() calls between pages for cooperative cancellation, and optionally emitting progress.

♻️ Add cooperative cancellation
           while (hasMore) {
+            kotlinx.coroutines.yield()
             val items =
               localCacheRepository
                 .fetchDetailedItems(BATCH_SIZE, page)
app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt (1)

589-591: storageType is computed twice in separate composition scopes.

cachingModelView.getBookStorageType(book) is called at Line 259 (content block) and again at Line 591 (downloads block). Since getBookStorageType is a synchronous call and the book is the same, consider hoisting this computation alongside the volumes derivation (around Line 156) so both blocks can share it.

♻️ Hoist storageType alongside volumes
+  val storageType =
+    remember(bookDetail?.id) {
+      bookDetail?.let { cachingModelView.getBookStorageType(it) }
+    }
+
   val volumes =
     remember(bookDetail, cacheProgress.status, cacheVersion) {
       bookDetail?.let { cachingModelView.getVolumes(it) }
         ?: emptyList()
     }

Then remove the two remember(book.id) { cachingModelView.getBookStorageType(book) } calls at Lines 259 and 591, and use the hoisted value (with a null fallback if needed).

Comment on lines +16 to +19
"api_key": [
{
"current_key": "AIzaSyAB8o-65tB6TzUbdaqVAP9XnTl1wgC6dks"
}
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

GCP API key committed to repository — ensure it is restricted in Cloud Console.

While google-services.json is routinely committed to Android projects (and Google documents it as safe to include in source control), the Firebase/GCP API key on Lines 18 and 37 is nonetheless visible to anyone with repo access. Static analysis (Gitleaks) correctly flags this.

Mitigations to apply:

  1. Restrict the API key in the Google Cloud Console → Credentials: limit it by Android app (package name + SHA-1 fingerprint) and by API (only the specific Firebase APIs you use).
  2. If this repository is public, also consider whether the project number and app IDs leak more surface area than intended — Firebase App Check can further lock down backend access.
  3. Ensure .gitignore patterns are intentional — some teams choose to distribute this file via CI secrets instead of checking it in for public repos.

No code change needed if the key is properly restricted, but leaving it unrestricted in a public repo is a security posture gap.

Also applies to: 35-38

🧰 Tools
🪛 Gitleaks (8.30.0)

[high] 18-18: Uncovered a GCP API key, which could lead to unauthorized access to Google Cloud services and data breaches.

(gcp-api-key)

🤖 Prompt for AI Agents
In `@app/google-services.json` around lines 16 - 19, The committed Firebase/GCP
API key is exposed in google-services.json under the "api_key" -> "current_key"
field; restrict that key in the Google Cloud Console (Credentials) by applying
Android app restrictions (package name + SHA-1) and API restrictions to only
needed Firebase APIs, and if this repo is public remove or stop committing
google-services.json (or serve it via CI secrets) and consider enabling Firebase
App Check for additional protection; also review .gitignore to prevent
accidental commits of google-services.json going forward.

Comment on lines +41 to +54
fun trackEvent(
eventName: String,
value: String,
) {
if (BuildConfig.DEBUG) return

try {
// Clarity sendCustomEvent takes a string value for the event name/payload
Clarity.sendCustomEvent("$eventName: [REDACTED]")
Timber.d("Clarity Event tracked: $eventName with value: [REDACTED]")
} catch (e: Exception) {
Timber.e(e, "Failed to track Clarity event: $eventName with value: [REDACTED]")
}
}
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

value parameter is accepted but silently discarded — misleading API.

The value parameter is never used; Line 49 sends "$eventName: [REDACTED]" to Clarity regardless of what value is passed. Callers passing a value will expect it to be tracked. Additionally, appending ": [REDACTED]" to the event name pollutes Clarity's event namespace (e.g., "sync_progress: [REDACTED]" instead of "sync_progress").

Either remove the value parameter entirely (and have callers use the single-arg overload), or send the actual value if it contains no PII. If intentional, add a @Suppress("UNUSED_PARAMETER") and a KDoc note.

Option A: Remove unused parameter, delegate to single-arg overload
     fun trackEvent(
       eventName: String,
-      value: String,
     ) {
       if (BuildConfig.DEBUG) return
 
       try {
-        // Clarity sendCustomEvent takes a string value for the event name/payload
-        Clarity.sendCustomEvent("$eventName: [REDACTED]")
-        Timber.d("Clarity Event tracked: $eventName with value: [REDACTED]")
+        Clarity.sendCustomEvent(eventName)
+        Timber.d("Clarity Event tracked: $eventName")
       } catch (e: Exception) {
-        Timber.e(e, "Failed to track Clarity event: $eventName with value: [REDACTED]")
+        Timber.e(e, "Failed to track Clarity event: $eventName")
       }
     }
📝 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
fun trackEvent(
eventName: String,
value: String,
) {
if (BuildConfig.DEBUG) return
try {
// Clarity sendCustomEvent takes a string value for the event name/payload
Clarity.sendCustomEvent("$eventName: [REDACTED]")
Timber.d("Clarity Event tracked: $eventName with value: [REDACTED]")
} catch (e: Exception) {
Timber.e(e, "Failed to track Clarity event: $eventName with value: [REDACTED]")
}
}
fun trackEvent(
eventName: String,
) {
if (BuildConfig.DEBUG) return
try {
Clarity.sendCustomEvent(eventName)
Timber.d("Clarity Event tracked: $eventName")
} catch (e: Exception) {
Timber.e(e, "Failed to track Clarity event: $eventName")
}
}
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/analytics/ClarityTracker.kt` around
lines 41 - 54, The trackEvent function accepts a value parameter but never uses
it and appends a redacted suffix to the event name, which misleads callers and
pollutes Clarity event names; fix by removing the unused value parameter from
the signature of org.grakovne.lissen.analytics.ClarityTracker.trackEvent and
implement a single-argument overload that preserves existing behavior (i.e.,
call Clarity.sendCustomEvent(eventName) and Timber.d with eventName) and update
all callers to use the single-arg overload (or provide a two-arg overload that
delegates to the single-arg one if you need backward compatibility);
alternatively, if you intend to keep the parameter, explicitly use or sanitize
value before sending it via Clarity.sendCustomEvent and add a KDoc or
`@Suppress`("UNUSED_PARAMETER") if it must remain unused.

Comment on lines +20 to +22
com.google.firebase.crashlytics.FirebaseCrashlytics
.getInstance()
.recordException(ex)
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

recordException reports as non-fatal — potential duplicate with Crashlytics' own fatal handler.

Firebase Crashlytics automatically captures uncaught exceptions when it's initialized. Calling recordException here records it as a non-fatal, so the same crash may appear twice in the dashboard — once as fatal (auto-captured) and once as non-fatal.

If the intent is just to ensure the crash reaches Crashlytics before the toast delay, consider calling FirebaseCrashlytics.getInstance().sendUnsentReports() instead, or simply rely on Crashlytics' built-in handler (which typically chains with the default handler).

🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/common/CrashHandler.kt` around lines
20 - 22, The current CrashHandler use of
FirebaseCrashlytics.getInstance().recordException(ex) records the throwable as a
non-fatal event and can duplicate the same uncaught crash that Crashlytics
already captures; update the CrashHandler (the code in CrashHandler.kt where
recordException is called) to either remove the recordException call and rely on
Crashlytics' default uncaught exception handler, or replace it with
FirebaseCrashlytics.getInstance().sendUnsentReports() (or another appropriate
send/flush method) to ensure delivery without creating a duplicate non-fatal
record, and keep any existing toast/delay logic intact so the crash is not
double-reported.

Comment on lines +96 to +135
suspend fun refreshServerAvailabilitySync(): Boolean {
delay(500)
val isConnectedToInternet = isNetworkAvailable()
_networkStatus.emit(isConnectedToInternet)

val hostUrl = preferences.getHost()
if (hostUrl.isNullOrBlank()) {
_isServerAvailable.emit(false)
return@launch
}
if (!isConnectedToInternet) {
_isServerAvailable.emit(false)
return false
}

try {
val url = java.net.URL(hostUrl)
val port = if (url.port == -1) url.defaultPort else url.port
val address = java.net.InetSocketAddress(url.host, port)
val hostUrl = preferences.getHost()
if (hostUrl.isNullOrBlank()) {
_isServerAvailable.emit(false)
return false
}

java.net.Socket().use { socket ->
socket.connect(address, 2000)
}
return try {
val url = java.net.URL(hostUrl)
val port = if (url.port == -1) url.defaultPort else url.port
val address = java.net.InetSocketAddress(url.host, port)

_isServerAvailable.emit(true)
} catch (e: Exception) {
Timber.e(e, "Server reachability check failed for $hostUrl")
_isServerAvailable.emit(false)
}
java.net.Socket().use { socket ->
socket.connect(address, 2000)
}

_isServerAvailable.emit(true)
initialRetryCount = MAX_INITIAL_RETRIES // Stop retries once connected
true
} catch (e: Exception) {
Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${initialRetryCount + 1})")
_isServerAvailable.emit(false)

if (initialRetryCount < MAX_INITIAL_RETRIES) {
initialRetryCount++
delay(300L)
refreshServerAvailabilitySync()
} else {
false
}
}
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

Retry logic has two defects: delay compounding and one-shot retry counter.

1. Delay compounding in retries: The 500ms debounce delay at Line 97 is hit on every recursive call (Line 131), so each retry actually waits 800ms (500 + 300) instead of the intended 300ms.

2. initialRetryCount is never reset: Once it reaches MAX_INITIAL_RETRIES (via success at Line 122 or exhaustion), all future calls from network events or host changes get zero retries. This means a host change (via hostFlow) or a network reconnect will do a single attempt with no retry, which likely isn't the intent.

3. Minor: Recursive approach is harder to follow than a simple loop and risks the compounding issue above.

Proposed fix: use a loop and reset retry count per invocation
     suspend fun refreshServerAvailabilitySync(): Boolean {
         delay(500)
         val isConnectedToInternet = isNetworkAvailable()
         _networkStatus.emit(isConnectedToInternet)
 
         if (!isConnectedToInternet) {
             _isServerAvailable.emit(false)
             return false
         }
 
         val hostUrl = preferences.getHost()
         if (hostUrl.isNullOrBlank()) {
             _isServerAvailable.emit(false)
             return false
         }
 
-        return try {
-            val url = java.net.URL(hostUrl)
-            val port = if (url.port == -1) url.defaultPort else url.port
-            val address = java.net.InetSocketAddress(url.host, port)
-
-            java.net.Socket().use { socket ->
-                socket.connect(address, 2000)
-            }
-
-            _isServerAvailable.emit(true)
-            initialRetryCount = MAX_INITIAL_RETRIES // Stop retries once connected
-            true
-        } catch (e: Exception) {
-            Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${initialRetryCount + 1})")
-            _isServerAvailable.emit(false)
-
-            if (initialRetryCount < MAX_INITIAL_RETRIES) {
-                initialRetryCount++
-                delay(300L)
-                refreshServerAvailabilitySync()
-            } else {
-                false
-            }
-        }
+        val url = java.net.URL(hostUrl)
+        val port = if (url.port == -1) url.defaultPort else url.port
+        val address = java.net.InetSocketAddress(url.host, port)
+
+        repeat(MAX_INITIAL_RETRIES) { attempt ->
+            try {
+                java.net.Socket().use { socket ->
+                    socket.connect(address, 2000)
+                }
+                _isServerAvailable.emit(true)
+                return true
+            } catch (e: Exception) {
+                Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${attempt + 1})")
+                _isServerAvailable.emit(false)
+                if (attempt < MAX_INITIAL_RETRIES - 1) {
+                    delay(300L)
+                }
+            }
+        }
+        return false
     }

This also eliminates the initialRetryCount instance field entirely, removing the thread-safety concern (see next comment).

📝 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
suspend fun refreshServerAvailabilitySync(): Boolean {
delay(500)
val isConnectedToInternet = isNetworkAvailable()
_networkStatus.emit(isConnectedToInternet)
val hostUrl = preferences.getHost()
if (hostUrl.isNullOrBlank()) {
_isServerAvailable.emit(false)
return@launch
}
if (!isConnectedToInternet) {
_isServerAvailable.emit(false)
return false
}
try {
val url = java.net.URL(hostUrl)
val port = if (url.port == -1) url.defaultPort else url.port
val address = java.net.InetSocketAddress(url.host, port)
val hostUrl = preferences.getHost()
if (hostUrl.isNullOrBlank()) {
_isServerAvailable.emit(false)
return false
}
java.net.Socket().use { socket ->
socket.connect(address, 2000)
}
return try {
val url = java.net.URL(hostUrl)
val port = if (url.port == -1) url.defaultPort else url.port
val address = java.net.InetSocketAddress(url.host, port)
_isServerAvailable.emit(true)
} catch (e: Exception) {
Timber.e(e, "Server reachability check failed for $hostUrl")
_isServerAvailable.emit(false)
}
java.net.Socket().use { socket ->
socket.connect(address, 2000)
}
_isServerAvailable.emit(true)
initialRetryCount = MAX_INITIAL_RETRIES // Stop retries once connected
true
} catch (e: Exception) {
Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${initialRetryCount + 1})")
_isServerAvailable.emit(false)
if (initialRetryCount < MAX_INITIAL_RETRIES) {
initialRetryCount++
delay(300L)
refreshServerAvailabilitySync()
} else {
false
}
}
suspend fun refreshServerAvailabilitySync(): Boolean {
delay(500)
val isConnectedToInternet = isNetworkAvailable()
_networkStatus.emit(isConnectedToInternet)
if (!isConnectedToInternet) {
_isServerAvailable.emit(false)
return false
}
val hostUrl = preferences.getHost()
if (hostUrl.isNullOrBlank()) {
_isServerAvailable.emit(false)
return false
}
val url = java.net.URL(hostUrl)
val port = if (url.port == -1) url.defaultPort else url.port
val address = java.net.InetSocketAddress(url.host, port)
repeat(MAX_INITIAL_RETRIES) { attempt ->
try {
java.net.Socket().use { socket ->
socket.connect(address, 2000)
}
_isServerAvailable.emit(true)
return true
} catch (e: Exception) {
Timber.e(e, "Server reachability check failed for $hostUrl (Attempt ${attempt + 1})")
_isServerAvailable.emit(false)
if (attempt < MAX_INITIAL_RETRIES - 1) {
delay(300L)
}
}
}
return false
}
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt` around
lines 96 - 135, The refreshServerAvailabilitySync() retry logic wrongly
re-applies the 500ms debounce on each recursive retry and uses an
instance-scoped initialRetryCount that is never reset, causing compounding
delays and one-shot retries; replace the recursion with a local-loop retry
inside refreshServerAvailabilitySync(): perform the initial
delay/isNetworkAvailable()/host null check once at the start, then attempt the
socket connect in a for/while loop that uses a local retry counter (reset on
each invocation) and delays 300ms between attempts, emit _isServerAvailable and
_networkStatus appropriately on success/failure, and remove the instance field
initialRetryCount entirely so each call gets its own retry budget.

Comment on lines 177 to +192
suspend fun syncLibraryPage(
libraryId: String,
pageSize: Int,
pageNumber: Int,
): OperationResult<Unit> =
providePreferredChannel()
.fetchBooks(libraryId, pageSize, pageNumber)
.map { localCacheRepository.cacheBooks(it.items) }
OperationResult
.Success(Unit)
.also {
backgroundScope.launch {
try {
syncAllLibraries()
} catch (e: Exception) {
Timber.e(e, "Failed to sync all libraries")
}
}
}
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

syncLibraryPage ignores its libraryId, pageSize, and pageNumber parameters.

The method signature suggests a targeted page sync, but the implementation returns Success immediately and fires off syncAllLibraries() in the background, ignoring all three parameters. This is misleading for callers (e.g., LibraryViewModel.refreshLibrary at Line 249 passes specific values). Either align the implementation with the contract or simplify the signature.

♻️ Suggested: remove unused parameters or use them

If the intent is truly to sync all libraries in the background regardless:

-    suspend fun syncLibraryPage(
-      libraryId: String,
-      pageSize: Int,
-      pageNumber: Int,
-    ): OperationResult<Unit> =
+    fun triggerBackgroundSync(): OperationResult<Unit> =
       OperationResult
         .Success(Unit)
         .also {
           backgroundScope.launch {
             try {
               syncAllLibraries()
             } catch (e: Exception) {
               Timber.e(e, "Failed to sync all libraries")
             }
           }
         }
🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/content/BookRepository.kt` around
lines 177 - 192, The current syncLibraryPage function ignores its parameters and
always calls syncAllLibraries; change it to perform a targeted page sync using
the provided libraryId, pageSize, and pageNumber instead of calling
syncAllLibraries. Replace the backgroundScope.launch block to call a suspend
worker like syncLibraryPageTask(libraryId, pageSize, pageNumber) (or create such
a helper) and handle exceptions with Timber.e(e, "...") as before; keep
returning OperationResult.Success(Unit) immediately if you want fire-and-forget
semantics, or await the task and return a success/failure based on the result if
you want synchronous behavior. Ensure you reference the existing function name
syncLibraryPage and the launched coroutine (backgroundScope.launch) when making
the change so callers like LibraryViewModel.refreshLibrary receive the intended
behavior.

Comment on lines +37 to +43
var isMainLoading by remember(imageRequest) { mutableStateOf(true) }
var isThumbnailLoaded by remember(thumbnailRequest) { mutableStateOf(false) }

LaunchedEffect(imageRequest) {
isMainLoading = true
onLoadingStateChanged(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 | 🟠 Major

Potential race between LaunchedEffect and onSuccess for cache-hit images.

When imageRequest changes, remember already initializes isMainLoading = true. The LaunchedEffect then also sets it to true and fires onLoadingStateChanged(true), but it runs as a coroutine launched after composition is committed. If Coil resolves the image from memory/disk cache very quickly, onSuccess (line 82-85) could set isMainLoading = false and call onLoadingStateChanged(false) before or around the same time as the LaunchedEffect body executes, which would then overwrite isMainLoading back to true — leaving the component stuck in a loading state.

Consider moving the onLoadingStateChanged(true) callback into a SideEffect or invoking it conditionally, or removing the LaunchedEffect entirely since remember(imageRequest) already handles the state reset:

Proposed fix
- var isMainLoading by remember(imageRequest) { mutableStateOf(true) }
+ var isMainLoading by remember(imageRequest) { mutableStateOf(true).also { onLoadingStateChanged(true) } }
  var isThumbnailLoaded by remember(thumbnailRequest) { mutableStateOf(false) }
 
- LaunchedEffect(imageRequest) {
-   isMainLoading = true
-   onLoadingStateChanged(true)
- }

Note: Calling onLoadingStateChanged inside remember's initializer may not be ideal either (side-effects in composition). A cleaner alternative is a SideEffect that fires only when isMainLoading transitions to true, or use snapshotFlow.

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt`
around lines 37 - 43, The LaunchedEffect that sets isMainLoading and calls
onLoadingStateChanged races with Coil’s onSuccess; remove the LaunchedEffect and
keep isMainLoading initialized by remember(imageRequest) only, then add a
SideEffect (or snapshotFlow observing isMainLoading) that invokes
onLoadingStateChanged(true) only when isMainLoading transitions to true so a
later onSuccess handler (in onSuccess) can reliably set isMainLoading = false
and call onLoadingStateChanged(false) without being overwritten; reference the
variables and callbacks isMainLoading, remember(imageRequest), LaunchedEffect,
SideEffect/snapshotFlow, and the onSuccess block to locate and update the code.

Comment on lines +76 to +77
val preparingBookId by playerViewModel.preparingBookId.observeAsState(null)
val preparingError by playerViewModel.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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "preparingError" --type=kt -C 3

Repository: SurjitSahoo/kahani-android

Length of output: 96


🏁 Script executed:

rg -n "preparingBookId" --type=kt -C 3

Repository: SurjitSahoo/kahani-android

Length of output: 96


🏁 Script executed:

cat -n app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt | head -100

Repository: SurjitSahoo/kahani-android

Length of output: 4785


🏁 Script executed:

cat -n app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt | sed -n '100,250p'

Repository: SurjitSahoo/kahani-android

Length of output: 5601


🏁 Script executed:

rg "preparingError" --type=kotlin -C 3

Repository: SurjitSahoo/kahani-android

Length of output: 4476


🏁 Script executed:

rg "preparingBookId" --type=kotlin -C 3

Repository: SurjitSahoo/kahani-android

Length of output: 11463


🏁 Script executed:

cat -n app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt | sed -n '275,305p'

Repository: SurjitSahoo/kahani-android

Length of output: 1244


🏁 Script executed:

cat -n app/src/main/kotlin/org/grakovne/lissen/ui/screens/details/BookDetailScreen.kt | sed -n '140,165p'

Repository: SurjitSahoo/kahani-android

Length of output: 1731


Gate loading/error state with preparingBookId == book.id to avoid showing incorrect states for other books.

The mini player displays a specific book (book: DetailedItem), but currently the loading and error states trigger whenever preparingBookId is non-null, regardless of which book is being prepared. This causes the spinner and error icon to display incorrectly when a different book is being loaded.

Compare with BookDetailScreen, which correctly uses preparingBookId == bookId to gate the loading indicator. Apply the same pattern here:

val isLoading = preparingBookId == book.id
val isError = preparingError && preparingBookId == book.id
🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt`
around lines 76 - 77, The loading/error states are not gated to the current
book; compute local flags using the observed values so the spinner/error show
only for this book: derive val isLoading = preparingBookId == book.id and val
isError = preparingError && preparingBookId == book.id (observed from
playerViewModel.preparingBookId and preparingError) and replace usages that
currently check preparingBookId or preparingError directly with isLoading and
isError inside MiniPlayerComposable (where the spinner and error icon rendering
occur).

Comment on lines +741 to +749
for (i in 0 until cachedItems.itemCount) {
val book = cachedItems[i] ?: continue
val selectionsForBook = selectedByBook[book.id] ?: continue

val volumes = viewModel.getVolumes(book)
selectionsForBook.forEach { selection ->
val volume = volumes.find { it.id == selection.fileId }
total += volume?.size ?: 0L
}
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

Iterating all paging items may silently skip pages that haven't been loaded yet.

cachedItems[i] returns null for items whose page hasn't been fetched. Both calculateReclaimSize (line 742) and deleteSelectedVolumes (line 763) iterate 0 until cachedItems.itemCount and skip nulls with ?: continue / mapNotNull. If the user selects volumes from items that have scrolled out of the paging window, those items may not be resident and will be silently skipped — the reclaim size will be under-reported, and more critically, deleteSelectedVolumes will silently fail to delete those volumes.

Consider maintaining a separate lookup (e.g. a Map<String, DetailedItem> populated as items are rendered) rather than re-reading from the paging source at deletion time, or ensure all relevant pages are loaded before these operations run.

Also applies to: 761-764

🤖 Prompt for AI Agents
In
`@app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsSettingsScreen.kt`
around lines 741 - 749, calculateReclaimSize and deleteSelectedVolumes currently
iterate 0 until cachedItems.itemCount and skip null entries, which silently
ignores selections whose pages aren't loaded; fix by maintaining a persistent
lookup (e.g., MutableMap<String, DetailedItem> renderedItemCache) that is
populated when items are bound/rendered (update in the composable or adapter
where items are accessed) and then use renderedItemCache.lookup(book.id) and
renderedItemCache.lookup(volumeId) inside calculateReclaimSize and
deleteSelectedVolumes (fall back to loading from viewModel.getVolumes(book) only
if the cache misses), or alternatively trigger loading of the necessary paging
pages before computing/deleting to guarantee presence — update the code paths
that reference cachedItems[i], calculateReclaimSize, and deleteSelectedVolumes
to use this cache/fallback so selections aren't skipped.

Comment on lines +233 to +257
fun refreshLibrary(forceRefresh: Boolean = false) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
fetchRecentListening()
}
}
}
val isAvailable =
if (forceRefresh) {
networkService.refreshServerAvailabilitySync()
} else {
networkService.isServerAvailable.value
}

val shouldSync = (forceRefresh || isAvailable) && !preferences.isForceCache()

if (shouldSync) {
val libraryId = preferences.getPreferredLibrary()?.id

if (libraryId != null) {
bookRepository.syncLibraryPage(
libraryId = libraryId,
pageSize = PAGE_SIZE,
pageNumber = 0,
)
}

bookRepository.syncRepositories()
}
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

refreshLibrary triggers duplicate sync work.

When shouldSync is true, the method calls both syncLibraryPage (Line 249) and syncRepositories (Line 256). Looking at their implementations:

  • syncLibraryPage launches syncAllLibraries() in the background, which calls syncFullLibrary for every library.
  • syncRepositories (in BookRepository) also calls syncFullLibrary(libraryId) directly.

This means syncFullLibrary runs at least twice for the preferred library — once from each path — potentially causing redundant network calls for minified/detailed book fetches, duplicate cover prefetches, and wasted bandwidth.

Consider removing one of the two calls or coordinating them to avoid overlapping work.

🤖 Prompt for AI Agents
In `@app/src/main/kotlin/org/grakovne/lissen/viewmodel/LibraryViewModel.kt` around
lines 233 - 257, refreshLibrary currently triggers duplicate work by calling
bookRepository.syncLibraryPage(...) and then bookRepository.syncRepositories(),
which results in syncFullLibrary running twice for the preferred library (via
syncAllLibraries inside syncLibraryPage and directly in syncRepositories). Fix
by ensuring only one path performs a full sync for the preferred library: either
remove the call to bookRepository.syncRepositories() when
preferences.getPreferredLibrary() is non-null (i.e., when syncLibraryPage is
invoked for that library), or change BookRepository.syncRepositories() to skip
libraries already synced by syncLibraryPage (e.g., accept an optional
excludedLibraryId and avoid calling syncFullLibrary for that id). Update
refreshLibrary (function refreshLibrary) and/or
BookRepository.syncRepositories/syncAllLibraries/syncFullLibrary accordingly so
the preferred library is not synced twice.

Comment on lines +277 to +278
<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>
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

Consent message claims "anonymous" data but hashed user identifiers are pseudonymous, not anonymous.

The analytics consent message states "sharing anonymous technical data," but ClarityComponent sends a SHA-256 hash of username@host to Microsoft Clarity for user re-identification. Under GDPR, pseudonymized data (hashed identifiers that can correlate sessions) is not anonymous. Consider rewording to "pseudonymous" or "non-personally-identifiable" to accurately represent what is collected.

🤖 Prompt for AI Agents
In `@app/src/main/res/values/strings.xml` around lines 277 - 278, The consent copy
in resources (strings analytics_consent_title and analytics_consent_message)
incorrectly states "anonymous" while ClarityComponent sends a SHA-256 hash of
username@host (pseudonymous identifier); update the strings to accurately
reflect pseudonymous or non‑personally‑identifiable data (e.g., replace
"anonymous" with "pseudonymous" or "non-personally-identifiable") and ensure
messaging aligns with ClarityComponent's hashing behavior and any privacy docs.

This was referenced Feb 8, 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