diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bbd8f560d..800b896d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,6 +32,7 @@ These values are part of the shipping product. Renames or recreations break upgr | Feed address repair after the update | Unique work name `feed-address-upgrade-repair` (`UpgradeRepairWorker`); step 4 and sync catch-up record `noBackupFilesDir/upgrade_repair_notifications`; per-show progress in Room `upgrade_repair_shows`; never renamed | | BuildConfig | Prefer `BOXLORE_*`; Gradle still dual-reads `BOXCAST_*` fallbacks | | Episode / media IDs | `rss:` prefixes, negative RSS IDs, mediaId prefixes, and `customCacheKey` schemes stay as implemented. PI **episode supplements** also use negative episode IDs (generated under an `rss:` feed namespace) but keep `Episode.podcastId` as the Podcast Index show id — they are not `rss:` library rows. Episode identity (show ID, catalog key), recipe IDs, collisions and aliases follow [`docs/EPISODE_IDS.md`](docs/EPISODE_IDS.md) | +| Renumbered shows | A Podcast Index show ID whose details answer as another show ID is recorded in `boxlore_renumbered_shows`; its follow moves to the new show ID, and no stored episode ID, show namespace or listener row changes ([renumbered shows](docs/EPISODE_IDS.md#renumbered-shows)). `rss:` shows are never renumbered | | Playback instance | One UI-scoped `PlaybackRepository`; routes and workers must not construct a second one | | Object graph order | DB → `PodcastRepository` → `QueueRepository` → `PlaybackRepository` → `QueueManager` → `SmartDownloadManager` | | Smart Queue refill | Owned by `BoxLorePlaybackService` only | @@ -255,6 +256,7 @@ Historical FQCNs and preference file names remain reachable so upgrades from old | — | `boxlore_discovery_last_good` | `PodcastRepository` (`:core:catalog` `RecommendationsLastGood`; last good `/v2` picks per surface and country; do not rename) | | — | `boxlore_feed_reports` | `AppContainer` (`:core:catalog` `SharedPreferencesFeedReportStamps`; last broken-feed report time per show; do not rename) | | — | `boxlore_notification_reregistrations` | `AppContainer` (`:core:catalog` `SharedPreferencesPendingNotificationRegistrations`; shows whose notifications wait to be registered again after an address change; do not rename) | +| — | `boxlore_renumbered_shows` | `AppContainer` (`:core:rss` `SharedPreferencesRenumberedShows`; Podcast Index show IDs that now answer as another show ID, old ID → new ID; follows move to the new show and its catalog reads link the old show's listener rows, see [renumbered shows](docs/EPISODE_IDS.md#renumbered-shows); do not rename) | | `boxcast_api_config` | `boxlore_api_config` | `BoxLoreAppRoot` | | `boxcast_referrer_prefs` | `boxlore_referrer_prefs` | `InstallReferrerManager` | diff --git a/app/README.md b/app/README.md index 11e6573e1..6329fecd7 100644 --- a/app/README.md +++ b/app/README.md @@ -194,6 +194,7 @@ Unacknowledged tablet launches first show an opaque alpha notice with the Home f - The unique work names `episode-share-registration-` (`EpisodeShareRegistrationWorker`), `podcast-notification-registration-` (`fcm/PodcastNotificationRegistrationWorker`) and `feed-address-upgrade-repair` (`sync/UpgradeRepairWorker`) are permanent. - The feed address repair's step 4 record lives in `noBackupFilesDir/upgrade_repair_notifications` (`FileUpgradeNotificationMarker`), so a restore never brings it and the restored phone registers notifications again; stable name. - SharedPreferences file `boxlore_feed_reports` (`SharedPreferencesFeedReportStamps`, opened by `AppContainer`) holds broken-feed report times; stable name. +- SharedPreferences file `boxlore_renumbered_shows` (`SharedPreferencesRenumberedShows`, opened by `AppContainer`) records Podcast Index shows that now answer as another show ID; stable name. `AppContainer` hands it to `LocalEpisodeCatalogRepository` (catalog reads link a renumbered show's listener rows) and `SubscriptionRepository` (`RenumberingWiring`), and `PodcastRepository(onShowRenumbered = …)` moves a follow whenever a details answer names another show ([docs/EPISODE_IDS.md](../docs/EPISODE_IDS.md#renumbered-shows)). - Build config reads `BOXLORE_API_BASE_URL`, `BOXLORE_PUBLIC_KEY`, and the public `BOXLORE_CAST_RECEIVER_ID`, with legacy API/key local-property names still accepted for existing developer environments. - Core modules own Room filenames, DataStore names, SharedPreferences names, ranking identity, RSS IDs, and playback media IDs. - The last known `feed_first` answer lives in `noBackupFilesDir/feed_first_flag` (`1` or `0`), and the v2 kill switches' answers in `noBackupFilesDir/kill_switch_upgrade_repair_off`, `kill_switch_address_check_off` and `kill_switch_v2_discovery_off`, so a restore or device transfer never brings another phone's answers. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 406dce16d..fc2ca0557 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -22,6 +22,7 @@ import cx.aswin.boxlore.core.catalog.LegacyRssRepairActivation import cx.aswin.boxlore.core.catalog.LegacyRssRepairCatalog import cx.aswin.boxlore.core.catalog.LegacyRssRepairRuntime import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.catalog.RenumberingWiring import cx.aswin.boxlore.core.catalog.RolloutSwitches import cx.aswin.boxlore.core.catalog.RoomDiscoveryEpisodeIds import cx.aswin.boxlore.core.catalog.RoomEpisodeAliasReads @@ -68,7 +69,9 @@ import cx.aswin.boxlore.core.ranking.RankingFeedbackRepository import cx.aswin.boxlore.core.ranking.RankingRuntimeControls import cx.aswin.boxlore.core.rss.EpisodeSupplementRepository import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository +import cx.aswin.boxlore.core.rss.RenumberedShows import cx.aswin.boxlore.core.rss.RssPodcastRepository +import cx.aswin.boxlore.core.rss.SharedPreferencesRenumberedShows import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker import cx.aswin.boxlore.fcm.PodcastNotificationRegistrationWorker import cx.aswin.boxlore.share.EpisodeShareRegistrationWorker @@ -171,9 +174,19 @@ class AppContainer( DownloadRepository.relinkDownloadCache(appContext, oldId, newId) }, onCatalogPersisted = { id -> autoDownloadCoordinator.scanCached(id) }, + renumberedShows = renumberedShows, ) } + /** + * Shows Podcast Index renumbered (an old show ID that now answers as another), kept in + * `boxlore_renumbered_shows`: follows move to the new show and catalog reads link the old + * show's listener rows to its episodes. + */ + val renumberedShows: RenumberedShows by lazy { + SharedPreferencesRenumberedShows(appContext.getSharedPreferences(SharedPreferencesRenumberedShows.FILE_NAME, Context.MODE_PRIVATE)) + } + /** * Shared source-aware catalog for automation, foreground sync and push hydration. Every refresh * through it follows a feed that moved for good (P4 PR 14), behind `feed-fix-moved-feed-off`. @@ -242,6 +255,8 @@ class AppContainer( telemetry = feedTelemetry, isFollowed = { id -> database.podcastDao().getPodcast(id)?.isSubscribed == true }, ), + // A show Podcast Index renumbered keeps one follow, under the show it now answers as. + onShowRenumbered = { oldId, answer -> subscriptionRepository.renumberedFollows.adoptAnswer(oldId, answer) }, ) } @@ -425,6 +440,11 @@ class AppContainer( appContext.getSharedPreferences(SharedPreferencesPendingNotificationRegistrations.FILE_NAME, Context.MODE_PRIVATE), ), ), + renumbering = + RenumberingWiring( + shows = renumberedShows, + linkEpisodes = { oldId, newId -> localEpisodeCatalogRepository.linkRenumberedShow(oldId, newId) }, + ), ) } diff --git a/core/catalog/README.md b/core/catalog/README.md index e0be6eac3..bca5f358c 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -12,6 +12,7 @@ Failed consent writes keep activation off, and local notification flags prevent - `SubscribedEpisodeCatalog` routes the shared local catalog contract by podcast ID to the PI or true RSS adapter. Shared negative IDs from a source switch resolve to the active subscription rather than a retired PI compatibility row. Foreground refresh, Home tips, Smart Downloads freshness, auto-download discovery and push hydration support RSS-only shows without PI lookups or required promotion. With a `FeedMoveFollower` (the app passes one behind `feed-fix-moved-feed-off`), every refresh of a Podcast Index show through it (the show page, pushes, auto-download, restores, the ingest after a follow) checks for a feed that moved for good (permanent redirects or `itunes:new-feed-url`, same title; the stored title when the caller sent none) and saves the move through the address writer below. Requests that ask for move detection themselves (the background pass) are left to their caller. - **Saved feed addresses (one writer).** `FeedAddressUpdater` (`SubscriptionRepository.feedAddresses`, `updateFeedAddress(FeedAddressChange)`, `ensureHttpsFeedUrl`, `adoptMovedFeed`) is the only writer of a show's saved address. In one Room transaction (`PodcastDao.replaceFeedAddress`) it writes the HTTPS publisher address and the transport address, keeps the old address in `previousFeedUrl` for undo, records where the address came from (`feedUrlSource`: a `FeedUrlSources` value or the registry's `feedSource`) and when the registry confirmed it (`feedUrlCheckedAt`), clears every stored ETag and Last-Modified of the show (the row, its catalog feed and its feed-only extras), and marks a followed show for the next sync push (not for an address that came from the cloud, and never an unfollowed row, whose push could end the follow on another device). A write the registry didn't confirm keeps the stored daily-check stamp. It is a compare-and-set on the address it read, and `FeedAddressChange.onlyReplacing` narrows it to the address a caller read: a move is adopted only from the saved address that was read (a push or a restore may read another one, `FeedMoveFollower`), and a recovered address replaces only the address that failed. It records the show as pending (`PendingNotificationRegistrations`, the app's `SharedPreferencesPendingNotificationRegistrations`) before the write, and writes nothing (`NOT_SAVED`) when that record couldn't be saved, which no caller treats as done: a cloud pull fails before its pull time is saved (`FeedAddressNotSavedException`), the daily check leaves the show due for the next pass, and a restore reports the show, asks the app to register the show's notifications again when they are on (`FeedAddressWiring.requestNotificationRegistration`; any failure of that request is caught), and clears the mark only once Firebase acknowledged (`registrationSettled`), and only the request the job read before registering (`pendingMark`; each request gets a new mark), so an address change made while a job ran stays pending for the job that replaced it; each foreground pass asks again for every pending show (`resumePendingRegistrations`), so one lost with the process or given up on by the background job is never dropped. It reports a correction of a followed show's address as `feed_url_corrected` (`FeedAddressWiring.telemetry`). `rss:` shows and addresses that aren't HTTPS are refused. A cloud pull never puts back the address a confirmed correction (`FeedUrlSources.isConfirmedCorrection`: the registry, a feed move, a broken-feed report or the `/podcast` re-check) replaced: it returns `KEPT_CORRECTION` and marks the follow for a push, so the account gets the corrected address back. Merges of pulled rows write through `PodcastDao.upsertKeepingFeedAddress`, so an older copy never writes its address over one saved meanwhile. A follow keeps a saved HTTPS address (`FeedUrlSources.followedFeedUrl`), and a missing address from a show's details is filled through the writer. Every writer goes through it: missing-address recovery, the dead-address re-check, moved feeds, broken-feed reports, the daily check, auto-download, backup restores and cloud pulls (`SubscriptionSyncResolver(feedAddresses = …)` saves a pulled address for a Podcast Index show this way and leaves the row clean; without it, as in older tests, the row is written as before). +- **Renumbered shows (one follow).** `RenumberedShowFollows` (`SubscriptionRepository.renumberedFollows`: `adoptAnswer`, `adoptKnown`, `currentId`, `endedFormerFollows`, `collapseFollowsQuietly`) keeps one follow when Podcast Index renumbers a show ([docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md#renumbered-shows)). `PodcastRepository(onShowRenumbered = …)` reports every details answer for a canonical numeric show ID that names another one, before the answer is returned, so every existing details call detects it (the show page, a cloud pull's enrichment, address recovery). The answer is recorded (`RenumberingWiring.shows`, `boxlore_renumbered_shows` in the app) and a follow of the old show moves to the new one in one transaction (`PodcastDao.moveRenumberedFollow`, rules in `RenumberedFollowMerge`): the new show keeps its own row and namespace, or is created without one, with notifications and auto-download on when either was, and its own genre, icon, sort and trims, else the old show's; it keeps its own follow time or takes the time of the move, never the old show's earlier one, which any later unfollow of the new show on another device would beat in sync. Both rows are read, merged and written inside the DAO's transaction, so a pull or toggle never lands between the read and the write. The old show is unfollowed like any unfollow. Both rows are dirty, so the next push carries the follow and the unfollow, and a pull of the old show's older follow loses to the newer unfollow. Notifications for the new show are recorded pending (`FeedAddressUpdater.markRegistrationPending`) before the row turns them on and then registered through the acknowledged job; the old show's are turned off. Folder memberships, Home pins and the manual order move; the old show's catalog starts its unfollowed countdown; the old show's listener rows are linked to the new show's episodes (`RenumberingWiring.linkEpisodes`, `:core:rss`). A new show followed through its own RSS feed keeps that follow and only the old one ends, its folder, pin and order places moving to the RSS show and only the old show's catalog countdown starting; when the old follow has notifications and the RSS show's doesn't (turning them on needs the listener's consent), both follows stay. `rss:` shows are never touched. Each foreground pass first moves follows of shows recorded as renumbered, then asks details only of followed shows that share a feed address or podcast GUID with another follow, at most six per pass (`RenumberedShowFollows.collapseFollows`, run quietly by the pass, order from `RenumberedFollowMerge.lookupOrder`): a show is asked once per process after an answer, and a failed lookup is asked again on a later pass after the shows not yet asked; a show is never matched by title. `toggleSubscription(podcast, shownFollowed)` decides the direction from what the caller showed (the show page passes its own state, which counts a follow under the old number; other callers show the show's own row): asked to follow, a follow under the old number moves here with its settings; asked to unfollow, every follow of the show ends (`endedFormerFollows`), and unfollowing a followed `rss:` feed also ends a follow kept under a number its show replaced (`endFormerFollowsOf`). A follow moves only once the renumbering is saved; when saving fails it stays under the old number. A toggle never adds a second follow and a Subscribe tap never unfollows. `isFollowedUnderFormerId` tells the show page about a follow still under the old number. The daily address check also passes on merges the server confirmed with Podcast Index (`V2ShowAddress.mergedInto`): `FeedAddressDailyCheck` hands each to `adoptKnown(oldId, confirmedNewId)`, which records the renumbering and moves the follow without a details lookup, and the merged show's own address is never swapped. - `PodcastNotificationRegistrar` (`SubscriptionRepository.registerPodcastNotifications`) registers a Podcast Index show's notifications again and waits for Firebase to acknowledge both the `tracked_podcasts/` node and the `new_ep_` topic, the same node and topic as turning notifications on. The node is one per show for every follower and its write replaces it whole, so it is written only with an address (once the show has a latest episode); otherwise only the topic is subscribed. `NotificationRegistrationOutcome.RETRY` is retried by the app's capped background job; rules that refuse the write give `DROPPED`. - **Daily address check.** `FeedAddressDailyCheck` runs at the start of each foreground pass (behind `RolloutSwitches.addressCheck`). It asks `POST /v2/shows/feeds` for every followed Podcast Index show with a saved address not checked in the last day, in chunks of at most 500, and swaps an address only for a confirmed move: the registry flags the show for address repair, the address is the registry's own (`registry:` source), HTTPS (an http-only address is never adopted, since background reads need HTTPS), different from the saved one, and not the address the saved one replaced (whatever replaced it: a feed move, dead-address recovery or a cloud pull). `fetchUrl: null` keeps the saved address. The new address is read first (`DirectFeedRecovery.addressRead`, a catalog refresh from it) and saved only when that read works, so an address the phone can't read never replaces one it can. Each show the registry answered for is stamped (`podcasts.feedUrlCheckedAt`): a swap writes the stamp with the address, and any other show is stamped after its swap had its chance. A failed call or `registryAvailable: false` stamps and swaps nothing. Follows with a change made on this phone that the account hasn't received yet (dirty and synced before) wait for the push; follows never synced to an account are checked. A different `epNs` is only logged (D12). - **Broken-feed reports.** `FeedReporter` sends `POST /v2/feed-report` (`{id, status}`, never an address) for every failed background read of a Podcast Index show (`RefreshOutcome.Failure.reportStatus`), behind the same switch, never while offline, at most once per show a day and 20 a day in all, the API's own limit (`FeedReportLimiter`, one per app, shared with the show page, which stops 5 short so followed shows always have room; times kept in `FeedReportStamps`, the app's `SharedPreferencesFeedReportStamps`). Only a report the API answered counts: a failed send or a 429 uses up nothing. Its answer is an address to try only when it differs, is HTTPS and the saved address looks dead. @@ -161,6 +162,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - `FileUpgradeNotificationMarker` keeps the feed address repair's step 4 record (show IDs registered again, a completion flag and whether the sync catch-up was asked for; a properties file written atomically) in the file the app opens under `noBackupFilesDir` (`upgrade_repair_notifications`), so Android restore never brings it. The repair's per-show progress lives in Room `upgrade_repair_shows` with the stored `UpgradeRepairState` values. Stable storage names and values: never rename. - `SharedPreferencesFeedReportStamps` keeps each show's last broken-feed report time in SharedPreferences file `boxlore_feed_reports` (key: show ID, value: epoch ms; entries older than a day are dropped). `FeedUrlSources` values (`feed_move`, `podcast_lookup`, `cloud_sync`, `feed_report`, `backup_restore`, `registry`) are stored in `podcasts.feedUrlSource`. Stable storage names and values: never rename. - `RecommendationsLastGood` stores the last good `/v2` picks in SharedPreferences file `boxlore_discovery_last_good`, keys `v2_recommendations|personal|` and `v2_recommendations|home|` (episode JSON as the answer named them) plus a `|fallback` key each. Stable storage names: never rename. +- Renumbered shows are recorded in SharedPreferences file `boxlore_renumbered_shows` (`:core:rss`; key: the old show ID, value: the show ID it answers as). A move rewrites no stored episode ID, namespace or listener row; it writes the two `podcasts` rows, folder memberships, Home pins and the manual order. - Main Room database identity is owned by `:core:database` and exposed through catalog APIs. - User preference and cache file names are owned by `:core:prefs` and catalog cache code. - RSS podcast IDs and negative episode IDs are owned by `:core:rss`. @@ -172,6 +174,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - Saved feed addresses: `FeedAddressUpdaterTest` (Robolectric Room: one update writes both addresses, keeps the old one, resets every validator, marks the row dirty, re-registers and reports once; undo; `rss:` and non-HTTPS refused; a changed row left alone; fills, cloud addresses and unfollowed shows aren't corrections and an unfollowed row stays clean; a failed registration request of any kind keeps the address; a registration stays pending until acknowledged and is asked for again, also after the process died; no address written when its request couldn't be saved; a redirect from an address other than the saved one is never adopted; a re-follow keeps a corrected address), `FeedAddressDailyCheckTest` (501 followed shows make two calls, nothing again the same day; only confirmed moves, read before they are saved; an unreadable registry address never saved and tried again the next day; a swapped show stamped by its swap; never back to the address the saved one replaced, whatever replaced it; follows changed on the phone wait; a failed call or an unavailable registry stamps nothing, so the `/podcast` fallback runs once, and never for a show the check reached; the switch), `FeedAddressRecordedAnswersTest` (the API's own `/v2/shows/feeds` and `/v2/feed-report` answers, `fixtures/v2`, through the app's client: a registry move, a dump address, an unknown show, an http-only show, an unavailable registry and a refused report), `FeedReportsTest` and `SharedPreferencesFeedReportStampsTest` (once per show a day, 20 a day in all, limiters sharing one store counting each other's reports, a failed or refused send counting nothing, kept across processes, never offline, for `rss:` shows, without a read or with the switch off; only a readable new address offered back), `PodcastNotificationRegistrarTest` (the same node and topic, the shared node never written without its address, nothing without notifications, retry and refused outcomes, a cloud pull that changes only the address re-registering on the same node and topic, a pulled previous address never undoing a registry correction, and a merge never writing an older copy's address over a newer one), `SubscribedEpisodeCatalogTest` (moves followed on every refresh path, never for `rss:` shows or with the switch off, caller-handled requests left alone), and `DirectFeedRecoveryTest` (every failed read reported, the once-a-day `/podcast` re-check sharing the daily stamp, the report's address tried before the `/podcast` re-check, a passing failure keeping its address, a candidate replacing only the address that failed). - `PodcastRepositoryPlayerPrefsTest` checks the player preferences (and their legacy-file migration) open on first use, not while the repository is constructed on the main thread at startup. - Unit tests live under `core/catalog/src/test`. +- Renumbered shows: `RenumberedShowFollowsTest` (Robolectric Room through `SubscriptionRepository`): both shows followed (the listener's case) collapse into the new show with notifications on, its own follow time, genre and namespace, a pending registration and folder membership; a follow of the old show only moves with its settings, followed at the move, but never its namespace; a pulled unfollow of the new show older than the move, and a pull from the start (the old follow, enriched through `PodcastRepository` on MockWebServer, then the new show's unfollow), leave the new show followed; a second answer moves nothing, and a pull of both old follows neither undoes the unfollow nor overwrites the unpushed merge; a later follow of the old number from another device collapses on the next pass without a lookup; the pass asks only shows followed twice under one feed (address compared without scheme, `www.` or trailing slash), once per process, never a lookalike by title; with eight candidates and an offline first pass, later passes ask every one, failed ones again and answered ones once; a toggle offered as Unfollow ends both follows, and one offered as Subscribe (a search card, or a page on the new number) follows the new show with the old follow's settings; `rss:` shows, same-ID answers and a new show followed through its own feed (the folder place moves to the RSS show; a follow with notifications stays when the RSS show has none, and ends when that feed is unfollowed; the followed feed counts when an unfollowed one is linked too); a renumbering that can't be saved moves no follow. `RenumberedFollowMergeTest` covers the merge rules (a follow time never moved earlier, never older than the new show's last unfollow), shared feeds by address or GUID and ID lists. `PodcastRepositoryRenumberingTest` (MockWebServer): an answer naming another show ID is reported before it is returned, same-ID and address answers report nothing, and a failing report still returns the answer. - `PodcastRepositoryApiV2Test` (Robolectric, MockWebServer): the production `api` and `apiV2` reach the same base URL and open the HTTP cache once; test overrides open none. - `PodcastRepositoryRequestBoundsTest` (MockWebServer that, like the API, serves only 40 ids per `/sync` call): a 60-show library syncs every show in three chunks of at most 20 with at most two in flight, a failed chunk drops only its own shows, and legacy, v2 and bootstrap recommendation requests send at most 12 interests. - Episode links and shares: `EpisodeLinkResolverTest` (resolution order, old IDs to the phone's own row, evicted aliases, recipe IDs with and without a show, API answers translated to local rows, an old Podcast Index link answered with its recipe ID opening the recipe-ID row, a v1 answer matched by enclosure, missing vs unreachable and the HTTP status mapping, `needsFeed` hit and miss, stored namespace, contract keys only, a duplicated feed item), `EpisodeLinkRoomTest` (Room aliases: trusted only, recipe collisions, namespace; recorded `/v2/episode` `needsFeed` answer read through `/v2/feed/` and parsed; a recorded `/v2/episode` Podcast Index answer resolving to a stored recipe-ID row by key and by alias; an enclosure-keyed row and the v1 fallback when v2 is unavailable; `getEpisode` never reads the feed; 404 vs 429 vs 503; push match by recipe ID), `EpisodeSharePreparerTest` (alias targets, RSS shows linked to a Podcast Index show, enclosure keys, phone-only IDs shared by their key's recipe ID, the offline and slow paths going out at once with the retry scheduled, dropped requests, `rss:` and keyless episodes, the recorded register body and every answer class). `InstallReferrerManagerTest` covers a share link's `show` in the install referrer. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheck.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheck.kt index 66fe44a81..95835757b 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheck.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheck.kt @@ -41,6 +41,11 @@ class FeedAddressDailyCheck( private val markChecked: suspend (podcastId: String, atMillis: Long) -> Unit, private val enabled: () -> Boolean, private val nowMillis: () -> Long = System::currentTimeMillis, + /** + * A show the server says Podcast Index merged into another ([V2ShowAddress.mergedInto]): the + * follow moves there (`RenumberedShowFollows.adoptKnown`). Its own address isn't swapped. + */ + private val onMerged: suspend (oldId: String, newId: String) -> Unit = { _, _ -> }, ) { /** What one [run] did. */ data class Outcome(val checked: Int, val swapped: Int, val unchecked: Int) { @@ -87,6 +92,11 @@ class FeedAddressDailyCheck( /** Swaps or stamps one show the registry answered for; true when its address was swapped. */ private suspend fun applyOne(row: PodcastEntity, result: V2ShowAddress, now: Long): Boolean { + result.validMergedInto?.let { newId -> + merged(row.podcastId, newId.toString()) + markChecked(row.podcastId, now) + return false + } noteNamespace(row, result) val target = confirmedMove(row, result) val update = if (target != null && readable(row.podcastId, target)) replaceAddress(change(row, result, target, now)) else null @@ -97,6 +107,16 @@ class FeedAddressDailyCheck( return swapped } + private suspend fun merged(oldId: String, newId: String) { + try { + onMerged(oldId, newId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Moving the follow of merged show $oldId to $newId failed", e) + } + } + private fun change(row: PodcastEntity, result: V2ShowAddress, target: String, now: Long) = FeedAddressChange( podcastId = row.podcastId, feedUrl = target, diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdater.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdater.kt index 8240187d4..b4bc4f142 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdater.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdater.kt @@ -184,6 +184,17 @@ class FeedAddressUpdater( pendingRegistrations.all().filterNot { it.startsWith("rss:") }.forEach(::requestRegistration) } + /** + * Records a pending registration for [podcastId], whose notifications are about to be turned on + * by another writer (a follow moved from a show Podcast Index renumbered), before that writer + * saves the row; false when it couldn't be saved, so the caller writes nothing. Ask for it with + * [requestPendingRegistration] once the row is saved. + */ + fun markRegistrationPending(podcastId: String): Boolean = !podcastId.startsWith("rss:") && pendingRegistrations.add(podcastId) + + /** Asks for [podcastId]'s registration recorded by [markRegistrationPending]. */ + fun requestPendingRegistration(podcastId: String) = requestRegistration(podcastId) + /** [podcastId]'s pending registration, read by the background job before it registers; null when none is pending. */ fun pendingMark(podcastId: String): Long? = pendingRegistrations.mark(podcastId) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt index 2016375d0..e9f27af8d 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt @@ -79,6 +79,13 @@ class PodcastRepository( * default stores nothing, so show pages read the server's list (P4 PR 16, plan section 11). */ feedFirstWiring: FeedFirstWiring = FeedFirstWiring(), + /** + * Called, before [getPodcastDetails] returns, with each answer for a numeric show ID that names + * another show ID: Podcast Index renumbered the show. The app records it and moves a follow of + * the old show to the new one (`RenumberedShowFollows.adoptAnswer`); the default does + * nothing. A failure is logged and the answer still returned. + */ + private val onShowRenumbered: suspend (oldId: String, answer: Podcast) -> Unit = { _, _ -> }, ) : cx.aswin.boxlore.core.domain.ports.PodcastCatalogPort { internal val episodeFeedReader: EpisodeFeedReader by lazy { feedReader ?: RssEpisodeFeedReader(cx.aswin.boxlore.core.rss.RssFeedClient()) { apiV2 } } @@ -575,7 +582,19 @@ class PodcastRepository( if (feedId.startsWith("rss:")) { return@withContext getRssPodcastDetails(feedId) } - getNetworkPodcastDetails(feedId) + getNetworkPodcastDetails(feedId)?.also { answer -> reportRenumbering(feedId, answer) } + } + + /** Hands an answer for [feedId] that names another numeric show ID to [onShowRenumbered]. */ + private suspend fun reportRenumbering(feedId: String, answer: Podcast) { + if (!cx.aswin.boxlore.core.rss.RenumberedShowIds.isRenumbering(feedId, answer.id)) return + try { + onShowRenumbered(feedId, answer) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w("PodcastRepository", "Renumbered show $feedId → ${answer.id} not recorded", e) + } } private suspend fun getRssPodcastDetails(feedId: String): Podcast? = try { diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RenumberedFollowMerge.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RenumberedFollowMerge.kt new file mode 100644 index 000000000..a121db635 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RenumberedFollowMerge.kt @@ -0,0 +1,189 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.model.EpisodeRecipeId +import cx.aswin.boxlore.core.model.Podcast + +/** + * The rows a follow of a show Podcast Index renumbered moves to: the old show's follow ends up under + * the show it now answers as, and only there (docs/EPISODE_IDS.md §5, renumbered shows). Pure; + * [RenumberedShowFollows] writes the result. + * + * - **The new show already followed:** its row is kept, with the two follows' settings merged: + * its own follow time, notifications and auto-download on when either was, and the new + * show's own genre, icon, sort and playback trims, else the old show's. + * - **The new show not followed:** it is followed now with the old show's settings. Its + * details come from the new show's answer, else its stored row, else the old show's row. It never + * takes the old show's namespace or address bookkeeping: a stored row keeps its own + * ([cx.aswin.boxlore.core.database.PodcastDao.upsert]), a new one has none until its catalog is read. + * - **The old show** is unfollowed as an unfollow does it: notifications, auto-download and its + * custom genre cleared, its unfollow time now. + * Both rows are dirty, so the next sync push carries the follow and the unfollow; a pull of the old + * show's older follow loses to the newer unfollow ([cx.aswin.boxlore.core.catalog.sync.SubscriptionSyncResolver]). + * + * A follow time is never moved earlier: sync compares a follow with an unfollow by their times + * ([cx.aswin.boxlore.core.catalog.sync.SubscriptionSyncResolver]), so a follow written with the old + * show's earlier time would lose to any unfollow of the new show made since, on any device. The + * new show keeps its own follow time, or takes the time of the move, like any new follow. + * + * Added for renumbered shows. + */ +object RenumberedFollowMerge { + /** The two rows to write and what moved. */ + data class Move( + val kept: PodcastEntity, + val retired: PodcastEntity, + /** Notifications are on for [kept] and weren't on its stored row: they need registering. */ + val notificationsMoved: Boolean, + /** Auto-download is on for [kept] and wasn't on its stored row. */ + val autoDownloadMoved: Boolean, + ) + + fun move(old: PodcastEntity, newId: String, current: PodcastEntity?, answer: Podcast?, nowMs: Long): Move { + val kept = if (current?.isSubscribed == true) mergedInto(current, old) else followed(newId, old, current, answer, nowMs) + val retired = + old.copy( + isSubscribed = false, + subscribedAt = 0L, + unsubscribedAt = maxOf(nowMs, old.subscribedAt + 1), + isDirty = true, + notificationsEnabled = false, + autoDownloadEnabled = false, + customGenre = null, + customGenreIcon = null, + ) + return Move( + kept = kept, + retired = retired, + notificationsMoved = kept.notificationsEnabled && current?.let { it.isSubscribed && it.notificationsEnabled } != true, + autoDownloadMoved = kept.autoDownloadEnabled && current?.let { it.isSubscribed && it.autoDownloadEnabled } != true, + ) + } + + /** [current] (followed) with [old]'s follow merged in. */ + private fun mergedInto(current: PodcastEntity, old: PodcastEntity): PodcastEntity { + val keepsOwnGenre = !current.customGenre.isNullOrBlank() + val keepsOwnSort = current.preferredSort != null + return current.copy( + subscribedAt = current.subscribedAt, + unsubscribedAt = 0L, + isDirty = true, + notificationsEnabled = current.notificationsEnabled || old.notificationsEnabled, + autoDownloadEnabled = current.autoDownloadEnabled || old.autoDownloadEnabled, + customGenre = if (keepsOwnGenre) current.customGenre else old.customGenre, + customGenreIcon = if (keepsOwnGenre) current.customGenreIcon else old.customGenreIcon, + preferredSort = if (keepsOwnSort) current.preferredSort else old.preferredSort, + type = if (keepsOwnSort) current.type else old.type, + skipBeginningOverrideMs = current.skipBeginningOverrideMs ?: old.skipBeginningOverrideMs, + skipEndingOverrideMs = current.skipEndingOverrideMs ?: old.skipEndingOverrideMs, + feedUrl = current.feedUrl ?: old.feedUrl, + ) + } + + /** [newId] followed with [old]'s settings; details from [answer], else [stored], else [old]. */ + private fun followed(newId: String, old: PodcastEntity, stored: PodcastEntity?, answer: Podcast?, nowMs: Long): PodcastEntity { + val shown = (stored ?: unstoredRowOf(newId, old, nowMs)).withAnswer(answer) + return shown.copy( + isSubscribed = true, + subscribedAt = maxOf(nowMs, (stored?.unsubscribedAt ?: 0L) + 1L), + unsubscribedAt = 0L, + isDirty = true, + type = if (old.preferredSort != null) old.type else shown.type, + latestEpisode = shown.latestEpisode?.let { it.copy(podcastId = newId, podcastTitle = it.podcastTitle ?: shown.title) }, + preferredSort = old.preferredSort ?: shown.preferredSort, + notificationsEnabled = old.notificationsEnabled, + autoDownloadEnabled = old.autoDownloadEnabled, + skipBeginningOverrideMs = old.skipBeginningOverrideMs ?: shown.skipBeginningOverrideMs, + skipEndingOverrideMs = old.skipEndingOverrideMs ?: shown.skipEndingOverrideMs, + sourceType = PodcastEntity.SOURCE_PODCAST_INDEX, + customGenre = old.customGenre, + customGenreIcon = old.customGenreIcon, + ) + } + + /** + * A new row for [newId] with [old]'s show details only: none of its namespace, address + * bookkeeping, validators, link, latest episode or sync marks, which belong to [old]'s number. + */ + private fun unstoredRowOf(newId: String, old: PodcastEntity, nowMs: Long): PodcastEntity = PodcastEntity( + podcastId = newId, + title = old.title, + author = old.author, + imageUrl = old.imageUrl, + description = old.description, + genre = old.genre, + type = old.type, + lastRefreshed = nowMs, + podcastGuid = old.podcastGuid, + medium = old.medium, + feedUrl = old.feedUrl, + ) + + /** This row with [answer]'s show details where it has them; the stored address stays. */ + private fun PodcastEntity.withAnswer(answer: Podcast?): PodcastEntity { + if (answer == null) return this + return copy( + title = answer.title.ifBlank { title }, + author = answer.artist.ifBlank { author }, + imageUrl = answer.imageUrl.ifBlank { imageUrl }, + description = answer.description ?: description, + genre = answer.genre.takeIf(String::isNotBlank) ?: genre, + podcastGuid = answer.podcastGuid ?: podcastGuid, + fundingUrl = answer.fundingUrl ?: fundingUrl, + fundingMessage = answer.fundingMessage ?: fundingMessage, + medium = answer.medium ?: medium, + updateFrequency = answer.updateFrequency ?: updateFrequency, + location = answer.location ?: location, + license = answer.license ?: license, + ).withAnswerState(answer) + } + + /** The answer's flags, address and latest episode where this row has none (part of [withAnswer]). */ + private fun PodcastEntity.withAnswerState(answer: Podcast): PodcastEntity = copy( + hasValue = answer.hasValue || hasValue, + isLocked = answer.isLocked || isLocked, + feedUrl = feedUrl ?: answer.feedUrl?.takeIf(String::isNotBlank), + latestEpisode = latestEpisode ?: answer.latestEpisode, + ) + + /** [ids] with [oldId] replaced by [newId] in its place, once (a pin list, a manual order). */ + fun replacedId(ids: List, oldId: String, newId: String): List { + if (oldId !in ids) return ids + return ids.map { if (it == oldId) newId else it }.distinct() + } + + /** + * The followed Podcast Index shows that may be one renumbered show followed twice: two or more + * follows with the same feed address or podcast GUID. Only canonical numeric show IDs; a show + * is never matched by its title. Asking each one's details settles which ([RenumberedShowFollows]). + */ + fun sharedFeedCandidates(followed: List): List { + val shows = followed.filter { it.isSubscribed && !it.isRss && EpisodeRecipeId.showIdOrNull(it.podcastId) != null } + val byAddress = shows.mapNotNull { row -> normalizedAddress(row.feedUrl)?.let { it to row.podcastId } } + val byGuid = shows.mapNotNull { row -> row.podcastGuid?.trim()?.lowercase()?.takeIf(String::isNotEmpty)?.let { "guid:$it" to row.podcastId } } + return (byAddress + byGuid) + .groupBy({ it.first }, { it.second }) + .values + .filter { it.distinct().size > 1 } + .flatten() + .distinct() + } + + /** + * The [candidates] the foreground pass asks for, at most [maxLookups]: never one [answered] + * already in this process, shows not asked yet before ones whose lookup [failed] (asked again, + * so an offline first pass doesn't skip a show until the process ends), each group in + * [candidates]' order. + */ + fun lookupOrder(candidates: List, answered: Set, failed: Set, maxLookups: Int): List = + candidates + .filterNot { it in answered } + .sortedBy { it in failed } + .take(maxLookups) + + /** A feed address compared without its scheme, case, `www.` or trailing slash. */ + internal fun normalizedAddress(url: String?): String? { + val trimmed = url?.trim()?.lowercase()?.takeIf(String::isNotEmpty) ?: return null + return trimmed.substringAfter("://").removePrefix("www.").trimEnd('/').takeIf(String::isNotEmpty) + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RenumberedShowFollows.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RenumberedShowFollows.kt new file mode 100644 index 000000000..8c1e057c6 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RenumberedShowFollows.kt @@ -0,0 +1,367 @@ +package cx.aswin.boxlore.core.catalog + +import android.util.Log +import cx.aswin.boxlore.core.database.AutoDownloadDao +import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.core.prefs.UserPreferencesRepository +import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository +import cx.aswin.boxlore.core.rss.RenumberedShowIds +import cx.aswin.boxlore.core.rss.RenumberedShows +import java.util.Collections +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * What [RenumberedShowFollows] needs besides the show rows: the app's other per-follow state. + * [SubscriptionRepository] provides it. + */ +internal interface RenumberedFollowEffects { + /** Saves a pending notification registration for [podcastId] before its row turns them on; false when it couldn't be saved. */ + fun markRegistrationPending(podcastId: String): Boolean + + /** Asks for [podcastId]'s pending registration once its row is saved. */ + fun requestRegistration(podcastId: String) + + /** Turns the old show's notifications off with Firebase (its node's address and its topic), as an unfollow does. */ + suspend fun unregisterNotifications(row: PodcastEntity) + + /** Records an auto-download setting change through the auto-download records. */ + suspend fun autoDownloadChanged(podcastId: String, enabled: Boolean) + + /** Moves folder memberships, Home pins and the manual order from [oldId] to [newId]. */ + suspend fun moveLibraryPlacement(oldId: String, newId: String) + + /** Starts the old show's unfollowed catalog countdown and, when [newId] is given, clears the new show's. */ + suspend fun catalogFollowChanged(oldId: String, newId: String?) + + /** Saves a missing feed address of the followed [podcastId] through the one address writer. */ + suspend fun fillMissingAddress(podcastId: String, feedUrl: String?) + + /** Links [oldId]'s listener rows to [newId]'s stored episodes now (each catalog read links again). */ + suspend fun linkEpisodes(oldId: String, newId: String) +} + +/** + * [RenumberedFollowEffects] on the app's stores: the address writer's pending registrations, + * Firebase through [unregister], auto-download records, folders, Home pins and the manual order, + * the catalog's unfollowed countdown and the episode links ([link]). + */ +internal class RenumberedFollowWrites( + private val podcastDao: PodcastDao, + private val feedAddresses: FeedAddressUpdater, + private val unregister: suspend (PodcastEntity) -> Unit, + private val library: Library, + private val link: suspend (oldId: String, newId: String) -> Unit, +) : RenumberedFollowEffects { + /** The optional stores a follow's placement and settings live in; null where a caller doesn't wire them. */ + data class Library( + val folders: FolderRepository?, + val prefs: UserPreferencesRepository?, + val autoDownload: AutoDownloadDao?, + val catalog: LocalEpisodeCatalogPort?, + ) + + override fun markRegistrationPending(podcastId: String): Boolean = feedAddresses.markRegistrationPending(podcastId) + + override fun requestRegistration(podcastId: String) = feedAddresses.requestPendingRegistration(podcastId) + + override suspend fun unregisterNotifications(row: PodcastEntity) = unregister(row) + + override suspend fun autoDownloadChanged(podcastId: String, enabled: Boolean) { + library.autoDownload?.changeSetting(podcastId, enabled, System.currentTimeMillis() / MILLIS_PER_SECOND) + } + + override suspend fun moveLibraryPlacement(oldId: String, newId: String) { + library.folders?.let { folders -> + folders.getFolders().filter { oldId in it.podcastIds && newId !in it.podcastIds }.forEach { folders.addPodcastToFolder(newId, it.id) } + folders.removePodcastFromAllFolders(oldId) + folders.syncLinkedGenres() + } + library.prefs?.let { prefs -> + val pins = prefs.homePinnedPodcastIdsStream.first() + RenumberedFollowMerge.replacedId(pins, oldId, newId).takeIf { it != pins }?.let { prefs.setHomePinnedPodcastIds(it) } + val order = prefs.subscriptionManualOrderStream.first() + RenumberedFollowMerge.replacedId(order, oldId, newId).takeIf { it != order }?.let { prefs.setSubscriptionManualOrder(it) } + } + } + + override suspend fun catalogFollowChanged(oldId: String, newId: String?) { + val catalog = library.catalog ?: return + catalog.setUnsubscribedTtl(oldId, System.currentTimeMillis() + LocalEpisodeCatalogRepository.UNSUBSCRIBE_TTL_MS) + if (newId != null) catalog.setUnsubscribedTtl(newId, null) + } + + override suspend fun fillMissingAddress(podcastId: String, feedUrl: String?) { + if (TrackedPodcastRtdbLogic.httpsFeedUrl(podcastDao.getPodcast(podcastId)?.feedUrl) != null) return + val https = TrackedPodcastRtdbLogic.httpsFeedUrl(feedUrl) ?: return + feedAddresses.update(FeedAddressChange(podcastId = podcastId, feedUrl = https, source = FeedUrlSources.PODCAST_LOOKUP)) + } + + override suspend fun linkEpisodes(oldId: String, newId: String) = link(oldId, newId) + + private companion object { + const val MILLIS_PER_SECOND = 1000L + } +} + +/** + * Keeps one follow for a show Podcast Index renumbered (docs/EPISODE_IDS.md §5, renumbered shows). + * When show details asked for an old show ID answer with another one ([adoptAnswer]), the renumbering + * is recorded ([RenumberedShows]) and a follow of the old show moves to the show it now answers as + * ([RenumberedFollowMerge]): both rows read, merged and written in one transaction and marked for + * the next sync push, notifications registered for the new show through the pending-registration + * journal before the row turns them on (never dropped), the old show's notifications turned off, + * folder memberships, Home pins and the manual order moved, and the old show's listener rows + * linked to the new show's episodes. `rss:` shows are never touched. A new show followed through + * its RSS feed keeps that follow and only the old follow ends, its library places moving to the + * feed's show; when the old follow has notifications and the feed's doesn't (which needs the + * listener's consent), both follows stay, so notifications are never dropped. + * + * Every step is idempotent, so the same answer arriving from several callers (the show page, a + * cloud pull, the foreground pass) moves the follow once. Moves are serialized. + * + * Added for renumbered shows. + */ +class RenumberedShowFollows internal constructor( + private val podcastDao: PodcastDao, + val shows: RenumberedShows, + private val effects: RenumberedFollowEffects, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private val moves = Mutex() + + /** Shows whose details the foreground pass got an answer for in this process, so each is asked once. */ + private val checkedThisProcess: MutableSet = Collections.synchronizedSet(HashSet()) + + /** Shows whose details lookup failed in this process: asked again, after shows not asked yet. */ + private val failedThisProcess: MutableSet = Collections.synchronizedSet(HashSet()) + + /** The show [podcastId] now answers as, when it was renumbered; null otherwise. */ + fun currentId(podcastId: String): String? = shows.currentId(podcastId) + + /** + * Records an answer for [oldId] that named another show ([answer]) and moves a follow of [oldId] + * to it. Returns true when a follow moved; false when [answer] isn't a renumbering of [oldId], + * nothing was followed, or the renumbering couldn't be saved (the follow then stays, so it is + * never under a number the phone no longer links to the old one). + */ + suspend fun adoptAnswer(oldId: String, answer: Podcast): Boolean { + if (!RenumberedShowIds.isRenumbering(oldId, answer.id)) return false + if (!shows.recordOrWarn(oldId, answer.id)) return false + return moveFollow(oldId, answer.id, answer) + } + + /** + * Moves a follow of [oldId] to the show it was recorded as renumbered to, if any. With + * [confirmedNewId] (a merge the server confirmed with Podcast Index, from the daily address + * check) that renumbering is recorded first, and nothing moves unless it was saved. Returns true + * when a follow moved. + */ + suspend fun adoptKnown(oldId: String, confirmedNewId: String? = null): Boolean { + if (confirmedNewId != null) { + if (!RenumberedShowIds.isRenumbering(oldId, confirmedNewId)) return false + if (!shows.recordOrWarn(oldId, confirmedNewId)) return false + } + val newId = shows.currentId(oldId) ?: return false + return moveFollow(oldId, newId, answer = null) + } + + /** Moves every follow of a show renumbered into [podcastId] onto it (a show page about to toggle its follow). */ + suspend fun adoptFormerFollows(podcastId: String): Boolean { + var moved = false + for (oldId in shows.formerIds(podcastId)) moved = moveFollow(oldId, podcastId, answer = null) || moved + return moved + } + + /** + * Before a toggle of [podcast]'s follow: settles follows of shows renumbered into it, by what the + * caller showed ([shownFollowed]; null means [podcast]'s own follow or a followed `rss:` feed linked + * to it, as follow buttons other than the show page show it). The direction is decided before + * anything moves: + * - **Shown as not followed** (the listener asked to follow): such follows move to [podcast] + * with their settings ([adoptFormerFollows]). True when [podcast] is now followed: the toggle + * is done. Otherwise false, and the toggle follows [podcast] as usual. + * - **Shown as followed** (the listener asked to unfollow): the follows move first, then any + * that couldn't move end with [unfollow]. False while [podcast] (or its linked feed) is + * followed, so the toggle ends that follow too; true when only former follows were ended. + * No former follows, or an `rss:` show: false, and the toggle goes ahead as usual. + */ + suspend fun endedFormerFollows(podcast: Podcast, shownFollowed: Boolean? = null, unfollow: suspend (PodcastEntity) -> Unit): Boolean { + if (podcast.isRss || shows.formerIds(podcast.id).isEmpty()) return false + val askedToUnfollow = shownFollowed ?: podcastDao.isFollowedAsShown(podcast.id) + adoptFormerFollows(podcast.id) + val followed = podcastDao.isFollowedAsShown(podcast.id) + if (!askedToUnfollow) return followed + val ended = endFormerFollowsOf(podcast.id, unfollow) + return !followed && ended + } + + /** + * Ends follows still under a show renumbered into [podcastId] (one kept beside a follow of + * [podcastId]'s `rss:` feed for its notifications), when the listener unfollowed that show or + * its feed. True when one ended. + */ + suspend fun endFormerFollowsOf(podcastId: String, unfollow: suspend (PodcastEntity) -> Unit): Boolean { + val left = shows.formerIds(podcastId).mapNotNull { id -> podcastDao.getPodcast(id)?.takeIf { it.isSubscribed && !it.isRss } } + left.forEach { unfollow(it) } + return left.isNotEmpty() + } + + /** True when a show recorded as renumbered into [podcastId] is still followed under its old number. */ + suspend fun isFollowedUnderFormerId(podcastId: String): Boolean = + shows.formerIds(podcastId).any { id -> podcastDao.getPodcast(id)?.let { it.isSubscribed && !it.isRss } == true } + + /** + * The foreground pass's step: follows of shows recorded as renumbered move, then followed shows + * that share a feed address or podcast GUID with another follow are asked for their details + * once per process ([lookupDetails], at most [maxLookups]), which settles any renumbering among them. + */ + suspend fun collapseFollows(lookupDetails: suspend (String) -> Podcast?, maxLookups: Int = MAX_LOOKUPS_PER_PASS) { + val followed = podcastDao.getSubscribedPodcastIndexPodcasts() + for (row in followed) { + if (shows.currentId(row.podcastId) != null) adoptKnown(row.podcastId) + } + val candidates = + RenumberedFollowMerge.lookupOrder( + candidates = RenumberedFollowMerge.sharedFeedCandidates(podcastDao.getSubscribedPodcastIndexPodcasts()), + answered = checkedThisProcess.toSet(), + failed = failedThisProcess.toSet(), + maxLookups = maxLookups, + ) + for (id in candidates) { + val answer = + try { + lookupDetails(id) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Details lookup for $id failed", e) + null + } + if (answer == null) { + failedThisProcess += id + continue + } + checkedThisProcess += id + failedThisProcess -= id + if (podcastDao.getPodcast(id)?.isSubscribed == true) adoptAnswer(id, answer) + } + } + + /** [collapseFollows] for the foreground pass, run before it reads feeds: a failure is logged and the pass goes on. */ + suspend fun collapseFollowsQuietly(lookupDetails: suspend (String) -> Podcast?) = runStep("collapse renumbered follows") { collapseFollows(lookupDetails) } + + private suspend fun moveFollow(oldId: String, newId: String, answer: Podcast?): Boolean { + if (!RenumberedShowIds.isRenumbering(oldId, newId)) return false + val moved = moves.withLock { moveFollowLocked(oldId, newId, answer) } + // Linking is local and idempotent: run it for every answer, so rows that arrived since are linked. + runStep("link episodes of $oldId to $newId") { effects.linkEpisodes(oldId, newId) } + return moved + } + + /** What one move wrote, read and decided in its transaction. */ + private sealed interface MoveOutcome { + /** Nothing written. */ + data object None : MoveOutcome + + /** The follow moved to the new show. */ + data class Moved(val old: PodcastEntity, val move: RenumberedFollowMerge.Move) : MoveOutcome + + /** The new show is followed through its own feed ([feedShow]): only the old follow ended. */ + data class EndedForFeed(val old: PodcastEntity, val feedShow: PodcastEntity) : MoveOutcome + } + + private suspend fun moveFollowLocked(oldId: String, newId: String, answer: Podcast?): Boolean { + var outcome: MoveOutcome = MoveOutcome.None + // Read, merge and write in one transaction, so no other writer's change to either row is overwritten. + podcastDao.moveRenumberedFollow { outcome = movedRows(oldId, newId, answer) } + when (val done = outcome) { + MoveOutcome.None -> return false + is MoveOutcome.EndedForFeed -> { + Log.i(TAG, "Follow of renumbered show $oldId ended: $newId is followed through ${done.feedShow.podcastId}") + finishOldShow(done.old, placementId = done.feedShow.podcastId, followedCatalogId = null) + } + is MoveOutcome.Moved -> { + Log.i(TAG, "Follow of renumbered show $oldId moved to $newId") + if (done.move.notificationsMoved) effects.requestRegistration(newId) + if (done.move.autoDownloadMoved) runStep("auto-download of $newId") { effects.autoDownloadChanged(newId, true) } + runStep("address of $newId") { effects.fillMissingAddress(newId, answer?.feedUrl ?: done.old.feedUrl) } + finishOldShow(done.old, placementId = newId, followedCatalogId = newId) + } + } + return true + } + + /** The rows of one move, read and written inside [PodcastDao.moveRenumberedFollow]'s transaction. */ + private suspend fun movedRows(oldId: String, newId: String, answer: Podcast?): MoveOutcome { + val old = podcastDao.getPodcast(oldId)?.takeIf { it.isSubscribed && !it.isRss } ?: return MoveOutcome.None + val current = podcastDao.getPodcast(newId) + if (current?.isRss == true) return MoveOutcome.None + val move = RenumberedFollowMerge.move(old, newId, current, answer, nowMillis()) + val feedShow = podcastDao.getSubscribedRssPodcastLinkedTo(newId) + if (feedShow != null) { + // The new show is followed through its own feed: that follow stays as it is and the old one + // ends, unless that would drop notifications the feed's follow can't take without the + // listener's consent: then both stay until the listener turns one of them off or on. + if (old.notificationsEnabled && !feedShow.notificationsEnabled) { + Log.w(TAG, "Follow of $oldId kept: $newId is followed through ${feedShow.podcastId}, whose notifications are off") + return MoveOutcome.None + } + podcastDao.upsert(move.retired) + return MoveOutcome.EndedForFeed(old, feedShow) + } + // The registration is saved before the row turns notifications on, so it is never dropped. + if (move.notificationsMoved && !effects.markRegistrationPending(newId)) { + Log.w(TAG, "Follow of $oldId not moved to $newId: its notification registration couldn't be saved") + return MoveOutcome.None + } + podcastDao.upsert(move.kept) + podcastDao.upsert(move.retired) + return MoveOutcome.Moved(old, move) + } + + /** + * The old show's leftovers once its follow ended: notifications, auto-download, its folder, + * Home pin and manual-order places (moved to [placementId], the follow that remains), and its + * catalog countdown (and [followedCatalogId]'s cleared, when that follow is a Podcast Index show). + */ + private suspend fun finishOldShow(old: PodcastEntity, placementId: String, followedCatalogId: String?) { + if (old.notificationsEnabled) runStep("notifications of ${old.podcastId}") { effects.unregisterNotifications(old) } + if (old.autoDownloadEnabled) runStep("auto-download of ${old.podcastId}") { effects.autoDownloadChanged(old.podcastId, false) } + runStep("library placement of ${old.podcastId}") { effects.moveLibraryPlacement(old.podcastId, placementId) } + runStep("catalog of ${old.podcastId}") { effects.catalogFollowChanged(old.podcastId, followedCatalogId) } + } + + /** One follow-up step; a failure is logged and leaves the moved follow in place. */ + private suspend fun runStep(name: String, step: suspend () -> Unit) { + try { + step() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Renumbered show step failed: $name", e) + } + } + + private companion object { + const val TAG = "RenumberedShows" + + /** Details asked for per foreground pass at most: only shows followed twice under one feed qualify. */ + const val MAX_LOOKUPS_PER_PASS = 6 + } +} + +/** Records [oldId] → [newId]; false (and logged) when it couldn't be saved, so no follow moves. */ +private fun RenumberedShows.recordOrWarn(oldId: String, newId: String): Boolean = record(oldId, newId).also { saved -> + if (!saved) Log.w("RenumberedShows", "Renumbering $oldId → $newId not saved; the follow stays") +} + +/** [podcastId] followed itself or through a followed `rss:` feed linked to it, as follow buttons show it. */ +private suspend fun PodcastDao.isFollowedAsShown(podcastId: String): Boolean = + getPodcast(podcastId)?.isSubscribed == true || + getSubscribedRssPodcastLinkedTo(podcastId) != null diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSync.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSync.kt index 022bab62b..870fe0e81 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSync.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSync.kt @@ -302,6 +302,7 @@ class SubscriptionForegroundSync( replaceAddress = subscriptionRepository::updateFeedAddress, markChecked = subscriptionRepository::markFeedAddressChecked, enabled = enabled, + onMerged = { oldId, newId -> subscriptionRepository.renumberedFollows.adoptKnown(oldId, confirmedNewId = newId) }, ), reporter = FeedReporter( @@ -330,6 +331,8 @@ class SubscriptionForegroundSync( return { SubscriptionForegroundSyncIngest.sweepExpiredLocalCatalogs(localEpisodeCatalog) runDailyAddressCheck(addressJobs.dailyCheck, subscriptionRepository) + // One follow per renumbered show before feeds are read (RenumberedShowFollows.collapseFollowsQuietly). + subscriptionRepository.renumberedFollows.collapseFollowsQuietly(podcastRepository::getPodcastDetails) val ids = subscriptionRepository.subscribedPodcastIds.first() val recoveryNow = System.currentTimeMillis() recoverMissingFeedUrls( diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionRepository.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionRepository.kt index 8cab021e9..20433478f 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionRepository.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscriptionRepository.kt @@ -13,6 +13,8 @@ import cx.aswin.boxlore.core.ranking.FeedbackTarget import cx.aswin.boxlore.core.ranking.RankingAction import cx.aswin.boxlore.core.ranking.RankingFeedbackRepository import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository +import cx.aswin.boxlore.core.rss.MemoryRenumberedShows +import cx.aswin.boxlore.core.rss.RenumberedShows import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -29,6 +31,22 @@ data class FeedAddressWiring( val pendingRegistrations: PendingNotificationRegistrations = MemoryPendingNotificationRegistrations(), ) +/** + * Where renumbered shows are recorded and how their listener rows are linked to the show they now + * answer as ([RenumberedShowFollows]). The defaults keep the record for one process and link nothing. + */ +data class RenumberingWiring( + val shows: RenumberedShows = MemoryRenumberedShows(), + val linkEpisodes: suspend (oldId: String, newId: String) -> Unit = { _, _ -> }, +) { + internal fun follows( + podcastDao: PodcastDao, + feedAddresses: FeedAddressUpdater, + library: RenumberedFollowWrites.Library, + unregister: suspend (PodcastEntity) -> Unit, + ): RenumberedShowFollows = RenumberedShowFollows(podcastDao, shows, RenumberedFollowWrites(podcastDao, feedAddresses, unregister, library, linkEpisodes)) +} + @Suppress("TooManyFunctions", "LongParameterList", "kotlin:S107") // Constructor injection from AppContainer; no DI framework. class SubscriptionRepository( private val podcastDao: PodcastDao, @@ -41,11 +59,15 @@ class SubscriptionRepository( private val rssNotificationRegistrations: RssNotificationRegistrationStore = MemoryRssNotificationRegistrations(), private val requestRssNotificationSync: () -> Unit = {}, feedAddressWiring: FeedAddressWiring = FeedAddressWiring(), + renumbering: RenumberingWiring = RenumberingWiring(), ) : PodcastNotificationSyncPort { /** The one writer of saved feed addresses (P4 PR 14). */ val feedAddresses: FeedAddressUpdater = feedAddressWiring.run { FeedAddressUpdater(podcastDao, requestNotificationRegistration, telemetry, pendingRegistrations) } + /** One follow for a show Podcast Index renumbered (docs/EPISODE_IDS.md §5, renumbered shows). */ + val renumberedFollows: RenumberedShowFollows = renumbering.follows(podcastDao, feedAddresses, RenumberedFollowWrites.Library(folderRepository, userPreferencesRepository, autoDownloadDao, localEpisodeCatalog)) { updateFirebaseSubscription(it.podcastId, it.title, it.imageUrl, false) } + private val notificationRegistrar by lazy { PodcastNotificationRegistrar(podcastDao) } val subscribedPodcastIds: Flow> = @@ -63,8 +85,17 @@ class SubscriptionRepository( .getSubscribedPodcasts() .map { list -> list.map { it.toPodcast() } } + /** + * Follows [podcast], or unfollows it when followed. [shownFollowed] is the follow state the + * caller showed, when it counts more than [podcast]'s own row (the show page counts a follow + * under a number Podcast Index retired): a follow of a show renumbered into [podcast] is + * [podcast]'s, so it moves here when the listener asked to follow, and ends with [podcast]'s + * when they asked to unfollow; a toggle never adds a second follow + * ([RenumberedShowFollows.endedFormerFollows]). + */ @Suppress("kotlin:S6619") - suspend fun toggleSubscription(podcast: Podcast) { + suspend fun toggleSubscription(podcast: Podcast, shownFollowed: Boolean? = null) { + if (renumberedFollows.endedFormerFollows(podcast, shownFollowed) { unsubscribeInternal(it.toPodcast(), it, it) }) return val existing = podcastDao.getPodcast(podcast.id) val linkedRss = if (!podcast.isRss) { @@ -76,6 +107,10 @@ class SubscriptionRepository( if (activeEntity?.isSubscribed == true) { unsubscribeInternal(podcast, activeEntity, existing) + // A feed's follow ends with any follow kept under a number its show replaced. + activeEntity.linkedPodcastIndexId?.takeIf { activeEntity.isRss }?.let { showId -> + renumberedFollows.endFormerFollowsOf(showId) { unsubscribeInternal(it.toPodcast(), it, it) } + } } else { // Subscribe (Upsert to ensure we have data for offline/Jump Back In) val entity = diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheckTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheckTest.kt index 48ef1a80e..d4af26bcb 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheckTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheckTest.kt @@ -169,6 +169,38 @@ class FeedAddressDailyCheckTest { assertEquals(OLD, rows.getValue("1").feedUrl) } + @Test + fun `a show the server says Podcast Index merged moves its follow, keeps its address and is stamped`() = runTest { + add(show("1258562")) + add(show("2")) + val merges = mutableListOf>() + val merging = + FeedAddressDailyCheck( + followedShows = { rows.values.toList() }, + fetchAddresses = { ids -> + V2ShowsFeedsResponse( + true, + ids.map { id -> if (id == 1258562L) address(id, url = null).copy(mergedInto = 7976624L) else address(id).copy(mergedInto = id) }, + ) + }, + readsAt = { _, _ -> true }, + replaceAddress = { change -> + changes += change + FeedAddressUpdate.UPDATED + }, + markChecked = { id, at -> rows[id] = rows.getValue(id).copy(feedUrlCheckedAt = at) }, + enabled = { true }, + nowMillis = { now }, + onMerged = { old, new -> merges += old to new }, + ) + + merging.run() + + assertEquals(listOf("1258562" to "7976624"), merges, "a mergedInto naming the show itself is ignored") + assertEquals(listOf("2"), changes.map { it.podcastId }, "the merged show's address is never swapped") + assertEquals(now, rows.getValue("1258562").feedUrlCheckedAt) + } + @Test fun `a swapped show is stamped by the swap itself, never stamped first and left unswapped`() = runTest { add(show("1")) diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryRenumberingTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryRenumberingTest.kt new file mode 100644 index 000000000..4d4a39c17 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryRenumberingTest.kt @@ -0,0 +1,102 @@ +package cx.aswin.boxlore.core.catalog + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.database.BoxLoreDatabase +import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.database.RssEpisodeDao +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.core.network.NetworkModule +import cx.aswin.boxlore.core.rss.RssPodcastRepository +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Show details asked for one numeric show ID that answer as another report a renumbering before + * the answer is returned, so every existing details call (the show page, a cloud pull's + * enrichment, address recovery) can move a follow; answers for the same ID, an address or a + * failing hook report nothing or still return the answer. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PodcastRepositoryRenumberingTest { + private lateinit var server: MockWebServer + private val reported = mutableListOf>() + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + RssPodcastRepository.clearInstanceForTests() + } + + @Test + fun anAnswerNamingAnotherShowIdIsReportedBeforeItIsReturned() = runBlocking { + server.enqueue(feed(id = 7976624)) + + val answer = repository().getPodcastDetails("1258562") + + assertEquals("7976624", answer?.id) + assertEquals(listOf("1258562" to "7976624"), reported) + } + + @Test + fun answersForTheSameIdOrAnAddressReportNothing() = runBlocking { + server.enqueue(feed(id = 7976624)) + server.enqueue(feed(id = 7976624)) + + repository().getPodcastDetails("7976624") + repository().getPodcastDetails("url:https%3A%2F%2Ffeeds.example%2Fshow.xml") + + assertEquals(emptyList>(), reported) + } + + @Test + fun aFailingHookStillReturnsTheAnswer() = runBlocking { + server.enqueue(feed(id = 7976624)) + + val answer = repository(onRenumbered = { _, _ -> error("not saved") }).getPodcastDetails("1258562") + + assertEquals("7976624", answer?.id) + } + + private fun repository(onRenumbered: suspend (String, Podcast) -> Unit = { old, answer -> reported += old to answer.id }): PodcastRepository { + val context = ApplicationProvider.getApplicationContext() + val client = OkHttpClient.Builder().readTimeout(5, TimeUnit.SECONDS).build() + val base = server.url("/").toString() + val database = mock(BoxLoreDatabase::class.java) + `when`(database.podcastDao()).thenReturn(mock(PodcastDao::class.java)) + `when`(database.rssEpisodeDao()).thenReturn(mock(RssEpisodeDao::class.java)) + return PodcastRepository( + baseUrl = base, + publicKey = "unused", + context = context, + rssRepository = RssPodcastRepository.createForTests(context, database), + boxLoreApi = NetworkModule.createBoxLoreApi(base, client), + boxLoreApiV2 = NetworkModule.createBoxLoreApiV2(base, client), + onShowRenumbered = onRenumbered, + ) + } + + private fun feed(id: Long) = + MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("""{"status":"true","feed":{"id":$id,"title":"The Desi Crime Podcast","url":"https://feeds.acast.example/desi"}}""") +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/RenumberedFollowMergeTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/RenumberedFollowMergeTest.kt new file mode 100644 index 000000000..543103ce9 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/RenumberedFollowMergeTest.kt @@ -0,0 +1,91 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.database.PodcastEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The pure rules a renumbered show's follow moves by ([RenumberedFollowMerge]). */ +class RenumberedFollowMergeTest { + @Test + fun aFollowedNewShowKeepsItsOwnChoicesAndFollowTimeAndTakesAnyOnSwitch() { + val old = row("1", subscribedAt = 100L).copy(notificationsEnabled = true, customGenre = "Old genre", preferredSort = "oldest", skipEndingOverrideMs = 5_000L) + val current = row("2", subscribedAt = 200L).copy(autoDownloadEnabled = true, customGenre = "New genre", customGenreIcon = "icon") + + val move = RenumberedFollowMerge.move(old, "2", current, answer = null, nowMs = 1_000L) + + assertEquals("the follow time is never moved earlier", 200L, move.kept.subscribedAt) + assertTrue(move.kept.notificationsEnabled) + assertTrue(move.kept.autoDownloadEnabled) + assertEquals("New genre", move.kept.customGenre) + assertEquals("icon", move.kept.customGenreIcon) + assertEquals("oldest", move.kept.preferredSort) + assertEquals(5_000L, move.kept.skipEndingOverrideMs) + assertTrue(move.notificationsMoved) + assertFalse("auto-download was already on", move.autoDownloadMoved) + assertFalse(move.retired.isSubscribed) + assertEquals(1_000L, move.retired.unsubscribedAt) + assertTrue(move.kept.isDirty && move.retired.isDirty) + } + + @Test + fun anUnfollowedStoredNewShowKeepsItsNamespaceAndTakesTheOldFollow() { + val old = row("1", subscribedAt = 100L).copy(epNs = 1L, customGenre = "Crime") + val stored = row("2", subscribedAt = 0L).copy(isSubscribed = false, unsubscribedAt = 50L, epNs = 2L, title = "Stored title") + + val move = RenumberedFollowMerge.move(old, "2", stored, answer = null, nowMs = 1_000L) + + assertTrue(move.kept.isSubscribed) + assertEquals("followed at the move, never at the old show's earlier time", 1_000L, move.kept.subscribedAt) + assertEquals(0L, move.kept.unsubscribedAt) + assertEquals("Crime", move.kept.customGenre) + assertEquals("Stored title", move.kept.title) + assertEquals("a stored row keeps its own namespace, never the old show's", 2L, move.kept.epNs) + } + + @Test + fun aMovedFollowIsNeverOlderThanTheNewShowsLastUnfollow() { + val old = row("1", subscribedAt = 100L) + // A clock behind the stored unfollow (another device's, pulled earlier) still follows after it. + val stored = row("2", subscribedAt = 0L).copy(isSubscribed = false, unsubscribedAt = 5_000L) + + val move = RenumberedFollowMerge.move(old, "2", stored, answer = null, nowMs = 1_000L) + + assertEquals(5_001L, move.kept.subscribedAt) + } + + @Test + fun sharedFeedsAreFoundByAddressOrGuidNeverByTitle() { + val followed = + listOf( + row("1", feedUrl = "https://feeds.acast.com/public/shows/x"), + row("2", feedUrl = "http://www.feeds.acast.com/public/shows/x/"), + row("3", feedUrl = "https://feeds.megaphone.fm/x").copy(title = "Same title"), + row("4", feedUrl = "https://other.example/y").copy(title = "Same title"), + row("5", feedUrl = "https://a.example").copy(podcastGuid = "GUID-1"), + row("6", feedUrl = "https://b.example").copy(podcastGuid = "guid-1"), + row("rss:7", feedUrl = "https://feeds.acast.com/public/shows/x").copy(sourceType = PodcastEntity.SOURCE_RSS), + ) + + assertEquals(setOf("1", "2", "5", "6"), RenumberedFollowMerge.sharedFeedCandidates(followed).toSet()) + } + + @Test + fun idListsReplaceTheOldIdInPlaceOnce() { + assertEquals(listOf("a", "2", "b"), RenumberedFollowMerge.replacedId(listOf("a", "1", "b"), "1", "2")) + assertEquals(listOf("2", "a"), RenumberedFollowMerge.replacedId(listOf("2", "a", "1"), "1", "2")) + assertEquals(listOf("a"), RenumberedFollowMerge.replacedId(listOf("a"), "1", "2")) + } + + private fun row(id: String, subscribedAt: Long = 100L, feedUrl: String? = null) = PodcastEntity( + podcastId = id, + title = "Show $id", + author = "", + imageUrl = "", + description = null, + isSubscribed = true, + subscribedAt = subscribedAt, + feedUrl = feedUrl, + ) +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/RenumberedShowFollowsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/RenumberedShowFollowsTest.kt new file mode 100644 index 000000000..8721acab2 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/RenumberedShowFollowsTest.kt @@ -0,0 +1,523 @@ +package cx.aswin.boxlore.core.catalog + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.catalog.sync.SubscriptionSyncResolver +import cx.aswin.boxlore.core.database.BoxLoreDatabase +import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.model.FolderDisplaySize +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.core.model.SubscriptionFolder +import cx.aswin.boxlore.core.network.NetworkModule +import cx.aswin.boxlore.core.network.model.UserSubscriptionSyncDto +import cx.aswin.boxlore.core.rss.MemoryRenumberedShows +import cx.aswin.boxlore.core.rss.RenumberedShows +import cx.aswin.boxlore.core.rss.RssPodcastRepository +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * One follow for a show Podcast Index renumbered, through [SubscriptionRepository] on real Room: + * the listener's case (both shows followed after tapping Subscribe on the renumbered page), a follow + * of the old show only, cloud pulls that must not undo the move, notifications that move through + * the pending-registration journal, the foreground pass and the show page's toggle. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RenumberedShowFollowsTest { + private lateinit var database: BoxLoreDatabase + private lateinit var podcastDao: PodcastDao + private lateinit var repository: SubscriptionRepository + private val shows = MemoryRenumberedShows() + private val pending = MemoryPendingNotificationRegistrations() + private val registrationRequests = mutableListOf() + private val links = mutableListOf>() + private val folders = MembershipFolders() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + podcastDao = database.podcastDao() + repository = + SubscriptionRepository( + podcastDao = podcastDao, + folderRepository = folders, + feedAddressWiring = FeedAddressWiring(requestNotificationRegistration = { registrationRequests += it }, pendingRegistrations = pending), + renumbering = RenumberingWiring(shows = shows, linkEpisodes = { old, new -> links += old to new }), + ) + } + + @After + fun tearDown() { + database.close() + RssPodcastRepository.clearInstanceForTests() + } + + @Test + fun bothShowsFollowedCollapseIntoTheNewShowWithMergedSettings() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null)) + podcastDao.upsert(row(NEW, notifications = false, subscribedAt = FOLLOWED_AT, epNs = NEW.toLong(), genre = "True crime")) + folders.add(OLD, "folder-1") + + assertTrue(repository.renumberedFollows.adoptAnswer(OLD, answer())) + + val kept = podcastDao.getPodcast(NEW)!! + assertTrue(kept.isSubscribed) + assertTrue("notifications on when either was", kept.notificationsEnabled) + assertEquals(FOLLOWED_AT, kept.subscribedAt) + assertEquals("True crime", kept.customGenre) + assertEquals("the new show keeps its namespace", NEW.toLong(), kept.epNs) + assertTrue("the follow reaches the next push", kept.isDirty) + val retired = podcastDao.getPodcast(OLD)!! + assertFalse(retired.isSubscribed) + assertFalse(retired.notificationsEnabled) + assertTrue("the unfollow reaches the next push", retired.isDirty) + assertTrue(retired.unsubscribedAt > FOLLOWED_AT) + assertEquals(setOf(NEW), pending.all()) + assertEquals(listOf(NEW), registrationRequests) + assertEquals(setOf(NEW), folders.membersOf("folder-1")) + assertEquals(NEW, shows.currentId(OLD)) + assertEquals(listOf(OLD to NEW), links) + assertEquals(setOf(NEW), repository.subscribedPodcastIds.first()) + } + + @Test + fun aFollowOfTheOldShowOnlyMovesWithItsSettingsButNotItsNamespace() = runTest { + podcastDao.upsert( + row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = OLD.toLong(), genre = "Crime") + .copy(autoDownloadEnabled = true, preferredSort = "oldest", type = "serial", skipBeginningOverrideMs = 30_000L), + ) + + assertTrue(repository.renumberedFollows.adoptAnswer(OLD, answer())) + + val kept = podcastDao.getPodcast(NEW)!! + assertTrue(kept.isSubscribed) + assertTrue("followed at the move, never at the old show's earlier time", kept.subscribedAt > FOLLOWED_AT) + assertTrue(kept.notificationsEnabled) + assertTrue(kept.autoDownloadEnabled) + assertEquals("Crime", kept.customGenre) + assertEquals("oldest", kept.preferredSort) + assertEquals(30_000L, kept.skipBeginningOverrideMs) + assertEquals("The Desi Crime Podcast", kept.title) + assertEquals(FEED, kept.feedUrl) + assertNull("a new row never takes the old show's namespace", kept.epNs) + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + assertEquals(listOf(NEW), registrationRequests) + } + + @Test + fun aMergeTheServerConfirmedMovesTheFollowWithoutAnyDetailsLookup() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null)) + + assertTrue(repository.renumberedFollows.adoptKnown(OLD, confirmedNewId = NEW)) + + assertTrue(podcastDao.getPodcast(NEW)!!.isSubscribed) + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + assertEquals(NEW, repository.renumberedFollows.currentId(OLD)) + assertFalse("the same merge again moves nothing", repository.renumberedFollows.adoptKnown(OLD, confirmedNewId = NEW)) + assertFalse("not a renumbering", repository.renumberedFollows.adoptKnown(NEW, confirmedNewId = NEW)) + assertFalse("rss: shows are never merged", repository.renumberedFollows.adoptKnown("rss:abc", confirmedNewId = NEW)) + } + + @Test + fun aMoveIsIdempotentAndAPullOfTheOldFollowDoesNotUndoIt() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null)) + podcastDao.upsert(row(NEW, notifications = false, subscribedAt = FOLLOWED_AT, epNs = NEW.toLong())) + repository.renumberedFollows.adoptAnswer(OLD, answer()) + + assertFalse("a second answer moves nothing", repository.renumberedFollows.adoptAnswer(OLD, answer())) + assertEquals(listOf(NEW), registrationRequests) + + // The cloud still holds both follows from before the move. + val resolver = SubscriptionSyncResolver(podcastDao = podcastDao) + resolver.resolveSubscription(remote(OLD, notifications = true), syncedAt = FOLLOWED_AT + 10) + resolver.resolveSubscription(remote(NEW, notifications = false), syncedAt = FOLLOWED_AT + 10) + + val retired = podcastDao.getPodcast(OLD)!! + assertFalse("the older follow loses to the newer unfollow", retired.isSubscribed) + assertTrue("and the unfollow is pushed again", retired.isDirty) + val kept = podcastDao.getPodcast(NEW)!! + assertTrue(kept.isSubscribed) + assertTrue("an unpushed merge isn't overwritten by the pull", kept.notificationsEnabled) + } + + @Test + fun aRepeatedFollowOfTheOldShowFromAnotherDeviceCollapsesOnTheNextPass() = runTest { + podcastDao.upsert(row(OLD, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null)) + repository.renumberedFollows.adoptAnswer(OLD, answer()) + // An older build elsewhere follows the old number again later; the pull brings it back. + podcastDao.upsert(podcastDao.getPodcast(OLD)!!.copy(isSubscribed = true, subscribedAt = FOLLOWED_AT * 2, unsubscribedAt = 0L, isDirty = false)) + var lookups = 0 + + repository.renumberedFollows.collapseFollows( + lookupDetails = { + lookups++ + null + }, + ) + + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + assertTrue(podcastDao.getPodcast(NEW)!!.isSubscribed) + assertEquals("a recorded renumbering needs no lookup", 0, lookups) + } + + @Test + fun theForegroundPassAsksOnlyShowsFollowedTwiceUnderOneFeedOncePerProcess() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null)) + podcastDao.upsert(row(NEW, notifications = false, subscribedAt = FOLLOWED_AT, epNs = NEW.toLong()).copy(feedUrl = FEED.replace("https://", "http://") + "/")) + podcastDao.upsert(row(LOOKALIKE, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null).copy(feedUrl = "https://feeds.megaphone.example/desi")) + val asked = mutableListOf() + val lookup: suspend (String) -> Podcast? = { id -> + asked += id + if (id == OLD) answer() else answer().copy(id = id) + } + + repository.renumberedFollows.collapseFollows(lookupDetails = lookup) + repository.renumberedFollows.collapseFollows(lookupDetails = lookup) + + assertEquals("never matched by title, asked once each", setOf(OLD, NEW), asked.toSet()) + assertEquals(2, asked.size) + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + assertTrue(podcastDao.getPodcast(NEW)!!.isSubscribed) + assertTrue(podcastDao.getPodcast(LOOKALIKE)!!.isSubscribed) + } + + @Test + fun aMovedFollowSurvivesAPullOfANewerUnfollowOfTheNewShow() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null)) + // The new show was followed by mistake and unfollowed since, here and on another device. + podcastDao.upsert(row(NEW, notifications = false, subscribedAt = 0L, epNs = NEW.toLong()).copy(isSubscribed = false, unsubscribedAt = UNFOLLOWED_AT)) + assertTrue(repository.renumberedFollows.adoptAnswer(OLD, answer())) + + val resolver = SubscriptionSyncResolver(podcastDao = podcastDao) + resolver.resolveSubscription(tombstone(NEW, UNFOLLOWED_AT), syncedAt = UNFOLLOWED_AT + 10) + + val kept = podcastDao.getPodcast(NEW)!! + assertTrue("the older unfollow never beats the moved follow", kept.isSubscribed) + assertTrue("and the follow is pushed again", kept.isDirty) + assertTrue(kept.notificationsEnabled) + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + } + + @Test + fun aPullFromTheStartMovesTheOldFollowAndKeepsItAgainstTheNewShowsUnfollow() = runTest { + val server = MockWebServer() + server.start() + try { + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(DETAILS_OF_NEW)) + val resolver = SubscriptionSyncResolver(podcastDao = podcastDao, podcastRepository = podcastRepository(server)) + + // A new device pulls the old follow, whose details name the new show, then the new show's unfollow. + resolver.resolveSubscription(remote(OLD, notifications = true), syncedAt = UNFOLLOWED_AT + 10) + resolver.resolveSubscription(tombstone(NEW, UNFOLLOWED_AT), syncedAt = UNFOLLOWED_AT + 10) + + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + val kept = podcastDao.getPodcast(NEW)!! + assertTrue("the show stays followed", kept.isSubscribed) + assertTrue(kept.notificationsEnabled) + assertEquals(NEW, shows.currentId(OLD)) + } finally { + server.shutdown() + } + } + + @Test + fun theShowPageToggleOfferingUnfollowEndsEveryFollow() = runTest { + podcastDao.upsert(row(OLD, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null)) + shows.record(OLD, NEW) + + // The page for the new show read the old follow as its own and offered Unfollow. + repository.toggleSubscription(answer(), shownFollowed = true) + + assertFalse(repository.isSubscribed(OLD)) + assertFalse(repository.isSubscribed(NEW)) + } + + @Test + fun aSubscribeOfferedWhileTheOldShowIsFollowedFollowsTheNewShowWithItsSettings() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null, genre = "Crime")) + shows.record(OLD, NEW) + + // A search card or a page opened on the new number shows Subscribe from the new show's own row. + repository.toggleSubscription(answer()) + + val kept = podcastDao.getPodcast(NEW)!! + assertTrue(kept.isSubscribed) + assertTrue("the old follow's settings come along", kept.notificationsEnabled) + assertEquals("Crime", kept.customGenre) + assertFalse(repository.isSubscribed(OLD)) + + // The same from the show page, which says what it showed. + podcastDao.upsert(kept.copy(isSubscribed = false, unsubscribedAt = kept.subscribedAt + 1)) + podcastDao.upsert(podcastDao.getPodcast(OLD)!!.copy(isSubscribed = true, subscribedAt = kept.subscribedAt + 2, unsubscribedAt = 0L)) + repository.toggleSubscription(answer(), shownFollowed = false) + assertTrue(repository.isSubscribed(NEW)) + assertFalse(repository.isSubscribed(OLD)) + } + + @Test + fun theForegroundPassAsksBeyondTheFirstLookupsAndRetriesFailedOnes() = runTest { + // Four shows each followed twice under one feed: eight candidates, the renumbered pair among them. + val pairs = listOf("1001" to "1002", "1003" to "1004", "1005" to "1006") + for ((index, pair) in pairs.withIndex()) { + for (id in pair.toList()) { + podcastDao.upsert(row(id, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null).copy(title = "A$index", feedUrl = "https://feeds.example/$index")) + } + } + podcastDao.upsert(row(OLD, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null).copy(title = "Z")) + podcastDao.upsert(row(NEW, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null).copy(title = "Z")) + val asked = mutableListOf() + var offline = true + val lookup: suspend (String) -> Podcast? = { id -> + asked += id + when { + offline -> error("offline") + id == OLD -> answer() + else -> answer().copy(id = id) + } + } + + repository.renumberedFollows.collapseFollows(lookupDetails = lookup) + assertEquals("at most six per pass", 6, asked.size) + assertTrue("nothing settled offline", podcastDao.getPodcast(OLD)!!.isSubscribed) + + offline = false + asked.clear() + repository.renumberedFollows.collapseFollows(lookupDetails = lookup) + repository.renumberedFollows.collapseFollows(lookupDetails = lookup) + + assertEquals("every candidate is asked, failed ones again, answered ones once", 8, asked.size) + assertEquals(8, asked.toSet().size) + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + assertTrue(podcastDao.getPodcast(NEW)!!.isSubscribed) + } + + @Test + fun rssShowsAndNonRenumberingsAreNeverTouched() = runTest { + podcastDao.upsert(row(OLD, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null)) + val feedShow = + row("rss:abc", notifications = false, subscribedAt = FOLLOWED_AT, epNs = null) + .copy(sourceType = PodcastEntity.SOURCE_RSS, linkedPodcastIndexId = NEW) + podcastDao.upsert(feedShow) + + assertFalse(repository.renumberedFollows.adoptAnswer("rss:abc", answer())) + assertFalse(repository.renumberedFollows.adoptAnswer(OLD, answer().copy(id = OLD))) + assertTrue(podcastDao.getPodcast(OLD)!!.isSubscribed) + + // The new show is followed through its own feed: that follow stays, the old one ends. + folders.add(OLD, "folder-1") + assertTrue(repository.renumberedFollows.adoptAnswer(OLD, answer())) + assertEquals(feedShow.copy(isDirty = feedShow.isDirty), podcastDao.getPodcast("rss:abc")) + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + assertNull("no follow is added under the Podcast Index number", podcastDao.getPodcast(NEW)) + assertEquals("the folder place goes to the feed's show", setOf("rss:abc"), folders.membersOf("folder-1")) + } + + @Test + fun aFollowWithNotificationsStaysWhenTheNewShowIsFollowedThroughItsFeedWithout() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null)) + podcastDao.upsert( + row("rss:abc", notifications = false, subscribedAt = FOLLOWED_AT, epNs = null) + .copy(sourceType = PodcastEntity.SOURCE_RSS, linkedPodcastIndexId = NEW), + ) + folders.add(OLD, "folder-1") + + assertFalse(repository.renumberedFollows.adoptAnswer(OLD, answer())) + + val old = podcastDao.getPodcast(OLD)!! + assertTrue("notifications are never dropped", old.isSubscribed && old.notificationsEnabled) + assertEquals(setOf(OLD), folders.membersOf("folder-1")) + assertFalse(podcastDao.getPodcast("rss:abc")!!.notificationsEnabled) + } + + @Test + fun theFollowedFeedCountsWhenAnUnfollowedOneIsLinkedToo() = runTest { + podcastDao.upsert(row(OLD, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null)) + podcastDao.upsert( + row("rss:earlier", notifications = false, subscribedAt = 0L, epNs = null) + .copy(sourceType = PodcastEntity.SOURCE_RSS, linkedPodcastIndexId = NEW, isSubscribed = false), + ) + podcastDao.upsert( + row("rss:abc", notifications = false, subscribedAt = FOLLOWED_AT, epNs = null) + .copy(sourceType = PodcastEntity.SOURCE_RSS, linkedPodcastIndexId = NEW), + ) + + assertTrue(repository.renumberedFollows.adoptAnswer(OLD, answer())) + + assertNull("the feed's follow is the show's: none is added under the new number", podcastDao.getPodcast(NEW)) + assertTrue(podcastDao.getPodcast("rss:abc")!!.isSubscribed) + assertFalse(podcastDao.getPodcast(OLD)!!.isSubscribed) + } + + @Test + fun unfollowingTheFeedAlsoEndsTheFollowKeptUnderTheOldNumber() = runTest { + podcastDao.upsert(row(OLD, notifications = true, subscribedAt = FOLLOWED_AT, epNs = null)) + podcastDao.upsert( + row("rss:abc", notifications = false, subscribedAt = FOLLOWED_AT, epNs = null) + .copy(sourceType = PodcastEntity.SOURCE_RSS, linkedPodcastIndexId = NEW), + ) + assertFalse(repository.renumberedFollows.adoptAnswer(OLD, answer())) + + repository.toggleSubscription(podcastDao.getPodcast("rss:abc")!!.toPodcast(), shownFollowed = true) + + assertFalse(podcastDao.getPodcast("rss:abc")!!.isSubscribed) + assertFalse("the page no longer counts a follow", podcastDao.getPodcast(OLD)!!.isSubscribed) + } + + @Test + fun aRenumberingThatCouldNotBeSavedMovesNoFollow() = runTest { + val unsaved = + object : RenumberedShows { + override fun all(): Map = emptyMap() + + override fun record(oldId: String, newId: String): Boolean = false + } + val repo = + SubscriptionRepository( + podcastDao = podcastDao, + folderRepository = folders, + feedAddressWiring = FeedAddressWiring(requestNotificationRegistration = { registrationRequests += it }, pendingRegistrations = pending), + renumbering = RenumberingWiring(shows = unsaved, linkEpisodes = { old, new -> links += old to new }), + ) + podcastDao.upsert(row(OLD, notifications = false, subscribedAt = FOLLOWED_AT, epNs = null)) + + assertFalse(repo.renumberedFollows.adoptAnswer(OLD, answer())) + assertFalse(repo.renumberedFollows.adoptKnown(OLD, confirmedNewId = NEW)) + + assertTrue("the follow stays where the phone can still find it", podcastDao.getPodcast(OLD)!!.isSubscribed) + assertNull(podcastDao.getPodcast(NEW)) + } + + private fun row(id: String, notifications: Boolean, subscribedAt: Long, epNs: Long?, genre: String? = null) = PodcastEntity( + podcastId = id, + title = "The Desi Crime Podcast", + author = "Host", + imageUrl = "https://img.example/$id.jpg", + description = null, + isSubscribed = true, + subscribedAt = subscribedAt, + isDirty = false, + notificationsEnabled = notifications, + feedUrl = FEED, + epNs = epNs, + customGenre = genre, + ) + + private fun tombstone(id: String, at: Long) = UserSubscriptionSyncDto( + podcastId = id, + isSubscribed = false, + subscribedAt = 0L, + unsubscribedAt = at, + updatedAt = at, + ) + + private fun podcastRepository(server: MockWebServer): PodcastRepository { + val context = ApplicationProvider.getApplicationContext() + val client = OkHttpClient.Builder().readTimeout(5, TimeUnit.SECONDS).build() + val base = server.url("/").toString() + return PodcastRepository( + baseUrl = base, + publicKey = "unused", + context = context, + rssRepository = RssPodcastRepository.createForTests(context, database), + boxLoreApi = NetworkModule.createBoxLoreApi(base, client), + boxLoreApiV2 = NetworkModule.createBoxLoreApiV2(base, client), + onShowRenumbered = { old, answer -> repository.renumberedFollows.adoptAnswer(old, answer) }, + ) + } + + private fun answer() = Podcast(id = NEW, title = "The Desi Crime Podcast", artist = "Host", imageUrl = "https://img.example/new.jpg", feedUrl = FEED) + + private fun remote(id: String, notifications: Boolean) = UserSubscriptionSyncDto( + podcastId = id, + isSubscribed = true, + subscribedAt = FOLLOWED_AT, + notificationsEnabled = notifications, + feedUrl = FEED, + updatedAt = FOLLOWED_AT, + ) + + /** Folders that only record memberships. */ + private class MembershipFolders : FolderRepository { + private val members = HashMap>() + + fun add(podcastId: String, folderId: String) { + members.getOrPut(folderId) { mutableSetOf() } += podcastId + } + + fun membersOf(folderId: String): Set = members[folderId].orEmpty() + + override val folders: Flow> = emptyFlow() + override val folderNames: Flow> = emptyFlow() + + override suspend fun getFolders(): List = members.map { (id, ids) -> SubscriptionFolder(id = id, name = id, podcastIds = ids.toList()) } + + override suspend fun getFolder(folderId: String): SubscriptionFolder? = getFolders().firstOrNull { it.id == folderId } + + override fun getFolderFlow(folderId: String): Flow = emptyFlow() + + override suspend fun createFolder( + name: String, + icon: String?, + displaySize: FolderDisplaySize, + linkedGenre: String?, + showPodcastGrid: Boolean, + podcastIds: List, + ): SubscriptionFolder = SubscriptionFolder(id = name, name = name) + + override suspend fun restoreFolder(folder: SubscriptionFolder): SubscriptionFolder = folder + + override suspend fun updateFolder(folder: SubscriptionFolder) = Unit + + override suspend fun deleteFolder(folderId: String) = Unit + + override suspend fun addPodcastToFolder(podcastId: String, folderId: String) = add(podcastId, folderId) + + override suspend fun removePodcastFromFolder(podcastId: String, folderId: String) { + members[folderId]?.remove(podcastId) + } + + override suspend fun removePodcastFromAllFolders(podcastId: String) { + members.values.forEach { it.remove(podcastId) } + } + + override suspend fun setPodcastsForFolder(folderId: String, podcastIds: List) { + members[folderId] = podcastIds.toMutableSet() + } + + override suspend fun syncLinkedGenres() = Unit + + override suspend fun autoOrganizeSubscribedShows(defaultDisplaySize: FolderDisplaySize?, showPodcastGrid: Boolean) = Unit + } + + private companion object { + const val OLD = "1258562" + const val NEW = "7976624" + const val LOOKALIKE = "5200907" + const val FOLLOWED_AT = 1_700_000_000_000L + + /** After [FOLLOWED_AT], before the test runs. */ + const val UNFOLLOWED_AT = FOLLOWED_AT + 60_000L + const val DETAILS_OF_NEW = """{"status":"true","feed":{"id":7976624,"title":"The Desi Crime Podcast","url":"https://feeds.acast.example/public/shows/desi-crime"}}""" + const val FEED = "https://feeds.acast.example/public/shows/desi-crime" + } +} diff --git a/core/database/README.md b/core/database/README.md index 9bde7a300..717c73bae 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -103,6 +103,7 @@ src/main/java/cx/aswin/boxlore/core/database/ - Entity/table schemas and migrations are release-critical persisted contracts. Never edit a migration or an exported schema once a phone may have run it: Room refuses to open a database whose schema hash differs at the same version. Later schema changes go to a new version (v40 and up). - `BoxLoreDatabase.builder` is the one database configuration, used by `getDatabase` and the upgrade tests. Upgrades from version 12 on run `BoxLoreDatabase.ALL_MIGRATIONS`; a version with no path fails to open with an error instead of silently erasing history, queue, downloads and likes. Versions 1–11, which predate every migration, and opening a newer file with an older build (a downgrade) still recreate the database empty. - `EpisodeAliasProvenance.ORPHAN_REMATCH` (`orphan_rematch`) marks a phone-minted ID whose state the orphan rematch moved onto a catalog row it matched by its exact enclosure address. `EpisodeAliasProvenance.CATALOG_KEY_MATCH` (`catalog_key_match`) marks a catalog row's ID that joined an episode because a trusted alias already held its (show, key) under another ID (the episode's row was evicted and the feed read again). `EpisodeAliasProvenance.RSS_SOURCE_LINK` (`rss_source_link`) marks an RSS episode ID of the feed a Podcast Index show was switched to, recorded under the Podcast Index show as a non-owner (ownership then follows the owner rule): trusted when an old ID's trusted key is the RSS episode's key and no other RSS episode has it, or an old ID's exact audio address names it alone, ambiguous otherwise until a later link finds exact evidence. `EpisodeAliasProvenance.RSS_SOURCE_AUDIO_MATCH` (`rss_source_audio_match`) marks an old ID of the Podcast Index show, with no usable key before, that such a link settled by its exact audio address. `EpisodeAliasProvenance.CLOUD_SYNC` (`cloud_sync`) marks an ID a cloud sync pull recorded: trusted when the pulled row named, by catalog key (else by a recipe ID exactly one key of the show has), an episode this phone already holds under a trusted ID; unresolved when the phone doesn't know the episode yet. Such an alias is never the owner when it is recorded. Stored provenance and state strings are never renamed. +- `EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH` (`renumbered_show_match`) marks an ID of a Podcast Index show that Podcast Index renumbered, with no usable key before (no alias, or an unresolved one under the old show), that joined one stored episode of the show it now answers as: recorded trusted under the new show with that episode's key, as a non-owner, while its history, queue and download rows keep their ID and old show ([docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md#renumbered-shows), `RenumberedShowLinker` in `:core:rss`). It also marks the trusted aliases of an old show's (show, key), the shape of a show whose catalog was read before the renumbering, moved to the new show's (show, key) with that episode's recipe ID, as non-owners. `PodcastDao.moveRenumberedFollow { … }` runs a follow move in one transaction: the caller reads both rows, merges them and writes them through `upsert` (each keeps its stored namespace) with no other writer in between; `LocalEpisodeCatalogDao.listForShow` reads the identity, title and date of one show's stored rows for that link (`LocalEpisodeLinkRow`, no descriptions), and `PodcastDao.getSubscribedRssPodcastLinkedTo` finds the followed `rss:` feed linked to a show even when an unfollowed one is linked too. DAO additions only; Room stays at version 39. - Episode aliases, group state, the `legacy` mark and alias provenance are permanent: old local IDs (Podcast Index, phone-minted negative and recipe IDs) keep resolving through them after their catalog rows are evicted. Alias `catalogKey` values are the recipe-trimmed keys of [`docs/EPISODE_IDS.md`](../../docs/EPISODE_IDS.md). - Package root is `cx.aswin.boxlore.core.database`. - Ranking persistence is owned by `:core:ranking` in `adaptive_ranking_database`. diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/EpisodeAliasEntity.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/EpisodeAliasEntity.kt index 7bbfa1dd9..f275c0e96 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/EpisodeAliasEntity.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/EpisodeAliasEntity.kt @@ -120,6 +120,19 @@ object EpisodeAliasProvenance { */ const val RSS_SOURCE_AUDIO_MATCH = "rss_source_audio_match" + /** + * An ID of a Podcast Index show that Podcast Index renumbered (its details now answer as another + * show ID), with no usable key (no alias, or an unresolved one under the old show), settled under + * the show it now answers as: exactly one of that show's catalog rows has the audio address of + * the ID's rows (else, for queue and download rows, the title and a date within two days), no + * other old ID points at it, and that row's ID is a trusted, keyed alias of the new show. The ID + * joins that episode as a non-owner; its history, queue and download rows keep their ID and show. + * Also marks a trusted alias of an old-show episode (show, key) moved to the new show's episode + * with the same key, with that episode's recipe ID, as a non-owner, once no catalog row holds it. + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ + const val RENUMBERED_SHOW_MATCH = "renumbered_show_match" + /** * Recorded by a catalog read for a new episode that took its recipe ID. * 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 8d39f4a62..0c8906e07 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 @@ -75,6 +75,16 @@ interface LocalEpisodeCatalogDao { ) suspend fun listIdentities(podcastId: String): List + /** + * The identity, title and date of every stored episode of one show, for linking the listener + * rows of a show Podcast Index renumbered to this show's episodes (`RenumberedShowLinker` in + * `:core:rss`). Descriptions and other large columns aren't read. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ + @Query("SELECT episodeId, guid, audioUrl, title, publishedDate, legacy FROM local_episodes WHERE podcastId = :podcastId") + suspend fun listForShow(podcastId: String): List + /** * Owners of these episode IDs across every show, so a write can check before [upsertEpisodes] * replaces a row (docs/EPISODE_IDS.md §4). Callers pass at most 500 IDs per call. diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt index d0dcfb1f0..6fa960b40 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeEntity.kt @@ -99,5 +99,20 @@ data class LocalEpisodeEntity( /** Lightweight identity map for sticky upserts (no payload). */ data class LocalEpisodeIdentity(val episodeId: String, val guid: String, val audioUrl: String,) +/** + * What linking a renumbered show's listener rows reads of one stored episode + * ([LocalEpisodeCatalogDao.listForShow]); [guid] stores the catalog key. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ +data class LocalEpisodeLinkRow( + val episodeId: String, + val guid: String, + val audioUrl: String, + val title: String, + val publishedDate: Long, + val legacy: Boolean, +) + /** Which (show, catalog key) holds a local episode ID; [guid] stores the catalog key. */ data class LocalEpisodeOwner(val episodeId: String, val podcastId: String, val guid: String,) diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt index 5791970e2..15684e657 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/PodcastDao.kt @@ -87,9 +87,38 @@ interface PodcastDao { @Query("SELECT * FROM podcasts WHERE linkedPodcastIndexId = :podcastIndexId LIMIT 1") suspend fun getRssPodcastLinkedTo(podcastIndexId: String): PodcastEntity? + /** + * The followed `rss:` feed linked to [podcastIndexId], when several feed rows (one unfollowed) + * are linked to it. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ + @Query( + """ + SELECT * FROM podcasts + WHERE linkedPodcastIndexId = :podcastIndexId AND sourceType = 'rss' AND isSubscribed = 1 + ORDER BY subscribedAt DESC LIMIT 1 + """, + ) + suspend fun getSubscribedRssPodcastLinkedTo(podcastIndexId: String): PodcastEntity? + @Query("SELECT * FROM podcasts WHERE podcastId = :id") suspend fun getPodcast(id: String): PodcastEntity? + /** + * Runs [move] in one transaction: it reads the old and new show rows of a follow moving from a + * show Podcast Index renumbered, merges them and writes both through [upsert] (so each keeps its + * stored namespace), with no other writer (a cloud pull, a toggle, the foreground pass) landing + * between the read and the write. The caller writes both rows dirty, so the next sync push + * carries the follow and the unfollow. + * + * Added for renumbered shows (`RenumberedShowFollows` in `:core:catalog`). + */ + @Transaction + suspend fun moveRenumberedFollow(move: suspend () -> Unit) { + move() + } + @Query("SELECT * FROM podcasts WHERE podcastId IN (:ids)") suspend fun getPodcastsByIds(ids: List): List diff --git a/core/network/README.md b/core/network/README.md index ff86af9d1..1fc02797b 100644 --- a/core/network/README.md +++ b/core/network/README.md @@ -10,7 +10,7 @@ Owns the Retrofit API boundary, OkHttp/Retrofit construction, request and respon - `BoxLoreApiV2` is the `/v2` surface for builds that use boxlore's own episode IDs. Paths are relative (`v2/...`) and there is no app-key parameter. It covers show details and addresses (`getPodcast`, `getShowFeeds` for the daily check of up to 500 shows, `getFeed` for an http-only show's feed document, `reportFeed`), episodes (`getEpisode`, `getEpisodes` as the last-resort list, `registerEpisode` before sharing, `getSimilarEpisodes`), search (`search`, `searchSemantic`) and discovery (trending, home bootstrap POST and GET, recommendations, because-you-like, curated vibe and curiosity, briefing metadata and `getBriefingResource`). Discovery routes answer in the v1 response shapes. - On `/v2`, an episode's `id` is its recipe ID. `EpisodeItem` carries the optional `servedId` (the ID older builds were given) and `catalogKey`, both absent on v1. `EpisodeItem.v2Identity()` returns recipe ID, show ID, catalog key and served ID (as a decimal string), or null when `id` is outside the recipe range or the show is missing. Use it only on `/v2` answers. - `V2EpisodeIds` holds the range checks: recipe IDs in `[2^52, 2^53 − 1]`, show IDs and namespaces in `1..2^53 − 1`, `isEpisodeShowId` for the episode routes (`/v2/episode`, `/v2/episodes` and registration take only shows below 2^52), a strict `parseShowId` for stored show IDs (canonical digits only; `rss:` shows return null) and `isLookupEpisodeId` (never a negative local ID). Legacy episode IDs stay strings. `V2EpisodesPaging.isValid` mirrors the `/v2/episodes` bounds (show below 2^52, `limit` 1–1000, `offset` 0–10000). The routes answer 400 outside these rules, which is permanent, so check before calling and never retry it. - - Show answers (`V2PodcastResponse`, `V2ShowAddress`, `V2FeedReportResponse`, `V2EpisodeReply.NeedsFeed`) share `V2FeedAddress`: `fetchUrl` (null means keep the saved address), `fetchVia` (`direct` or `proxy`), `transportUrl` and `feedSource`. `readTarget(showId)` says how to read the document: `V2FeedReadTarget.Direct(url)` on `fetchUrl`, or `V2FeedReadTarget.ViaApi(showId)` for a proxied show, which is read only with `getFeed(showId)`. `transportUrl` is an API URL that needs the session: the RSS client never fetches it, and it is never saved as the show's address. `validEpNs` and `V2EpisodeRegisterResponse.validRecipeId` return null outside their ranges. + - Show answers (`V2PodcastResponse`, `V2ShowAddress (with `mergedInto`, the show Podcast Index merged it into)`, `V2FeedReportResponse`, `V2EpisodeReply.NeedsFeed`) share `V2FeedAddress`: `fetchUrl` (null means keep the saved address), `fetchVia` (`direct` or `proxy`), `transportUrl` and `feedSource`. `readTarget(showId)` says how to read the document: `V2FeedReadTarget.Direct(url)` on `fetchUrl`, or `V2FeedReadTarget.ViaApi(showId)` for a proxied show, which is read only with `getFeed(showId)`. `transportUrl` is an API URL that needs the session: the RSS client never fetches it, and it is never saved as the show's address. `validEpNs` and `V2EpisodeRegisterResponse.validRecipeId` return null outside their ranges. - `getEpisode` decodes its 200 answer into the sealed `V2EpisodeReply`: `Found` (the episode) or `NeedsFeed` (read the show's feed and match by recipe ID). A 404 is an error response. - `getFeed` streams the document. It sends `Cache-Control: no-store`, so the shared HTTP cache never stores feeds, and takes an optional `If-None-Match`. A 304 is not a successful Retrofit response, so callers check `code()`. - Request models validate on construction: `V2ShowsFeedsRequest` holds 1–500 show IDs, `V2FeedReportRequest` takes a `V2FeedReportStatus` value (`http_NNN`, `timeout`, `dns`, `tls`, `parse`, `network`, `other`), and `V2EpisodeRegisterRequest` needs a show below 2^52 and a catalog key (the trimmed GUID, else the trimmed enclosure URL) of at most 2,048 characters, as the route does. A null field is left out of the JSON. diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/V2Models.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/V2Models.kt index 1ac1af11a..0f14a2b33 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/V2Models.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/V2Models.kt @@ -222,9 +222,18 @@ data class V2ShowAddress( val flags: V2ShowFlags? = null, @SerialName("verifiedAt") val verifiedAt: Long? = null, + /** + * The show Podcast Index merged [id] into (it removed a duplicate listing or renumbered the + * show), as the server confirmed with Podcast Index; null otherwise. + */ + @SerialName("mergedInto") + val mergedInto: Long? = null, ) : V2FeedAddress { /** [epNs] when it is a valid namespace, else null. */ val validEpNs: Long? get() = epNs?.takeIf(V2EpisodeIds::isShowId) + + /** [mergedInto] when it is another valid show ID, else null. */ + val validMergedInto: Long? get() = mergedInto?.takeIf { V2EpisodeIds.isShowId(it) && it != id } } /** diff --git a/core/rss/README.md b/core/rss/README.md index d3f12bf31..6aed1cf8e 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -19,6 +19,7 @@ Failed automatic fetches record a six-hour retry boundary while keeping catalogs - `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). +- `RenumberedShows` records Podcast Index shows that now answer as another show ID (old ID → new ID): `MemoryRenumberedShows` for one process, `SharedPreferencesRenumberedShows` in `boxlore_renumbered_shows` for the app; `RenumberedShowIds` holds the pure rules (only another canonical numeric show ID counts, chains resolve to the latest show, a show numbered back replaces the reverse entry). `LocalEpisodeCatalogRepository(renumberedShows = …)` links each renumbered show's listener rows on every catalog read of the show it now answers as, and `linkRenumberedShow(oldId, newId)` links them at once ([docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md#renumbered-shows)). - The episode recipe (`EpisodeRecipeId`) lives in [`:core:model`](../model/README.md), so `:core:database` can use it too. `StickyEpisodeIdentity.catalogKey` delegates to its `catalogKey`, so stored keys don't change. The shared fixture and its test stay here: `EpisodeRecipeIdTest` checks the model's recipe against `src/test/resources/fixtures/episode-recipe-vectors.json`, which the scripts copy also reads. The full identity rules are in [`docs/EPISODE_IDS.md`](../../docs/EPISODE_IDS.md). - `RssSourceMatcher` provides migration and matching heuristics between Podcast Index and RSS sources. - `EpisodeSupplementRepository` implements `EpisodeSupplementPort`: fetches a PI show’s public feed and caches **feed-only** episodes under the Podcast Index id. This is **not** a subscription — it never creates `rss:` library rows, never migrates/retires a PI subscription, and never writes FCM topics or autodownload flags. Prefer `RssPodcastRepository` / `RssSubscriptionPort` for true RSS library ownership. Catalog subscription code may read `hasDirectFeedOptIn` when attaching `feedUrl` to notification RTDB rows. `listDirectFeedOptIns` exports HTTPS publisher URLs for library JSON; `restoreDirectFeedOptIn` writes a stub row (no ETag) so import can keep Missing episodes? on even before the feed GET, and does not replace existing extras. @@ -113,6 +114,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - **Reserved ID.** Any other clash (a stored phone mint another show holds, a recipe ID another episode holds) gets a reserved negative ID (`StickyEpisodeIdentity.reservedEpisodeId`), minted in the show's namespace rather than the feed address, so it stays the same across refreshes, address moves and, through its alias, eviction. Reserved IDs from before (minted under the feed address) keep their stored rows. - The guard returns the rows to write, the stored keys it corrected with where each old key came from (`KeyRepair`: re-trimmed, synthetic, audio address), the reserved IDs it handed out, and any row with no free ID after 8 reserved tries. The refresh stores the corrected keys' provenance and the reserved IDs on aliases in the same transaction, and logs both. A row with no free ID makes the refresh write nothing and fail, so a partial catalog is never marked complete. - The legacy RSS upgrade keeps its IDs for listener state, so it stays on RSS instead when another show holds one, as a catalog row or as an evicted episode's alias. It takes ownership of an episode only from an owner whose ID holds no listener state. +- **Renumbered shows keep their listening** (`RenumberedShowLinker`, `RenumberedShowLinks`; [docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md#renumbered-shows)). When Podcast Index answers a show ID as another one, the old show's history, queue and download rows keep their IDs and show. Inside each catalog read of the new show (after the orphan rematch, in the read's transaction) and once when the renumbering is recorded, an old ID with no alias or an unresolved one under the old show, that no catalog row holds, joins the one new-show episode its rows name by exact audio address (else, for queue and download rows, title and a date within two days), when no other old ID names it and that episode's ID is a trusted, keyed alias of the new show. Its alias becomes trusted under the new show with that key and recipe ID (`renumbered_show_match`), as a non-owner. An old ID with a trusted alias of an old-show episode (the shape of any show whose catalog was read before the renumbering) joins the new show's episode with the same key, when the new show holds exactly one under a trusted ID: every trusted alias of the old (show, key) moves to the new (show, key) with its recipe ID (`renumbered_show_match`, non-owners) and the old episode's group settings merge in by the latest like and completion decisions (`RenumberedShowLinks.joinedGroup`, `mergedGroupState`). The old show's catalog copy is dropped first once its follow has ended, as its unfollowed countdown would; while the old show is followed its catalog holds those IDs and nothing moves. Either way the new show's episodes read the old rows' played, progress, likes and downloads. Ambiguous aliases and IDs a catalog row holds are never rewritten. The record lives in `boxlore_renumbered_shows` (a stable storage name, ARCHITECTURE.md); Room is unchanged. - Package root is `cx.aswin.boxlore.core.rss`. - Feed-first copies (P4 PR 16) live in the existing `local_episode_feeds`/`local_episodes` tables; no schema change. Their v39 columns are written through their own updates: `archiveComplete` after a whole-feed read, `lastOpenedAt` on every show-page read or open, and `followed` with the unsubscribe TTL (`setUnsubscribedTtl` writes both). Cleanup deletes only those two tables' rows of shows followed by neither record: history, downloads, the queue and `episode_aliases` stay. @@ -121,6 +123,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - Unit tests live under `core/rss/src/test`. - Existing coverage includes deterministic ID contracts, source matching heuristics, exact legacy URL/GUID upgrade gates, feed-client helpers, episode-supplement matching (including empty titles and distant same-title dates), list merge, tip-resolution (PI id vs negative feed id, blank metadata, match-only baseline rows filled from the feed), supplement artwork fallback (item / channel / show), repository require/failure mapping, disconnect / auto-opt-in predicates, `restoreDirectFeedOptIn` / `listDirectFeedOptIns` (HTTPS stub, ignore http/`rss:`, keep existing extras), local-catalog refresh outcomes (unchanged validators, not-ready validator bypass, empty parse, blank/`rss:` ids), bounded `getWindow`, TTL recheck on expiry sweep, structural ready cutover, restored-listener remap/merge behavior, sticky RSS tip reuse when a stored id differs from the minted id, entity toPodcast notification/download/custom-genre flag preservation, dead-address classification and report statuses (`FeedReadFailureTest`, `LocalEpisodeCatalogRepositoryTest.refreshFailureSaysWhetherTheSavedAddressLooksGone`, `refreshFailsWhenParsedFeedHasNoEpisodes`), moved feeds (`FeedMoveTest`: redirect chains, `itunes:new-feed-url`, same-show check; `refreshReportsAPermanentMoveOnlyWhenAskedAndTheShowMatches`), and episode ID clashes (`LocalEpisodeCollisionGuardTest`: cross-show, same-show other key, within one batch, stable reserved IDs, exhaustion, legacy keys ending in a newline, tab, CR LF, space plus newline, no-break space, U+2028 or U+180E (written as escapes and trimmed with the recipe's set, so the JVM agrees with Android), the unique (show, key) index modelled as Room's REPLACE, synthetic and audio-address legacy heals whether the row arrives with its own mint or another ID (a recipe or listener-held ID), and the cases kept apart; `LocalEpisodeCatalogRepositoryTest`: two shows refreshing one feed keep their own rows, a full refresh over legacy keys keeps one row per episode on its stored ID, and a legacy row holding the item's own mint heals to the real key while its unresolved alias becomes trusted with the heal's provenance). - `ListenerStateMovesTest` covers the narrowed state moves: trusted and ambiguous IDs are never rematched, the catalog row that joins a trusted orphan by key, exact and title-and-date rematches, a moved ID's alias, the latest like decision in an orphan merge, completion kept unless the group settings cleared it later, and the switch's links (trusted for an exact, unique key, which a weaker match from another old ID doesn't undo, and for a lookalike show only with the same audio address or title too; an old ID without a usable key settled by an exact, unique audio address of an episode with a GUID, and not when two old IDs or two episodes share it, the episode has no GUID or the unresolved alias is another show's; ambiguous for a shared key or a title match alone; nothing for ambiguous old IDs, unmatched rows or IDs the show already holds; an ambiguous link turned trusted later, never the reverse, and no other alias touched). `ListenerStateMovesRoomTest` runs the rematch through an in-memory Room database (Robolectric): an evicted episode read again under a new ID joins its episode with nothing moved and reads once; only unresolved and alias-less IDs move, and only an exact match records an alias; moved listening merges into the episode's owner. `OrphanRematchRoomTest` runs it after the real refresh: an ID another show's catalog row holds is never rematched; a stored row's new alias joins a trusted orphan's episode, which then reads one state with progress going to the orphan's ID; an unresolved orphan moves into the owner of the row that took its recipe ID and joins that episode; an ID a listener match adopted owns its episode and takes a later orphan; an orphan merged into a feed show's owner row keeps that row's show. `ShowSwitchRoomTest` switches a show with history, a like, a completion, a queue entry, a completed download, a session and a rollup under its old IDs: nothing moves, the feed's episodes get trusted or ambiguous aliases, the trusted ones read the projected like, completion and progress, write progress to the old owner and play its download, an ID played before the show was followed and a v39 address-keyed ID join by their exact audio address, another show's look-alike rows get no alias, listening the feed show held before the switch reads as one episode, switching again or following the Podcast Index show again (with its catalog evicted and read again) keeps every row, alias and ID, and that read never takes a linked feed ID, even one that came to own its episode. It also covers a lookalike show trusting a key only with a second signal, an ambiguous link turned trusted once its old ID gains the key, adding a followed show's feed by its address, unfollowing the feed show and adding it again, restoring the feed show with its link (by backup relink and by `restoreSubscription`), and the RSS follow upgrade stopping before it touches a switched show's Podcast Index show. +- `RenumberedShowRoomTest` (Robolectric, in-memory `BoxLoreDatabase`) reproduces a renumbered show as found on a listener's phone: the old show's history, mostly completed, with unresolved v39 aliases and no catalog rows, and the new show's catalog read under recipe IDs. Linking makes the new show's episodes read completed through the real listener store while every catalog and history row keeps its ID and show; a later catalog read links a row a pull brought since; a show with no recorded renumbering links nothing; and audio that matches nothing, a match two old IDs claim, a trusted alias of a key the new show doesn't have and a lookalike show's rows stay as they were. It also covers the shape of a show followed since episodes are keyed: the old show's catalog read under recipe IDs with completed history and a like under them, then the new show's catalog: every old episode joins the new one with its key, completion and the like read on the new show, the old copy is dropped and no ID changes; while the old show is still followed, its catalog and aliases stay. `RenumberedShowLinksTest` covers queue and download rows matched by title and a date inside and outside the two-day window, a title two new episodes share, group settings merged into an episode that has its own, and a key two new episodes share. `RenumberedShowsTest` covers what counts as a renumbering, chains, a show numbered back and the stored record. - MockWebServer is available for feed-fetch tests. - `RecipeIdRoomTest` (Robolectric, in-memory `BoxLoreDatabase`) runs the real refresh through Room: a new episode's recipe ID and trusted alias; stored PI, negative, served and earlier reserved IDs kept across refreshes and an address move; forced same-show and cross-show clashes (an alias recorded, the reserved ID stable across refreshes, an address move and eviction, an evicted show's alias blocking a third show, completed downloads and queue entries of both clashing episodes unchanged); eviction read back through aliases; a changed GUID as a new episode whose old row and history stay; the legacy shapes the v39 upgrade leaves (a padded GUID with its re-trimmed trusted alias, a padded GUID with no alias, a GUID-less audio-keyed row with its unresolved alias, and a synthetic-key row and a GUID-less row evicted before any read), each keeping its ID and history across a read, eviction and a re-read; listener-held IDs kept without a re-key (an ID equal to the item's own mint, a played feed-only extra after a follow, a Podcast Index ID in history on the first read, a restored orphan) while an orphan whose episode has a stored row is still rematched; a taken alias ID falling back to the recipe ID; a different server namespace changing nothing; `rss:` shows through `RssPodcastRepository` keeping phone mints with no aliases; non-canonical shows and items outside the contract keeping phone mints; the legacy RSS upgrade recording aliases, taking ownership from an earlier read's owner so the show reads back under the carried IDs after eviction, staying on RSS when that owner's ID holds history under another show, and stopping at another show's alias. `LocalEpisodeCatalogPersistTest` covers the plan (stored, alias, listener-held, recipe and phone-mint sources, fallback IDs, the namespace, older-key aliases, the hash seam), `ListenerIdAdoptionTest` which IDs count and how they match, `CatalogAliasRecordsTest` every alias rule, `LocalEpisodeCollisionGuardTest` the alias checks, fallback order and show-namespace reserved IDs, and `LocalEpisodeCatalogRepositoryTest` the refresh on the fake DAO with `InMemoryCatalogIdentityStore`. diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepository.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepository.kt index 505a2862f..d11dfaa0b 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepository.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRepository.kt @@ -28,13 +28,19 @@ class LocalEpisodeCatalogRepository private constructor( private val reads: LocalEpisodeCatalogReads, private val refreshDeps: LocalCatalogRefreshDeps, private val onCatalogPersisted: suspend (String) -> Unit, + private val renumberedLinker: RenumberedShowLinker? = null, ) : LocalEpisodeCatalogPort by reads { + /** + * @param renumberedShows shows Podcast Index renumbered; each catalog read of the show one now + * answers as links its listener rows to the show's episodes ([RenumberedShowLinker]) + */ constructor( database: BoxLoreDatabase, feedClient: RssFeedClient = RssFeedClient(), downloadCacheRelinker: DownloadCacheRelinker = DownloadCacheRelinker { _, _ -> false }, onCatalogPersisted: suspend (String) -> Unit = {}, - ) : this(database, feedClient, downloadCacheRelinker, onCatalogPersisted, RecipeIdHash.RECIPE) + renumberedShows: RenumberedShows = RenumberedShows.NONE, + ) : this(database, feedClient, downloadCacheRelinker, onCatalogPersisted, RecipeIdHash.RECIPE, renumberedShows) /** [recipeHash] is a test seam: collision tests force recipe clashes through real Room (P4 PR 9). */ internal constructor( @@ -43,22 +49,43 @@ class LocalEpisodeCatalogRepository private constructor( downloadCacheRelinker: DownloadCacheRelinker, onCatalogPersisted: suspend (String) -> Unit, recipeHash: RecipeIdHash, + renumberedShows: RenumberedShows = RenumberedShows.NONE, ) : this( - dao = database.localEpisodeCatalogDao(), + database = database, feedClient = feedClient, - runInTransaction = { block -> database.withTransaction { block() } }, - isFeedUnchanged = { url, etag, lastModified -> + downloadCacheRelinker = downloadCacheRelinker, + onCatalogPersisted = onCatalogPersisted, + recipeHash = recipeHash, + renumberedLinker = RenumberedShowLinker(database, renumberedShows), + ) + + @Suppress("LongParameterList") + private constructor( + database: BoxLoreDatabase, + feedClient: RssFeedClient, + downloadCacheRelinker: DownloadCacheRelinker, + onCatalogPersisted: suspend (String) -> Unit, + recipeHash: RecipeIdHash, + renumberedLinker: RenumberedShowLinker, + ) : this( + reads = LocalEpisodeCatalogReads(database.localEpisodeCatalogDao()) { url, etag, lastModified -> feedClient.confirmUnchanged(url, etag, lastModified) }, - reconcileListenerState = - LocalCatalogListenerStateReconciler( - database = database, - downloadCacheRelinker = downloadCacheRelinker, - )::reconcile, - megaGetGate = PublisherFeedRefreshGate.permits, - identity = RoomCatalogIdentityStore(database), + refreshDeps = + LocalCatalogRefreshDeps( + dao = database.localEpisodeCatalogDao(), + feedClient = feedClient, + runInTransaction = { block -> database.withTransaction { block() } }, + isFeedUnchanged = { url, etag, lastModified -> + feedClient.confirmUnchanged(url, etag, lastModified) + }, + reconcileListenerState = listenerStateLinks(LocalCatalogListenerStateReconciler(database, downloadCacheRelinker), renumberedLinker), + megaGetGate = PublisherFeedRefreshGate.permits, + identity = RoomCatalogIdentityStore(database), + recipeHash = recipeHash, + ), onCatalogPersisted = onCatalogPersisted, - recipeHash = recipeHash, + renumberedLinker = renumberedLinker, ) @Suppress("LongParameterList") @@ -120,6 +147,18 @@ class LocalEpisodeCatalogRepository private constructor( outcome } + /** + * Links [formerId]'s listener rows to [currentId]'s stored episodes now, once Podcast Index was + * found to answer [formerId] as [currentId] ([RenumberedShowLinker]); each later catalog read of + * [currentId] links again. Returns the number of IDs linked; 0 without the app's wiring. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ + suspend fun linkRenumberedShow(formerId: String, currentId: String): Int = withContext(Dispatchers.IO) { + val linker = renumberedLinker ?: return@withContext 0 + refreshLocks.getOrPut(currentId) { Mutex() }.withLock { linker.linkInTransaction(formerId, currentId) } + } + override suspend fun isRefreshDue(podcastId: String, feedUrl: String, nowMillis: Long): Boolean { val existing = refreshDeps.dao.getFeed(podcastId) return existing?.feedUrl?.trim() != feedUrl.trim() || !shouldSkipQuiet(existing, nowMillis = nowMillis) @@ -137,13 +176,27 @@ class LocalEpisodeCatalogRepository private constructor( feedClient: RssFeedClient = RssFeedClient(), downloadCacheRelinker: DownloadCacheRelinker = DownloadCacheRelinker { _, _ -> false }, onCatalogPersisted: suspend (String) -> Unit = {}, + renumberedShows: RenumberedShows = RenumberedShows.NONE, ): LocalEpisodeCatalogRepository = LocalEpisodeCatalogRepository( database = database, feedClient = feedClient, downloadCacheRelinker = downloadCacheRelinker, onCatalogPersisted = onCatalogPersisted, + renumberedShows = renumberedShows, ) + /** + * The listener-state step of a catalog read, in its transaction: the orphan rematch, then the + * links of shows renumbered into the show read ([RenumberedShowLinker]). + */ + private fun listenerStateLinks( + reconciler: LocalCatalogListenerStateReconciler, + renumberedLinker: RenumberedShowLinker, + ): suspend (String, List) -> Unit = { podcastId, rows -> + reconciler.reconcile(podcastId, rows) + renumberedLinker.linkFormerShows(podcastId) + } + internal fun resolveHttps(primary: String, fallback: String?,): String? { val candidate = primary.trim().ifBlank { fallback.orEmpty() } return candidate.takeIf { it.startsWith("https://", ignoreCase = true) } diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RenumberedShowLinker.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RenumberedShowLinker.kt new file mode 100644 index 000000000..00729cfca --- /dev/null +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RenumberedShowLinker.kt @@ -0,0 +1,300 @@ +package cx.aswin.boxlore.core.rss + +import androidx.room.withTransaction +import cx.aswin.boxlore.core.database.BoxLoreDatabase +import cx.aswin.boxlore.core.database.EpisodeAliasEntity +import cx.aswin.boxlore.core.database.EpisodeAliasProvenance +import cx.aswin.boxlore.core.database.EpisodeAliasState +import cx.aswin.boxlore.core.database.EpisodeGroupStateEntity +import cx.aswin.boxlore.core.database.LocalEpisodeLinkRow +import cx.aswin.boxlore.core.model.EpisodeRecipeId +import kotlin.math.abs + +/** + * Links the listener rows of a show Podcast Index renumbered ([RenumberedShows]) to the episodes of + * the show it now answers as, so those episodes read the old rows' state (played, progress, likes, + * downloads) on the new show's page. No ID changes: history, queue and download rows keep their ID + * and their old show; only aliases (and an episode's group settings) are written ([RenumberedShowLinks]): + * - **IDs with no usable key** (no alias, or an unresolved one under the old show) join the one new + * show's episode their rows name by audio address, else title and date. + * - **Episodes of the old show's catalog** (trusted aliases of an old show's (show, key), the shape + * of any show followed since episodes are keyed) join the new show's episode with that key: every + * trusted alias of the old (show, key) moves to the new show's (show, key) as a non-owner, and the + * old episode's group settings (like and completion decisions) merge into the new one's. The old + * show's own catalog copy, once its follow has ended, is dropped first, as its unfollowed + * countdown would drop it, so no catalog row of the old show holds those IDs. + * + * Linking is idempotent and runs inside every catalog read of the new show (from + * [LocalEpisodeCatalogRepository]'s refresh transaction) and once when a renumbering is recorded, + * so rows that arrive later (a cloud pull, a restore) are linked by the next read. It never + * touches an ambiguous alias, an ID a catalog row holds, or an alias of another show. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ +internal class RenumberedShowLinker(private val database: BoxLoreDatabase, private val shows: RenumberedShows,) { + private val identityDao get() = database.episodeIdentityDao() + private val catalogDao get() = database.localEpisodeCatalogDao() + + /** Links every show recorded as renumbered into [currentId]. Runs in the caller's transaction. */ + suspend fun linkFormerShows(currentId: String) { + val formerIds = shows.formerIds(currentId) + for (formerId in formerIds) link(formerId, currentId) + } + + /** [link] in its own transaction; the number of IDs linked. */ + suspend fun linkInTransaction(formerId: String, currentId: String): Int = database.withTransaction { link(formerId, currentId) } + + /** + * Links [formerId]'s history, queue and download IDs to [currentId]'s stored episodes. Runs in the + * caller's transaction; returns the number of IDs linked. + */ + suspend fun link(formerId: String, currentId: String): Int { + if (!RenumberedShowIds.isRenumbering(formerId, currentId)) return 0 + val references = database.listenerReferences(formerId) + if (references.isEmpty()) return 0 + val ids = references.map { it.episodeId }.distinct() + val aliasesById = aliasesOf(ids) + val trustedKeys = RenumberedShowLinks.trustedKeysOf(ids, aliasesById, formerId) + if (trustedKeys.isEmpty() && ids.none { RenumberedShowLinks.isSettleable(aliasesById[it], formerId) }) return 0 + val rows = catalogDao.listForShow(currentId) + if (rows.isEmpty()) return 0 + if (trustedKeys.isNotEmpty()) dropEndedCatalog(formerId) + val catalogHeld = heldIds(ids) + val rowAliasesById = aliasesOf(rows.map { it.episodeId }) + val epNs = namespaceOf(currentId) + val nowMs = System.currentTimeMillis() + val links = + RenumberedShowLinks.plan( + formerId = formerId, + currentId = currentId, + references = references, + aliasesById = aliasesById, + catalogRows = rows, + rowAliasesById = rowAliasesById, + catalogHeldIds = catalogHeld, + epNs = epNs, + nowMs = nowMs, + ) + links.forEach { identityDao.upsertAlias(it) } + var joined = 0 + for (key in trustedKeys) joined += joinGroup(formerId, currentId, key, rows, rowAliasesById, epNs) + val linked = links.size + joined + if (linked > 0) android.util.Log.i(LOG_TAG, "Show $currentId: linked $linked IDs of renumbered show $formerId") + return linked + } + + /** + * Moves the trusted aliases of [formerId]'s episode [key] onto [currentId]'s episode with that + * key, and merges its group settings there; the number of aliases moved (0 when nothing qualifies). + */ + @Suppress("LongParameterList") + private suspend fun joinGroup( + formerId: String, + currentId: String, + key: String, + rows: List, + rowAliasesById: Map, + epNs: Long?, + ): Int { + val group = identityDao.aliasesForKey(formerId, key).filter { it.state == EpisodeAliasState.TRUSTED } + val moved = + RenumberedShowLinks.joinedGroup( + formerGroup = group, + key = key, + currentId = currentId, + catalogRows = rows, + rowAliasesById = rowAliasesById, + catalogHeldIds = heldIds(group.map { it.aliasId }), + epNs = epNs, + ) + if (moved.isEmpty()) return 0 + moved.forEach { identityDao.upsertAlias(it) } + val formerState = identityDao.getGroupState(formerId, key) + RenumberedShowLinks.mergedGroupState(formerState, identityDao.getGroupState(currentId, key), currentId, key)?.let { identityDao.upsertGroupState(it) } + if (formerState != null) identityDao.deleteGroupState(formerId, key) + return moved.size + } + + /** Drops [formerId]'s catalog copy once its follow ended, as its unfollowed countdown would; a followed show keeps it. */ + private suspend fun dropEndedCatalog(formerId: String) { + if (database.podcastDao().getPodcast(formerId)?.isSubscribed == true) return + if (catalogDao.getFeed(formerId) == null && catalogDao.listForShow(formerId).isEmpty()) return + catalogDao.deleteCatalog(formerId) + } + + private suspend fun heldIds(ids: List): Set = + ids.distinct().chunked(MAX_IDS_PER_QUERY).flatMap { catalogDao.findOwners(it) }.mapTo(HashSet()) { it.episodeId } + + private suspend fun namespaceOf(podcastId: String): Long? = + EpisodeNamespaces.choose(podcastId, catalogDao.getFeed(podcastId)?.epNs, database.podcastDao().getEpNs(podcastId)) + + private suspend fun aliasesOf(ids: List): Map = + ids.distinct().chunked(MAX_IDS_PER_QUERY).flatMap { identityDao.getAliases(it) }.associateBy { it.aliasId } + + private companion object { + const val MAX_IDS_PER_QUERY = 500 + const val LOG_TAG = "RenumberedShowLinker" + } +} + +/** + * Which old IDs of a renumbered show join which episodes of the show it now answers as + * ([RenumberedShowLinker]). An old ID joins one episode when: + * - it has no alias, or an unresolved one under the old show (a v39 listener-only ID, an episode + * played before the show was followed), and no catalog row of any show holds it; + * - its rows name exactly one of the new show's stored episodes: by exact audio address, else (for + * queue and download rows, which carry a date) by title and a date within two days, the rules a + * catalog read uses to keep a listener-held ID ([ListenerIdAdoption]); + * - no other old ID names that episode; and + * - that episode's own ID is a trusted, keyed alias of the new show, so there is an episode to join. + * The old ID's alias becomes trusted under the new show with that episode's key and recipe ID, + * provenance [EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH], as a non-owner: ownership then follows + * the owner rule (the ID with the latest listening owns). Anything weaker leaves the alias as it was. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ +internal object RenumberedShowLinks { + /** True when [alias] (an old ID's) may be settled: none yet, or unresolved under [formerId]. */ + fun isSettleable(alias: EpisodeAliasEntity?, formerId: String): Boolean = + alias == null || (alias.state == EpisodeAliasState.UNRESOLVED && alias.podcastId == formerId) + + /** The keys of [formerId]'s episodes that [ids] (its listener rows' IDs) hold through a trusted alias. */ + fun trustedKeysOf(ids: Collection, aliasesById: Map, formerId: String): List = + ids + .mapNotNull { id -> aliasesById[id]?.takeIf { it.state == EpisodeAliasState.TRUSTED && it.podcastId == formerId }?.catalogKey } + .distinct() + + /** + * The aliases of [formerGroup] (every trusted alias of the old show's episode [key]) moved onto + * [currentId]'s episode with that key: trusted, with that episode's recipe ID, as non-owners, + * provenance [EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH], each keeping its ID and creation + * time. Empty when a catalog row still holds one of the IDs ([catalogHeldIds]: the old show's + * live copy), or [currentId] has no single stored episode with [key] whose own ID is a trusted + * alias of it. + */ + @Suppress("LongParameterList") + fun joinedGroup( + formerGroup: List, + key: String, + currentId: String, + catalogRows: List, + rowAliasesById: Map, + catalogHeldIds: Set, + epNs: Long?, + ): List { + val group = formerGroup.filter { it.state == EpisodeAliasState.TRUSTED && it.catalogKey == key } + if (group.isEmpty() || group.any { it.aliasId in catalogHeldIds }) return emptyList() + val row = catalogRows.singleOrNull { keyOf(it) == key } ?: return emptyList() + val rowAlias = + rowAliasesById[row.episodeId] + ?.takeIf { it.state == EpisodeAliasState.TRUSTED && it.podcastId == currentId && it.catalogKey == key } + ?: return emptyList() + val recipeId = rowAlias.recipeId ?: epNs?.let { runCatching { EpisodeRecipeId.forNamespace(it, key) }.getOrNull() } + return group.map { + it.copy(podcastId = currentId, recipeId = recipeId, owner = false, provenance = EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH) + } + } + + /** + * The old episode's group settings ([former]) merged into the new one's ([current]): each like + * and completion decision keeps its latest time, as the projected state reads them. Null when + * the old episode has none. + */ + fun mergedGroupState(former: EpisodeGroupStateEntity?, current: EpisodeGroupStateEntity?, currentId: String, key: String): EpisodeGroupStateEntity? { + former ?: return null + val base = current ?: EpisodeGroupStateEntity(podcastId = currentId, catalogKey = key) + return base.copy( + likedAt = maxOf(base.likedAt, former.likedAt), + likeClearedAt = maxOf(base.likeClearedAt, former.likeClearedAt), + completedAt = maxOf(base.completedAt, former.completedAt), + completeClearedAt = maxOf(base.completeClearedAt, former.completeClearedAt), + version = maxOf(base.version, former.version) + 1L, + ) + } + + /** [row]'s recipe-trimmed catalog key; null for a legacy row's synthetic `id:` key. */ + private fun keyOf(row: LocalEpisodeLinkRow): String? { + if (row.legacy && row.guid == "id:${row.episodeId}") return null + return EpisodeRecipeId.trimKey(row.guid) + } + + @Suppress("LongParameterList", "kotlin:S107") // One input per store the plan reads. + fun plan( + formerId: String, + currentId: String, + references: List, + aliasesById: Map, + catalogRows: List, + rowAliasesById: Map, + catalogHeldIds: Set, + epNs: Long?, + nowMs: Long, + ): List { + val index = CatalogIndex(catalogRows) + val targets = + references + .groupBy { it.episodeId } + .filterKeys { id -> id !in catalogHeldIds && isSettleable(aliasesById[id], formerId) } + .mapNotNull { (oldId, rows) -> targetOf(rows, index)?.let { oldId to it } } + val claimants = targets.groupingBy { it.second.episodeId }.eachCount() + return targets + .filter { (_, row) -> claimants[row.episodeId] == 1 } + .mapNotNull { (oldId, row) -> linkOf(oldId, row, currentId, rowAliasesById, aliasesById[oldId], epNs, nowMs) } + } + + /** The one catalog row [rows] name: by audio address, else by title and date; null when none or several. */ + private fun targetOf(rows: List, index: CatalogIndex): LocalEpisodeLinkRow? { + val byAudio = + rows + .mapNotNull { row -> row.enclosureUrl?.trim()?.takeIf(String::isNotEmpty) } + .mapNotNull { audio -> index.byAudio[audio]?.singleOrNull() } + .distinctBy { it.episodeId } + if (byAudio.isNotEmpty()) return byAudio.singleOrNull() + return rows + .mapNotNull { row -> byTitleAndDate(row, index) } + .distinctBy { it.episodeId } + .singleOrNull() + } + + private fun byTitleAndDate(row: LocalCatalogListenerReference, index: CatalogIndex): LocalEpisodeLinkRow? { + val title = row.title?.trim().orEmpty() + val date = row.publishedDate ?: return null + if (title.isEmpty()) return null + return index.byTitle[title]?.singleOrNull { abs(it.publishedDate - date) <= LocalCatalogOrphanRematch.DATE_WINDOW_SECONDS } + } + + /** The new show's stored episodes by trimmed audio address and by trimmed title, built once per pass. */ + private class CatalogIndex(rows: List) { + val byAudio: Map> = rows.groupBy { it.audioUrl.trim() } + val byTitle: Map> = rows.groupBy { it.title.trim() } + } + + @Suppress("LongParameterList") + private fun linkOf( + oldId: String, + row: LocalEpisodeLinkRow, + currentId: String, + rowAliasesById: Map, + existing: EpisodeAliasEntity?, + epNs: Long?, + nowMs: Long, + ): EpisodeAliasEntity? { + if (row.legacy && row.guid == "id:${row.episodeId}") return null + val key = EpisodeRecipeId.trimKey(row.guid) ?: return null + val rowAlias = + rowAliasesById[row.episodeId] + ?.takeIf { it.state == EpisodeAliasState.TRUSTED && it.podcastId == currentId && it.catalogKey == key } + ?: return null + val recipeId = rowAlias.recipeId ?: epNs?.let { runCatching { EpisodeRecipeId.forNamespace(it, key) }.getOrNull() } + return EpisodeAliasEntity( + aliasId = oldId, + podcastId = currentId, + catalogKey = key, + recipeId = recipeId, + state = EpisodeAliasState.TRUSTED, + owner = false, + provenance = EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH, + createdAt = existing?.createdAt ?: nowMs, + ) + } +} diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RenumberedShows.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RenumberedShows.kt new file mode 100644 index 000000000..f373869e7 --- /dev/null +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RenumberedShows.kt @@ -0,0 +1,133 @@ +package cx.aswin.boxlore.core.rss + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import cx.aswin.boxlore.core.model.EpisodeRecipeId + +/** + * Podcast Index shows that Podcast Index renumbered: show details asked for an old show ID answered + * with another show ID (the same feed under a new number). Each entry maps the old ID to the ID it + * answered as. The phone keeps every stored ID where it is: the follow moves to the new show + * (`RenumberedShowFollows` in `:core:catalog`), and the old show's history, queue and download IDs + * join the new show's episodes as aliases ([RenumberedShowLinker]). docs/EPISODE_IDS.md §5, + * renumbered shows. + * + * Only canonical numeric Podcast Index show IDs are recorded ([EpisodeRecipeId.showIdOrNull]); `rss:` + * shows never are. Each entry is the latest answer for its old ID: recording `B → A` after + * `A → B` replaces the first, so a show Podcast Index numbers back never loops. + */ +interface RenumberedShows { + /** Old show ID → the show ID it answered as, for every recorded show. */ + fun all(): Map + + /** + * Records that [oldId] answered as [newId]; false when it isn't a renumbering (the same ID, a + * non-numeric ID) or couldn't be saved. Saving an entry already recorded changes nothing. + */ + fun record(oldId: String, newId: String): Boolean + + /** The show [podcastId] now answers as, following later renumberings; null when it wasn't renumbered. */ + fun currentId(podcastId: String): String? = RenumberedShowIds.currentId(all(), podcastId) + + /** Every show recorded as renumbered into [podcastId], directly or through another renumbering. */ + fun formerIds(podcastId: String): Set = RenumberedShowIds.formerIds(all(), podcastId) + + companion object { + /** Records nothing; callers built without the app's wiring. */ + val NONE: RenumberedShows = + object : RenumberedShows { + override fun all(): Map = emptyMap() + + override fun record(oldId: String, newId: String): Boolean = false + } + } +} + +/** + * The pure rules of [RenumberedShows]: which pairs are renumberings, and how chains resolve. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ +object RenumberedShowIds { + /** Longest chain followed; a longer one (never seen) stops there instead of looping. */ + private const val MAX_CHAIN = 8 + + /** True when an answer for [oldId] naming [newId] means Podcast Index renumbered the show. */ + fun isRenumbering(oldId: String, newId: String): Boolean = + oldId != newId && EpisodeRecipeId.showIdOrNull(oldId) != null && EpisodeRecipeId.showIdOrNull(newId) != null + + /** [entries] with `oldId → newId` recorded: the reverse entry (a show numbered back) is dropped. */ + fun withEntry(entries: Map, oldId: String, newId: String): Map { + val updated = LinkedHashMap(entries) + if (updated[newId] == oldId) updated.remove(newId) + updated[oldId] = newId + return updated + } + + fun currentId(entries: Map, podcastId: String): String? { + var current = entries[podcastId] ?: return null + val seen = hashSetOf(podcastId, current) + repeat(MAX_CHAIN) { + val next = entries[current] ?: return current + if (!seen.add(next)) return current + current = next + } + return current + } + + fun formerIds(entries: Map, podcastId: String): Set = + entries.keys.filterTo(LinkedHashSet()) { oldId -> oldId != podcastId && currentId(entries, oldId) == podcastId } +} + +/** [RenumberedShows] for one process only (tests, and callers built without the app's wiring). */ +class MemoryRenumberedShows : RenumberedShows { + private var entries: Map = emptyMap() + + @Synchronized + override fun all(): Map = entries + + @Synchronized + override fun record(oldId: String, newId: String): Boolean { + if (!RenumberedShowIds.isRenumbering(oldId, newId)) return false + entries = RenumberedShowIds.withEntry(entries, oldId, newId) + return true + } +} + +/** + * [RenumberedShows] in SharedPreferences file [FILE_NAME]: one key per old show ID, the show ID it + * answered as its value. A restored copy only points the same old shows at the same new ones, + * which is harmless. Stable storage name: never rename. + * + * Added for renumbered shows (docs/EPISODE_IDS.md §5, renumbered shows). + */ +class SharedPreferencesRenumberedShows(private val prefs: SharedPreferences) : RenumberedShows { + private var cached: Map? = null + + // Synchronized with [record], so a load that started before a save never replaces the saved map. + @Synchronized + override fun all(): Map = cached ?: load().also { cached = it } + + @Synchronized + @SuppressLint("ApplySharedPref") + override fun record(oldId: String, newId: String): Boolean { + if (!RenumberedShowIds.isRenumbering(oldId, newId)) return false + if (all()[oldId] == newId) return true + val editor = prefs.edit().putString(oldId, newId) + if (prefs.getString(newId, null) == oldId) editor.remove(newId) + // Written to disk at once: the follow that moves next depends on it. + val saved = editor.commit() + cached = load() + return saved + } + + private fun load(): Map = + prefs.all.entries + .mapNotNull { (key, value) -> (value as? String)?.takeIf { RenumberedShowIds.isRenumbering(key, it) }?.let { key to it } } + .toMap() + + companion object { + /** Stable storage name (ARCHITECTURE.md, SharedPreferences files): never rename. */ + const val FILE_NAME = "boxlore_renumbered_shows" + } +} 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 b1b4d2e88..bd53909fc 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 @@ -7,6 +7,7 @@ import cx.aswin.boxlore.core.database.LocalEpisodeCatalogDao import cx.aswin.boxlore.core.database.LocalEpisodeEntity import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity import cx.aswin.boxlore.core.database.LocalEpisodeIdentity +import cx.aswin.boxlore.core.database.LocalEpisodeLinkRow import cx.aswin.boxlore.core.database.LocalEpisodeOwner import cx.aswin.boxlore.core.database.RssEpisodeEntity import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort @@ -752,6 +753,10 @@ class LocalEpisodeCatalogRepositoryTest { LocalEpisodeIdentity(it.episodeId, it.guid, it.audioUrl) } + override suspend fun listForShow(podcastId: String): List = episodes + .filter { it.podcastId == podcastId } + .map { LocalEpisodeLinkRow(it.episodeId, it.guid, it.audioUrl, it.title, it.publishedDate, it.legacy) } + override suspend fun findOwners(episodeIds: List): List = episodes .filter { it.episodeId in episodeIds } .map { LocalEpisodeOwner(it.episodeId, it.podcastId, it.guid) } diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowLinksTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowLinksTest.kt new file mode 100644 index 000000000..4498da756 --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowLinksTest.kt @@ -0,0 +1,104 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.EpisodeAliasEntity +import cx.aswin.boxlore.core.database.EpisodeAliasProvenance +import cx.aswin.boxlore.core.database.EpisodeAliasState +import cx.aswin.boxlore.core.database.EpisodeGroupStateEntity +import cx.aswin.boxlore.core.database.LocalEpisodeLinkRow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The matching rules of [RenumberedShowLinks] that the Room test doesn't reach: queue and download + * rows matched by title and date, group settings merged into an episode that has its own, and a + * key two new episodes share. + */ +class RenumberedShowLinksTest { + @Test + fun aQueueOrDownloadRowJoinsByTitleAndADateWithinTwoDays() { + val rows = listOf(catalogRow("new-1", "guid-1", title = "Episode 1", publishedDate = PUBLISHED)) + val inside = reference("-11", title = " Episode 1 ", publishedDate = PUBLISHED + LocalCatalogOrphanRematch.DATE_WINDOW_SECONDS) + val outside = reference("-12", title = "Episode 1", publishedDate = PUBLISHED - LocalCatalogOrphanRematch.DATE_WINDOW_SECONDS - 1) + + val links = plan(references = listOf(inside, outside), rows = rows) + + assertEquals(listOf("-11"), links.map { it.aliasId }) + assertEquals("guid-1", links.single().catalogKey) + assertEquals(EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH, links.single().provenance) + } + + @Test + fun aTitleTwoNewEpisodesShareWithinTheWindowJoinsNeither() { + val rows = + listOf( + catalogRow("new-1", "guid-1", title = "Trailer", publishedDate = PUBLISHED), + catalogRow("new-2", "guid-2", title = "Trailer", publishedDate = PUBLISHED + 60), + ) + + assertTrue(plan(references = listOf(reference("-11", title = "Trailer", publishedDate = PUBLISHED)), rows = rows).isEmpty()) + } + + @Test + fun groupSettingsMergeIntoAnEpisodeThatHasItsOwnKeepingEachLatestDecision() { + val former = EpisodeGroupStateEntity(OLD, "guid-1", likedAt = 9_000L, completedAt = 2_000L, completeClearedAt = 1_000L, version = 3L) + val current = EpisodeGroupStateEntity(NEW, "guid-1", likedAt = 4_000L, likeClearedAt = 5_000L, completedAt = 1_500L, version = 7L) + + val merged = RenumberedShowLinks.mergedGroupState(former, current, NEW, "guid-1") + + assertEquals(EpisodeGroupStateEntity(NEW, "guid-1", likedAt = 9_000L, likeClearedAt = 5_000L, completedAt = 2_000L, completeClearedAt = 1_000L, version = 8L), merged) + } + + @Test + fun aKeyTwoNewEpisodesShareJoinsNoGroup() { + val rows = listOf(catalogRow("new-1", "guid-1"), catalogRow("new-2", " guid-1")) + val group = listOf(alias("-11", OLD, "guid-1")) + + val moved = + RenumberedShowLinks.joinedGroup( + formerGroup = group, + key = "guid-1", + currentId = NEW, + catalogRows = rows, + rowAliasesById = rows.associate { it.episodeId to alias(it.episodeId, NEW, "guid-1") }, + catalogHeldIds = emptySet(), + epNs = NEW.toLong(), + ) + + assertTrue(moved.isEmpty()) + } + + private fun plan(references: List, rows: List) = RenumberedShowLinks.plan( + formerId = OLD, + currentId = NEW, + references = references, + aliasesById = emptyMap(), + catalogRows = rows, + rowAliasesById = rows.associate { it.episodeId to alias(it.episodeId, NEW, it.guid.trim()) }, + catalogHeldIds = emptySet(), + epNs = NEW.toLong(), + nowMs = 1L, + ) + + private fun catalogRow(id: String, guid: String, title: String = "Episode $id", publishedDate: Long = PUBLISHED) = + LocalEpisodeLinkRow(episodeId = id, guid = guid, audioUrl = "https://audio.example/$id.mp3", title = title, publishedDate = publishedDate, legacy = false) + + /** A queue or download row: no audio address, a title and a date. */ + private fun reference(id: String, title: String, publishedDate: Long) = + LocalCatalogListenerReference(episodeId = id, enclosureUrl = null, title = title, publishedDate = publishedDate) + + private fun alias(id: String, show: String, key: String) = EpisodeAliasEntity( + aliasId = id, + podcastId = show, + catalogKey = key, + recipeId = null, + state = EpisodeAliasState.TRUSTED, + provenance = EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH, + ) + + private companion object { + const val OLD = "1258562" + const val NEW = "7976624" + const val PUBLISHED = 1_700_000_000L + } +} diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowRoomTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowRoomTest.kt new file mode 100644 index 000000000..c19db6478 --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowRoomTest.kt @@ -0,0 +1,226 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.EpisodeAliasProvenance +import cx.aswin.boxlore.core.database.EpisodeAliasState +import cx.aswin.boxlore.core.database.EpisodeGroupStateEntity +import cx.aswin.boxlore.core.database.ListeningHistoryEntity +import cx.aswin.boxlore.core.database.episodeListenerStore +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome +import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A show Podcast Index renumbered, with the shape of the data found on a listener's phone: the old + * show's history (most of it completed) and its unresolved v39 aliases, no catalog rows under the + * old show, and the new show's catalog read under recipe IDs with trusted aliases. Linking makes the + * new show's episodes read the old rows' completion, through real Room and the real listener store, + * and no stored ID or row changes (docs/EPISODE_IDS.md §5, renumbered shows). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RenumberedShowRoomTest : RoomCatalogTestBase() { + private val shows = MemoryRenumberedShows() + + private fun renumberedRepo() = LocalEpisodeCatalogRepository(database, feed, DownloadCacheRelinker { _, _ -> false }, {}, RecipeIdHash.RECIPE, shows) + + /** The phone's data before the fix: the old show's listening, the new show's catalog. */ + private suspend fun seedPhoneShape() { + follow(OLD, epNs = null) + follow(NEW) + for ((index, id) in OLD_IDS.withIndex()) { + listened(id, OLD, audio = audioOf(index), completed = index < COMPLETED) + seedUnresolved(id, OLD, EpisodeAliasProvenance.UPGRADE_LISTENER_ROW) + } + feed.items = OLD_IDS.indices.map { Item("guid-$it", title = "Episode $it", audio = audioOf(it)) } + Item("guid-new", title = "New episode") + // The new show's catalog was read before the renumbering was known. + assertTrue(refresh(NEW) is RefreshOutcome.Success) + } + + @Test + fun theNewShowsEpisodesReadTheOldShowsCompletionWithoutAnyIdChange() = runBlocking { + seedPhoneShape() + val catalogBefore = rows(NEW) + val historyBefore = historyRows() + assertTrue(shows.record(OLD, NEW)) + + assertEquals(OLD_IDS.size, renumberedRepo().linkRenumberedShow(OLD, NEW)) + + assertEquals("catalog rows keep their recipe IDs", catalogBefore, rows(NEW)) + assertEquals("history rows keep their IDs, show and state", historyBefore, historyRows()) + for ((index, id) in OLD_IDS.withIndex()) { + val alias = requireNotNull(alias(id)) + assertEquals(NEW, alias.podcastId) + assertEquals("guid-$index", alias.catalogKey) + assertEquals(EpisodeAliasState.TRUSTED, alias.state) + assertEquals(EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH, alias.provenance) + assertFalse("joins as a non-owner", alias.owner) + assertEquals(recipe(NEW, "guid-$index").toLong(), alias.recipeId) + } + val completed = database.episodeListenerStore().projectCompletedIds(OLD_IDS.take(COMPLETED)) + for (index in 0 until COMPLETED) { + assertTrue("episode $index reads completed on the new show", recipe(NEW, "guid-$index") in completed) + } + assertFalse("an unfinished episode stays unfinished", recipe(NEW, "guid-${OLD_IDS.size - 1}") in completed) + assertFalse(recipe(NEW, "guid-new") in completed) + assertEquals("linking again changes nothing", 0, renumberedRepo().linkRenumberedShow(OLD, NEW)) + } + + @Test + fun eachCatalogReadOfTheNewShowLinksRowsThatArrivedSince() = runBlocking { + seedPhoneShape() + shows.record(OLD, NEW) + renumberedRepo().linkRenumberedShow(OLD, NEW) + // A cloud pull brings another old-show row after the first link. + val late = "41000000099" + feed.items = feed.items + Item("guid-late", title = "Late", audio = "https://sphinx.example/late.mp3") + listened(late, OLD, audio = "https://sphinx.example/late.mp3", completed = true) + + assertTrue(refresh(NEW, repo = renumberedRepo()) is RefreshOutcome.Success) + + assertEquals(NEW, alias(late)?.podcastId) + assertEquals("guid-late", alias(late)?.catalogKey) + assertTrue(recipe(NEW, "guid-late") in database.episodeListenerStore().projectCompletedIds(listOf(late))) + assertEquals(OLD, database.listeningHistoryDao().getHistoryItem(late)?.podcastId) + } + + @Test + fun onlyExactUniqueEvidenceLinksAndSettledAliasesAreNeverRewritten() = runBlocking { + follow(OLD, epNs = null) + follow(NEW) + follow(LOOKALIKE) + // No audio match; a match two old IDs claim; a trusted alias of the old show; a lookalike show's row. + listened("41000000010", OLD, audio = "https://elsewhere.example/a.mp3", completed = true) + seedUnresolved("41000000010", OLD, EpisodeAliasProvenance.UPGRADE_LISTENER_ROW) + listened("41000000011", OLD, audio = SHARED, completed = true) + listened("41000000012", OLD, audio = SHARED, completed = true) + listened("41000000013", OLD, audio = audioOf(1), completed = true) + seedAlias("41000000013", OLD, "guid-old-key") + listened("41000000014", LOOKALIKE, audio = audioOf(2), completed = true) + feed.items = listOf(Item("guid-shared", audio = SHARED), Item("guid-1", audio = audioOf(1)), Item("guid-2", audio = audioOf(2))) + assertTrue(refresh(NEW) is RefreshOutcome.Success) + shows.record(OLD, NEW) + + assertEquals(0, renumberedRepo().linkRenumberedShow(OLD, NEW)) + + assertEquals(EpisodeAliasState.UNRESOLVED, alias("41000000010")?.state) + assertEquals(null, alias("41000000011")) + assertEquals(null, alias("41000000012")) + assertEquals("a trusted alias keeps its show and key", OLD, alias("41000000013")?.podcastId) + assertEquals("guid-old-key", alias("41000000013")?.catalogKey) + assertEquals("another show is never linked, whatever its title", null, alias("41000000014")) + } + + /** + * The shape of any show followed since episodes are keyed: the old show's catalog was read under + * its recipe IDs and the listening is under them, trusted under the old show. Once its follow + * ended and the new show's catalog was read, linking joins each old episode to the new episode + * with its key: completion and likes read on the new show and no stored ID changes. + */ + @Test + fun episodesOfTheOldShowsCatalogJoinTheNewShowsEpisodesWithoutAnyIdChange() = runBlocking { + val keys = OLD_IDS.indices.map { "guid-$it" } + feed.items = keys.mapIndexed { index, key -> Item(key, title = "Episode $index", audio = audioOf(index)) } + follow(OLD) + assertTrue(refresh(OLD) is RefreshOutcome.Success) + val oldIds = keys.map { recipe(OLD, it) } + assertEquals(oldIds, keys.map { rows(OLD)[it] }) + for ((index, id) in oldIds.withIndex()) listened(id, OLD, audio = audioOf(index), completed = index < COMPLETED) + val liked = EpisodeGroupStateEntity(podcastId = OLD, catalogKey = keys[0], likedAt = 7_000L, version = 1L) + database.episodeIdentityDao().upsertGroupState(liked) + // The follow moved to the new show, whose catalog was then read under its own recipe IDs. + database.podcastDao().upsert(database.podcastDao().getPodcast(OLD)!!.copy(isSubscribed = false)) + follow(NEW) + assertTrue(refresh(NEW) is RefreshOutcome.Success) + val catalogBefore = rows(NEW) + val historyBefore = oldIds.mapNotNull { database.listeningHistoryDao().getHistoryItem(it) } + shows.record(OLD, NEW) + + assertEquals(oldIds.size, renumberedRepo().linkRenumberedShow(OLD, NEW)) + + assertEquals("the new show's rows keep their recipe IDs", catalogBefore, rows(NEW)) + assertEquals("history keeps its IDs, show and state", historyBefore, oldIds.mapNotNull { database.listeningHistoryDao().getHistoryItem(it) }) + assertTrue("the ended show's copy is dropped, as its countdown would", rows(OLD).isEmpty()) + for ((index, id) in oldIds.withIndex()) { + val alias = requireNotNull(alias(id)) + assertEquals(NEW, alias.podcastId) + assertEquals(keys[index], alias.catalogKey) + assertEquals(recipe(NEW, keys[index]).toLong(), alias.recipeId) + assertEquals(EpisodeAliasState.TRUSTED, alias.state) + assertEquals(EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH, alias.provenance) + assertFalse(alias.owner) + assertTrue(id in database.episodeListenerStore().memberIds(recipe(NEW, keys[index]))) + } + val completed = database.episodeListenerStore().projectCompletedIds(oldIds.take(COMPLETED)) + for (index in 0 until COMPLETED) assertTrue("episode $index reads completed on the new show", recipe(NEW, keys[index]) in completed) + assertFalse(recipe(NEW, keys.last()) in completed) + assertEquals("the like decision moves with the episode", 7_000L, database.episodeIdentityDao().getGroupState(NEW, keys[0])?.likedAt) + assertNull(database.episodeIdentityDao().getGroupState(OLD, keys[0])) + assertEquals("linking again changes nothing", 0, renumberedRepo().linkRenumberedShow(OLD, NEW)) + } + + @Test + fun aStillFollowedOldShowKeepsItsCatalogAndItsEpisodes() = runBlocking { + feed.items = listOf(Item("guid-0", audio = audioOf(0))) + follow(OLD) + assertTrue(refresh(OLD) is RefreshOutcome.Success) + val oldId = recipe(OLD, "guid-0") + listened(oldId, OLD, audio = audioOf(0), completed = true) + follow(NEW) + assertTrue(refresh(NEW) is RefreshOutcome.Success) + shows.record(OLD, NEW) + + assertEquals(0, renumberedRepo().linkRenumberedShow(OLD, NEW)) + + assertEquals(OLD, alias(oldId)?.podcastId) + assertEquals(mapOf("guid-0" to oldId), rows(OLD)) + } + + @Test + fun withoutARecordedRenumberingACatalogReadLinksNothing() = runBlocking { + seedPhoneShape() + + assertTrue(refresh(NEW, repo = renumberedRepo()) is RefreshOutcome.Success) + + for (id in OLD_IDS) assertEquals(EpisodeAliasState.UNRESOLVED, alias(id)?.state) + } + + private suspend fun listened(id: String, show: String, audio: String, completed: Boolean) { + database.listeningHistoryDao().upsert( + ListeningHistoryEntity( + episodeId = id, + podcastId = show, + episodeTitle = "Old title $id", + episodeImageUrl = null, + podcastImageUrl = null, + episodeAudioUrl = audio, + podcastName = "The Desi Crime Podcast", + progressMs = if (completed) 60_000L else 5_000L, + durationMs = 60_000L, + isCompleted = completed, + lastPlayedAt = 1_000L + id.takeLast(3).toLong(), + ), + ) + } + + private suspend fun historyRows(): List = + OLD_IDS.mapNotNull { database.listeningHistoryDao().getHistoryItem(it) } + + private companion object { + const val OLD = "1258562" + const val NEW = "7976624" + const val LOOKALIKE = "5200907" + const val COMPLETED = 3 + const val SHARED = "https://sphinx.example/shared.mp3" + val OLD_IDS = listOf("41000000001", "41000000002", "41000000003", "41000000004") + + fun audioOf(index: Int) = "https://sphinx.example/e/$index/media.mp3" + } +} diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowsTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowsTest.kt new file mode 100644 index 000000000..9d03bec2e --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/RenumberedShowsTest.kt @@ -0,0 +1,64 @@ +package cx.aswin.boxlore.core.rss + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The record of shows Podcast Index renumbered ([RenumberedShows]): what counts, chains, loops and storage. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RenumberedShowsTest { + @Test + fun onlyAnotherCanonicalNumericShowIdIsARenumbering() { + assertTrue(RenumberedShowIds.isRenumbering("1258562", "7976624")) + assertFalse(RenumberedShowIds.isRenumbering("1258562", "1258562")) + assertFalse("rss: shows are never renumbered", RenumberedShowIds.isRenumbering("rss:123", "7976624")) + assertFalse(RenumberedShowIds.isRenumbering("1258562", "rss:7976624")) + assertFalse("non-canonical IDs are rejected", RenumberedShowIds.isRenumbering("01258562", "7976624")) + assertFalse(RenumberedShowIds.isRenumbering("url:https%3A%2F%2Ffeeds.example", "7976624")) + } + + @Test + fun chainsResolveToTheLatestShowAndLoopsStop() { + val shows = MemoryRenumberedShows() + assertTrue(shows.record("1", "2")) + assertTrue(shows.record("2", "3")) + + assertEquals("3", shows.currentId("1")) + assertEquals("3", shows.currentId("2")) + assertNull(shows.currentId("3")) + assertEquals(setOf("1", "2"), shows.formerIds("3")) + + // Numbered back: the newest answer replaces the reverse entry, so nothing loops. + assertTrue(shows.record("3", "2")) + assertEquals("2", shows.currentId("3")) + assertNull(shows.currentId("2")) + assertEquals(setOf("1", "3"), shows.formerIds("2")) + } + + @Test + fun theStoredRecordSurvivesANewInstanceAndRejectsNonRenumberings() { + val prefs = + ApplicationProvider + .getApplicationContext() + .getSharedPreferences(SharedPreferencesRenumberedShows.FILE_NAME, Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val shows = SharedPreferencesRenumberedShows(prefs) + + assertTrue(shows.record("1258562", "7976624")) + assertFalse(shows.record("7976624", "7976624")) + assertFalse(shows.record("rss:1", "7976624")) + + val reopened = SharedPreferencesRenumberedShows(prefs) + assertEquals(mapOf("1258562" to "7976624"), reopened.all()) + assertEquals("7976624", reopened.currentId("1258562")) + assertEquals(setOf("1258562"), reopened.formerIds("7976624")) + } +} diff --git a/docs/EPISODE_IDS.md b/docs/EPISODE_IDS.md index 8fbd5eff9..6988470c5 100644 --- a/docs/EPISODE_IDS.md +++ b/docs/EPISODE_IDS.md @@ -76,6 +76,19 @@ Collision tests force clashes with a test-only hash override, both cross-show an Any weaker match (title or date only, a key several RSS episodes share) is ambiguous. An old ID whose alias is ambiguous links nothing. Linking runs again after a restore brings the RSS show back with its link, after the RSS show is followed again and after each read of its feed: it adds links for rows that arrived since and turns an ambiguous `rss_source_link` alias trusted once exact evidence exists; it never downgrades a trusted alias or touches an alias of another provenance. A catalog read never reuses a linked RSS ID for the Podcast Index show. Writes through a linked ID keep the owner's row under its own show and episode metadata. Links exist only on the phone. A library backup carries the RSS show's link, so a restore from it links again, but without the old IDs' keys only exact audio addresses prove an episode there; cloud sync carries each trusted row's key but not the link yet, so a device restored from the cloud links nothing. +### Renumbered shows + +Podcast Index sometimes gives a show a new number: details asked for the old show ID answer with another show ID for the same feed. The old ID stays the namespace of every ID issued under it, and nothing is re-keyed. + +- **Detection.** Any details answer for a canonical numeric show ID A that names another canonical numeric show ID B (the show page, a cloud pull's enrichment, address recovery) records A → B in `boxlore_renumbered_shows` before the answer is used. The daily address check also records a merge the server confirmed with Podcast Index (`mergedInto` in `/v2/shows/feeds`: Podcast Index removed a duplicate listing or renumbered the show) and moves the follow without a details lookup. A show is never matched by its title. The foreground pass asks for details only for followed shows that share a feed address or podcast GUID with another follow, at most six per pass: each show once per process after an answer, a failed lookup again on a later pass after the shows not yet asked. `rss:` shows are never renumbered. +- **One follow.** A follow of A moves to B in one transaction that reads both rows, merges them and writes them, so no other writer lands in between: B keeps its own row and namespace (or is created without one, taking its own when its catalog is read), notifications and auto-download on when either was, and B's own genre, sort and trims, else A's. B keeps its own follow time, or is followed at the time of the move: never at A's earlier time, because sync compares a follow with an unfollow by their times, and any unfollow of B made since (on any device) would then beat the moved follow. A is unfollowed like any unfollow. Both rows go out with the next sync push, and a pull of A's older follow loses to the newer unfollow; a later follow of A from an older build moves again on the next pass. Notifications for B are recorded as pending before the row turns them on, then registered through the acknowledged registration job. Folder memberships, Home pins and the manual order move to B. A B followed through its own RSS feed keeps that follow and only A's ends, its library places moving to the RSS show; when A has notifications and the RSS show doesn't (which needs the listener's consent), both follows stay, so notifications are never dropped. +- **Listening.** A's history, queue and download rows keep their IDs and their show. Each catalog read of B, and the move itself, links them: + - An A ID with no alias, or an unresolved one under A, that no catalog row holds, joins the one B episode its rows name by exact audio address (else, for queue and download rows, title and a date within two days), when no other A ID names it and that episode's ID is a trusted alias of B. Its alias becomes trusted under B with the episode's key and recipe ID (provenance `renumbered_show_match`), as a non-owner. + - An A ID with a trusted alias of (A, key), the shape of any show whose catalog was read before the renumbering, joins B's episode with the same key when B holds exactly one such episode under a trusted ID: every trusted alias of (A, key) moves to (B, key) with B's recipe ID and provenance `renumbered_show_match`, as a non-owner, and (A, key)'s group settings merge into (B, key)'s by the latest like and completion decision. Once A's follow has ended, A's catalog copy is dropped first, as its unfollowed countdown would drop it; while A is still followed its catalog holds those IDs and nothing moves. + + Either way B's episodes read A's played, progress, likes and downloads, and ownership follows the owner rule. Anything weaker, ambiguous aliases and IDs a catalog row holds stay as they are. +- **Show page.** A's route opens as B. A page is followed when the show it shows is, the route's show is, or any show recorded as renumbered into it still is, whichever number it was opened with, so Subscribe is never offered for B while A is followed; opening the page moves such a follow. The toggle's direction comes from what the caller showed: a Subscribe tap (from the page, a search card or any follow button that read only B's row) moves A's follow to B with its settings, and an Unfollow tap ends both. B's artwork colours the page. When a route's details answer names B and B has no stored row, that answer shows while B's own details load, and stays if they fail. + ## 6. Validation - Recipe fields and recipe API parameters must be safe integers in `[2^52, 2^53 − 1]`. diff --git a/feature/info/README.md b/feature/info/README.md index 82d2f83a0..0b38396d0 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -42,6 +42,8 @@ Disclosure recognizes canonical RSS IDs even with stale source metadata and uses - `ArtworkInfoTheme` scopes Material 3 artwork palettes to both detail screens, using episode art before show-art fallback. It reads covers through the shared `:core:designsystem` artwork extractor (`loadArtworkPalette` / `artworkColorScheme`, the same picker the player and Home's hero cards use): the page is a Material scheme built from the cover's seed, as a custom theme would be, and a cover read once colours both pages and the player from one process-wide cache. Monochrome, missing and failed artwork retains the app scheme; route-keyed results avoid leaking the previous show’s color. The page paints its own artwork-toned background instead of exposing the saved app background. One full-screen Box inside the Surface relaxes minimum constraints for sibling content/header/title layers, keeping sticky chrome at its intended height. All color roles ease together over 350ms (the page slide's length) after extraction, retargeting from their current visible colors; cached palettes are available immediately, and a cover already on screen (the card the listener tapped) is read straight from Coil's memory cache, so colours usually arrive with the page. System light/dark switches remain immediate. Light/dark and neutral Pure surfaces remain respected. The exact animated page palette, including restrained surface colors, is published by navigation-entry identity so outgoing screens cannot recolor the active route; the mini-player retains its saved app colors. Incoming loading and artwork-extraction states retain the source entry’s palette from the first frame. The palette then eases into the destination artwork, or into the saved app palette after missing/monochrome/failed art. A stable saved-theme local keeps the transient navigation theme out of fallback resolution. The Appearance artwork switch disables extraction and restores both detail and navbar colors. The blurred-cover backdrop is as strong as header text allows (`artworkBackdropStrength`: up to 0.75, backing off until text over the cover's mean colour clears 4.5:1, never under 0.3; 0.48 before the palette arrives), easing on the same clock as the colours. Podcast row Play uses the theme's paired foreground for its accent rather than a black/white guess. +- **Renumbered shows.** When Podcast Index gives a show a new number ([docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md#renumbered-shows)), its old route and the new show are one page. `PodcastInfoViewModel.loadPodcast` opens a route recorded as renumbered (`RenumberedShowFollows.currentId`) as the new show, moving a follow still under the old number first; when the details answer for the route names another numeric show ID, the repository moves the follow as the answer arrives and the page loads again as that show (`loadShow`), following a show renumbered again up to four times, so it never shows one show's details over another's episodes. Every load first moves a follow still under a number recorded as renumbered into the page's show (`adoptFormerFollows`), and the page counts as followed when the shown show, the route's show or such a former show is (`RenumberedShowPageLogic.isFollowed`, `isFollowedUnderFormerId`), whichever number the page was opened with, so Subscribe is never offered for the new show while the old one is followed and "Missing episodes?" takes the followed path (hidden). When the route's details name the new show and it has no stored row, that answer shows while the page loads again (`RenumberedShowPageLogic.shownBeforeDetails`), so the page never drops back to loading, or to the error state when the second request fails. `pageShow` publishes the route and the show it resolved to (a renumbered show, or a followed `rss:` feed linked to it); `PodcastInfoScreen` themes the page with that show's artwork (`RenumberedShowPageLogic.artworkPodcast`) and never with a podcast left from another route while the page moves between shows. Its toggle passes the follow state it showed to `SubscriptionRepository.toggleSubscription(podcast, shownFollowed)`, so Subscribe follows (moving the old follow with its settings) and Unfollow ends both; it never adds a second follow. Window changes and recreation keep the same ViewModel, so the resolved show and its followed state stay put on phone, tablet, split and fold layouts. + ## Internal structure ```text @@ -119,6 +121,8 @@ src/main/java/cx/aswin/boxlore/feature/info/ ./gradlew :feature:info:testDebugUnitTest ``` - `PodcastInfoSupplementSupportTest` and `PodcastEpisodeSearchLogicTest` cover overlapping pages and repeated ids. +- `RenumberedShowPageLogicTest` covers renumbered show pages: what counts as a renumbering (never an `rss:` feed or an address), the follow under the old number counting (on the old route and on the new number's own page), the first answer showing until the new show's details arrive, the renumbered show colouring its old route, and a podcast from another route never colouring the page while it moves between shows, back and forth. `PodcastInfoAdaptiveTransitionTest` keeps a renumbered show's page followed under its old route (Unsubscribe, never Subscribe) across every window gate and recreation. +- **Manual acceptance (renumbered show).** Follow a show whose Podcast Index number changed (open it from Library or an old link). (1) Phone: the page shows Subscribed and takes the show's artwork colours; there is no "Missing episodes?" offer; Library lists the show once, and episodes played under the old number show as played once the page has read the feed. (2) Unfollow from the page: the show leaves Library and no second follow appears. (3) Repeat (1) on a tablet, in split screen and across a fold change: the page stays followed and coloured. ## CI relevance diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreen.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreen.kt index 9bdf0375f..5013a7c81 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreen.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreen.kt @@ -120,7 +120,10 @@ fun PodcastInfoScreen( modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsState() - cx.aswin.boxlore.feature.info.components.ArtworkInfoTheme(listOf((uiState as? PodcastInfoUiState.Success)?.podcast?.takeIf { it.id == podcastId }?.let { it.imageUrl.takeIf(String::isNotBlank) ?: it.fallbackImageUrl }), isLoading = uiState is PodcastInfoUiState.Loading) { + val pageShow by viewModel.pageShow.collectAsState() + // The route's show, or the show this route resolved to (a linked feed, a renumbered show); never a previous page's. + val artworkPodcast = cx.aswin.boxlore.feature.info.logic.RenumberedShowPageLogic.artworkPodcast(uiState, podcastId, pageShow) + cx.aswin.boxlore.feature.info.components.ArtworkInfoTheme(listOf(artworkPodcast?.let { it.imageUrl.takeIf(String::isNotBlank) ?: it.fallbackImageUrl }), isLoading = uiState is PodcastInfoUiState.Loading) { val queuedEpisodeIds by viewModel.queuedEpisodeIds.collectAsState() val downloadedEpisodeIds by viewModel.downloadedEpisodeIds.collectAsState() val downloadingEpisodeIds by viewModel.downloadingEpisodeIds.collectAsState() diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoViewModel.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoViewModel.kt index 862856c45..278f3475e 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoViewModel.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoViewModel.kt @@ -23,12 +23,15 @@ import cx.aswin.boxlore.core.playback.withEpisodeAliases import cx.aswin.boxlore.core.prefs.HomePinnedShows import cx.aswin.boxlore.feature.info.logic.PodcastInfoAsyncResultLogic import cx.aswin.boxlore.feature.info.logic.PodcastInfoFolderSyncLogic +import cx.aswin.boxlore.feature.info.logic.PodcastInfoPageShow import cx.aswin.boxlore.feature.info.logic.PodcastInfoPullRefreshLogic +import cx.aswin.boxlore.feature.info.logic.RenumberedShowPageLogic import cx.aswin.boxlore.feature.info.logic.ShowFeedLogic import cx.aswin.boxlore.feature.info.logic.enablePodcastNotifications import cx.aswin.boxlore.feature.info.logic.podcastSearchResponseIsCurrent import cx.aswin.boxlore.feature.info.logic.togglePodcastAutoDownload import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -116,6 +119,15 @@ class PodcastInfoViewModel( /** Id passed to the latest [loadPodcast] call, for [retryLoad]. */ private var requestedPodcastId: String? = null private val _currentPodcastIdFlow = MutableStateFlow("") + + private val _pageShow = MutableStateFlow(PodcastInfoPageShow()) + + /** + * The route this page was opened for and the show it resolved to (a followed `rss:` feed linked + * to it, or the show Podcast Index renumbered it to), so the screen themes the page with that + * show's artwork and never with a previous page's ([RenumberedShowPageLogic.artworkPodcast]). + */ + val pageShow: StateFlow = _pageShow.asStateFlow() private var currentOffset: Int = 0 private var searchJob: Job? = null @@ -520,6 +532,9 @@ class PodcastInfoViewModel( private const val TAG = "PodcastInfoViewModel" private const val PAGE_SIZE = 20 private const val OLDEST_PAGE_SIZE = 50 + + /** Renumberings a page follows in one load (a show renumbered again); a longer chain stops there instead of looping. */ + private const val MAX_RENUMBERINGS_PER_LOAD = 4 private const val MAX_SELECTION_EPISODES = 100 private const val SEARCH_DEBOUNCE_MS = 500L } @@ -612,17 +627,44 @@ class PodcastInfoViewModel( requestedPodcastId = podcastId currentPodcastId = podcastId _currentPodcastIdFlow.value = podcastId + _pageShow.value = PodcastInfoPageShow(routePodcastId = podcastId, shownPodcastId = podcastId) currentOffset = 0 viewModelScope.launch { - val linkedRss = localCatalog.getSubscribedRssLinkedTo(podcastId) - val effectivePodcastId = linkedRss?.id ?: podcastId - if (effectivePodcastId != podcastId) { + // A show Podcast Index renumbered opens as the show it now answers as, where its follow moved. + val renumberedFollows = subscriptionRepository.renumberedFollows + val renumberedTo = renumberedFollows.currentId(podcastId) + if (renumberedTo != null) renumberedFollows.adoptKnown(podcastId) + // Follows still under a number Podcast Index retired for this show become this show's. + renumberedFollows.adoptFormerFollows(renumberedTo ?: podcastId) + loadShow(podcastId, renumberedTo ?: podcastId, renumberingsLeft = MAX_RENUMBERINGS_PER_LOAD) + } + } + + /** + * Loads the page of [routePodcastId] as [showId]: the route's show, or the show Podcast Index + * renumbered it to. When the show's details answer as another numeric show ID, the follow moves + * there ([cx.aswin.boxlore.core.catalog.RenumberedShowFollows.adoptAnswer]) and the page + * loads again as that show, following a chain of renumberings up to [renumberingsLeft] times, + * with that answer as [answered]: it shows while the page loads when the show has no stored row, + * and stays when the next load fails. + */ + // The show-page load moved here from loadPodcast unchanged, apart from renumbering (kotlin:S3776); + // Sonar's S6619 misreads the nullable stored row. + @Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth", "ReturnCount", "kotlin:S3776", "kotlin:S6619") + private suspend fun CoroutineScope.loadShow(routePodcastId: String, showId: String, renumberingsLeft: Int, answered: Podcast? = null) { + run { + val linkedRss = + localCatalog.getSubscribedRssLinkedTo(showId) + ?: if (showId != routePodcastId) localCatalog.getSubscribedRssLinkedTo(routePodcastId) else null + val effectivePodcastId = linkedRss?.id ?: showId + if (effectivePodcastId != currentPodcastId) { currentPodcastId = effectivePodcastId _currentPodcastIdFlow.value = effectivePodcastId } + setShownPodcastId(routePodcastId, effectivePodcastId) val localPodcast = linkedRss ?: localCatalog.getLocalPodcast(effectivePodcastId) - val isSubscribed = subscriptionRepository.isSubscribed(effectivePodcastId) - var currentPodcast = localPodcast + val isSubscribed = isPageFollowed(routePodcastId, effectivePodcastId) + var currentPodcast = RenumberedShowPageLogic.shownBeforeDetails(localPodcast, answered, effectivePodcastId) if (currentPodcast != null) { if (wasSubscribedAtStart == null) { @@ -688,6 +730,18 @@ class PodcastInfoViewModel( val (apiPodcast, page) = fetchPodcastAndEpisodes(effectivePodcastId, limit, sortParam) + if (apiPodcast != null && + renumberingsLeft > 0 && + RenumberedShowPageLogic.isRenumbered(effectivePodcastId, apiPodcast.id) + ) { + // Podcast Index renumbered the show: one follow, under the show it now answers as + // (the repository moved it as the answer arrived; this makes sure before the page reads it). + subscriptionRepository.renumberedFollows.adoptAnswer(effectivePodcastId, apiPodcast) + if (requestedPodcastId == routePodcastId) { + loadShow(routePodcastId, apiPodcast.id, renumberingsLeft - 1, answered = apiPodcast) + } + return + } if (apiPodcast != null) { val apiPodcastWithFallback = cx.aswin.boxlore.feature.info.logic.PodcastInfoEnrichLogic.enrichPodcastWithFallback( @@ -700,10 +754,10 @@ class PodcastInfoViewModel( currentPodcast = apiPodcastWithFallback currentPodcastId = apiPodcastWithFallback.id _currentPodcastIdFlow.value = apiPodcastWithFallback.id + setShownPodcastId(routePodcastId, apiPodcastWithFallback.id) trackScreenViewed(apiPodcastWithFallback.id, apiPodcastWithFallback.title) - val latestIsSubscribed = - subscriptionRepository.isSubscribed(apiPodcastWithFallback.id) + val latestIsSubscribed = isPageFollowed(routePodcastId, apiPodcastWithFallback.id) if (wasSubscribedAtStart == null) { wasSubscribedAtStart = isSubscribed @@ -852,6 +906,22 @@ class PodcastInfoViewModel( } } + /** Records the show [routePodcastId]'s page resolved to, while that route is still the one requested. */ + private fun setShownPodcastId(routePodcastId: String, shownPodcastId: String) { + if (requestedPodcastId == routePodcastId) _pageShow.value = PodcastInfoPageShow(routePodcastId, shownPodcastId) + } + + /** + * The page's follow: the show it shows, the route's show that show replaced, or any show + * recorded as renumbered into it, still followed under its old number ([RenumberedShowPageLogic.isFollowed]). + */ + private suspend fun isPageFollowed(routePodcastId: String, shownPodcastId: String): Boolean = + RenumberedShowPageLogic.isFollowed( + shownFollowed = subscriptionRepository.isSubscribed(shownPodcastId), + routeFollowed = routePodcastId != shownPodcastId && subscriptionRepository.isSubscribed(routePodcastId), + formerFollowed = subscriptionRepository.renumberedFollows.isFollowedUnderFormerId(shownPodcastId), + ) + private suspend fun fetchPodcastAndEpisodes( podcastId: String, limit: Int, @@ -1463,8 +1533,9 @@ class PodcastInfoViewModel( } else { didSubscribe = true } - subscriptionRepository.toggleSubscription(currentState.podcast) - val isSubscribed = subscriptionRepository.isSubscribed(currentState.podcast.id) + // The page's own follow state decides the direction: it counts a follow under a retired number. + subscriptionRepository.toggleSubscription(currentState.podcast, shownFollowed = wasSubscribed) + val isSubscribed = isPageFollowed(requestedPodcastId ?: currentState.podcast.id, currentState.podcast.id) applyToggledSubscriptionUi(currentState, isSubscribed) if (isSubscribed && !wasSubscribed) { launch { refreshTipAfterSubscribe() } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/RenumberedShowPageLogic.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/RenumberedShowPageLogic.kt new file mode 100644 index 000000000..a7651a587 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/RenumberedShowPageLogic.kt @@ -0,0 +1,49 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.core.rss.RenumberedShowIds +import cx.aswin.boxlore.feature.info.PodcastInfoUiState + +/** + * The show a page shows for its route: the route's own show, a followed `rss:` feed linked to it, + * or the show Podcast Index renumbered it to ([shownPodcastId]). Empty before the first load. + */ +data class PodcastInfoPageShow(val routePodcastId: String = "", val shownPodcastId: String = "") + +/** + * Show-page rules for a show Podcast Index renumbered: the route's show (the old number) and the + * show it now answers as are one page (docs/EPISODE_IDS.md §5, renumbered shows). + */ +internal object RenumberedShowPageLogic { + /** True when details asked for [requestedId] answered as [answeredId], another numeric show ID. */ + fun isRenumbered(requestedId: String, answeredId: String): Boolean = RenumberedShowIds.isRenumbering(requestedId, answeredId) + + /** + * The page is followed when the show it shows is, the route's show it replaced still is, or a + * show recorded as renumbered into it still is (a follow not moved yet, whichever number the + * page was opened with): Subscribe is never offered for a show the listener follows under its + * old number, and the page takes the followed paths (no "Missing episodes?" offer). + */ + fun isFollowed(shownFollowed: Boolean, routeFollowed: Boolean, formerFollowed: Boolean = false): Boolean = + shownFollowed || routeFollowed || formerFollowed + + /** + * What the page shows before the show's details arrive: its stored row, else the details + * answer that named this show for a renumbered route ([answered], when it is [shownPodcastId]), + * so a renumbered page never drops back to loading, or to an error when the next request fails. + */ + fun shownBeforeDetails(stored: Podcast?, answered: Podcast?, shownPodcastId: String): Podcast? = + stored ?: answered?.takeIf { it.id == shownPodcastId } + + /** + * The podcast whose artwork themes the page for [routePodcastId]: the loaded podcast when it is + * the route's show, or the show [page] says this route resolved to. A podcast left from another + * route (the page moving between shows) is never used. + */ + fun artworkPodcast(state: PodcastInfoUiState, routePodcastId: String, page: PodcastInfoPageShow): Podcast? { + val podcast = (state as? PodcastInfoUiState.Success)?.podcast ?: return null + if (podcast.id == routePodcastId) return podcast + val resolvedForRoute = page.routePodcastId == routePodcastId && page.shownPodcastId.isNotEmpty() && page.shownPodcastId == podcast.id + return podcast.takeIf { resolvedForRoute } + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/InfoPhoneGoldenTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/InfoPhoneGoldenTest.kt index cb799ab40..610290b9e 100644 --- a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/InfoPhoneGoldenTest.kt +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/InfoPhoneGoldenTest.kt @@ -45,6 +45,7 @@ class InfoPhoneGoldenTest(configuration: PhoneGoldenConfiguration) { `when`(model.episodePendingDownloadRemoval).thenReturn(MutableStateFlow(null)) `when`(model.hideCompletedInShowDetails).thenReturn(MutableStateFlow(false)) `when`(model.isPinnedToHome).thenReturn(MutableStateFlow(false)) + `when`(model.pageShow).thenReturn(MutableStateFlow(cx.aswin.boxlore.feature.info.logic.PodcastInfoPageShow())) `when`(model.globalSkipBeginningMs).thenReturn(MutableStateFlow(0L)) `when`(model.globalSkipEndingMs).thenReturn(MutableStateFlow(0L)) `when`(model.autoDownloadBackgroundChecksEnabled).thenReturn(MutableStateFlow(false)) diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoAdaptiveTransitionTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoAdaptiveTransitionTest.kt index 38eb7c02c..1f46352f6 100644 --- a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoAdaptiveTransitionTest.kt +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoAdaptiveTransitionTest.kt @@ -105,6 +105,7 @@ class PodcastInfoAdaptiveTransitionTest { } private val firstPage = episodes.take(20) private val uiState = MutableStateFlow(PodcastInfoUiState.Success(podcast, firstPage, isSubscribed = true)) + private val pageShow = MutableStateFlow(cx.aswin.boxlore.feature.info.logic.PodcastInfoPageShow()) private val hideCompleted = MutableStateFlow(false) private val completed = MutableStateFlow(emptySet()) private val model = mock(PodcastInfoViewModel::class.java) @@ -167,6 +168,26 @@ class PodcastInfoAdaptiveTransitionTest { verify(model, times(2)).loadPodcast("1") } + @Test + fun `a renumbered show's page stays followed under its old route across window changes and recreation`() { + // Route "1" (the old number) shows the show Podcast Index renumbered it to, which the follow moved to. + pageShow.value = cx.aswin.boxlore.feature.info.logic.PodcastInfoPageShow(routePodcastId = "1", shownPodcastId = RENUMBERED_ID) + uiState.value = PodcastInfoUiState.Success(podcast.copy(id = RENUMBERED_ID), firstPage, isSubscribed = true) + render() + fixture.compose.onNodeWithContentDescription("Unsubscribe").assertExists() + + // Subscribe is never offered in any layout, whether or not the layout shows the follow button. + acrossWindowChanges { + fixture.compose.onNodeWithContentDescription("Subscribe").assertDoesNotExist() + assertEquals(RENUMBERED_ID, (uiState.value as PodcastInfoUiState.Success).podcast.id) + } + recreate(FOLD_TABLETOP) + fixture.compose.onNodeWithContentDescription("Subscribe").assertDoesNotExist() + recreate(COMPACT) + fixture.compose.onNodeWithContentDescription("Unsubscribe").assertExists() + fixture.compose.onNodeWithContentDescription("Subscribe").assertDoesNotExist() + } + @Test fun `search query is run again after process death once the first page has loaded`() { render() @@ -820,6 +841,7 @@ class PodcastInfoAdaptiveTransitionTest { `when`(model.episodePendingDownloadRemoval).thenReturn(MutableStateFlow(null)) `when`(model.hideCompletedInShowDetails).thenReturn(hideCompleted) `when`(model.isPinnedToHome).thenReturn(MutableStateFlow(false)) + `when`(model.pageShow).thenReturn(pageShow) `when`(model.globalSkipBeginningMs).thenReturn(MutableStateFlow(0L)) `when`(model.globalSkipEndingMs).thenReturn(MutableStateFlow(0L)) `when`(model.autoDownloadBackgroundChecksEnabled).thenReturn(MutableStateFlow(true)) @@ -863,6 +885,7 @@ class PodcastInfoAdaptiveTransitionTest { private enum class Exit { CONFIRM, CANCEL, BACK, OUTSIDE } private companion object { + const val RENUMBERED_ID = "7976624" const val LIST_TAG = "podcast_info_episode_list" const val LOADING_TAG = "podcast_info_loading" const val REFRESH_TAG = "podcast_info_refresh_indicator" diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/RenumberedShowPageLogicTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/RenumberedShowPageLogicTest.kt new file mode 100644 index 000000000..36a1d90e4 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/RenumberedShowPageLogicTest.kt @@ -0,0 +1,90 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.PodcastInfoUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The show page of a show Podcast Index renumbered: the old number's route and the show it now + * answers as are one page (followed state, the "Missing episodes?" path, artwork colours), and a + * podcast left from another route never colours the page. + */ +class RenumberedShowPageLogicTest { + @Test + fun onlyAnotherNumericShowIdIsARenumbering() { + assertTrue(RenumberedShowPageLogic.isRenumbered(OLD, NEW)) + assertFalse(RenumberedShowPageLogic.isRenumbered(NEW, NEW)) + assertFalse("a followed feed linked to the show isn't a renumbering", RenumberedShowPageLogic.isRenumbered(OLD, "rss:abc")) + assertFalse(RenumberedShowPageLogic.isRenumbered("url:https%3A%2F%2Ffeeds.example", NEW)) + } + + @Test + fun theFollowUnderTheOldNumberCountsSoSubscribeIsNeverOffered() { + assertTrue(RenumberedShowPageLogic.isFollowed(shownFollowed = false, routeFollowed = true)) + assertTrue(RenumberedShowPageLogic.isFollowed(shownFollowed = true, routeFollowed = false)) + assertFalse(RenumberedShowPageLogic.isFollowed(shownFollowed = false, routeFollowed = false)) + } + + @Test + fun aPageOpenedOnTheNewNumberCountsAFollowStillUnderTheOldOne() { + // Opened from search or a notification on the new number while the old number is still followed. + assertTrue(RenumberedShowPageLogic.isFollowed(shownFollowed = false, routeFollowed = false, formerFollowed = true)) + } + + @Test + fun theAnswerThatNamedTheNewShowShowsUntilItsOwnDetailsArrive() { + val stored = Podcast(id = NEW, title = "Stored", artist = "", imageUrl = "") + val answered = Podcast(id = NEW, title = "Answered", artist = "", imageUrl = "") + + assertEquals("a stored row wins", "Stored", RenumberedShowPageLogic.shownBeforeDetails(stored, answered, NEW)?.title) + assertEquals("no stored row: the first answer, never a loading or error page", "Answered", RenumberedShowPageLogic.shownBeforeDetails(null, answered, NEW)?.title) + assertNull("an answer about another show is never shown", RenumberedShowPageLogic.shownBeforeDetails(null, answered, OTHER)) + assertNull(RenumberedShowPageLogic.shownBeforeDetails(null, null, NEW)) + } + + @Test + fun theRenumberedShowColoursThePageOfItsOldRoute() { + val state = success(NEW) + + val podcast = RenumberedShowPageLogic.artworkPodcast(state, OLD, PodcastInfoPageShow(routePodcastId = OLD, shownPodcastId = NEW)) + + assertEquals(NEW, podcast?.id) + assertEquals(NEW, RenumberedShowPageLogic.artworkPodcast(success(NEW), NEW, PodcastInfoPageShow())?.id) + } + + @Test + fun aPodcastFromAnotherRouteNeverColoursThePageWhileItMoves() { + // The page moved from the renumbered show's route to another show's: until that show loads, + // the previous podcast stays in the state and must not colour the new route. + val previous = success(NEW) + + assertNull(RenumberedShowPageLogic.artworkPodcast(previous, OTHER, PodcastInfoPageShow(routePodcastId = OTHER, shownPodcastId = OTHER))) + assertNull(RenumberedShowPageLogic.artworkPodcast(previous, OTHER, PodcastInfoPageShow(routePodcastId = OLD, shownPodcastId = NEW))) + assertNull(RenumberedShowPageLogic.artworkPodcast(PodcastInfoUiState.Loading, OLD, PodcastInfoPageShow(OLD, NEW))) + } + + @Test + fun theColoursFollowTheRouteBackAndForth() { + val pages = listOf(OLD to NEW, OTHER to OTHER, OLD to NEW) + val shown = pages.map { (route, show) -> RenumberedShowPageLogic.artworkPodcast(success(show), route, PodcastInfoPageShow(route, show))?.id } + + assertEquals(listOf(NEW, OTHER, NEW), shown) + } + + private fun success(id: String) = + PodcastInfoUiState.Success( + podcast = Podcast(id = id, title = "Show $id", artist = "", imageUrl = "https://img.example/$id.jpg"), + episodes = emptyList(), + isSubscribed = true, + ) + + private companion object { + const val OLD = "1258562" + const val NEW = "7976624" + const val OTHER = "920666" + } +}