diff --git a/app/README.md b/app/README.md index 7384f26cb..ca411f3c8 100644 --- a/app/README.md +++ b/app/README.md @@ -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. diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index a5bbddaf0..5ece5077c 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -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( @@ -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), discoverySections = settingsState.discoverySections, onSetDiscoverySection = { section, on -> scope.launch { @@ -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() }, +) diff --git a/core/database/README.md b/core/database/README.md index 822b1067f..eecc34436 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -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()`). diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt index 0c8906e07..3266555fb 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeCatalogDao.kt @@ -306,6 +306,44 @@ interface LocalEpisodeCatalogDao { ) suspend fun isUnfollowedCopy(podcastId: String): Boolean + /** + * Stored copies the listener's manual clear removes (Settings › Clear saved shows): followed by + * neither record, with or without an unsubscribe TTL, so a show unfollowed recently goes too. + * + * Added in P4 PR 20 of the own-episode-IDs plan (plan section 11, decision D10). + */ + @Query( + """ + SELECT f.podcastId FROM local_episode_feeds f + WHERE f.followed = 0 + AND NOT EXISTS (SELECT 1 FROM podcasts p WHERE p.podcastId = f.podcastId AND p.isSubscribed = 1) + """, + ) + suspend fun listClearableCopyIds(): List + + /** + * Deletes [podcastId]'s stored copy when it is still one [listClearableCopyIds] lists, checked + * again inside the transaction so a follow that lands meanwhile keeps it. P4 PR 20. + */ + @Transaction + suspend fun deleteCatalogIfClearable(podcastId: String): Boolean { + if (!isClearableCopy(podcastId)) return false + deleteCatalog(podcastId) + return true + } + + /** True when [podcastId]'s stored copy is one [listClearableCopyIds] lists. */ + @Query( + """ + SELECT EXISTS( + SELECT 1 FROM local_episode_feeds f + WHERE f.podcastId = :podcastId AND f.followed = 0 + AND NOT EXISTS (SELECT 1 FROM podcasts p WHERE p.podcastId = f.podcastId AND p.isSubscribed = 1) + ) + """, + ) + suspend fun isClearableCopy(podcastId: String): Boolean + @Query("DELETE FROM local_episodes WHERE podcastId = :podcastId") suspend fun deleteEpisodes(podcastId: String) diff --git a/core/domain/README.md b/core/domain/README.md index f6aaf9233..94c244e72 100644 --- a/core/domain/README.md +++ b/core/domain/README.md @@ -19,7 +19,7 @@ Owns thin domain ports and small result types used by ViewModels, repositories, - `ConnectivityStatusPort` and connectivity status types. - `DeviceIdentityPort` for cross-device sync attribution. - `FeedTelemetryPort` reports feed reads (`feedFetched`) and saved-address swaps (`feedUrlCorrected`) without a dependency on `:core:analytics`; `AppContainer` wires the analytics-backed one and `NONE` reports nothing. Implementations must not throw. -- `ShowPageFeedPort` is the feed-first show page's stored feed copy of a Podcast Index show, followed or not (P4 PR 16): `cached` (episode count, whether it is the whole archive, when it was read, oldest-first), `read` (`NEWEST` or `ARCHIVE` of an HTTPS address; `Stored`, `NotModified` or `Failed` with the `/v2/feed-report` status, never a throw), `readTransport` (an http-only feed through boxlore's transport: the caller's fetch gets the copy's ETag and answers `Transported` `Document`, `NotModified`, `ReadDirect` or `Failed`; it runs under the read's lock and download gate), `search` (bounded in the query), `markOpened` and `cleanUp` (unfollowed copies not opened in 30 days, then a size cap; never history, downloads, the queue or aliases). Pages of the copy go through `LocalEpisodeCatalogPort.getPage`. `:core:rss` implements it (`LocalEpisodeCatalogRepository.showPages`). +- `ShowPageFeedPort` is the feed-first show page's stored feed copy of a Podcast Index show, followed or not (P4 PR 16): `cached` (episode count, whether it is the whole archive, when it was read, oldest-first), `read` (`NEWEST` or `ARCHIVE` of an HTTPS address; `Stored`, `NotModified` or `Failed` with the `/v2/feed-report` status, never a throw), `readTransport` (an http-only feed through boxlore's transport: the caller's fetch gets the copy's ETag and answers `Transported` `Document`, `NotModified`, `ReadDirect` or `Failed`; it runs under the read's lock and download gate), `search` (bounded in the query), `markOpened` and `cleanUp` (unfollowed copies not opened in 30 days, then a size cap; never history, downloads, the queue or aliases), and since P4 PR 20 `unfollowedCount` / `clearUnfollowed` for Settings › Clear saved shows (every unfollowed copy, whatever its age and including one still under its unsubscribe TTL; the same rows as `cleanUp`, and a show followed meanwhile keeps its copy; both default to 0). Pages of the copy go through `LocalEpisodeCatalogPort.getPage`. `:core:rss` implements it (`LocalEpisodeCatalogRepository.showPages`). - `LocalEpisodeCatalogPort.RefreshRequest.reason` defaults to `NORMAL`; automatic refresh and `AUTO_DOWNLOAD` share the persisted six-hour freshness gate. `MANUAL` and `NEW_RELEASE` bypass quiet/HEAD shortcuts. `canProceed` rechecks authorization before network/persistence, `runPostPersistCallback` prevents background metadata recovery from invoking ungated ingest callbacks, and `isRefreshDue` supports bounded foreground batches. `RefreshOutcome.Failure.addressGone` says the saved address looks dead rather than temporarily failing, and `RefreshOutcome.Failure.reportStatus` says what the read saw in the words `/v2/feed-report` takes (`http_404`, `timeout`, `dns`, `tls`, `parse`, `network`, `other`; null when no read happened); with `RefreshRequest.detectMove` (and a `PodcastMeta.title` to compare), `RefreshOutcome.Success.movedTo` carries a confirmed new HTTPS address. `RefreshRequest.serverEpNs` passes the episode namespace the server reported for the show; it is only compared with the stored one and never changes an ID. Publisher RSS and Room remain the source of subscribed episodes. diff --git a/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt index 024ca106c..539063bfc 100644 --- a/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt +++ b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt @@ -114,4 +114,18 @@ interface ShowPageFeedPort { * shows were removed. */ suspend fun cleanUp(nowMillis: Long, keepPodcastId: String? = null): Int + + /** + * How many unfollowed shows have a saved copy (Settings › Clear saved shows), including shows + * unfollowed recently whose copy still waits for its unsubscribe TTL. P4 PR 20. + */ + suspend fun unfollowedCount(): Int = 0 + + /** + * Removes every unfollowed show's saved copy, whatever its age or unsubscribe TTL (Settings › + * Clear saved shows). A show followed meanwhile keeps its copy. + * The same rows as [cleanUp]: listening history, downloads, the queue and episode aliases stay. + * Returns how many shows were removed. P4 PR 20 (decision D10). + */ + suspend fun clearUnfollowed(): Int = 0 } diff --git a/core/rss/README.md b/core/rss/README.md index 290b9cbbc..07d99c13c 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -16,6 +16,7 @@ Failed automatic fetches record a six-hour retry boundary while keeping catalogs - `RssPodcastRepository.legacySubscriptionRepair` exposes `LegacyRssSubscriptionRepair`. `inspect` returns a `LegacyRssFeedSnapshot` without mutating Room; `upgrade` returns a `LegacyRssUpgradeOutcome` and atomically copies a verified RSS catalog under a Podcast Index id without re-keying episodes, and records a trusted alias (`rss_follow_upgrade`) for every copied ID whose key follows the identity contract, so the IDs survive eviction. The copied IDs own their episodes: a trusted owner of the same (show, key) under another ID, left by an earlier read of the show's catalog, is demoted in the same transaction and stays trusted, so a read after eviction comes back under the copied IDs that hold the listener state. When such an owner's ID holds history, a download or a queue entry under any show, the upgrade writes nothing and returns `TARGET_ALREADY_IN_USE`. Both share the per-podcast refresh lock with `refreshCatalog`. - `LocalEpisodeCatalogRepository` implements `LocalEpisodeCatalogPort`: full publisher-feed catalog under a Podcast Index id, sticky upsert by guid, and a structural per-show ready gate (complete persisted count; a non-null unsubscribe `ttlExpiresAt` is not ready so unsubscribed reads stay on Podcast Index). `getWindow` with `aroundEpisodeId` uses `continuationRows` (`getEpisodesAfter`) to fetch the anchor and subsequent forward chronological episodes. A not-ready catalog always bypasses HEAD validators and performs the repair GET. Listener-id or cached PI-tip mismatches never permanently hide a successfully persisted publisher feed. HEAD/quiet skip and mega GET (capped at 2) remain for ready catalogs. It never creates `rss:` rows; incremental upsert never shrinks existing rows. HEAD validators are not reused after the publisher `feedUrl` changes. `sweepExpired` rechecks TTL before delete so a refresh that cleared `ttlExpiresAt` is kept. `:core:catalog` `SubscriptionForegroundSync` runs that sweep on each launch / Subscriptions refresh pass. - `LocalEpisodeCatalogRepository.showPages` implements `ShowPageFeedPort` (`ShowPageFeedCatalog`) for the feed-first show page: a stored copy of a Podcast Index show's feed, followed or not, in the same `local_episodes` rows and through the same write (sticky IDs, recipe IDs for new items, aliases, the collision guard) under the same per-show lock. A first read takes the newest 100 items with `RssFeedClient.fetchNewest`, a streaming read that stops early (`FeedPrefixReader`, RSS `` or Atom ``, cut documents closed so they parse); the archive read takes the whole feed. `FeedRowPolicy` records `archiveComplete` apart from `ready`: a show-page read never makes a copy the followed live list and never runs the post-write work (auto-download, delivery). An oldest-first feed (classified in document order) is always read whole. `ShowPageFeedRules`: the newest read sends the copy's validators; an archive read of a partial copy goes out unconditional, so a 304 that covers only the stored prefix is never taken as the archive; a copy older than an hour is read again; cleanup removes unfollowed copies not opened in 30 days, then the least recently opened over 40 shows or 20,000 episodes. A document read through boxlore's transport (`readTransport`, with the copy's ETag, under the download gate) is parsed under one fixed namespace per show, and the row never records the transport or http address. A show-page write keeps the stored row's unsubscribe TTL and `ready`, read again inside its transaction, so a recently unfollowed show keeps its cheap resubscribe and TTL sweep and an unfollow during the read stays. A newest read that meets no stored item leaves a gap, so `archiveComplete` goes back to false and the next archive read goes out without validators. A new feed row takes `followed` from `podcasts.isSubscribed`. In-show search of a copy is limited in the query (`searchLimited`). `RssFeedClient.readFeedBody` is the 25 MB cap every feed read keeps (`FeedTooLargeException`), also for transport documents. Followed shows' refreshes now also set `archiveComplete`. P4 PR 16. +- `ShowPageFeedCatalog.unfollowedCount` / `clearUnfollowed` (P4 PR 20): the Settings count and "Clear saved shows", which removes every unfollowed show's saved copy whatever its age, including a recently unfollowed show still under its unsubscribe TTL that `cleanUp` leaves to the TTL sweep (`listClearableCopyIds` / `deleteCatalogIfClearable`: history, downloads, queue and aliases stay; a show followed meanwhile keeps its copy). Tests: `ShowPageFeedCatalogTest.clearingSavedShowsRemovesEveryUnfollowedCopyWhateverItsAgeAndKeepsListenerState`, `clearingSavedShowsAlsoRemovesARecentlyUnfollowedShowStillUnderItsTtl`, `clearingSavedShowsKeepsAShowFollowedAgain`. - `StickyEpisodeIdentity` / `LocalEpisodeCatalogPersist` / `FeedOrderLogic` / `LocalCatalogReadyLogic` / `LocalCatalogOrphanRematch` are the identity and freshness helpers. `LocalCatalogListenerStateReconciler` (the orphan rematch) safely re-keys orphaned negative restore ids after a full persist (unique enclosure, then unique title within 48 hours measured in epoch seconds), merging history, sessions/rollups, and queue state into the chosen catalog id. It moves only unresolved legacy negative IDs, after consulting aliases. An ID that a catalog row of any show still holds (an older duplicate row, a changed GUID, an item the feed dropped) or whose alias is trusted or ambiguous (an evicted row's ID, for example) is a known episode, not an orphan, and never moves. An orphan that a new feed item provably owns is never re-keyed either: the read already gave that item the orphan's ID (`ListenerIdAdoption`, below). When a catalog row now holds a trusted orphan's (show, key) under another ID (its row was evicted and the feed read again, for example with a Podcast Index match), the row's ID joins that episode as a trusted alias (`EpisodeAliasProvenance.CATALOG_KEY_MATCH`), so both IDs read as one episode and nothing moves. Only phone-minted IDs with no alias or an unresolved one, whose episode already has a stored row or that match no new item uniquely, are rematched and moved; their history merges into the row the catalog row's episode reads (its owner when the episode is stored under several IDs); merged into another ID's row, it keeps that row's show and episode details, so an owner row of a show switched to its feed stays under that show. The merged like is the latest like decision of the two rows and the episode's group settings (`EpisodeListenerProjection.likeDecision`), so an older like can't undo a later unlike; completion is kept from either row unless the group settings cleared it later. When the match was exact (its enclosure address names one catalog row) and the catalog row has a trusted alias, the moved ID becomes a trusted alias of that episode (`EpisodeAliasProvenance.ORPHAN_REMATCH`), so a later copy of it counts as the same episode and isn't rematched again; a title and date match records no alias. Completed downloads move only when `DownloadCacheRelinker` confirms cached bytes reached the new key; otherwise the legacy row/cache is retained. Ambiguous or metadata-poor rows are retained rather than guessed and no longer block source cutover. - `ListenerStateMoves` holds the rules for the one path that still moves listener state between stored IDs, the orphan rematch (which IDs are settled, the catalog row that joins a trusted orphan's episode by key, the alias a moved ID gets, the merged completion), and the links that replace the former move when a Podcast Index show switches to its RSS feed (`switchedShowLinks`, `linkWrite`). `SwitchedShowLinker` writes those links. `RssPodcastRepository.confirmPodcastIndexLink`, and adding the feed of a followed Podcast Index show by its address, switch the show without moving anything: history, likes, completion, the queue, downloads, sessions and rollups stay under their stored IDs and show, and no download cache is relinked. Instead, each RSS episode that matches an episode those rows hold gets an alias under the Podcast Index show (`EpisodeAliasProvenance.RSS_SOURCE_LINK`), recorded as a non-owner; ownership then follows the owner rule like any trusted ID. The link is trusted, so the RSS episode reads the old IDs' projected state, its progress goes to the episode's owner and offline playback uses the ID that holds the completed download, when the old ID's trusted key is the RSS episode's key and no other RSS episode has it (for a show the listener confirmed as a lookalike, without the same feed address or podcast GUID, only with the same audio address or title on one of the old ID's rows as well), or when an old ID with no alias or an unresolved one names exactly one RSS episode with a GUID by its exact audio address and no other old ID points at it: that old ID is settled first, trusted under the Podcast Index show with the episode's key (`EpisodeAliasProvenance.RSS_SOURCE_AUDIO_MATCH`, the owner only when the episode has none). Any weaker match (title or date only, a key several RSS episodes share) is ambiguous; a weaker match from another old ID never undoes an exact one. An old ID whose alias is ambiguous links nothing, and an RSS episode whose ID the show's rows already hold gets no alias. Linking is idempotent and runs again after a library backup restore (`RssPodcastRepository.relinkSwitchedShow`), when the RSS show is followed again or restored with its link (`restoreSubscription`), and after every refresh of its feed (`refreshCatalog`, `refreshCatalogIfNeeded`, and each persisted read of `RssEpisodeCatalog`): it adds links for rows that arrived since and turns an ambiguous `rss_source_link` alias trusted once exact evidence exists, never downgrading a trusted alias or touching another alias. A Podcast Index show's namespace for the aliases' recipe IDs is its catalog's stored `epNs`, then the show's (decision D12). Every ID stays as it is, so following the Podcast Index show again keeps everything; its catalog read never reuses a linked RSS ID (`NewEpisodeIds` skips `rss_source_link` aliases). - `RssIdGenerator` creates deterministic `rss:` podcast IDs and negative episode IDs (mint only, not refresh). diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt index 9bced9221..5dcb2463f 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt @@ -117,6 +117,14 @@ internal class ShowPageFeedCatalog( victims.count { podcastId -> lockFor(podcastId).withLock { deps.dao.deleteCatalogIfUnfollowed(podcastId) } } } + override suspend fun unfollowedCount(): Int = withContext(Dispatchers.IO) { deps.dao.listClearableCopyIds().size } + + override suspend fun clearUnfollowed(): Int = withContext(Dispatchers.IO) { + // Unlike cleanUp, a recently unfollowed show under its TTL goes too. Each delete checks again + // that the show is unfollowed, so a show followed meanwhile keeps its copy. + deps.dao.listClearableCopyIds().count { id -> lockFor(id).withLock { deps.dao.deleteCatalogIfClearable(id) } } + } + private class Fetched(val result: RssFetchResult, val complete: Boolean) /** The read, or null for a 304 that answers it. */ diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt index bd53909fc..722ff9781 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepositoryTest.kt @@ -838,6 +838,10 @@ class LocalEpisodeCatalogRepositoryTest { override suspend fun isUnfollowedCopy(podcastId: String): Boolean = feeds[podcastId]?.let { !it.followed && it.ttlExpiresAt == null } == true + override suspend fun listClearableCopyIds(): List = feeds.values.filter { !it.followed }.map { it.podcastId } + + override suspend fun isClearableCopy(podcastId: String): Boolean = feeds[podcastId]?.followed == false + override suspend fun setEpNsIfUnset(podcastId: String, epNs: Long,) { feeds[podcastId]?.takeIf { it.epNs == null }?.let { feeds[podcastId] = it.copy(epNs = epNs) } } diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt index 2a431598f..b8e975157 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt @@ -244,6 +244,54 @@ class ShowPageFeedCatalogTest : RoomCatalogTestBase() { assertEquals("aliases outlive the copy", aliasesBefore, database.episodeIdentityDao().aliasesForShow(SHOW).size) } + @Test + fun clearingSavedShowsRemovesEveryUnfollowedCopyWhateverItsAgeAndKeepsListenerState() = runBlocking { + document = feedXml(5) + pages.read(request(Scope.ARCHIVE)) + pages.read(request(Scope.ARCHIVE, show = OTHER_SHOW)) + pages.read(request(Scope.ARCHIVE, show = THIRD_SHOW)) + follow(THIRD_SHOW) + val played = rows(SHOW).getValue("g1") + history(played, SHOW) + completedDownload(played, SHOW, "Ep 1") + queue(played, SHOW, "https://cdn.example/g1.mp3", "Ep 1") + assertEquals(2, pages.unfollowedCount()) + + assertEquals("both unfollowed copies go, though opened just now", 2, pages.clearUnfollowed()) + + assertNull(pages.cached(SHOW)) + assertNull(pages.cached(OTHER_SHOW)) + assertNotNull("a followed show's catalog stays", pages.cached(THIRD_SHOW)) + assertNotNull(database.listeningHistoryDao().getHistoryItem(played)) + assertNotNull(database.downloadedEpisodeDao().getDownload(played)) + assertNotNull(database.queueDao().getQueueItemByEpisodeId(played)) + assertEquals(0, pages.unfollowedCount()) + assertEquals(0, pages.clearUnfollowed()) + } + + @Test + fun clearingSavedShowsAlsoRemovesARecentlyUnfollowedShowStillUnderItsTtl() = runBlocking { + pages.read(request(Scope.NEWEST)) + catalog.setUnsubscribedTtl(SHOW, Long.MAX_VALUE / 2) + database.podcastDao().upsert(PodcastEntity(podcastId = SHOW, title = "Show", author = "", imageUrl = "", description = null, isSubscribed = false)) + assertEquals("the automatic cleanup leaves it to the TTL sweep", 0, pages.cleanUp(Long.MAX_VALUE / 4)) + + assertEquals(1, pages.unfollowedCount()) + assertEquals(1, pages.clearUnfollowed()) + assertNull(pages.cached(SHOW)) + } + + @Test + fun clearingSavedShowsKeepsAShowFollowedAgain() = runBlocking { + pages.read(request(Scope.NEWEST)) + catalog.setUnsubscribedTtl(SHOW, 5L) + catalog.setUnsubscribedTtl(SHOW, null) + + assertEquals(0, pages.unfollowedCount()) + assertEquals(0, pages.clearUnfollowed()) + assertNotNull(pages.cached(SHOW)) + } + @Test fun followAndUnfollowWriteTheFollowedColumn() = runBlocking { pages.read(request(Scope.NEWEST)) diff --git a/feature/settings/README.md b/feature/settings/README.md index 8136b8ac1..19552671c 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -149,6 +149,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ ## Persistence & identity - **Library → Charts and Recommendations** (29 Oct, D9): one thing per row. Charts › Country opens `ContentRegionPickerSheet`; Recommendations › Languages (summary such as "English, Hindi", `languagesSummary`) opens `ContentLanguagesSheet` with "Include English". Both sheets' open state is `rememberSaveable`, so an open sheet stays through rotation, folds, resizes and recreation (`LibraryLanguagesAdaptiveTest`: phone, tablet single pane, split, the short-height gate, both ways). `RegionSettings` / `LibraryDiscoveryPreferences` carry `englishOff` and `onSetEnglishOff`; analytics `content_english_off_changed`. Manual acceptance: on a phone and a tablet, pick India → Languages shows "Stays on with Hindi"; pick Germany, German only → switch English off → Home, search and vibes show German shows (English ones only where German runs thin); fold or rotate with the sheet open and it stays. +- **Library → Storage → Clear saved shows** (P4 PR 20, D10): the row counts unfollowed shows with saved episode lists ("Episode lists of 12 shows you don’t follow", "Nothing saved…" opens nothing) through `SavedShowsControl` (`RegionSettings.savedShows`, wired in `:app` to `ShowPageFeedPort.unfollowedCount` / `clearUnfollowed`); a confirmation (`ClearSavedShowsDialog`, test tags `settings_clear_saved_shows_*`) says history, downloads and queue stay. The open confirmation saves the count it opened with (`rememberSaveable`), so after recreation it still names that count instead of 0 while the row counts again (`ClearSavedShowsAdaptiveTest`: phone, tablet, split and short windows, recreation, one clear per confirm); analytics `saved_shows_cleared`. Manual acceptance: open two shows you don't follow, then Library › Storage shows 2; clear on a phone and a tablet; the row reads "Nothing saved…", the shows load again when opened, and your history and downloads are unchanged. - **Library → LeaveMeAlone** (`ProfileSettingsDestination.Recommendations`, titled LeaveMeAlone, parent Library, deep link `recommendations`) holds every switchable section: On Home (The Brief, Picked for you, Because you like, Time-of-day picks) and On episode pages (Try these episodes). Library opens it from its own card below Discovery (subtext "Turn off recommendations across the app", also used on the Appearance → Home link), and Appearance → Home links to it with `onOpenRecommendationsFromHome`, which records Appearance → Home as the Back target. The page reads `RegionSettings.discoverySections` and saves through `trackedDiscoverySectionSetter`, which sends `recommendation_section_toggled` (source `settings`). In the tablet split it highlights Library and keeps its Back arrow; the rows hold no local state, so resizing shows the stored values. `AppearanceUiState.discoverySections` is read-only: Navigation hides **Explore opens to** while Picked for you is off, since Explore then has only Top. Covered by `AppearancePagesTest`, `AppearanceActionsTrackedTest`, `SettingsBackNavigationTest` and `TabletSettingsLayoutTest`. - Settings read and write DataStore and `BoxcastPrefs` through `:core:prefs` APIs. - The Support world assignment comes from the PostHog flag `support-world` (variants `control`, `neon`, `orbit`, `alchemy`, `abyss`, `sky`). Nothing about it is persisted locally; the debug override lives only in memory. diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index f00cb58f0..f9f7c89c5 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -98,6 +98,8 @@ data class RegionSettings( /** The listener switched English off (D9); [contentLanguages] already leaves it out when that holds. */ val englishOff: Boolean = false, val onSetEnglishOff: (Boolean) -> Unit = {}, + /** Saved episode lists of unfollowed shows (Library › Storage, P4 PR 20). */ + val savedShows: cx.aswin.boxlore.feature.settings.pages.SavedShowsControl = cx.aswin.boxlore.feature.settings.pages.SavedShowsControl(), val discoverySections: DiscoverySections = DiscoverySections(), val onSetDiscoverySection: (DiscoverySection, Boolean) -> Unit = { _, _ -> }, ) @@ -551,6 +553,14 @@ private fun createLibraryDiscoveryPreferences( regionSettings.onSetContentLanguages(it) }, englishOff = regionSettings.englishOff, + savedShows = + regionSettings.savedShows.copy( + clear = { + regionSettings.savedShows.clear().also { cleared -> + AnalyticsHelper.trackSettingsInteraction("saved_shows_cleared", cleared.toString()) + } + }, + ), onSetEnglishOff = { AnalyticsHelper.trackSettingsInteraction("content_english_off_changed", it.toString()) regionSettings.onSetEnglishOff(it) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/dialogs/ClearSavedShowsDialog.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/dialogs/ClearSavedShowsDialog.kt new file mode 100644 index 000000000..a347f2579 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/dialogs/ClearSavedShowsDialog.kt @@ -0,0 +1,65 @@ +package cx.aswin.boxlore.feature.settings.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.layout.tabletSheetScroll + +object SettingsClearSavedShowsTestTags { + const val CONFIRM = "settings_clear_saved_shows_confirm" + const val CANCEL = "settings_clear_saved_shows_cancel" +} + +/** + * Confirms clearing the saved episode lists of shows the listener opened but doesn't follow + * (Library settings › Storage, P4 PR 20). Says what goes, that it comes back on the next open, and + * what stays. + */ +@Composable +internal fun ClearSavedShowsDialog( + count: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(imageVector = Icons.Rounded.DeleteSweep, contentDescription = null) }, + title = { Text("Clear saved shows?") }, + text = { + Column(Modifier.tabletSheetScroll(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "${savedShowsLabel(count)} you don’t follow will be removed from this phone. They load again when you open a show.", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "Shows you follow, your listening history, downloads and queue aren’t affected.", + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + confirmButton = { + Button(onClick = onConfirm, modifier = Modifier.testTag(SettingsClearSavedShowsTestTags.CONFIRM)) { + Text("Clear") + } + }, + dismissButton = { + TextButton(onClick = onDismiss, modifier = Modifier.testTag(SettingsClearSavedShowsTestTags.CANCEL)) { + Text("Cancel") + } + }, + ) +} + +/** "Episode lists of 1 show" / "Episode lists of 12 shows". */ +internal fun savedShowsLabel(count: Int): String = if (count == 1) "Episode lists of 1 show" else "Episode lists of $count shows" diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt index 2b0538ce6..9046b0ea8 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.DeleteSweep import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.Public @@ -23,7 +24,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -36,10 +41,14 @@ import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable import cx.aswin.boxlore.core.model.ContentRegions import cx.aswin.boxlore.feature.settings.R +import cx.aswin.boxlore.feature.settings.components.SettingsActionRow import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup import cx.aswin.boxlore.feature.settings.components.SettingsNavigationRow import cx.aswin.boxlore.feature.settings.components.SettingsScaffold +import cx.aswin.boxlore.feature.settings.dialogs.ClearSavedShowsDialog +import cx.aswin.boxlore.feature.settings.dialogs.savedShowsLabel +import kotlinx.coroutines.launch /** * Discovery region and language preferences for [LibrarySettingsPage]. @@ -53,8 +62,13 @@ data class LibraryDiscoveryPreferences( /** The listener's "Include English" choice, inverted (D9). */ val englishOff: Boolean = false, val onSetEnglishOff: (Boolean) -> Unit = {}, + /** Saved episode lists of shows the listener opened but doesn't follow (P4 PR 20). */ + val savedShows: SavedShowsControl = SavedShowsControl(), ) +/** Counts and clears the saved copies of unfollowed shows (`ShowPageFeedPort.unfollowedCount` / `clearUnfollowed`). */ +data class SavedShowsControl(val count: suspend () -> Int = { 0 }, val clear: suspend () -> Int = { 0 },) + @Composable internal fun LibrarySettingsPage( discoveryPreferences: LibraryDiscoveryPreferences, @@ -128,6 +142,7 @@ internal fun LibrarySettingsPage( }, ) } + SavedShowsGroup(discoveryPreferences.savedShows, onOpen = collapseCountryFaq) } if (showCountrySheet) { @@ -256,3 +271,49 @@ private fun FaqParagraph(text: String) { /** The Languages row's summary: the languages in effect, as the listener reads them ("English, Hindi"). */ internal fun languagesSummary(languages: List): String = languages.mapNotNull { ContentRegions.LANGUAGE_LABELS[it] }.joinToString(", ") + +/** The Clear saved shows row's summary: counting, nothing saved, or how many shows have saved lists. */ +internal fun savedShowsSupportingText(count: Int?): String = when (count) { + null -> "Counting…" + 0 -> "Nothing saved for shows you don’t follow" + else -> "${savedShowsLabel(count)} you don’t follow" +} + +/** + * Library settings › Storage › Clear saved shows (P4 PR 20): the count of unfollowed shows with + * saved episode lists, and a confirmation before they are cleared. The open confirmation is + * saveable, so it stays through window changes and recreation. + */ +@Composable +private fun SavedShowsGroup(control: SavedShowsControl, onOpen: () -> Unit) { + // The count the open confirmation shows, saved so it survives recreation; null while closed. + var confirming by rememberSaveable { mutableStateOf(null) } + // Counted again after each clear; null while counting. + var reads by remember { mutableIntStateOf(0) } + val count by produceState(initialValue = null, reads) { value = control.count() } + val scope = rememberCoroutineScope() + SettingsGroup(title = "Storage") { + SettingsActionRow( + title = "Clear saved shows", + supportingText = savedShowsSupportingText(count), + icon = Icons.Rounded.DeleteSweep, + onClick = { + onOpen() + count?.takeIf { it > 0 }?.let { confirming = it } + }, + ) + } + confirming?.let { shown -> + ClearSavedShowsDialog( + count = shown, + onConfirm = { + confirming = null + scope.launch { + control.clear() + reads++ + } + }, + onDismiss = { confirming = null }, + ) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/ClearSavedShowsAdaptiveTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/ClearSavedShowsAdaptiveTest.kt new file mode 100644 index 000000000..f46344e54 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/ClearSavedShowsAdaptiveTest.kt @@ -0,0 +1,119 @@ +package cx.aswin.boxlore.feature.settings.pages + +import android.app.Application +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.feature.settings.dialogs.SettingsClearSavedShowsTestTags +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Library settings › Storage › Clear saved shows (P4 PR 20): the count, the confirmation, the clear + * and the count read again, and an open confirmation staying put (with its callbacks) through phone, + * tablet, split and short windows and recreation. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w1300dp-h1300dp-mdpi", application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ClearSavedShowsAdaptiveTest { + @get:Rule val compose = createComposeRule() + private val window = mutableStateOf(BoxloreWindowLayout(360f, 740f)) + private var saved = 12 + private var clears = 0 + + @Composable + private fun page() { + BoxLoreTheme(dynamicColor = false) { + CompositionLocalProvider(LocalBoxloreWindowLayout provides window.value) { + LibrarySettingsPage( + discoveryPreferences = + LibraryDiscoveryPreferences( + currentRegion = "in", + contentLanguages = listOf("en", "hi"), + onSetRegion = {}, + onSetContentLanguages = {}, + savedShows = + SavedShowsControl( + count = { saved }, + clear = { + clears++ + saved.also { saved = 0 } + }, + ), + ), + onAddRssClick = {}, + onBack = {}, + ) + } + } + } + + @Test + fun `confirming clears once and the row reads the new count`() { + compose.setContent { page() } + compose.onNodeWithText("Episode lists of 12 shows you don’t follow").performScrollTo().performClick() + compose.onNodeWithText("Clear saved shows?").assertIsDisplayed() + compose.onNodeWithTag(SettingsClearSavedShowsTestTags.CONFIRM).performClick() + compose.waitForIdle() + assertEquals(1, clears) + compose.onNodeWithText("Nothing saved for shows you don’t follow").performScrollTo().assertIsDisplayed() + } + + @Test + fun `cancel clears nothing and nothing saved opens no dialog`() { + compose.setContent { page() } + compose.onNodeWithText("Episode lists of 12 shows you don’t follow").performScrollTo().performClick() + compose.onNodeWithTag(SettingsClearSavedShowsTestTags.CANCEL).performClick() + assertEquals(0, clears) + compose.onNodeWithText("Clear saved shows?").assertDoesNotExist() + } + + @Test + fun `an open confirmation survives phone, tablet, split and short windows and still clears once`() { + compose.setContent { page() } + compose.onNodeWithText("Episode lists of 12 shows you don’t follow").performScrollTo().performClick() + listOf( + BoxloreWindowLayout(599f, 900f), + BoxloreWindowLayout(600f, 900f), + BoxloreWindowLayout(1280f, 800f), + BoxloreWindowLayout(1280f, 479f), + BoxloreWindowLayout(360f, 740f), + ).forEach { next -> + compose.runOnIdle { window.value = next } + compose.waitForIdle() + compose.onNodeWithText("Clear saved shows?").assertIsDisplayed() + } + compose.onNodeWithTag(SettingsClearSavedShowsTestTags.CONFIRM).performClick() + compose.waitForIdle() + assertEquals(1, clears) + } + + @Test + fun `an open confirmation keeps the count it opened with through recreation`() { + val restoration = StateRestorationTester(compose) + restoration.setContent { page() } + compose.onNodeWithText("Episode lists of 12 shows you don’t follow").performScrollTo().performClick() + saved = 0 + restoration.emulateSavedInstanceStateRestore() + compose.onNodeWithText("Clear saved shows?").assertIsDisplayed() + compose + .onNodeWithText("Episode lists of 12 shows you don’t follow", substring = true) + .assertIsDisplayed() + } +}