Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc
- `ArtworkNavigationHostTheme` captures source colors via a synchronous destination listener, paints the slide/fade underlay with the departing page’s background, and gives entering loaders temporary inherited colors. Regular pages (including Home) and their navbar use the saved app theme as one unit from the first frame, while only their loaders ease back after the slide; detail loaders keep their own entry-scoped handoff until artwork is resolved. Back also starts with the departing palette before restoring the returning page’s own colors, including rapid returns while its composition is still retained; cold starts, opt-out and theme changes reset the fallback safely. The root Scaffold and predictive-Back wrapper use the current page background so shrinking the page does not reveal the saved-theme surface. Navigation routes, duration, gesture handling and playback remain unchanged.
- `BoxLoreNavHost` owns app route registration and delegates screen bodies to feature modules; stack-slide transitions follow the visible Home → Explore → Library → Lore order, and Home’s first committed content frame unlocks the floating Lore launch animation.
- `NavGraphPodcastEpisodeDestinations` registers episode and podcast detail routes; episode full-path routes define `episodeDescription` as nullable with a default null value to safely handle empty, omitted, or special-character descriptions without argument bundle verification failures.
- `NavGraphSettingsDestinations` registers the settings hub, category pages, download settings, the dedicated `"feedback"` full-page destination wired to `:feature:settings:feedback:FeedbackScreen`, and the Support sub-page within the `"settings"` route (selected with `settings?page=support`), rendered by `:feature:settings:pages:SupportDevelopmentPage` (with listening-hours insights flow and support screen visibility callbacks to hide player chrome).
- `NavGraphSettingsDestinations` registers the settings hub, category pages, download settings, the dedicated `"feedback"` full-page destination wired to `:feature:settings:feedback:FeedbackScreen`, and the Support sub-page within the `"settings"` route (selected with `settings?page=support`), rendered by `:feature:settings:pages:SupportDevelopmentPage` (with listening-hours insights flow and support screen visibility callbacks to hide player chrome). Library settings' **Clear saved shows** (P4 PR 20) gets its `SavedShowsControl` from `savedShowsControl(container)`, which reads `localEpisodeCatalogRepository.showPages` (`unfollowedCount` / `clearUnfollowed`); `SettingsScreen` adds the `saved_shows_cleared` event. JVM coverage sits with the pieces: `ShowPageFeedCatalogTest` (`:core:rss`) and `ClearSavedShowsAdaptiveTest` (`:feature:settings`).
- The nested Account page also reports visibility to the shell. Account and onboarding hide the player overlay even when cloud restore introduces a current episode, while audio and playback state remain service-owned. Direct account entry suppresses chrome immediately from its route arguments; leaving Account restores normal player presentation. The existing Support and mode-switch suppression remain in place.
- Cold-start destination precedence (see `StartDestinationResolver`): incomplete onboarding → offline Downloads (no deep link) → Appearance **Open app to** Subscriptions or Downloads (no deep link) → Home. Deep links and push `target_route` still win over Open app to. When cold start opens Subscriptions or Downloads via that pref, `openedToLandingOnLaunch` makes Back navigate to Home (not Library hub); Library-tile entry still pops to Library. Offline-forced Downloads without that pref still exits on Back. That Home navigation is owned by `PredictiveBackWrapper` (NavHost has nothing to pop), so peek scale must restore after the gesture — see `:core:designsystem`.
- Process latest-episode sync (`SubscriptionForegroundSync`) and the versioned legacy RSS repair are started from `BoxLoreAppRoot` after onboarding. Repair is enabled only for upgraded installs running `0.0.18` through `0.0.30`, except `0.0.20`, starts only while online with at least one eligible pure `rss:` subscription, and admits at most one pass per app process. A fresh install on an allowed version writes the completed marker without inspecting or changing catalog rows, so updating a fresh `0.0.18` install to `0.0.19` cannot enable repair later. Unsupported versions and invalid package metadata fail closed. App foreground/screen-on events do not request another pass. While an eligible pass is active, the shell shows a persistent top popup with the boxlore loader; it cannot be dismissed and disappears when the pass settles. Returning to the app still requests normal subscription refresh (`SubscriptionResumeRefreshLogic` skips the first `ON_START` so Home keeps its 2s first-paint delay). Subscriptions-first launches still fetch because Library calls `requestRefresh` as soon as that screen is visible. Identified FCM payloads (guid/enclosure) that do not match a local episode no longer fall back to an unrelated newest tip.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraph
) { backStackEntry ->
val settingsPage = backStackEntry.arguments?.getString("page")
val fromOnboardingArg = backStackEntry.arguments?.getBoolean("fromOnboarding") ?: false
val isFromOnboarding = rememberSaveable {
fromOnboardingArg || !w.session.onboardingCompleted
}
val isFromOnboarding = rememberSaveable { fromOnboardingArg || !w.session.onboardingCompleted }

SettingsScreen(
repositories = SettingsRepositories(
Expand Down Expand Up @@ -121,6 +119,7 @@ private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraph
onSetContentLanguages = { languages -> scope.launch { w.userPrefs.setContentLanguages(languages) } },
englishOff = settingsState.contentEnglishOff,
onSetEnglishOff = { off -> scope.launch { w.userPrefs.setContentEnglishOff(off) } },
savedShows = savedShowsControl(container),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
discoverySections = settingsState.discoverySections,
onSetDiscoverySection = { section, on ->
scope.launch {
Expand Down Expand Up @@ -470,3 +469,9 @@ internal fun handleSettingsOnBack(
popBackStack()
}
}

/** Library settings › Storage › Clear saved shows, backed by the show-page copies (P4 PR 20). */
private fun savedShowsControl(container: cx.aswin.boxlore.AppContainer) = cx.aswin.boxlore.feature.settings.pages.SavedShowsControl(
count = { container.localEpisodeCatalogRepository.showPages.unfollowedCount() },
clear = { container.localEpisodeCatalogRepository.showPages.clearUnfollowed() },
)
2 changes: 1 addition & 1 deletion core/database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Owns the main Room database, entities, DAOs, type converters, and migrations for
- `EpisodeListenerStore` (from `BoxLoreDatabase.episodeListenerStore()`, so the playback service reaches it without `AppContainer`) keeps one listener state per episode across its stored IDs ([`docs/EPISODE_IDS.md`](../../docs/EPISODE_IDS.md) §5). An `EpisodeGroup` is every trusted alias of one (show, catalog key), owner first; any other ID is a group of its own and reads and writes exactly as before aliases existed. It works out the owner (the stored mark while that ID holds listener state, else the v39 upgrade's order through `EpisodeIdentityDao.chooseOwner`) and stores it in the transaction of the write that needs it (`historyId`). Reads project (`EpisodeListenerProjection`): the owner's row is the base, and progress, position and play time come from the most recently played row (the owner's, unless a pull, a restore or older-build data put later listening under another ID; an owner that holds only a like never hides another ID's progress); liked and completed are the latest decision across the rows and `episode_group_state`, a group setting winning a tie, so an older alias row can't bring back a cleared like or completion; an episode with one stored ID reads its own row unchanged. `projectRows`, `projectedRows` and `projectCompletedIds` count each episode once (the shared-alias query is watched as a Flow, so episodes with one ID cost nothing extra per emission); `projectCompletedIds` also counts an episode that only its group settings mark completed. `projectRowsByMember`, `projectedRowsByMember` and `projectedLikedIds` list an episode once per stored ID, for screens that look an episode up by the ID they show. `lastPlayedSession` is the projected last session for restores and Android Auto. Writes: `setLiked` writes an absolute like to every trusted ID and the group settings (an episode with one stored ID keeps the clock's time on its row, as before); `recordLike`, `recordCompletion` and the batched `recordCompletions` record a decision after the caller wrote the owner's row (an episode with one ID and no row records nothing); `historyIds` works out owners in batches; `removeGroupState`, `restoreGroupState` and `clearGroupStates` remove an episode's settings with its history and put them back on Undo; `downloadHolder(s)` names the ID whose completed download plays an episode; `memberIds`, `withAllAliases`, `groupTokens` and `queueIdentities` serve the queue, Smart Queue, Smart Downloads, sync and ranking; `queueRemovalIds` names the IDs a queue removal records: the removed ones and every other trusted ID of their episodes, queued here or not (a cloud sync pull keeps one queue entry per episode, so the cloud can still hold another), except the IDs of an episode that stays queued or plays; `withAllAliasesFlow`, `memberIdsFlow` and `aliasGroupsFlow` expand IDs to every trusted ID of their episodes in memory from the watched shared aliases, so download and queue indicators read an episode under the ID a screen shows. `ownerRow` gives a new history row written under another ID of the playing episode (its owner) the owner's own show (its stored row, else its catalog row, else its alias), and `keepingOwnerMetadata` keeps a stored owner row's show and episode metadata when another ID's page saves progress, so the owner row of a show switched to its feed stays under the Podcast Index show it belongs to. `showReplacementsFlow` maps each Podcast Index show a followed RSS show replaced (and the listener no longer follows) to that RSS show, for listening read by show. Like times in the group settings stay monotonic within an episode even when the clock reads earlier than a recorded decision.
- DAOs: `EpisodeIdentityDao` (alias and group-state reads and writes, `upsertGroupStates`, `getGroupStates`, `completedGroupStates`, `deleteGroupState`, `deleteAllGroupStates`, `sharedTrustedAliases` / `sharedTrustedAliasesFlow`, `chooseOwner` (the upgrade's owner order, read at runtime), `setOwner`, `holdsListenerState`, and `allAliases` / `allGroupStates` for the JSON library backup's identity block; when ownership may move and which states resolve a lookup belong to `EpisodeListenerStore`), `UpgradeRepairDao` (pending shows, per-show progress, `hasShows` for whether the upgrade seeded any, and `showCounts` (`UpgradeRepairShowCounts`, a query result) for the upgrade report), `EpisodeIdentityDao.knownRecipeIds` (analytics: an ID's own recipe ID or a trusted keyed alias's, never a guess; `EpisodeRecipeIdReadsTest`), `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `PodcastDao.upsert` and `LocalEpisodeCatalogDao.upsertFeed` keep a stored row's Room v39 columns (see Version 39 below). `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `ListeningHistoryDao.getHistoryItems`, `setLikeStatusForIds` and `setCompletionForIds` and `DownloadedEpisodeDao.getCompletedDownloadIds`, `getDownloads`, `countCompletedFlow` and `countActiveFlow` read and write an episode's rows under all its IDs. `PodcastDao.observeSwitchedRssShows` watches the followed RSS shows that replaced a Podcast Index show the listener no longer follows. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `ListeningHistoryDao.getDirtyListeningHistoryBatch` reads one push batch: up to the given number of unsynced rows, most recently played first, without episode descriptions, so a large backlog is never read in full. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL found on the device and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. App code replaces a saved address only through `PodcastDao.replaceFeedAddress(FeedAddressWrite)` (P4 PR 14, used by `:core:catalog`'s `FeedAddressUpdater`): one transaction that writes `feedUrl` and `transportUrl`, keeps the old address in `previousFeedUrl`, records `feedUrlSource` and `feedUrlCheckedAt`, clears the show's ETag and Last-Modified on its row, its `local_episode_feeds` row and its `episode_supplements` row, and marks the row dirty unless the address came from the cloud. It is a compare-and-set on the address the caller read (`feedUrl IS :expected`) and never touches `rss:` rows. `PodcastDao.markFeedUrlChecked` stamps the daily address check without marking the row dirty. No schema change. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`); `findOwners` reports which (show, key) holds each episode ID so writers check before `upsertEpisodes` replaces a row. `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. `LocalEpisodeCatalogDao.previousFeedUrl` reads `podcasts.previousFeedUrl`, so a catalog read still heals legacy rows minted under the address the saved one replaced (own-IDs P4 PR 14).
- `EpisodeSupplementDao.replaceAll` / `upsertSupplementAndOptionalItems` keep validator rows and tip items in one Room transaction.
- `LocalEpisodeCatalogDao` writes the v39 feed-first columns on their own (P4 PR 16; no schema change): `setArchiveComplete`, `setLastOpenedAt`, and `setTtlAndFollowed`, which follow and unfollow use so `followed` stays in step with the unsubscribe TTL. `listUnfollowedCopies` (`UnfollowedCatalogCopy`: last opened, last read, episode count) lists the copies the show page's cleanup may remove: followed by neither `local_episode_feeds.followed` nor `podcasts.isSubscribed`, and without an unsubscribe TTL. `deleteCatalogIfUnfollowed` checks that again in its transaction and deletes only the show's `local_episodes` and `local_episode_feeds` rows. `seedFollowed` sets a new feed row's `followed` from `podcasts.isSubscribed` (a show followed before its first read), and `searchLimited` is `search` with a `LIMIT` for the show page.
- `LocalEpisodeCatalogDao` writes the v39 feed-first columns on their own (P4 PR 16; no schema change): `setArchiveComplete`, `setLastOpenedAt`, and `setTtlAndFollowed`, which follow and unfollow use so `followed` stays in step with the unsubscribe TTL. `listUnfollowedCopies` (`UnfollowedCatalogCopy`: last opened, last read, episode count) lists the copies the show page's cleanup may remove: followed by neither `local_episode_feeds.followed` nor `podcasts.isSubscribed`, and without an unsubscribe TTL. `deleteCatalogIfUnfollowed` checks that again in its transaction and deletes only the show's `local_episodes` and `local_episode_feeds` rows. `listClearableCopyIds` / `deleteCatalogIfClearable` (P4 PR 20) are the listener's manual clear: the same two records, but with or without an unsubscribe TTL, so a recently unfollowed show goes too, and again checked inside the delete's transaction. `seedFollowed` sets a new feed row's `followed` from `podcasts.isSubscribed` (a show followed before its first read), and `searchLimited` is `search` with a `LIMIT` for the show page.
- `QueueDao.bumpQueueVersion` keeps `queue_metadata.recentRemovedEpisodes` (a JSON list of `QueueRemovalIdentity`: ID, recipe ID, show and catalog key, written with hand-named fields) in step with the plain `recentRemovedEpisodeIds`. A removal adds one entry per removed ID, with the episode's identity when the caller passes it; restoring the same episode under any ID (same ID, or same show and key) drops its entry, and a removal of an episode that stays queued under another ID isn't recorded there. Both lists keep the newest 50. `QueueDao.richRemovalsWithin` keeps the rich list in step after a pulled queue state replaced the plain list: only entries the plain list still holds and whose episode isn't queued again. Cloud sync pushes the rich list with the queue and pulls the server's (`:core:catalog` `sync.QueueSyncResolver`).
- `PodcastScoring.calculateScores` takes an optional `episodeKey`, so `:core:ranking` can count an episode stored under several IDs once; the default keeps each ID its own episode. `ScorablePodcast.listenerShowIds` (filled by `toScorable` from `Podcast.listenerShowIds` and `PodcastEntity.listenerShowIds`) lets an RSS show the listener switched to from a Podcast Index show count that show's history, unless that show is scored itself. `sortedByListening` orders shows for Most listened the same way.
- `ListeningInsightsMaintenance` for rolling session/rollup maintenance (via `BoxLoreDatabase.listeningInsightsMaintenance()`).
Expand Down
Loading
Loading