From abb0002031c2e890f41a68b0860a0dd5b8a32735 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 21:06:38 +0530 Subject: [PATCH 1/9] feat(catalog): update feed addresses in one place and check them daily --- ARCHITECTURE.md | 2 + app/README.md | 8 +- .../java/cx/aswin/boxlore/AppContainer.kt | 41 +++- .../PodcastNotificationRegistrationWorker.kt | 77 ++++++++ ...dcastNotificationRegistrationWorkerTest.kt | 102 ++++++++++ core/catalog/README.md | 10 +- .../core/catalog/DirectFeedRecovery.kt | 84 +++++--- .../core/catalog/FeedAddressDailyCheck.kt | 154 +++++++++++++++ .../core/catalog/FeedAddressUpdater.kt | 151 ++++++++++++++ .../aswin/boxlore/core/catalog/FeedReports.kt | 133 +++++++++++++ .../catalog/PodcastNotificationRegistrar.kt | 72 +++++++ .../core/catalog/RssNotificationReconciler.kt | 2 +- .../core/catalog/SubscribedEpisodeCatalog.kt | 47 ++++- .../catalog/SubscriptionForegroundSync.kt | 113 +++++++++-- .../core/catalog/SubscriptionRepository.kt | 67 ++++++- .../catalog/backup/LibraryBackupManager.kt | 5 +- .../catalog/sync/SubscriptionSyncResolver.kt | 35 +++- .../core/catalog/DirectFeedRecoveryTest.kt | 105 ++++++++++ .../core/catalog/FeedAddressDailyCheckTest.kt | 186 +++++++++++++++++ .../core/catalog/FeedAddressUpdaterTest.kt | 187 ++++++++++++++++++ .../boxlore/core/catalog/FeedReportsTest.kt | 99 ++++++++++ .../PodcastNotificationRegistrarTest.kt | 160 +++++++++++++++ .../SharedPreferencesFeedReportStampsTest.kt | 31 +++ .../catalog/SubscribedEpisodeCatalogTest.kt | 47 +++++ .../catalog/SubscriptionForegroundSyncTest.kt | 36 +--- core/database/README.md | 3 +- .../boxlore/core/database/FeedAddressWrite.kt | 26 +++ .../aswin/boxlore/core/database/PodcastDao.kt | 81 ++++++++ .../database/PodcastFeedAddressDaoTest.kt | 159 +++++++++++++++ core/domain/README.md | 2 +- .../domain/ports/LocalEpisodeCatalogPort.kt | 7 +- core/downloads/README.md | 2 +- .../core/downloads/AutoDownloadCoordinator.kt | 12 +- .../downloads/AutoDownloadCoordinatorTest.kt | 20 ++ core/rss/README.md | 4 +- .../aswin/boxlore/core/rss/FeedReadFailure.kt | 36 ++++ .../core/rss/LocalEpisodeCatalogRefresh.kt | 8 +- .../boxlore/core/rss/FeedReadFailureTest.kt | 13 ++ .../rss/LocalEpisodeCatalogRepositoryTest.kt | 2 + 39 files changed, 2221 insertions(+), 108 deletions(-) create mode 100644 app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt create mode 100644 app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheck.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdater.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheckTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt create mode 100644 core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt create mode 100644 core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b0ad66f5..011f861e0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,6 +28,7 @@ These values are part of the shipping product. Renames or recreations break upgr | WorkManager | `LegacyWorkerFactory` plus permanent `core.data.*` stubs resolve historical FQCNs | | Deep links | `boxlore://` and `boxcast://`, plus both HTTPS share-path prefixes. Patterns are only added: episode links may also carry `show=`, which fills the route's `podcastId` | | Episode share registration | Unique work names `episode-share-registration-` (`EpisodeShareRegistrationWorker`); never renamed | +| Notification re-registration | Unique work names `podcast-notification-registration-` (`PodcastNotificationRegistrationWorker`, after a saved feed address changes); 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) | | Playback instance | One UI-scoped `PlaybackRepository`; routes and workers must not construct a second one | @@ -251,6 +252,7 @@ Historical FQCNs and preference file names remain reachable so upgrades from old | — | `boxlore_now_playing_widget` | `NowPlayingWidgetSnapshotStore` (`:feature:widgets`; last rendered Now Playing snapshot JSON; do not rename) | | — | `boxlore_library_widget` | `LibraryWidgetSnapshotStore` (`:feature:widgets`; last rendered Subscriptions / New Episodes snapshot JSON; do not rename) | | — | `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) | | `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 f6cf044a1..4275d5d92 100644 --- a/app/README.md +++ b/app/README.md @@ -70,7 +70,8 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - **Feed fix off switches** (`sync/FeedFixSwitches`): PostHog kill switches for the feed and episode fixes, passed into core modules as lambdas. A fix runs unless its flag is on; no answer (PostHog not ready, flag missing or inactive, an error) keeps it on. `feed-fix-match-baseline-off` sends the feed-only extras' comparison list back from the 1,000-item `episodes/match` list to the 100-item page (`PodcastRepository.useEpisodeMatchBaseline`). `feed-fix-address-recheck-off`, `feed-fix-sync-fallback-off` and `feed-fix-moved-feed-off` turn off the subscription sync's dead-address re-check, its `/sync` fallback for failed feeds, and following moved feeds (`FeedFixToggles`). `FeedFixSwitchesTest` covers the default. The same fail-open switches pause the v2 build's jobs: `upgrade-repair-off` (the one-time repair of saved feed addresses after the update), `address-check-off` (the daily address check of followed shows), both without marking the work done, and `v2-discovery-off` (discovery back on the v1 routes). These three are read through `sync/SavedKillSwitch`, because the repair can start on a headless launch before PostHog is set up: once PostHog is ready its answer decides and is saved; while it is still starting, the last saved answer is used, and the job runs only when none was saved. - **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the other switches have no reader yet. - **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). -- **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). +- **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`. +- **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, and `feedTelemetry`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs; `rss:` shows keep their own journal worker. - NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. @@ -188,7 +189,8 @@ Unacknowledged tablet launches first show an opaque alpha notice with the Home f - `applicationId` is `cx.aswin.boxlore`. - Manifest service, receiver, activity, and worker class names are system-facing identities. - `LegacyWorkerFactory` preserves WorkManager upgrades from legacy worker class names. -- The unique work names `episode-share-registration-` (`EpisodeShareRegistrationWorker`) are permanent. +- The unique work names `episode-share-registration-` (`EpisodeShareRegistrationWorker`) and `podcast-notification-registration-` (`fcm/PodcastNotificationRegistrationWorker`) are permanent. +- SharedPreferences file `boxlore_feed_reports` (`SharedPreferencesFeedReportStamps`, opened by `AppContainer`) holds broken-feed report times; stable name. - 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. @@ -199,7 +201,7 @@ Episode push artwork keeps its six-second total deadline and uses 700ms per rout ## Testing notes -- `EpisodeShowLinksTest` opens every episode link on the real route and arguments: `?show=` from the share page and `&show=` share links on each share address, with and without `t`, a clip or `autoplay`, links the app builds, older links without a show, and the notifier's route. `WebAppLinksManifestTest` also checks every episode share pattern, with and without the show, is on a claimed host and path. `FcmPayloadParserTest`, `NewEpisodePushHydrationTest` and `NewEpisodeFcmLogicTest` cover the push fields, GUID/enclosure before catalog key and recipe ID, and the recipe-ID route. `EpisodeShareRegistrationWorkerTest` covers the unique name (unchanged by the audio address a GUID request carries), constraints, backoff, the attempt cap and invalid input. `NewEpisodeDeliveryWorkerTest` covers delivery names with and without a recipe ID or catalog key. `EpisodeShowLinksTest` also opens install-referrer routes with and without a show, and `DeepLinkAutoplayPlanTest` covers autoplay and seek for a link that resolves to the phone's own ID and a stale page for another link. +- `EpisodeShowLinksTest` opens every episode link on the real route and arguments: `?show=` from the share page and `&show=` share links on each share address, with and without `t`, a clip or `autoplay`, links the app builds, older links without a show, and the notifier's route. `WebAppLinksManifestTest` also checks every episode share pattern, with and without the show, is on a claimed host and path. `FcmPayloadParserTest`, `NewEpisodePushHydrationTest` and `NewEpisodeFcmLogicTest` cover the push fields, GUID/enclosure before catalog key and recipe ID, and the recipe-ID route. `EpisodeShareRegistrationWorkerTest` covers the unique name (unchanged by the audio address a GUID request carries), constraints, backoff, the attempt cap and invalid input. `PodcastNotificationRegistrationWorkerTest` covers its unique name per show (none for `rss:` shows, a newer request replacing the waiting one), constraints, backoff, the attempt cap, errors counted as retries and work without a show. `NewEpisodeDeliveryWorkerTest` covers delivery names with and without a recipe ID or catalog key. `EpisodeShowLinksTest` also opens install-referrer routes with and without a show, and `DeepLinkAutoplayPlanTest` covers autoplay and seek for a link that resolves to the phone's own ID and a stale page for another link. - Tablet context details use their native Back toolbar and keep docked playback in the browsing pane. The context player can be minimized independently. Fullscreen media requests share an app-owned window host across docked/context presentations, restoring the originating presentation when closed. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 355c2e56c..898148c55 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -2,12 +2,15 @@ package cx.aswin.boxlore import android.content.Context import cx.aswin.boxlore.connectivity.AndroidConnectivityObserver +import cx.aswin.boxlore.core.catalog.DiscoveryEpisodeIds +import cx.aswin.boxlore.core.catalog.DiscoveryRouting import cx.aswin.boxlore.core.catalog.EpisodeAliasReads import cx.aswin.boxlore.core.catalog.EpisodeSharePreparer import cx.aswin.boxlore.core.catalog.EpisodeShareRegistrar -import cx.aswin.boxlore.core.catalog.DiscoveryEpisodeIds -import cx.aswin.boxlore.core.catalog.DiscoveryRouting +import cx.aswin.boxlore.core.catalog.FeedAddressUpkeep +import cx.aswin.boxlore.core.catalog.FeedAddressWiring import cx.aswin.boxlore.core.catalog.FeedFixToggles +import cx.aswin.boxlore.core.catalog.FeedMoveFollower import cx.aswin.boxlore.core.catalog.FolderRepository import cx.aswin.boxlore.core.catalog.InstallReferrerManager import cx.aswin.boxlore.core.catalog.LegacyRssRepair @@ -16,12 +19,13 @@ 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.RolloutSwitches -import cx.aswin.boxlore.core.catalog.RoomEpisodeAliasReads import cx.aswin.boxlore.core.catalog.RoomDiscoveryEpisodeIds +import cx.aswin.boxlore.core.catalog.RoomEpisodeAliasReads import cx.aswin.boxlore.core.catalog.RoomEpisodeOfflineLookup import cx.aswin.boxlore.core.catalog.RoomFolderRepository import cx.aswin.boxlore.core.catalog.RoomLocalCatalog import cx.aswin.boxlore.core.catalog.SharedAppDependencies +import cx.aswin.boxlore.core.catalog.SharedPreferencesFeedReportStamps import cx.aswin.boxlore.core.catalog.SubscriptionForegroundSync import cx.aswin.boxlore.core.catalog.SubscriptionRepository import cx.aswin.boxlore.core.catalog.ports.SmartDownloadSyncPort @@ -59,6 +63,7 @@ import cx.aswin.boxlore.core.rss.EpisodeSupplementRepository import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository import cx.aswin.boxlore.core.rss.RssPodcastRepository import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker +import cx.aswin.boxlore.fcm.PodcastNotificationRegistrationWorker import cx.aswin.boxlore.share.EpisodeShareRegistrationWorker import cx.aswin.boxlore.sync.AnalyticsFeedTelemetry import cx.aswin.boxlore.sync.CloudSyncTriggerCoordinator @@ -162,12 +167,21 @@ class AppContainer( ) } - /** Shared source-aware catalog for automation, foreground sync and push hydration. */ + /** + * 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`. + */ val subscribedEpisodeCatalog: cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort by lazy { cx.aswin.boxlore.core.catalog.SubscribedEpisodeCatalog( localEpisodeCatalogRepository, rssPodcastRepository.episodeCatalog, - isSubscribed = { id -> database.podcastDao().getPodcast(id)?.isSubscribed == true } + isSubscribed = { id -> database.podcastDao().getPodcast(id)?.isSubscribed == true }, + feedMoves = + FeedMoveFollower( + enabled = { FeedFixSwitches.isOn(FeedFixSwitches.MOVED_FEED_OFF) }, + showTitle = { id -> database.podcastDao().getPodcast(id)?.title }, + adopt = { id, movedTo -> subscriptionRepository.adoptMovedFeed(id, movedTo) }, + ), ) } @@ -362,6 +376,7 @@ class AppContainer( subscribedEpisodeCatalog, userPreferencesRepository, recoverFeedUrl = { id -> cx.aswin.boxlore.core.catalog.TrackedPodcastRtdbLogic.httpsFeedUrl(podcastRepository.getPodcastDetails(id)?.feedUrl) }, + saveFeedUrl = { id, url -> subscriptionRepository.ensureHttpsFeedUrl(id, url) }, ) } @@ -384,6 +399,12 @@ class AppContainer( java.io.File(appContext.noBackupFilesDir, "rss_notification_registrations"), ), requestRssNotificationSync = { cx.aswin.boxlore.fcm.RssNotificationSyncWorker.enqueue(appContext) }, + // One feed address writer: acknowledged re-registration and feed_url_corrected (P4 PR 14). + feedAddressWiring = + FeedAddressWiring( + requestNotificationRegistration = { id -> PodcastNotificationRegistrationWorker.enqueue(appContext, id) }, + telemetry = feedTelemetry, + ), ) } @@ -400,6 +421,15 @@ class AppContainer( syncFallback = { FeedFixSwitches.isOn(FeedFixSwitches.SYNC_FALLBACK_OFF) }, movedFeed = { FeedFixSwitches.isOn(FeedFixSwitches.MOVED_FEED_OFF) }, ), + // Daily address check and broken-feed reports (P4 PR 14), behind address-check-off. + addressUpkeep = + FeedAddressUpkeep( + reportStamps = + SharedPreferencesFeedReportStamps( + appContext.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE), + ), + isOnline = connectivityStatus::isOnline, + ), ) } @@ -556,6 +586,7 @@ class AppContainer( podcastRepository = podcastRepository, rssPodcastRepository = rssPodcastRepository, notificationSyncPort = subscriptionRepository, + feedAddresses = subscriptionRepository.feedAddresses, ) val historyResolver = HistorySyncResolver( listeningHistoryDao = database.listeningHistoryDao(), diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt b/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt new file mode 100644 index 000000000..2b3dd46e1 --- /dev/null +++ b/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt @@ -0,0 +1,77 @@ +package cx.aswin.boxlore.fcm + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import cx.aswin.boxlore.core.catalog.NotificationRegistrationOutcome +import cx.aswin.boxlore.core.catalog.SharedAppDependenciesHolder +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException + +/** + * Registers a Podcast Index show's new-episode notifications again after its saved feed address + * changed, and retries until Firebase acknowledges it: the same `tracked_podcasts/` node and + * topic as turning notifications on ([cx.aswin.boxlore.core.catalog.PodcastNotificationRegistrar]). + * Network-gated, exponential backoff from 30 s, at most [MAX_ATTEMPTS] runs, so it never retries + * forever like the `rss:` journal worker ([RssNotificationSyncWorker]). + * + * The unique work name ([workName], prefix [WORK_NAME_PREFIX]) is a stable identity. REPLACE: the + * worker reads the show's row when it runs, so a newer address change restarts it with fresh attempts. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): notifications follow a corrected address. Permanent: never remove (MIGRATION.md §5). + */ +class PodcastNotificationRegistrationWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + internal var register: suspend (podcastId: String) -> NotificationRegistrationOutcome = { podcastId -> + SharedAppDependenciesHolder.require().subscriptionRepository.registerPodcastNotifications(podcastId) + } + + override suspend fun doWork(): Result { + val podcastId = inputData.getString(KEY_PODCAST_ID)?.takeIf { it.isNotBlank() && !it.startsWith("rss:") } ?: return Result.failure() + val outcome = + try { + register(podcastId) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + NotificationRegistrationOutcome.RETRY + } + return when (outcome) { + NotificationRegistrationOutcome.DONE, NotificationRegistrationOutcome.DROPPED -> Result.success() + NotificationRegistrationOutcome.RETRY -> if (runAttemptCount + 1 < MAX_ATTEMPTS) Result.retry() else Result.failure() + } + } + + companion object { + /** Permanent unique-work name prefix; the rest is the show ID. Never rename. */ + const val WORK_NAME_PREFIX = "podcast-notification-registration-" + + /** Runs per address change, the first included. */ + const val MAX_ATTEMPTS = 8 + + private const val BACKOFF_SECONDS = 30L + private const val KEY_PODCAST_ID = "podcastId" + + fun workName(podcastId: String): String = WORK_NAME_PREFIX + podcastId + + fun workRequest(podcastId: String): OneTimeWorkRequest = OneTimeWorkRequestBuilder() + .setInputData(Data.Builder().putString(KEY_PODCAST_ID, podcastId).build()) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + + /** Asks for [podcastId]'s notifications to be registered again; `rss:` shows have their own journal. */ + fun enqueue(context: Context, podcastId: String) { + if (podcastId.isBlank() || podcastId.startsWith("rss:")) return + WorkManager.getInstance(context).enqueueUniqueWork(workName(podcastId), ExistingWorkPolicy.REPLACE, workRequest(podcastId)) + } + } +} diff --git a/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt b/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt new file mode 100644 index 000000000..f8f30d429 --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt @@ -0,0 +1,102 @@ +package cx.aswin.boxlore.fcm + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.BackoffPolicy +import androidx.work.Configuration +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import cx.aswin.boxlore.core.catalog.NotificationRegistrationOutcome +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +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 + +/** + * Acknowledged notification registration after a Podcast Index show's address changed (own-episode-IDs + * P4 PR 14, plan section 11): one permanent unique work item per show, network-gated, capped retries. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class PodcastNotificationRegistrationWorkerTest { + private val context get() = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + WorkManagerTestInitHelper.initializeTestWorkManager(context, Configuration.Builder().setExecutor(SynchronousExecutor()).build()) + } + + @Test + fun `one work item per show under its permanent name, and never for rss shows`() { + PodcastNotificationRegistrationWorker.enqueue(context, "920666") + PodcastNotificationRegistrationWorker.enqueue(context, "920666") + PodcastNotificationRegistrationWorker.enqueue(context, "rss:abc") + + val name = PodcastNotificationRegistrationWorker.workName("920666") + assertEquals("podcast-notification-registration-920666", name) + val work = WorkManager.getInstance(context).getWorkInfosForUniqueWork(name).get() + assertEquals("a newer address change replaces the waiting one", 1, work.count { it.state == WorkInfo.State.ENQUEUED }) + assertTrue(WorkManager.getInstance(context).getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("rss:abc")).get().isEmpty()) + } + + @Test + fun `the work waits for a network and backs off exponentially`() { + val work = PodcastNotificationRegistrationWorker.workRequest("920666").workSpec + assertEquals(NetworkType.CONNECTED, work.constraints.requiredNetworkType) + assertEquals(BackoffPolicy.EXPONENTIAL, work.backoffPolicy) + assertEquals(30_000L, work.backoffDelayDuration) + } + + @Test + fun `an acknowledgement ends the work, and the retries stop at the cap`() = runBlocking { + assertEquals(ListenableWorker.Result.success(), worker(NotificationRegistrationOutcome.DONE).doWork()) + assertEquals(ListenableWorker.Result.success(), worker(NotificationRegistrationOutcome.DROPPED).doWork()) + assertEquals(ListenableWorker.Result.retry(), worker(NotificationRegistrationOutcome.RETRY, attempt = 0).doWork()) + assertEquals( + ListenableWorker.Result.retry(), + worker(NotificationRegistrationOutcome.RETRY, attempt = PodcastNotificationRegistrationWorker.MAX_ATTEMPTS - 2).doWork(), + ) + assertEquals( + ListenableWorker.Result.failure(), + worker(NotificationRegistrationOutcome.RETRY, attempt = PodcastNotificationRegistrationWorker.MAX_ATTEMPTS - 1).doWork(), + ) + } + + @Test + fun `an error counts as a retry, and work without a show fails without registering`() = runBlocking { + val failing = worker(NotificationRegistrationOutcome.DONE).apply { register = { error("Firebase not ready") } } + assertEquals(ListenableWorker.Result.retry(), failing.doWork()) + + var calls = 0 + val empty = TestListenableWorkerBuilder(context).build().apply { + register = { + calls++ + NotificationRegistrationOutcome.DONE + } + } + assertEquals(ListenableWorker.Result.failure(), empty.doWork()) + assertEquals(0, calls) + } + + private fun worker(outcome: NotificationRegistrationOutcome, attempt: Int = 0) = + TestListenableWorkerBuilder(context) + .setInputData(PodcastNotificationRegistrationWorker.workRequest("920666").workSpec.input) + .setRunAttemptCount(attempt) + .build() + .apply { + register = { id -> + assertEquals("920666", id) + outcome + } + } +} diff --git a/core/catalog/README.md b/core/catalog/README.md index 96e9355cb..6eb021325 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -10,7 +10,11 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su Failed consent writes keep activation off, and local notification flags prevent a failed revoke from implicitly re-enabling after restart. RSS sync isolates each feed and runs alongside PI sync; the source adapter explicitly implements every catalog operation. -- `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. +- `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 the row for the next sync push (not for an address that came from the cloud). It is a compare-and-set on the address it read. It then asks the app to register the show's notifications again when they are on (`FeedAddressWiring.requestNotificationRegistration`) and 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. 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). +- `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. `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 a confirmed feed move left. `fetchUrl: null` keeps the saved address. Each show the registry answered for is stamped (`podcasts.feedUrlCheckedAt`). 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 (`FeedReportLimiter`; times kept in `FeedReportStamps`, the app's `SharedPreferencesFeedReportStamps`). Its answer is an address to try only when it differs, is HTTPS and the saved address looks dead. - RSS notification activation requires explicit public-feed disclosure acceptance through `setNotificationsEnabled(..., acceptRssDisclosure = true, disclosedFeedUrl = savedUrl)`. `DeviceRssNotificationConsent` stores only URL fingerprints in `noBackupFilesDir`; changed URLs, restore and remote cloud settings cannot supply acceptance. Canonical `rss:` IDs require disclosure even with stale source metadata. Refused or revoked activation also removes this device's old registration; a library cleared during acceptance cannot publish a deleted show. Notification settings remain independent of auto-download. The shared checker receives only explicitly accepted public feeds. `TrackedPodcastRtdbLogic.topic` maps `rss:` to FCM-safe `rss_` while retaining canonical IDs in payloads. JSON/cloud RSS restore retains the original saved show ID across redirects. - RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. @@ -29,7 +33,7 @@ Failed consent writes keep activation off, and local notification flags prevent - **Caches.** `/v2` recommendation and because-you-like answers are cached under `v2|` keys, so a v1 answer never serves a v2 request or the reverse. - `RoomDiscoveryEpisodeIds` is the alias-table implementation (`episode_aliases`, trusted aliases only; a recipe ID two keys of one show share stays as it is; the audio-address match above); the app passes it as `discoveryEpisodeIds`. `DiscoveryRouting.install` gives the same switch and translator to code outside the repository: `BriefingLinks` builds the briefing chapters link on `/v2/briefings/chapters/` while v2 discovery is on (the transcript link keeps `/briefings/transcript/`, as the `/v2` briefing answer links it), and `ChapterRepository` translates related episodes of the chapters it fetches. - `RecommendationsV2Request` always sends `contractVersion: 2`, which the `/v2` recommendation bodies need. With the switch off, personal picks go straight to contract-1 `/recommendations` (`v1PersonalizedRecommendations`), exactly what shipped builds received: their v1 `recommendations/v2` request was always refused, so the switch restores the shipped behaviour. -- `SubscriptionForegroundSync` refreshes subscribed latest episodes (`syncSubscriptions` in chunks of 10). Cold start from Home still waits 2s for first paint; **Subscriptions appear** call `requestRefresh()`. Each pass starts with `sweepExpired` so unsubscribed catalogs past the 14-day TTL are deleted. PI `/sync` runs for shows that are not catalog-ready. Every subscribed PI show with an HTTPS `feedUrl` refreshes the local catalog (HEAD/quiet skip for ready rows; not-ready rows force the repair GET; one-time PI rematch only while `needsFullBackfill`). Missing HTTPS URLs are recovered via `GET /podcast` at most once per day, with bounded concurrency; one failed recovery does not abort the rest of the pass. A show whose own feed failed this pass is handled after the feed reads (`DirectFeedRecovery.recover`): when the failure says the saved address looks dead (`RefreshOutcome.Failure.addressGone`: 401/403/404/410/451, no such host, a failing certificate, or not a feed), `DirectFeedRecovery.recheckGoneFeedAddress` asks `GET /podcast` for the server's confirmed address at most once per day per show (the lookup counts only once the server answers); a different HTTPS address is read at once and saved only when that read works; shows still failing take their latest episode from `/sync` through the ordinary save, which never replaces a newer tip. A feed that moved for good (`RefreshOutcome.Success.movedTo`) has its new address saved. Never for `rss:` shows. `FeedFixToggles` (`addressRecheck`, `syncFallback`, `movedFeed`) are the app's PostHog kill switches; each stays on without an answer. `PodcastRepository.rolloutSwitches` (`RolloutSwitches`) carries the v2 build's switches from the app: `feedFirst` (show pages read the show's own feed instead of the server's list; off by default, and the app fixes it per process), and `upgradeRepair`, `addressCheck` and `v2Discovery` (on by default, and the app uses the last saved answer while PostHog is starting; off pauses the upgrade repair or the daily address check without marking them done, or sends discovery back to the v1 routes). `v2Discovery` is read by the discovery calls above; the others have no reader in this module yet. Automatic foreground feed work selects at most 10 due shows per pass, rotates past failures to prevent starvation, prioritizes the visible show, and caps concurrency at 2. Ready feeds share the RSS layer's persisted six-hour cooldown. The 15-minute timer pauses outside the foreground and leaving foreground cancels its in-flight automatic pass; the five-minute pass cooldown still coalesces repeated screen requests. A successful persist emits `directFeedRefreshed`. Subscribe calls `requestCatalogIngest` on the same application scope so the first publisher-feed persist is not cancelled when Podcast Info is popped; it ignores `requestRefresh` cooldown / in-flight gates. `catalogIngestFinished` emits when that one-shot ends (success, skip, or failure). The same pass patches RTDB `feedUrl` for notifying HTTPS shows. `:app` starts it after onboarding; Library `onScreenResume` calls `requestRefresh`. +- `SubscriptionForegroundSync` refreshes subscribed latest episodes (`syncSubscriptions` in chunks of 10). Cold start from Home still waits 2s for first paint; **Subscriptions appear** call `requestRefresh()`. Each pass starts with `sweepExpired` so unsubscribed catalogs past the 14-day TTL are deleted. PI `/sync` runs for shows that are not catalog-ready. Every subscribed PI show with an HTTPS `feedUrl` refreshes the local catalog (HEAD/quiet skip for ready rows; not-ready rows force the repair GET; one-time PI rematch only while `needsFullBackfill`). Missing HTTPS URLs are recovered via `GET /podcast` at most once per day, with bounded concurrency; one failed recovery does not abort the rest of the pass. A show whose own feed failed this pass is handled after the feed reads (`DirectFeedRecovery.recover`): the failure is reported (above), and when it says the saved address looks dead (`RefreshOutcome.Failure.addressGone`: 401/403/404/410/451, no such host, a failing certificate, or not a feed), the report's address is tried, else `DirectFeedRecovery.recheckGoneFeedAddress` asks `GET /podcast` for the server's confirmed address. That re-check is the daily check's fallback: it shares its per-show stamp (`feedUrlCheckedAt`), so it runs only for shows the daily check couldn't reach and never twice a day (the lookup counts only once the server answers); a different HTTPS address is read at once and saved only when that read works; shows still failing take their latest episode from `/sync` through the ordinary save, which never replaces a newer tip. A feed that moved for good (`RefreshOutcome.Success.movedTo`) has its new address saved. Never for `rss:` shows. `FeedFixToggles` (`addressRecheck`, `syncFallback`, `movedFeed`) are the app's PostHog kill switches; each stays on without an answer. `PodcastRepository.rolloutSwitches` (`RolloutSwitches`) carries the v2 build's switches from the app: `feedFirst` (show pages read the show's own feed instead of the server's list; off by default, and the app fixes it per process), and `upgradeRepair`, `addressCheck` and `v2Discovery` (on by default, and the app uses the last saved answer while PostHog is starting; off pauses the upgrade repair or the daily address check without marking them done, or sends discovery back to the v1 routes). `v2Discovery` is read by the discovery calls above and `addressCheck` by the daily check and the broken-feed reports; `upgradeRepair` has no reader in this module yet. Automatic foreground feed work selects at most 10 due shows per pass, rotates past failures to prevent starvation, prioritizes the visible show, and caps concurrency at 2. Ready feeds share the RSS layer's persisted six-hour cooldown. The 15-minute timer pauses outside the foreground and leaving foreground cancels its in-flight automatic pass; the five-minute pass cooldown still coalesces repeated screen requests. A successful persist emits `directFeedRefreshed`. Subscribe calls `requestCatalogIngest` on the same application scope so the first publisher-feed persist is not cancelled when Podcast Info is popped; it ignores `requestRefresh` cooldown / in-flight gates. `catalogIngestFinished` emits when that one-shot ends (success, skip, or failure). The same pass patches RTDB `feedUrl` for notifying HTTPS shows. `:app` starts it after onboarding; Library `onScreenResume` calls `requestRefresh`. - `LegacyRssRepair` runs once after onboarding for pre-fix OPML rows when enabled by the app-shell build/install gate. It fetches each subscribed `sourceType=rss` feed and accepts only an exact Podcast Index feed-URL variant or podcast GUID; title/author/artwork are never match inputs. Rows with `linkedPodcastIndexId` are skipped because they represent an intentional RSS-source choice. URL/GUID conflicts, lookup failures, active target subscriptions, or incomplete local catalogs leave the RSS row untouched. Successful rows adopt the PI id while retaining RSS episode ids, journaling DataStore id references for crash-safe completion, restoring notification routing for shows that had alerts enabled, and clearing any recommendation cache seeded by the old id. A pass starts only when an eligible row and connectivity are present, publishes `inProgress` for persistent app-shell status UI, and is admitted by a process-local one-shot gate. Three consecutive transient lookup/inspect failures stop the remaining sequential work. Fresh installs can persist the completed marker without starting a pass or showing progress. Once a pass settles without a pending ID transaction, its version marker is written even if individual feeds had transient failures, preventing foreground/banner loops; only a process killed mid-transaction can resume from the pending journal on a later launch. - `content.ContentOrchestrator`, `ServerGroupedSectionProvider`, `ContentContextEngine`, and related content contracts assemble discovery slates from catalog/v3 (no live `content/sections/v1` client). - `content.CuratedMoods` is the shared catalog of curated-mood IDs/titles used by Home daypart rails and Explore For You chips (`getCuratedVibe`). @@ -151,6 +155,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - Recommendation interests are trimmed, de-duplicated and capped at 12 (`boundedRecommendationInterests`) on `/recommendations/v2`, the legacy `/recommendations` fallback and the bootstrap recommendations request, matching the API's caps. - `PodcastRepository.getHomeRecommendations` replaces the old `getHomeBootstrapData`: Home's Picked for you and Explore's For you only used its recommendations, so it now sends `include = ["recommendations"]` on `POST /home/bootstrap` and returns `HomeRecommendations(items, isFallback)` without the Brief fallback fetch. Workers without `include` support still reply with every part, which the app ignores. +- `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. - 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. @@ -160,6 +165,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit ## Testing notes +- 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; a failed registration request keeps the address), `FeedAddressDailyCheckTest` (501 followed shows make two calls, nothing again the same day; only confirmed moves; 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), `FeedReportsTest` and `SharedPreferencesFeedReportStampsTest` (once per show a day, 20 a day in all, 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, nothing without notifications, retry and refused outcomes, and a cloud pull that changes only the address re-registering on the same node and topic), `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). - `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`. - `PodcastRepositoryApiV2Test` (Robolectric, MockWebServer): the production `api` and `apiV2` reach the same base URL and open the HTTP cache once; test overrides open none. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt index ef79d6030..647a3de7c 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt @@ -1,22 +1,40 @@ package cx.aswin.boxlore.core.catalog import android.util.Log -import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort -import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository +import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep import kotlinx.coroutines.CancellationException /** - * Shows whose own feed failed in a [SubscriptionForegroundSync] pass (P4a). + * Another address to try for a show whose saved one failed, and the path that offered it + * ([FeedUrlCorrectionStep.FEED_REPORT] or the daily check's `/podcast` fallback, + * [FeedUrlCorrectionStep.DAILY_CHECK]). + */ +internal data class AddressCandidate(val feedUrl: String, val step: FeedUrlCorrectionStep) { + /** The address change saved once [feedUrl] read successfully. */ + fun change(podcastId: String, nowMillis: Long): FeedAddressChange = FeedAddressChange( + podcastId = podcastId, + feedUrl = feedUrl, + source = if (step == FeedUrlCorrectionStep.FEED_REPORT) FeedUrlSources.FEED_REPORT else FeedUrlSources.PODCAST_LOOKUP, + checkedAt = nowMillis, + reason = FeedUrlCorrectionReason.DEAD, + step = step, + ) +} + +/** + * Shows whose own feed failed in a [SubscriptionForegroundSync] pass (P4a; reports since P4 PR 14). * - * Before, a saved HTTPS address was never re-checked, and a show with one never went to `/sync`, - * so a dead feed got no new episodes at all. Now a saved address that looks dead - * ([DirectFeedResolveResult.addressGone]) is re-checked against the server's confirmed address, at - * most once a day per show. The new address is read at once and saved only when that read works, - * so a server that offers another dead address (say, Podcast Index's own) never replaces the - * saved one. Shows still failing take their - * latest episode from `/sync` (which serves stale shows from their live feed), through the ordinary - * save that never replaces a newer tip. Never for `rss:` shows: their address is their identity and - * notification consent. + * Every failed read of a Podcast Index show is reported to the API ([DirectFeedSyncSeams.reportFeed], + * `/v2/feed-report`, limited per show and never while offline), which queues a re-check there. When + * the saved address looks dead ([DirectFeedResolveResult.addressGone]) another address is tried: + * the one the report answered with, else the server's confirmed address from `/podcast` + * ([DirectFeedSyncSeams.recheckAddress]), which runs only when the daily address check hasn't + * reached the show today (they share one daily stamp per show). The new address is read at once and + * saved only when that read works, so a server that offers another dead address (say, Podcast + * Index's own) never replaces the saved one. Shows still failing take their latest episode from + * `/sync` (which serves stale shows from their live feed), through the ordinary save that never + * replaces a newer tip. Never for `rss:` shows: their address is their identity and notification consent. */ internal object DirectFeedRecovery { private const val TAG = "DirectFeedRecovery" @@ -35,8 +53,8 @@ internal object DirectFeedRecovery { val stillFailing = mutableListOf() for ((id, result) in failed) { if (id.startsWith("rss:")) continue - val candidate = if (result.addressGone) recheck(directFeed, id) else null - val retried = candidate?.let { readAgain(id, it) } + val candidate = candidate(directFeed, id, result) + val retried = candidate?.let { readAgain(id, it.feedUrl) } if (candidate != null && retried != null && !retried.failed) { directFeed.saveAddress(id, candidate) } else { @@ -48,31 +66,43 @@ internal object DirectFeedRecovery { syncFromServer(stillFailing) } - private suspend fun recheck(directFeed: DirectFeedSyncSeams, id: String): String? = try { - directFeed.recheckAddress(id) + /** Reports the failed read, then picks another address only when the saved one looks dead. */ + private suspend fun candidate(directFeed: DirectFeedSyncSeams, id: String, result: DirectFeedResolveResult): AddressCandidate? { + val reported = guarded("Feed report", id) { directFeed.reportFeed(id, result.reportStatus) } + if (!result.addressGone) return null + reported?.let { return AddressCandidate(it, FeedUrlCorrectionStep.FEED_REPORT) } + return guarded("Feed address re-check", id) { directFeed.recheckAddress(id) } + ?.let { AddressCandidate(it, FeedUrlCorrectionStep.DAILY_CHECK) } + } + + private suspend fun guarded(what: String, id: String, block: suspend () -> String?): String? = try { + block() } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Feed address re-check failed for $id", e) + Log.e(TAG, "$what failed for $id", e) null } - /** The production [DirectFeedSyncSeams.recheckAddress]: [recheckGoneFeedAddress] behind its switch. */ + /** + * The production [DirectFeedSyncSeams.recheckAddress]: [recheckGoneFeedAddress] behind its + * switch, stamped with the daily address check's per-show stamp (`podcasts.feedUrlCheckedAt`). + */ fun addressRecheck( feedFixes: FeedFixToggles, subscriptionRepository: SubscriptionRepository, podcastRepository: PodcastRepository, - localEpisodeCatalog: LocalEpisodeCatalogPort?, ): suspend (String) -> String? = { id -> if (!feedFixes.addressRecheck()) { null } else { + val row = subscriptionRepository.getPodcastEntity(id) recheckGoneFeedAddress( id = id, now = System.currentTimeMillis(), - savedFeedUrl = subscriptionRepository.getPodcastEntity(id)?.feedUrl, - lastLookupAt = { localEpisodeCatalog?.lastFeedUrlLookupAt(id) ?: 0L }, - markLookup = { at -> localEpisodeCatalog?.markFeedUrlLookup(id, at) }, + savedFeedUrl = row?.feedUrl, + lastLookupAt = { row?.feedUrlCheckedAt ?: 0L }, + markLookup = { at -> subscriptionRepository.markFeedAddressChecked(id, at) }, lookupFeedUrl = { podcastRepository.getPodcastDetails(id)?.feedUrl }, ) } @@ -80,9 +110,11 @@ internal object DirectFeedRecovery { /** * Re-checks a dead saved HTTPS address against the server's confirmed one (`/podcast`, which - * returns the registry's address), at most once per [LocalEpisodeCatalogRepository.FEED_URL_LOOKUP_INTERVAL_MS] - * per show. The lookup counts only once the server answers, so an offline pass tries again. - * Nothing is saved here: [recover] saves the address only after it reads. + * returns the registry's address), at most once per [FeedAddressDailyCheck.CHECK_INTERVAL_MS] + * per show, sharing the daily check's stamp. The lookup counts only once the server answers, so + * an offline pass tries again. Nothing is saved here: [recover] saves the address only after it reads. + * This is the P4a per-show check, kept as the fallback of [FeedAddressDailyCheck] for shows + * it couldn't reach (`/v2/shows/feeds` failed, or the registry was unavailable). * @return a different HTTPS address to try, or null */ suspend fun recheckGoneFeedAddress( @@ -96,7 +128,7 @@ internal object DirectFeedRecovery { if (id.startsWith("rss:")) return null val current = TrackedPodcastRtdbLogic.httpsFeedUrl(savedFeedUrl) ?: return null val last = lastLookupAt() - if (last > 0L && now - last < LocalEpisodeCatalogRepository.FEED_URL_LOOKUP_INTERVAL_MS) return null + if (last in 1..now && now - last < FeedAddressDailyCheck.CHECK_INTERVAL_MS) return null val confirmed = lookupFeedUrl() ?: return null markLookup(now) val https = TrackedPodcastRtdbLogic.httpsFeedUrl(confirmed) ?: return null 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 new file mode 100644 index 000000000..8541981d6 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheck.kt @@ -0,0 +1,154 @@ +package cx.aswin.boxlore.core.catalog + +import android.util.Log +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep +import cx.aswin.boxlore.core.network.BoxLoreApiV2 +import cx.aswin.boxlore.core.network.model.V2EpisodeIds +import cx.aswin.boxlore.core.network.model.V2FetchVia +import cx.aswin.boxlore.core.network.model.V2ShowAddress +import cx.aswin.boxlore.core.network.model.V2ShowsFeedsRequest +import cx.aswin.boxlore.core.network.model.V2ShowsFeedsResponse +import kotlinx.coroutines.CancellationException +import retrofit2.awaitResponse + +/** + * The daily address check of followed shows: asks `/v2/shows/feeds` for every followed Podcast + * Index show not checked in the last day, in chunks of at most [V2ShowsFeedsRequest.MAX_IDS], and + * swaps a saved address only for a confirmed move ([confirmedMove]) through [FeedAddressUpdater]. + * + * Each show checked is stamped (`podcasts.feedUrlCheckedAt`) once the registry answers for it. The + * per-show `/podcast` re-check of a dead address (`DirectFeedRecovery`) reads the same stamp, so it + * runs only for shows this check couldn't reach (the call failed, or `registryAvailable` was false) + * and a show is never checked twice a day. Rows the phone changed and hasn't pushed yet + * ([changedOnPhone]) wait until they are clean. With [enabled] off (`address-check-off`) the check + * pauses without stamping anything. + * + * Replaces the P4a per-show `/podcast` re-check, which stays as the fallback. + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: daily check; decision 2). + * COMPAT(own-ids): followed shows stay reachable. Permanent: never remove (MIGRATION.md §5). + */ +class FeedAddressDailyCheck( + private val followedShows: suspend () -> List, + private val fetchAddresses: suspend (List) -> V2ShowsFeedsResponse?, + private val replaceAddress: suspend (FeedAddressChange) -> FeedAddressUpdate, + private val markChecked: suspend (podcastId: String, atMillis: Long) -> Unit, + private val enabled: () -> Boolean, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + /** What one [run] did. */ + data class Outcome(val checked: Int, val swapped: Int, val unchecked: Int) { + companion object { + val NOTHING = Outcome(checked = 0, swapped = 0, unchecked = 0) + } + } + + suspend fun run(): Outcome { + if (!enabled()) return Outcome.NOTHING + val now = nowMillis() + val due = + followedShows() + .filter { isDue(it, now) } + .mapNotNull { row -> V2EpisodeIds.parseShowId(row.podcastId)?.let { it to row } } + .toMap() + if (due.isEmpty()) return Outcome.NOTHING + var outcome = Outcome.NOTHING + for (chunk in due.keys.chunked(V2ShowsFeedsRequest.MAX_IDS)) { + val answer = fetch(chunk) + outcome = + if (answer == null || !answer.registryAvailable) { + outcome.copy(unchecked = outcome.unchecked + chunk.size) + } else { + val swapped = apply(answer.results, due, now) + outcome.copy(checked = outcome.checked + answer.results.count { it.id in due }, swapped = outcome.swapped + swapped) + } + } + Log.d(TAG, "Daily address check: $outcome") + return outcome + } + + private suspend fun fetch(ids: List): V2ShowsFeedsResponse? = try { + fetchAddresses(ids) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Address check of ${ids.size} shows failed", e) + null + } + + private suspend fun apply(results: List, due: Map, now: Long): Int = + results.count { result -> due[result.id]?.let { row -> applyOne(row, result, now) } == true } + + /** Stamps one show the registry answered for; true when its address was swapped. */ + private suspend fun applyOne(row: PodcastEntity, result: V2ShowAddress, now: Long): Boolean { + markChecked(row.podcastId, now) + noteNamespace(row, result) + val target = confirmedMove(row, result) ?: return false + val change = + FeedAddressChange( + podcastId = row.podcastId, + feedUrl = target, + source = result.feedSource ?: FeedUrlSources.REGISTRY, + checkedAt = now, + onlyReplacing = row.feedUrl, + reason = FeedUrlCorrectionReason.REGISTRY, + step = FeedUrlCorrectionStep.DAILY_CHECK, + ) + return replaceAddress(change) == FeedAddressUpdate.UPDATED + } + + /** The phone keeps its own namespace (decision D12); a different one is only logged. */ + private fun noteNamespace(row: PodcastEntity, result: V2ShowAddress) { + val served = result.validEpNs ?: return + if (row.epNs != null && row.epNs != served) Log.w(TAG, "Show ${row.podcastId} has namespace ${row.epNs} here, $served on the server") + } + + companion object { + private const val TAG = "FeedAddressDailyCheck" + + /** How long a check holds for one show. */ + const val CHECK_INTERVAL_MS = 24L * 60L * 60L * 1000L + + /** A followed Podcast Index show with a saved address, not checked in the last day, not waiting to be pushed. */ + internal fun isDue(row: PodcastEntity, now: Long): Boolean { + val checkable = row.isSubscribed && !row.isRss && !row.feedUrl.isNullOrBlank() + if (!checkable || changedOnPhone(row)) return false + val last = row.feedUrlCheckedAt + return last <= 0L || last > now || now - last >= CHECK_INTERVAL_MS + } + + /** + * The follow carries a change made on this phone that the account hasn't received yet: an + * account copy exists ([PodcastEntity.syncedAt]) and the row is dirty. Its address is left + * as it is until the push (decision 2: confirmed moves only, on follows not changed on the + * phone). Follows never synced to an account are this phone's alone and are checked. + */ + internal fun changedOnPhone(row: PodcastEntity): Boolean = row.isDirty && row.syncedAt > 0L + + /** + * The registry's address when it is a confirmed move away from the saved one: the registry + * flags the show for address repair, the address is the registry's own (`registry:` source), + * the phone can read it directly (HTTPS), it differs from the saved one, and it isn't the + * address a confirmed feed move left behind (which would swap back and forth). `fetchUrl: + * null` keeps the saved address. + */ + internal fun confirmedMove(row: PodcastEntity, result: V2ShowAddress): String? { + if (result.flags?.addressRepair != true) return null + if (result.feedSource?.startsWith(REGISTRY_SOURCE_PREFIX) != true) return null + if (result.fetchVia != null && result.fetchVia != V2FetchVia.DIRECT) return null + val https = TrackedPodcastRtdbLogic.httpsFeedUrl(result.fetchUrl) ?: return null + if (https == row.feedUrl?.trim()) return null + val leftByMove = row.feedUrlSource == FeedUrlSources.FEED_MOVE && row.previousFeedUrl?.trim() == https + return https.takeUnless { leftByMove } + } + + /** The production fetch: one `POST /v2/shows/feeds`; null for any answer but 200. */ + suspend fun fetchWith(api: BoxLoreApiV2, ids: List): V2ShowsFeedsResponse? { + val response = api.getShowFeeds(V2ShowsFeedsRequest(ids)).awaitResponse() + return if (response.isSuccessful) response.body() else null + } + + private const val REGISTRY_SOURCE_PREFIX = "registry:" + } +} 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 new file mode 100644 index 000000000..4771b0632 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdater.kt @@ -0,0 +1,151 @@ +package cx.aswin.boxlore.core.catalog + +import android.util.Log +import cx.aswin.boxlore.core.database.FeedAddressWrite +import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.model.FeedUrlCorrection +import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep + +/** + * Where a saved feed address came from, stored in `podcasts.feedUrlSource`. An address the + * registry answered with keeps the registry's own `feedSource` (for example `registry:apple`). + * Stored strings: never rename. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): feed address bookkeeping. Permanent: never remove (MIGRATION.md §5). + */ +object FeedUrlSources { + /** The feed moved for good: permanent redirects or `itunes:new-feed-url`, same show. */ + const val FEED_MOVE = "feed_move" + + /** The server's `/podcast` answer: address recovery, the dead-address re-check, auto-download. */ + const val PODCAST_LOOKUP = "podcast_lookup" + + /** Another device of the same account, through a cloud sync pull. */ + const val CLOUD_SYNC = "cloud_sync" + + /** The API's answer to a broken-feed report, read successfully before it was saved. */ + const val FEED_REPORT = "feed_report" + + /** A library backup being restored. */ + const val BACKUP_RESTORE = "backup_restore" + + /** The registry answered without naming its source. */ + const val REGISTRY = "registry" +} + +/** + * One request to replace a show's saved feed address, for [FeedAddressUpdater.update]. + * + * - [feedUrl]: the new publisher address; only HTTPS is saved. + * - [source]: where it came from, a [FeedUrlSources] value or the registry's `feedSource`. + * - [transportUrl]: the address to read through when the publisher's can't be read directly. + * - [checkedAt]: when the registry confirmed [feedUrl] (epoch ms), so the daily check and its + * fallback skip the show for a day; 0 when it wasn't checked against the registry. + * - [fromCloud]: the address came from the cloud, so the row is not marked for a push back. + * - [onlyReplacing]: when set, the change applies only while the saved address is still this one. + * - [reason] and [step]: why and by which path a followed show's address was corrected; both set + * send one `feed_url_corrected`. Fills of a missing address, restores and pulls leave them null. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + */ +data class FeedAddressChange( + val podcastId: String, + val feedUrl: String, + val source: String, + val transportUrl: String? = null, + val checkedAt: Long = 0L, + val fromCloud: Boolean = false, + val onlyReplacing: String? = null, + val reason: FeedUrlCorrectionReason? = null, + val step: FeedUrlCorrectionStep? = null, +) + +/** What [FeedAddressUpdater.update] did. */ +enum class FeedAddressUpdate { + /** The address was replaced. */ + UPDATED, + + /** The row already holds this address; nothing was written. */ + UNCHANGED, + + /** An `rss:` show, or an address that isn't HTTPS: refused. */ + REFUSED, + + /** No row for the show. */ + NO_SHOW, + + /** The saved address changed after it was read; nothing was written. */ + CHANGED_MEANWHILE, +} + +/** + * The one writer of a show's saved feed address. In one transaction ([PodcastDao.replaceFeedAddress]) + * it writes the publisher and transport addresses and keeps the old one for undo, resets every + * stored ETag and Last-Modified of the show, and marks the row for the next sync push (not for + * addresses that came from the cloud). It then asks for the show's new-episode notifications to be + * registered again, acknowledged, when they are on ([requestNotificationRegistration]; the same + * `tracked_podcasts/` node and topic), and reports a correction of a followed show's address + * as `feed_url_corrected`. + * + * Every address writer goes through it: recovery of a missing address, the dead-address re-check, + * feeds that moved, broken-feed reports, the daily check, auto-download, backup restores and cloud + * pulls. `rss:` shows are refused: their address is their identity and notification consent. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): saved addresses stay reachable. Permanent: never remove (MIGRATION.md §5). + */ +class FeedAddressUpdater( + private val podcastDao: PodcastDao, + private val requestNotificationRegistration: (podcastId: String) -> Unit = {}, + private val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, +) { + suspend fun update(change: FeedAddressChange): FeedAddressUpdate { + if (change.podcastId.startsWith("rss:")) return FeedAddressUpdate.REFUSED + val https = TrackedPodcastRtdbLogic.httpsFeedUrl(change.feedUrl) ?: return FeedAddressUpdate.REFUSED + val row = podcastDao.getPodcast(change.podcastId) ?: return FeedAddressUpdate.NO_SHOW + if (row.isRss) return FeedAddressUpdate.REFUSED + if (change.onlyReplacing != null && row.feedUrl != change.onlyReplacing) return FeedAddressUpdate.CHANGED_MEANWHILE + if (row.feedUrl == https) return FeedAddressUpdate.UNCHANGED + val written = + podcastDao.replaceFeedAddress( + FeedAddressWrite( + podcastId = row.podcastId, + expectedFeedUrl = row.feedUrl, + feedUrl = https, + transportUrl = change.transportUrl, + source = change.source, + checkedAt = change.checkedAt, + markDirty = !change.fromCloud, + ), + ) + if (!written) return FeedAddressUpdate.CHANGED_MEANWHILE + afterReplace( + podcastId = row.podcastId, + followed = row.isSubscribed, + notificationsOn = row.isSubscribed && row.notificationsEnabled, + hadAddress = !row.feedUrl.isNullOrBlank(), + change = change, + ) + return FeedAddressUpdate.UPDATED + } + + private fun afterReplace(podcastId: String, followed: Boolean, notificationsOn: Boolean, hadAddress: Boolean, change: FeedAddressChange) { + if (notificationsOn) { + try { + requestNotificationRegistration(podcastId) + } catch (e: IllegalStateException) { + Log.e(TAG, "Couldn't ask to register notifications again for $podcastId", e) + } + } + val reason = change.reason ?: return + val step = change.step ?: return + if (followed && hadAddress) telemetry.feedUrlCorrected(FeedUrlCorrection(podcastId, reason, step)) + } + + private companion object { + const val TAG = "FeedAddressUpdater" + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt new file mode 100644 index 000000000..d03267921 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt @@ -0,0 +1,133 @@ +package cx.aswin.boxlore.core.catalog + +import android.content.SharedPreferences +import android.util.Log +import cx.aswin.boxlore.core.network.BoxLoreApiV2 +import cx.aswin.boxlore.core.network.model.V2EpisodeIds +import cx.aswin.boxlore.core.network.model.V2FeedReportRequest +import cx.aswin.boxlore.core.network.model.V2FeedReportResponse +import cx.aswin.boxlore.core.network.model.V2FeedReportStatus +import cx.aswin.boxlore.core.network.model.V2FetchVia +import kotlinx.coroutines.CancellationException +import retrofit2.awaitResponse + +/** + * When each show's broken feed was last reported (epoch ms), kept across launches so a show is + * reported at most once a day however often the app starts. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). + */ +interface FeedReportStamps { + fun all(): Map + + fun mark(podcastId: String, atMillis: Long, keepSince: Long) +} + +/** [FeedReportStamps] for one process only (tests, and callers built without the app's wiring). */ +class MemoryFeedReportStamps : FeedReportStamps { + private val stamps = mutableMapOf() + + @Synchronized + override fun all(): Map = stamps.toMap() + + @Synchronized + override fun mark(podcastId: String, atMillis: Long, keepSince: Long) { + stamps.entries.removeAll { it.value < keepSince } + stamps[podcastId] = atMillis + } +} + +/** + * [FeedReportStamps] in SharedPreferences file [FILE_NAME]: one key per show ID, its last report + * time as a Long. Entries older than a day are dropped on each write. Stable storage name: never rename. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). + */ +class SharedPreferencesFeedReportStamps(private val prefs: SharedPreferences) : FeedReportStamps { + override fun all(): Map = prefs.all.mapNotNull { (key, value) -> (value as? Long)?.let { key to it } }.toMap() + + override fun mark(podcastId: String, atMillis: Long, keepSince: Long) { + val editor = prefs.edit() + for ((key, at) in all()) { + if (at < keepSince) editor.remove(key) + } + editor.putLong(podcastId, atMillis).apply() + } + + companion object { + const val FILE_NAME = "boxlore_feed_reports" + } +} + +/** + * Limits broken-feed reports: one per show per [PER_SHOW_INTERVAL_MS], and at most [DAILY_CAP] a + * day in all (the API takes 20 a day from one phone). + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). + */ +class FeedReportLimiter(private val stamps: FeedReportStamps) { + /** Claims a report of [podcastId] at [nowMillis]; false when the show or the day is used up. */ + @Synchronized + fun tryClaim(podcastId: String, nowMillis: Long): Boolean { + val since = nowMillis - PER_SHOW_INTERVAL_MS + val recent = stamps.all().filterValues { it > since && it <= nowMillis } + if (podcastId in recent || recent.size >= DAILY_CAP) return false + stamps.mark(podcastId, nowMillis, keepSince = since) + return true + } + + companion object { + const val PER_SHOW_INTERVAL_MS = 24L * 60L * 60L * 1000L + const val DAILY_CAP = 20 + } +} + +/** + * Tells the API that a followed show's feed couldn't be read (`POST /v2/feed-report`), which + * queues a re-check of the show's address there, and returns the address the API has now when it + * is a different HTTPS address the phone can read itself. The caller reads that address and saves + * it only when the read works. Sends nothing while the phone is offline, for an `rss:` show, when a + * read never happened, or past [FeedReportLimiter]'s limits. Never sends an address. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). + * COMPAT(own-ids): broken followed feeds reach the registry. Permanent: never remove (MIGRATION.md §5). + */ +class FeedReporter( + private val send: suspend (V2FeedReportRequest) -> V2FeedReportResponse?, + private val limiter: FeedReportLimiter, + private val isOnline: () -> Boolean, + private val enabled: () -> Boolean, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + /** + * @param status what the failed read saw, as the route takes it; null when no read happened + * @return the API's HTTPS address when it differs from [savedFeedUrl], else null + */ + suspend fun report(podcastId: String, status: String?, savedFeedUrl: String?): String? { + if (status == null || !V2FeedReportStatus.isValid(status) || !enabled()) return null + val showId = V2EpisodeIds.parseShowId(podcastId) ?: return null + if (!isOnline() || !limiter.tryClaim(podcastId, nowMillis())) return null + val answer = + try { + send(V2FeedReportRequest(id = showId, status = status)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Feed report for $podcastId failed", e) + null + } ?: return null + if (answer.fetchVia != null && answer.fetchVia != V2FetchVia.DIRECT) return null + val https = TrackedPodcastRtdbLogic.httpsFeedUrl(answer.fetchUrl) ?: return null + return https.takeIf { it != savedFeedUrl?.trim() } + } + + companion object { + private const val TAG = "FeedReporter" + + /** The production [send]: one `POST /v2/feed-report`; null for any answer but 200. */ + suspend fun sendWith(api: BoxLoreApiV2, request: V2FeedReportRequest): V2FeedReportResponse? { + val response = api.reportFeed(request).awaitResponse() + return if (response.isSuccessful) response.body() else null + } + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt new file mode 100644 index 000000000..a70231686 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt @@ -0,0 +1,72 @@ +package cx.aswin.boxlore.core.catalog + +import android.util.Log +import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.database.PodcastEntity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull + +/** What one acknowledged notification registration achieved. */ +enum class NotificationRegistrationOutcome { + /** Registered, or nothing to register (not followed, notifications off, no row). */ + DONE, + + /** It can never work (an `rss:` show, or the rules refused the write): stop trying. */ + DROPPED, + + /** Firebase didn't acknowledge in time: try again later. */ + RETRY, +} + +/** + * Registers a Podcast Index show's new-episode notifications again after its saved address + * changed, and waits for Firebase to acknowledge both writes: the `tracked_podcasts/` node + * (title, artwork, and the HTTPS address once the show has a latest episode) and the + * [TrackedPodcastRtdbLogic.topic] subscription. Same node and topic as turning notifications on, + * so nothing old builds or the checker read changes. The app retries [NotificationRegistrationOutcome.RETRY] + * with a capped background job. `rss:` shows have their own journal ([RssNotificationReconciler]). + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): notifications follow a corrected address. Permanent: never remove (MIGRATION.md §5). + */ +class PodcastNotificationRegistrar internal constructor( + private val podcastDao: PodcastDao, + private val firebase: TrackedPodcastFirebase, + private val timeoutMs: Long = DEFAULT_TIMEOUT_MS, +) { + constructor(podcastDao: PodcastDao) : this(podcastDao, LiveTrackedPodcastFirebase) + + suspend fun register(podcastId: String): NotificationRegistrationOutcome { + if (podcastId.startsWith("rss:")) return NotificationRegistrationOutcome.DROPPED + val row = podcastDao.getPodcast(podcastId) ?: return NotificationRegistrationOutcome.DONE + if (row.isRss) return NotificationRegistrationOutcome.DROPPED + if (!row.isSubscribed || !row.notificationsEnabled) return NotificationRegistrationOutcome.DONE + return try { + val acknowledged = withTimeoutOrNull(timeoutMs) { publish(row) } != null + if (acknowledged) NotificationRegistrationOutcome.DONE else NotificationRegistrationOutcome.RETRY + } catch (e: CancellationException) { + throw e + } catch (_: TrackedPodcastWriteRefused) { + Log.w(TAG, "Notification rules refused $podcastId") + NotificationRegistrationOutcome.DROPPED + } catch (e: Exception) { + Log.w(TAG, "Notification registration for $podcastId not acknowledged", e) + NotificationRegistrationOutcome.RETRY + } + } + + private suspend fun publish(row: PodcastEntity) { + // A stub row from a cloud pull has no details yet: subscribe the topic only, as turning notifications on does. + if (row.title.isNotBlank() && row.title != STUB_TITLE) { + val feedUrl = TrackedPodcastRtdbLogic.attachableFeedUrl(row.feedUrl, row.latestEpisode?.id) + firebase.set(row.podcastId, TrackedPodcastRtdbLogic.payload(row.title, row.imageUrl, feedUrl)) + } + firebase.subscribe(TrackedPodcastRtdbLogic.topic(row.podcastId)) + } + + private companion object { + const val TAG = "PodcastNotifications" + const val STUB_TITLE = "Loading..." + const val DEFAULT_TIMEOUT_MS = 30_000L + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RssNotificationReconciler.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RssNotificationReconciler.kt index b89f40076..aa42b36ce 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RssNotificationReconciler.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RssNotificationReconciler.kt @@ -53,7 +53,7 @@ internal interface TrackedPodcastFirebase { internal class TrackedPodcastWriteRefused : Exception("Tracked podcast rules refused the write") -private object LiveTrackedPodcastFirebase : TrackedPodcastFirebase { +internal object LiveTrackedPodcastFirebase : TrackedPodcastFirebase { override suspend fun set(key: String, payload: Map) = acknowledged { node(key).setValue(payload, it) } override suspend fun delete(path: String) = acknowledged { node(path).removeValue(it) } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt index 66c410f42..d3958a2f7 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt @@ -1,7 +1,33 @@ package cx.aswin.boxlore.core.catalog +import android.util.Log import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshRequest +import kotlinx.coroutines.CancellationException + +/** + * Follows feeds that moved for good on every catalog refresh path (the show page, pushes, + * auto-download, restores, the ingest after a follow), not only the background pass: a refresh of + * a Podcast Index show checks for a confirmed move ([RefreshRequest.detectMove]), and a move it + * finds is saved through [adopt] (the one address writer). [enabled] is the + * `feed-fix-moved-feed-off` switch; [showTitle] gives the stored title a move is compared with + * when the caller sent none. Requests that already ask for move detection are left to their caller. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: redirects and `itunes:new-feed-url`). + * COMPAT(own-ids): followed shows stay reachable. Permanent: never remove (MIGRATION.md §5). + */ +class FeedMoveFollower( + val enabled: () -> Boolean, + val showTitle: suspend (podcastId: String) -> String?, + val adopt: suspend (podcastId: String, movedTo: String) -> Unit, +) { + companion object { + /** Follows no moves (tests, and callers built without the app's wiring). */ + val NONE = FeedMoveFollower(enabled = { false }, showTitle = { null }, adopt = { _, _ -> }) + } +} /** Routes by show identity, never by episode sign (PI supplements also have negative IDs). */ @Suppress("TooManyFunctions") // Routes the complete read/refresh port without changing either storage contract. @@ -9,6 +35,7 @@ class SubscribedEpisodeCatalog( private val catalog: LocalEpisodeCatalogPort, private val rss: LocalEpisodeCatalogPort, private val isSubscribed: suspend (String) -> Boolean = { false }, + private val feedMoves: FeedMoveFollower = FeedMoveFollower.NONE, ) : LocalEpisodeCatalogPort { private fun source(id: String) = if (id.startsWith("rss:")) rss else catalog @@ -26,7 +53,21 @@ class SubscribedEpisodeCatalog( override suspend fun search(podcastId: String, query: String, meta: PodcastMeta) = source(podcastId).search(podcastId, query, meta) override suspend fun newest(podcastId: String, meta: PodcastMeta) = source(podcastId).newest(podcastId, meta) override suspend fun count(podcastId: String) = source(podcastId).count(podcastId) - override suspend fun refresh(request: LocalEpisodeCatalogPort.RefreshRequest) = source(request.podcastIndexId).refresh(request) + override suspend fun refresh(request: RefreshRequest): RefreshOutcome { + val id = request.podcastIndexId + if (id.startsWith("rss:") || request.detectMove || !feedMoves.enabled()) return source(id).refresh(request) + val title = request.meta.title?.takeIf(String::isNotBlank) ?: feedMoves.showTitle(id) + val outcome = catalog.refresh(request.copy(detectMove = true, meta = request.meta.copy(title = title))) + val movedTo = (outcome as? RefreshOutcome.Success)?.movedTo ?: return outcome + try { + feedMoves.adopt(id, movedTo) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Couldn't save the new address of $id", e) + } + return outcome + } override suspend fun isRefreshDue(podcastId: String, feedUrl: String, nowMillis: Long) = source(podcastId).isRefreshDue(podcastId, feedUrl, nowMillis) override suspend fun isPublisherFeedUnchanged(podcastId: String, feedUrl: String) = source(podcastId).isPublisherFeedUnchanged(podcastId, feedUrl) override suspend fun markFeedUrlLookup(podcastId: String, atMillis: Long) = source(podcastId).markFeedUrlLookup(podcastId, atMillis) @@ -36,4 +77,8 @@ class SubscribedEpisodeCatalog( catalog.sweepExpired(nowMillis) rss.sweepExpired(nowMillis) } + + private companion object { + const val TAG = "SubscribedEpisodeCatalog" + } } 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 8471df868..bea713575 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 @@ -248,6 +248,7 @@ class SubscriptionForegroundSync( feedNetworkDelayMs: Long = DEFAULT_FEED_NETWORK_DELAY_MS, chunkSize: Int = DEFAULT_CHUNK_SIZE, feedFixes: FeedFixToggles = FeedFixToggles(), + addressUpkeep: FeedAddressUpkeep = FeedAddressUpkeep(), ): SubscriptionForegroundSync { val preferred = AtomicReference(null) val refreshed = @@ -278,6 +279,33 @@ class SubscriptionForegroundSync( feedNetworkDelayMs = feedNetworkDelayMs, chunkSize = chunkSize, feedFixes = feedFixes, + addressJobs = addressJobs(podcastRepository, subscriptionRepository, addressUpkeep), + ), + ) + } + + /** The daily address check and broken-feed reports of followed shows (P4 PR 14), behind `address-check-off`. */ + private fun addressJobs( + podcastRepository: PodcastRepository, + subscriptionRepository: SubscriptionRepository, + upkeep: FeedAddressUpkeep, + ): FeedAddressJobs { + val enabled = podcastRepository.rolloutSwitches.addressCheck + return FeedAddressJobs( + dailyCheck = + FeedAddressDailyCheck( + followedShows = subscriptionRepository::followedShowRows, + fetchAddresses = { ids -> FeedAddressDailyCheck.fetchWith(podcastRepository.apiV2, ids) }, + replaceAddress = subscriptionRepository::updateFeedAddress, + markChecked = subscriptionRepository::markFeedAddressChecked, + enabled = enabled, + ), + reporter = + FeedReporter( + send = { request -> FeedReporter.sendWith(podcastRepository.apiV2, request) }, + limiter = FeedReportLimiter(upkeep.reportStamps), + isOnline = upkeep.isOnline, + enabled = enabled, ), ) } @@ -293,10 +321,12 @@ class SubscriptionForegroundSync( feedNetworkDelayMs: Long, chunkSize: Int, feedFixes: FeedFixToggles, + addressJobs: FeedAddressJobs, ): suspend () -> Unit { val refreshBatch = DirectFeedRefreshBatch() return { SubscriptionForegroundSyncIngest.sweepExpiredLocalCatalogs(localEpisodeCatalog) + runDailyAddressCheck(addressJobs.dailyCheck) val ids = subscriptionRepository.subscribedPodcastIds.first() val recoveryNow = System.currentTimeMillis() recoverMissingFeedUrls( @@ -313,18 +343,7 @@ class SubscriptionForegroundSync( ) syncSubscribedLatestEpisodes( loadIds = { ids }, - loadPodcastMeta = { id -> - subscriptionRepository.getPodcastEntity(id)?.let { entity -> - DirectFeedTipMeta( - feedUrl = entity.feedUrl, - title = entity.title, - imageUrl = entity.imageUrl, - genre = entity.genre, - artist = entity.author, - knownTip = entity.latestEpisode, - ) - } - }, + loadPodcastMeta = { id -> tipMeta(subscriptionRepository, id) }, syncChunk = { chunk -> podcastRepository.syncSubscriptions(chunk) }, saveLatest = { id, episode -> subscriptionRepository.updateLatestEpisode(id, episode) @@ -355,7 +374,7 @@ class SubscriptionForegroundSync( episodeSupplementPort = episodeSupplementPort, podcastRepository = podcastRepository, followMoves = feedFixes.movedFeed(), - adoptMovedFeed = { url -> subscriptionRepository.ensureHttpsFeedUrl(id, url) }, + adoptMovedFeed = { url -> subscriptionRepository.adoptMovedFeed(id, url) }, ) }, saveDirectFeedLatest = { id, episode -> @@ -376,8 +395,13 @@ class SubscriptionForegroundSync( } }, onFeedRefreshed = { refreshed.tryEmit(it) }, - recheckAddress = DirectFeedRecovery.addressRecheck(feedFixes, subscriptionRepository, podcastRepository, localEpisodeCatalog), - saveAddress = { id, url -> subscriptionRepository.ensureHttpsFeedUrl(id, url) }, + recheckAddress = DirectFeedRecovery.addressRecheck(feedFixes, subscriptionRepository, podcastRepository), + reportFeed = { id, status -> + addressJobs.reporter.report(id, status, subscriptionRepository.getPodcastEntity(id)?.feedUrl) + }, + saveAddress = { id, candidate -> + subscriptionRepository.updateFeedAddress(candidate.change(id, System.currentTimeMillis())) + }, syncFallbackEnabled = feedFixes.syncFallback, ), ) @@ -388,6 +412,29 @@ class SubscriptionForegroundSync( } } + private suspend fun tipMeta(subscriptionRepository: SubscriptionRepository, id: String): DirectFeedTipMeta? = + subscriptionRepository.getPodcastEntity(id)?.let { entity -> + DirectFeedTipMeta( + feedUrl = entity.feedUrl, + title = entity.title, + imageUrl = entity.imageUrl, + genre = entity.genre, + artist = entity.author, + knownTip = entity.latestEpisode, + ) + } + + /** Runs the daily address check before the pass reads feeds, so a swapped address is read at once. */ + private suspend fun runDailyAddressCheck(dailyCheck: FeedAddressDailyCheck) { + try { + dailyCheck.run() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Daily address check failed", e) + } + } + @Suppress("LongParameterList") private suspend fun resolveLocalCatalogTip( podcastId: String, @@ -446,7 +493,13 @@ class SubscriptionForegroundSync( DirectFeedResolveResult(tip = outcome.newest, persisted = false) is LocalEpisodeCatalogPort.RefreshOutcome.Failure -> { Log.w(TAG, "Local catalog refresh failed for $podcastId: ${outcome.message} (gone=${outcome.addressGone})") - DirectFeedResolveResult(tip = null, persisted = false, failed = true, addressGone = outcome.addressGone) + DirectFeedResolveResult( + tip = null, + persisted = false, + failed = true, + addressGone = outcome.addressGone, + reportStatus = outcome.reportStatus, + ) } } } @@ -817,13 +870,15 @@ internal data class DirectFeedTipMeta( /** * Result of an opted-in publisher-feed resolve (tip plus whether extras were persisted). * [failed]: the feed couldn't be read this pass; [addressGone]: and its saved address looks dead - * ([LocalEpisodeCatalogPort.RefreshOutcome.Failure.addressGone]). + * ([LocalEpisodeCatalogPort.RefreshOutcome.Failure.addressGone]); [reportStatus]: what the read saw, + * for `/v2/feed-report` ([LocalEpisodeCatalogPort.RefreshOutcome.Failure.reportStatus]). */ internal data class DirectFeedResolveResult( val tip: Episode?, val persisted: Boolean, val failed: Boolean = false, val addressGone: Boolean = false, + val reportStatus: String? = null, ) /** @@ -853,8 +908,28 @@ internal data class DirectFeedSyncSeams( val selectRefreshBatch: suspend (List) -> List = { it }, /** Re-checks a show's dead saved address: a different address to try, or null. */ val recheckAddress: suspend (String) -> String? = { null }, - /** Saves an address that [recheckAddress] offered and that then read successfully. */ - val saveAddress: suspend (id: String, feedUrl: String) -> Unit = { _, _ -> }, + /** + * Reports a failed read to the API (`/v2/feed-report`); returns the API's HTTPS address when it + * differs from the saved one. Null status: no read happened (P4 PR 14). + */ + val reportFeed: suspend (id: String, status: String?) -> String? = { _, _ -> null }, + /** Saves an address that [reportFeed] or [recheckAddress] offered and that then read successfully. */ + val saveAddress: suspend (id: String, candidate: AddressCandidate) -> Unit = { _, _ -> }, /** Whether shows whose own feed failed this pass get their latest episode from `/sync`. */ val syncFallbackEnabled: () -> Boolean = { true }, ) + +/** + * What the daily address check and broken-feed reports need from the app (P4 PR 14): where report + * times are kept across launches, and whether the phone is online (reports are never sent offline). + */ +data class FeedAddressUpkeep( + val reportStamps: FeedReportStamps = MemoryFeedReportStamps(), + val isOnline: () -> Boolean = { true }, +) + +/** The daily address check and the broken-feed reporter of one [SubscriptionForegroundSync] (P4 PR 14). */ +internal data class FeedAddressJobs( + val dailyCheck: FeedAddressDailyCheck, + val reporter: FeedReporter, +) 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 9ac9dbbba..5a9600b34 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 @@ -3,7 +3,10 @@ package cx.aswin.boxlore.core.catalog import cx.aswin.boxlore.core.catalog.ports.PodcastNotificationSyncPort import cx.aswin.boxlore.core.database.PodcastDao import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort +import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep import cx.aswin.boxlore.core.model.Podcast import cx.aswin.boxlore.core.prefs.UserPreferencesRepository import cx.aswin.boxlore.core.ranking.FeedbackTarget @@ -14,7 +17,17 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -@Suppress("TooManyFunctions") +/** + * What the one feed address writer needs from the app: how to ask for a show's notifications to be + * registered again in the background, and where `feed_url_corrected` goes. + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows). + */ +data class FeedAddressWiring( + val requestNotificationRegistration: (podcastId: String) -> Unit = {}, + val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, +) + +@Suppress("TooManyFunctions", "LongParameterList", "kotlin:S107") // Constructor injection from AppContainer; no DI framework. class SubscriptionRepository( private val podcastDao: PodcastDao, private val localEpisodeCatalog: LocalEpisodeCatalogPort? = null, @@ -25,7 +38,14 @@ class SubscriptionRepository( private val rssNotificationConsent: RssNotificationConsent = RssNotificationConsent.NONE, private val rssNotificationRegistrations: RssNotificationRegistrationStore = MemoryRssNotificationRegistrations(), private val requestRssNotificationSync: () -> Unit = {}, + feedAddressWiring: FeedAddressWiring = FeedAddressWiring(), ) : PodcastNotificationSyncPort { + /** The one writer of saved feed addresses (P4 PR 14). */ + val feedAddresses: FeedAddressUpdater = + FeedAddressUpdater(podcastDao, feedAddressWiring.requestNotificationRegistration, feedAddressWiring.telemetry) + + private val notificationRegistrar by lazy { PodcastNotificationRegistrar(podcastDao) } + val subscribedPodcastIds: Flow> = podcastDao .getSubscribedPodcasts() @@ -645,14 +665,49 @@ class SubscriptionRepository( /** * Writes an HTTPS publisher [feedUrl] onto the Room row so launch sync and - * notification RTDB can poll the same feed after library restore. + * notification RTDB can poll the same feed after library restore. Goes through the one address + * writer ([feedAddresses]); [source] records where the address came from. + */ + suspend fun ensureHttpsFeedUrl(podcastId: String, feedUrl: String, source: String = FeedUrlSources.PODCAST_LOOKUP,) { + feedAddresses.update(FeedAddressChange(podcastId = podcastId, feedUrl = feedUrl, source = source)) + } + + /** + * Replaces a show's saved feed address through the one address writer ([FeedAddressUpdater]). + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows). + */ + suspend fun updateFeedAddress(change: FeedAddressChange): FeedAddressUpdate = feedAddresses.update(change) + + /** + * Saves the new address of a feed that moved for good (permanent redirects or + * `itunes:new-feed-url`, same show) and reports the correction. + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: redirects). */ - suspend fun ensureHttpsFeedUrl(podcastId: String, feedUrl: String,) { - val https = TrackedPodcastRtdbLogic.httpsFeedUrl(feedUrl) ?: return - if (podcastDao.getPodcast(podcastId) == null) return - podcastDao.setFeedUrl(podcastId, https) + suspend fun adoptMovedFeed(podcastId: String, movedTo: String) { + feedAddresses.update( + FeedAddressChange( + podcastId = podcastId, + feedUrl = movedTo, + source = FeedUrlSources.FEED_MOVE, + reason = FeedUrlCorrectionReason.MOVED, + step = FeedUrlCorrectionStep.FEED_REFRESH, + ), + ) } + /** Followed shows' rows, for the daily address check (P4 PR 14). */ + suspend fun followedShowRows(): List = podcastDao.getSubscribedPodcastsList() + + /** Stamps the daily address check of one show (P4 PR 14); the `/podcast` fallback reads the same stamp. */ + suspend fun markFeedAddressChecked(podcastId: String, atMillis: Long) = podcastDao.markFeedUrlChecked(podcastId, atMillis) + + /** + * Registers a Podcast Index show's notifications again and waits for Firebase to acknowledge it; + * run by the app's capped background job after an address change ([PodcastNotificationRegistrar]). + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows). + */ + suspend fun registerPodcastNotifications(podcastId: String): NotificationRegistrationOutcome = notificationRegistrar.register(podcastId) + private suspend fun recoverFeedUrlIfMissing(podcast: Podcast) { if (podcast.isRss) return val stored = podcastDao.getPodcast(podcast.id)?.feedUrl diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupManager.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupManager.kt index 632f49d18..f16b19ac2 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupManager.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupManager.kt @@ -7,6 +7,7 @@ import cx.aswin.boxlore.core.catalog.BuildConfig import cx.aswin.boxlore.core.catalog.ExactPodcastLookupKey import cx.aswin.boxlore.core.catalog.ExactPodcastLookupResult import cx.aswin.boxlore.core.catalog.ExactPodcastLookupType +import cx.aswin.boxlore.core.catalog.FeedUrlSources import cx.aswin.boxlore.core.catalog.FolderRepository import cx.aswin.boxlore.core.catalog.LOCAL_CATALOG_WINDOW_BOUND import cx.aswin.boxlore.core.catalog.PodcastIndexSearchResult @@ -776,7 +777,7 @@ class LibraryBackupManager( actions = DirectFeedRestoreActions( restoreStub = { _, _ -> }, - ensureFeedUrl = { id, url -> subscriptionRepository.ensureHttpsFeedUrl(id, url) }, + ensureFeedUrl = { id, url -> subscriptionRepository.ensureHttpsFeedUrl(id, url, FeedUrlSources.BACKUP_RESTORE) }, invalidateCache = { id -> podcastRepository.invalidateEpisodesCache(id) }, refreshFeed = { id, url -> val entity = subscriptionRepository.getPodcastEntity(id) @@ -856,7 +857,7 @@ class LibraryBackupManager( actions = DirectFeedRestoreActions( restoreStub = { _, _ -> }, - ensureFeedUrl = { id, url -> subscriptionRepository.ensureHttpsFeedUrl(id, url) }, + ensureFeedUrl = { id, url -> subscriptionRepository.ensureHttpsFeedUrl(id, url, FeedUrlSources.BACKUP_RESTORE) }, invalidateCache = { id -> podcastRepository.invalidateEpisodesCache(id) }, refreshFeed = { id, url -> val entity = subscriptionRepository.getPodcastEntity(id) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt index 8f8255cef..8372ef3f7 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt @@ -1,5 +1,10 @@ package cx.aswin.boxlore.core.catalog.sync +import android.util.Log +import cx.aswin.boxlore.core.catalog.FeedAddressChange +import cx.aswin.boxlore.core.catalog.FeedAddressUpdate +import cx.aswin.boxlore.core.catalog.FeedAddressUpdater +import cx.aswin.boxlore.core.catalog.FeedUrlSources import cx.aswin.boxlore.core.catalog.FolderRepository import cx.aswin.boxlore.core.catalog.PodcastRepository import cx.aswin.boxlore.core.catalog.ports.PodcastNotificationSyncPort @@ -12,6 +17,11 @@ import cx.aswin.boxlore.core.rss.RssPodcastRepository * LWW (Last-Write-Wins) conflict resolver for podcast subscriptions. * Handles tombstones vs active subscriptions, settings merges, RSS feed ingestion, * and folder cleanups. + * + * A pulled address for a Podcast Index show goes through [feedAddresses], the one address writer + * (P4 PR 14): validators reset, notifications registered again with acknowledgement, the row left + * clean. Without it (older tests) the address is written with the row as before. `rss:` shows keep + * their own path: their address is their identity. */ class SubscriptionSyncResolver( private val podcastDao: PodcastDao, @@ -19,6 +29,7 @@ class SubscriptionSyncResolver( private val podcastRepository: PodcastRepository? = null, private val rssPodcastRepository: RssPodcastRepository? = null, private val notificationSyncPort: PodcastNotificationSyncPort? = null, + private val feedAddresses: FeedAddressUpdater? = null, ) { suspend fun resolveSubscription(remote: UserSubscriptionSyncDto, syncedAt: Long) { val local = podcastDao.getPodcast(remote.podcastId) @@ -201,9 +212,10 @@ class SubscriptionSyncResolver( notificationsEnabled = acceptedNotificationSetting(remote), customGenre = remote.customGenre, customGenreIcon = if (remote.customGenre == null) null else local.customGenreIcon, - feedUrl = remote.feedUrl ?: local.feedUrl, + feedUrl = rowFeedUrl(local, remote), ) podcastDao.upsert(updated) + applyPulledAddress(local, remote) if (!local.isRss) { if (remote.notificationsEnabled) { notificationSyncPort?.setNotificationTopicSubscribed(local.podcastId, true) @@ -229,14 +241,33 @@ class SubscriptionSyncResolver( customGenreIcon = genreIcon, autoDownloadEnabled = remote.autoDownloadEnabled, notificationsEnabled = acceptedNotificationSetting(remote), - feedUrl = remote.feedUrl ?: local.feedUrl, + feedUrl = rowFeedUrl(local, remote), subscribedAt = if (remoteSubTime > local.subscribedAt) remoteSubTime else local.subscribedAt, isDirty = false, syncedAt = syncedAt, ) podcastDao.upsert(updated) + applyPulledAddress(local, remote) if (updated.notificationsEnabled != local.notificationsEnabled) { notificationSyncPort?.setNotificationTopicSubscribed(local.podcastId, updated.notificationsEnabled) } } + + /** The address the merged row is written with: the stored one when [feedAddresses] applies the pulled one. */ + private fun rowFeedUrl(local: PodcastEntity, remote: UserSubscriptionSyncDto): String? = + if (routesAddress(local)) local.feedUrl else remote.feedUrl ?: local.feedUrl + + private fun routesAddress(local: PodcastEntity): Boolean = feedAddresses != null && !local.isRss && !local.podcastId.startsWith("rss:") + + /** A pulled address that differs from the stored one, saved through the one address writer (P4 PR 14). */ + private suspend fun applyPulledAddress(local: PodcastEntity, remote: UserSubscriptionSyncDto) { + val writer = feedAddresses?.takeIf { routesAddress(local) } ?: return + val pulled = remote.feedUrl?.takeIf { it.isNotBlank() && it != local.feedUrl } ?: return + val result = writer.update(FeedAddressChange(podcastId = local.podcastId, feedUrl = pulled, source = FeedUrlSources.CLOUD_SYNC, fromCloud = true)) + if (result != FeedAddressUpdate.UPDATED) Log.d(TAG, "Pulled address for ${local.podcastId} not saved: $result") + } + + private companion object { + const val TAG = "SubscriptionSyncResolver" + } } diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt new file mode 100644 index 000000000..5dab7a4f7 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt @@ -0,0 +1,105 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +/** + * Shows whose own feed failed in a foreground pass (`DirectFeedRecovery`; reports since own-episode-IDs + * P4 PR 14, plan section 11): every failed read of a Podcast Index show is reported, a dead address + * tries the report's answer before the daily check's `/podcast` fallback, and that fallback runs + * at most once a day per show. + */ +class DirectFeedRecoveryTest { + @Test + fun `every failed read is reported, and a dead address tries the report's answer before the podcast lookup`() = runTest { + val urls = + mapOf( + "reported" to "https://old.example/reported.xml", + "fallback" to "https://old.example/fallback.xml", + "slow" to "https://feeds.example/slow.xml", + "rss:own" to "https://feeds.example/own.xml", + ) + val reports = mutableListOf>() + val rechecked = mutableListOf() + val saved = mutableMapOf() + SubscriptionForegroundSync.syncSubscribedLatestEpisodes( + loadIds = { urls.keys }, + loadPodcastMeta = { id -> DirectFeedTipMeta(urls[id], id, null, null, null, null) }, + syncChunk = { emptyMap() }, + saveLatest = { _, _ -> }, + directFeed = + DirectFeedSyncSeams( + loadOptedInIds = { urls.keys }, + resolveFeedTip = { id, meta -> + when { + meta.feedUrl?.startsWith("https://new.example/") == true -> + DirectFeedResolveResult(tip = episode(id = "-9", podcastId = id), persisted = true) + id == "slow" -> DirectFeedResolveResult(tip = null, persisted = false, failed = true, reportStatus = "timeout") + else -> DirectFeedResolveResult(tip = null, persisted = false, failed = true, addressGone = true, reportStatus = "http_410") + } + }, + reportFeed = { id, status -> + reports += id to status + if (id == "reported" || id == "slow") "https://new.example/$id.xml" else null + }, + recheckAddress = { id -> + rechecked += id + "https://new.example/$id.xml" + }, + saveAddress = { id, candidate -> saved[id] = candidate }, + ), + ) + assertEquals( + setOf("reported" to "http_410", "fallback" to "http_410", "slow" to "timeout"), + reports.toSet(), + "every failed read of a Podcast Index show is reported, never an rss: show", + ) + assertEquals(listOf("fallback"), rechecked, "the podcast lookup only when the report offered nothing") + assertEquals(FeedUrlCorrectionStep.FEED_REPORT, saved["reported"]?.step) + assertEquals(FeedUrlCorrectionStep.DAILY_CHECK, saved["fallback"]?.step) + assertFalse(saved.containsKey("slow"), "a passing failure keeps its address, whatever the report answered") + } + + @Test + fun `a dead address is re-checked once a day, and only once the server answers`() = runTest { + // Same per-show stamp as the daily address check, so a show is never checked twice a day. + val day = FeedAddressDailyCheck.CHECK_INTERVAL_MS + var stamped: Long? = null + suspend fun check(now: Long, last: Long, confirmed: String?, saved: String? = "https://old.example/f.xml", id: String = "123") = + DirectFeedRecovery.recheckGoneFeedAddress( + id = id, + now = now, + savedFeedUrl = saved, + lastLookupAt = { last }, + markLookup = { stamped = it }, + lookupFeedUrl = { confirmed }, + ) + + assertEquals("https://new.example/f.xml", check(now = 2 * day, last = 0L, confirmed = "https://new.example/f.xml")) + assertEquals(2 * day, stamped) + + stamped = null + assertEquals(null, check(now = 2 * day, last = 2 * day - 1_000L, confirmed = "https://new.example/f.xml"), "within a day") + assertEquals(null, check(now = 3 * day, last = 0L, confirmed = null), "no answer") + assertEquals(null, stamped, "an offline pass doesn't use up the day") + assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "https://old.example/f.xml"), "same address") + assertEquals(3 * day, stamped) + assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "http://new.example/f.xml"), "http only") + assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "https://new.example/f.xml", id = "rss:abc"), "rss: shows keep theirs") + assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "https://new.example/f.xml", saved = null), "no saved address: recovery owns it") + } + + private fun episode(id: String, podcastId: String) = Episode( + id = id, + title = "T", + description = "", + audioUrl = "https://example.com/a.mp3", + publishedDate = 1L, + duration = 60, + podcastId = podcastId, + ) +} 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 new file mode 100644 index 000000000..3c4c1446a --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressDailyCheckTest.kt @@ -0,0 +1,186 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep +import cx.aswin.boxlore.core.network.model.V2ShowAddress +import cx.aswin.boxlore.core.network.model.V2ShowFlags +import cx.aswin.boxlore.core.network.model.V2ShowsFeedsResponse +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The daily address check of followed shows (own-episode-IDs P4 PR 14, plan section 11; decision 2): + * chunked calls, confirmed moves only, one stamp per show per day, and the `/podcast` fallback only + * for shows the check couldn't reach. + */ +class FeedAddressDailyCheckTest { + private val now = 10 * DAY + private val rows = mutableMapOf() + private val calls = mutableListOf>() + private val changes = mutableListOf() + private var enabled = true + private var answer: (List) -> V2ShowsFeedsResponse? = { ids -> V2ShowsFeedsResponse(true, ids.map { address(it) }) } + + private val check = + FeedAddressDailyCheck( + followedShows = { rows.values.filter { it.isSubscribed } }, + fetchAddresses = { ids -> + calls += ids + answer(ids) + }, + replaceAddress = { change -> + changes += change + FeedAddressUpdate.UPDATED + }, + markChecked = { id, at -> rows[id] = rows.getValue(id).copy(feedUrlCheckedAt = at) }, + enabled = { enabled }, + nowMillis = { now }, + ) + + @Test + fun `501 followed shows make two calls, and a second run the same day makes none`() = runTest { + (1..501).forEach { add(show(it.toString())) } + + val outcome = check.run() + + assertEquals(listOf(500, 1), calls.map { it.size }) + assertEquals(501, outcome.checked) + assertTrue(rows.values.all { it.feedUrlCheckedAt == now }) + calls.clear() + check.run() + assertTrue(calls.isEmpty(), "never twice a day") + } + + @Test + fun `only a confirmed move to a readable registry address is swapped`() = runTest { + add(show("1")) + add(show("2")) + add(show("3")) + add(show("4")) + add(show("5")) + add(show("6")) + add(show("7").copy(feedUrl = MOVED, previousFeedUrl = NEW, feedUrlSource = FeedUrlSources.FEED_MOVE)) + answer = { ids -> + V2ShowsFeedsResponse( + registryAvailable = true, + results = + ids.map { id -> + when (id) { + 1L -> address(id) + 2L -> address(id, repair = false) + 3L -> address(id, source = "catalog") + 4L -> address(id, url = "http://new.example/4.xml", via = "proxy") + 5L -> address(id, url = null) + 6L -> address(id, url = OLD) + else -> address(id) + } + }, + ) + } + + val outcome = check.run() + + assertEquals(listOf("1"), changes.map { it.podcastId }, "no flag, a dump address, http only, no address, the same address, or back to where a move left: kept") + val change = changes.single() + assertEquals(NEW, change.feedUrl) + assertEquals(OLD, change.onlyReplacing) + assertEquals("registry:apple", change.source) + assertEquals(now, change.checkedAt) + assertEquals(FeedUrlCorrectionReason.REGISTRY, change.reason) + assertEquals(FeedUrlCorrectionStep.DAILY_CHECK, change.step) + assertEquals(1, outcome.swapped) + assertTrue(rows.values.all { it.feedUrlCheckedAt == now }, "every show the registry answered for is checked") + } + + @Test + fun `follows changed on the phone wait for their push, other shows are left out`() = runTest { + add(show("1").copy(isDirty = true, syncedAt = 5L)) + add(show("2").copy(isDirty = true, syncedAt = 0L)) + add(show("3").copy(isSubscribed = false)) + add(show("rss:abc").copy(sourceType = PodcastEntity.SOURCE_RSS)) + add(show("4").copy(feedUrl = null)) + add(show("5").copy(feedUrlCheckedAt = now - DAY + 1)) + + check.run() + + assertEquals(listOf(listOf(2L)), calls, "a follow never synced to an account is this phone's alone and is checked") + assertEquals(0L, rows.getValue("1").feedUrlCheckedAt) + } + + @Test + fun `a failed call or an unavailable registry stamps and swaps nothing, so the fallback may run`() = runTest { + add(show("1")) + answer = { null } + assertEquals(1, check.run().unchecked) + answer = { ids -> V2ShowsFeedsResponse(false, ids.map { address(it) }) } + assertEquals(1, check.run().unchecked) + + assertTrue(changes.isEmpty()) + assertEquals(0L, rows.getValue("1").feedUrlCheckedAt) + assertEquals(NEW, fallback("1"), "the /podcast re-check runs for a show the check couldn't reach") + assertNull(fallback("1"), "and only once a day") + } + + @Test + fun `a show the daily check reached is never re-checked by the fallback the same day`() = runTest { + add(show("1")) + answer = { ids -> V2ShowsFeedsResponse(true, ids.map { address(it, repair = false) }) } + + check.run() + + assertNull(fallback("1")) + } + + @Test + fun `with the switch off nothing is asked or stamped`() = runTest { + add(show("1")) + enabled = false + + check.run() + + assertTrue(calls.isEmpty()) + assertEquals(0L, rows.getValue("1").feedUrlCheckedAt) + } + + private suspend fun fallback(id: String): String? = DirectFeedRecovery.recheckGoneFeedAddress( + id = id, + now = now, + savedFeedUrl = rows.getValue(id).feedUrl, + lastLookupAt = { rows.getValue(id).feedUrlCheckedAt }, + markLookup = { at -> rows[id] = rows.getValue(id).copy(feedUrlCheckedAt = at) }, + lookupFeedUrl = { NEW }, + ) + + private fun add(row: PodcastEntity) { + rows[row.podcastId] = row + } + + private fun show(id: String) = PodcastEntity( + podcastId = id, + title = "Show $id", + author = "Author", + imageUrl = "", + description = null, + isSubscribed = true, + feedUrl = OLD, + ) + + private fun address( + id: Long, + url: String? = NEW, + source: String = "registry:apple", + repair: Boolean = true, + via: String = "direct", + ) = V2ShowAddress(id = id, fetchUrl = url, fetchVia = via, feedSource = source, epNs = id, flags = V2ShowFlags(addressRepair = repair)) + + private companion object { + const val DAY = FeedAddressDailyCheck.CHECK_INTERVAL_MS + const val OLD = "https://old.example/feed.xml" + const val NEW = "https://new.example/feed.xml" + const val MOVED = "https://moved.example/feed.xml" + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt new file mode 100644 index 000000000..6121d760f --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt @@ -0,0 +1,187 @@ +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.database.BoxLoreDatabase +import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity +import cx.aswin.boxlore.core.database.LocalFeedOrder +import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.model.FeedFetchReport +import cx.aswin.boxlore.core.model.FeedUrlCorrection +import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep +import kotlinx.coroutines.test.runTest +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 + +/** + * The one saved-address writer (own-episode-IDs P4 PR 14, plan section 11): one atomic update, the + * old address kept for undo, `rss:` refused, notifications registered again and one + * `feed_url_corrected` per swap of a followed show. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class FeedAddressUpdaterTest { + private lateinit var database: BoxLoreDatabase + private lateinit var dao: PodcastDao + private val registrations = mutableListOf() + private val corrections = mutableListOf() + private lateinit var updater: FeedAddressUpdater + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + dao = database.podcastDao() + val telemetry = + object : FeedTelemetryPort { + override fun feedFetched(report: FeedFetchReport) = Unit + + override fun feedUrlCorrected(correction: FeedUrlCorrection) { + corrections += correction + } + } + updater = FeedAddressUpdater(dao, requestNotificationRegistration = { registrations += it }, telemetry = telemetry) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `a correction replaces the address, keeps the old one, resets validators, re-registers and reports once`() = runTest { + dao.upsertRow(show("920666", OLD, notifications = true).copy(feedEtag = "\"e\"", isDirty = false)) + database.localEpisodeCatalogDao().upsertFeed(catalogFeed("920666")) + + val result = updater.update(correction("920666")) + + assertEquals(FeedAddressUpdate.UPDATED, result) + val row = dao.getPodcast("920666")!! + assertEquals(NEW, row.feedUrl) + assertEquals(OLD, row.previousFeedUrl) + assertEquals("registry:apple", row.feedUrlSource) + assertEquals(7L, row.feedUrlCheckedAt) + assertNull(row.feedEtag) + assertTrue("an address found on the phone is pushed", row.isDirty) + assertNull(database.localEpisodeCatalogDao().getFeed("920666")!!.feedEtag) + assertEquals(listOf("920666"), registrations) + assertEquals(listOf(FeedUrlCorrection("920666", FeedUrlCorrectionReason.REGISTRY, FeedUrlCorrectionStep.DAILY_CHECK)), corrections) + + assertEquals(FeedAddressUpdate.UNCHANGED, updater.update(correction("920666"))) + assertEquals("an unchanged address does nothing more", 1, registrations.size) + assertEquals(1, corrections.size) + } + + @Test + fun `undo keeps the old address`() = runTest { + dao.upsertRow(show("920666", OLD)) + updater.update(correction("920666")) + + assertEquals(FeedAddressUpdate.UPDATED, updater.update(FeedAddressChange("920666", OLD, source = "undo"))) + + val row = dao.getPodcast("920666")!! + assertEquals(OLD, row.feedUrl) + assertEquals(NEW, row.previousFeedUrl) + } + + @Test + fun `rss shows and addresses that aren't HTTPS are refused`() = runTest { + dao.upsertRow(show("rss:abc", OLD).copy(sourceType = PodcastEntity.SOURCE_RSS, notificationsEnabled = true)) + dao.upsertRow(show("920666", OLD)) + + assertEquals(FeedAddressUpdate.REFUSED, updater.update(correction("rss:abc"))) + assertEquals(FeedAddressUpdate.REFUSED, updater.update(correction("920666").copy(feedUrl = "http://new.example/feed.xml"))) + assertEquals(FeedAddressUpdate.NO_SHOW, updater.update(correction("404"))) + + assertEquals(OLD, dao.getPodcast("rss:abc")!!.feedUrl) + assertEquals(OLD, dao.getPodcast("920666")!!.feedUrl) + assertTrue(registrations.isEmpty()) + assertTrue(corrections.isEmpty()) + } + + @Test + fun `an address that changed after it was read is left alone`() = runTest { + dao.upsertRow(show("920666", "https://moved.example/feed.xml")) + + assertEquals(FeedAddressUpdate.CHANGED_MEANWHILE, updater.update(correction("920666").copy(onlyReplacing = OLD))) + + assertEquals("https://moved.example/feed.xml", dao.getPodcast("920666")!!.feedUrl) + } + + @Test + fun `a filled missing address, a cloud address and an unfollowed show are not corrections`() = runTest { + dao.upsertRow(show("1", null, notifications = true)) + dao.upsertRow(show("2", OLD, notifications = true).copy(isDirty = false)) + dao.upsertRow(show("3", OLD).copy(isSubscribed = false)) + + updater.update(correction("1")) + updater.update(correction("2").copy(fromCloud = true, reason = null, step = null)) + updater.update(correction("3")) + + assertTrue(corrections.isEmpty()) + assertEquals(listOf("1", "2"), registrations) + assertFalse("an address from the cloud is not pushed back", dao.getPodcast("2")!!.isDirty) + assertEquals(NEW, dao.getPodcast("3")!!.feedUrl) + } + + @Test + fun `a failed request for registration never undoes the saved address`() = runTest { + val failing = FeedAddressUpdater(dao, requestNotificationRegistration = { throw IllegalStateException("WorkManager not ready") }) + dao.upsertRow(show("920666", OLD, notifications = true)) + + assertEquals(FeedAddressUpdate.UPDATED, failing.update(correction("920666"))) + assertEquals(NEW, dao.getPodcast("920666")!!.feedUrl) + } + + private fun correction(id: String) = FeedAddressChange( + podcastId = id, + feedUrl = NEW, + source = "registry:apple", + checkedAt = 7L, + reason = FeedUrlCorrectionReason.REGISTRY, + step = FeedUrlCorrectionStep.DAILY_CHECK, + ) + + private fun show(id: String, feedUrl: String?, notifications: Boolean = false) = PodcastEntity( + podcastId = id, + title = "Show", + author = "Author", + imageUrl = "", + description = null, + isSubscribed = true, + notificationsEnabled = notifications, + feedUrl = feedUrl, + ) + + private fun catalogFeed(id: String) = LocalEpisodeFeedEntity( + podcastId = id, + feedUrl = OLD, + feedEtag = "\"etag\"", + feedLastModified = "Wed", + fetchedAt = 1L, + itemCount = 1, + feedOrder = LocalFeedOrder.NEWEST_FIRST, + ttlExpiresAt = null, + needsFullBackfill = false, + copiedExtrasCount = 0, + ready = true, + feedUrlLookupAt = 0L, + ) + + private companion object { + const val OLD = "https://old.example/feed.xml" + const val NEW = "https://new.example/feed.xml" + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt new file mode 100644 index 000000000..035afda46 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt @@ -0,0 +1,99 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.network.model.V2FeedReportRequest +import cx.aswin.boxlore.core.network.model.V2FeedReportResponse +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Broken-feed reports of followed shows (own-episode-IDs P4 PR 14, plan section 11): limited per + * show and per day, never sent offline, never for `rss:` shows, and only a readable new address is + * offered back. + */ +class FeedReportsTest { + private var now = 100 * DAY + private var online = true + private var enabled = true + private val sent = mutableListOf() + private var answer: V2FeedReportResponse? = V2FeedReportResponse(queued = true, fetchUrl = NEW, fetchVia = "direct", feedSource = "registry:apple") + private val stamps = MemoryFeedReportStamps() + private val reporter = + FeedReporter( + send = { request -> + sent += request + answer + }, + limiter = FeedReportLimiter(stamps), + isOnline = { online }, + enabled = { enabled }, + nowMillis = { now }, + ) + + @Test + fun `a show is reported once a day, and the API's new address comes back`() = runTest { + assertEquals(NEW, reporter.report("920666", "http_410", OLD)) + assertNull(reporter.report("920666", "http_410", OLD), "the same day") + now += DAY + assertEquals(NEW, reporter.report("920666", "timeout", OLD)) + + assertEquals(listOf(V2FeedReportRequest(920666, "http_410"), V2FeedReportRequest(920666, "timeout")), sent) + } + + @Test + fun `at most twenty reports a day in all`() = runTest { + repeat(25) { reporter.report((it + 1).toString(), "dns", OLD) } + + assertEquals(FeedReportLimiter.DAILY_CAP, sent.size) + now += DAY + reporter.report("25", "dns", OLD) + assertEquals(FeedReportLimiter.DAILY_CAP + 1, sent.size, "a new day") + } + + @Test + fun `nothing is sent offline, for rss shows, without a read, or with the switch off`() = runTest { + online = false + assertNull(reporter.report("1", "network", OLD)) + online = true + assertNull(reporter.report("rss:abc", "http_404", OLD)) + assertNull(reporter.report("2", null, OLD)) + assertNull(reporter.report("3", "not-a-status", OLD)) + enabled = false + assertNull(reporter.report("4", "http_404", OLD)) + + assertTrue(sent.isEmpty()) + assertTrue(stamps.all().isEmpty(), "an unsent report doesn't use up the show's day") + } + + @Test + fun `the same address, an http-only one, no answer or a failure offers nothing`() = runTest { + answer = V2FeedReportResponse(queued = true, fetchUrl = OLD, fetchVia = "direct") + assertNull(reporter.report("1", "http_404", OLD)) + answer = V2FeedReportResponse(queued = true, fetchUrl = "http://new.example/feed.xml", fetchVia = "proxy", transportUrl = "https://api.example/v2/feed/2") + assertNull(reporter.report("2", "http_404", OLD)) + answer = null + assertNull(reporter.report("3", "http_404", OLD)) + val failing = FeedReporter(send = { error("offline after all") }, limiter = FeedReportLimiter(stamps), isOnline = { true }, enabled = { true }, nowMillis = { now }) + assertNull(failing.report("4", "http_404", OLD)) + } + + @Test + fun `old report times are dropped as new ones are written`() { + val limiter = FeedReportLimiter(stamps) + assertTrue(limiter.tryClaim("1", now)) + now += 2 * DAY + assertTrue(limiter.tryClaim("2", now)) + + assertEquals(setOf("2"), stamps.all().keys) + assertFalse(limiter.tryClaim("2", now + 1)) + } + + private companion object { + const val DAY = FeedReportLimiter.PER_SHOW_INTERVAL_MS + const val OLD = "https://old.example/feed.xml" + const val NEW = "https://new.example/feed.xml" + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt new file mode 100644 index 000000000..2fd1428ce --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt @@ -0,0 +1,160 @@ +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.Episode +import cx.aswin.boxlore.core.network.model.UserSubscriptionSyncDto +import java.io.IOException +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 + +/** + * Acknowledged notification registration of Podcast Index shows after an address change + * (own-episode-IDs P4 PR 14, plan section 11): the same `tracked_podcasts/` node and topic as + * turning notifications on, including after a cloud pull that changes only the address. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PodcastNotificationRegistrarTest { + private lateinit var database: BoxLoreDatabase + private lateinit var dao: PodcastDao + private val firebase = FakeFirebase() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + dao = database.podcastDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `a followed show with notifications on is registered on its node and topic, with its address`() = runTest { + dao.upsertRow(show("920666", notifications = true)) + + assertEquals(NotificationRegistrationOutcome.DONE, PodcastNotificationRegistrar(dao, firebase).register("920666")) + + assertEquals(mapOf("920666" to mapOf("title" to "Show", "imageUrl" to "https://img.example/a.jpg", "feedUrl" to NEW)), firebase.nodes) + assertEquals(listOf("new_ep_920666"), firebase.topics) + } + + @Test + fun `nothing is registered for a show without notifications, and rss shows are dropped`() = runTest { + dao.upsertRow(show("1", notifications = false)) + dao.upsertRow(show("2", notifications = true).copy(isSubscribed = false)) + val registrar = PodcastNotificationRegistrar(dao, firebase) + + assertEquals(NotificationRegistrationOutcome.DONE, registrar.register("1")) + assertEquals(NotificationRegistrationOutcome.DONE, registrar.register("2")) + assertEquals(NotificationRegistrationOutcome.DONE, registrar.register("missing")) + assertEquals(NotificationRegistrationOutcome.DROPPED, registrar.register("rss:abc")) + assertTrue(firebase.nodes.isEmpty()) + assertTrue(firebase.topics.isEmpty()) + } + + @Test + fun `no acknowledgement retries, and refused rules stop`() = runTest { + dao.upsertRow(show("920666", notifications = true)) + + firebase.failure = IOException("offline") + assertEquals(NotificationRegistrationOutcome.RETRY, PodcastNotificationRegistrar(dao, firebase).register("920666")) + firebase.failure = TrackedPodcastWriteRefused() + assertEquals(NotificationRegistrationOutcome.DROPPED, PodcastNotificationRegistrar(dao, firebase).register("920666")) + firebase.failure = null + firebase.hang = true + assertEquals(NotificationRegistrationOutcome.RETRY, PodcastNotificationRegistrar(dao, firebase, timeoutMs = 10L).register("920666")) + } + + @Test + fun `a pull that changes only the address re-registers on the same node and topic`() = runTest { + dao.upsertRow(show("920666", notifications = true).copy(feedUrl = OLD, isDirty = false, syncedAt = 1L, subscribedAt = 1L)) + val queued = mutableListOf() + val updater = FeedAddressUpdater(dao, requestNotificationRegistration = { queued += it }) + val resolver = SubscriptionSyncResolver(podcastDao = dao, feedAddresses = updater) + + resolver.resolveSubscription( + UserSubscriptionSyncDto(podcastId = "920666", isSubscribed = true, subscribedAt = 1L, notificationsEnabled = true, feedUrl = NEW, updatedAt = 2L), + syncedAt = 3L, + ) + queued.forEach { PodcastNotificationRegistrar(dao, firebase).register(it) } + + val row = dao.getPodcast("920666")!! + assertEquals(NEW, row.feedUrl) + assertEquals(OLD, row.previousFeedUrl) + assertEquals(FeedUrlSources.CLOUD_SYNC, row.feedUrlSource) + assertFalse("a pulled address is not pushed back", row.isDirty) + assertEquals(listOf("920666"), queued) + assertEquals(NEW, firebase.nodes.getValue("920666")["feedUrl"]) + assertEquals(listOf("new_ep_920666"), firebase.topics) + } + + @Test + fun `a pull that brings the same address registers nothing`() = runTest { + dao.upsertRow(show("920666", notifications = true).copy(isDirty = false, syncedAt = 1L, subscribedAt = 1L)) + val queued = mutableListOf() + val resolver = SubscriptionSyncResolver(podcastDao = dao, feedAddresses = FeedAddressUpdater(dao, requestNotificationRegistration = { queued += it })) + + resolver.resolveSubscription( + UserSubscriptionSyncDto(podcastId = "920666", isSubscribed = true, subscribedAt = 1L, notificationsEnabled = true, feedUrl = NEW, updatedAt = 2L), + syncedAt = 3L, + ) + + assertTrue(queued.isEmpty()) + } + + private fun show(id: String, notifications: Boolean) = PodcastEntity( + podcastId = id, + title = "Show", + author = "Author", + imageUrl = "https://img.example/a.jpg", + description = null, + isSubscribed = true, + notificationsEnabled = notifications, + feedUrl = NEW, + latestEpisode = Episode("1", "Episode", "", "https://cdn.example/1.mp3", podcastId = id), + ) + + private class FakeFirebase : TrackedPodcastFirebase { + val nodes = mutableMapOf>() + val topics = mutableListOf() + var failure: Exception? = null + var hang = false + + override suspend fun set(key: String, payload: Map) { + failure?.let { throw it } + if (hang) awaitCancellation() + nodes[key] = payload + } + + override suspend fun delete(path: String) = Unit + + override suspend fun subscribe(topic: String) { + topics += topic + } + + override suspend fun unsubscribe(topic: String) = Unit + } + + private companion object { + const val OLD = "https://old.example/feed.xml" + const val NEW = "https://new.example/feed.xml" + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt new file mode 100644 index 000000000..5c728be20 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt @@ -0,0 +1,31 @@ +package cx.aswin.boxlore.core.catalog + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Report times survive a new process, so a show is reported once a day however often the app starts (P4 PR 14). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SharedPreferencesFeedReportStampsTest { + private val context get() = ApplicationProvider.getApplicationContext() + + @Test + fun `a report claimed in one process holds in the next, and old times are dropped`() { + val day = FeedReportLimiter.PER_SHOW_INTERVAL_MS + val prefs = context.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE) + FeedReportLimiter(SharedPreferencesFeedReportStamps(prefs)).tryClaim("920666", 10 * day) + + val nextProcess = SharedPreferencesFeedReportStamps(context.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE)) + assertFalse(FeedReportLimiter(nextProcess).tryClaim("920666", 10 * day + 1)) + + FeedReportLimiter(nextProcess).tryClaim("1", 12 * day) + assertEquals(mapOf("1" to 12 * day), nextProcess.all()) + assertEquals("boxlore_feed_reports", SharedPreferencesFeedReportStamps.FILE_NAME) + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt index 831dcb491..aa0d0765a 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt @@ -50,4 +50,51 @@ class SubscribedEpisodeCatalogTest { SubscribedEpisodeCatalog(pi, rss).sweepExpired(123) assertEquals(listOf("pi:123", "rss:123"), visits) } + + @Test fun everyRefreshPathFollowsAFeedThatMovedForGood() = runTest { + val moved = LocalEpisodeCatalogPort.RefreshOutcome.Success(newest = null, itemCount = 1, ready = true, movedTo = "https://new.example/feed.xml") + val pi = FakeLocalEpisodeCatalogPort().apply { refreshOutcome = moved } + val rss = FakeLocalEpisodeCatalogPort().apply { refreshOutcome = moved } + var enabled = true + val adopted = mutableListOf>() + val shared = + SubscribedEpisodeCatalog( + pi, + rss, + feedMoves = FeedMoveFollower(enabled = { enabled }, showTitle = { "Stored title" }, adopt = { id, url -> adopted += id to url }), + ) + + // A push, the show page or auto-download: no move check of its own, no title. + shared.refresh(RefreshRequest("123", "https://old.example/feed.xml", reason = LocalEpisodeCatalogPort.RefreshReason.NEW_RELEASE)) + assertEquals(true, pi.lastRefreshRequest?.detectMove) + assertEquals("Stored title", pi.lastRefreshRequest?.meta?.title) + assertEquals(listOf("123" to "https://new.example/feed.xml"), adopted) + + // A caller that asks for move detection itself saves the move itself. + adopted.clear() + shared.refresh(RefreshRequest("123", "https://old.example/feed.xml", meta = LocalEpisodeCatalogPort.PodcastMeta(title = "Own"), detectMove = true)) + assertEquals("Own", pi.lastRefreshRequest?.meta?.title) + assertEquals(emptyList>(), adopted) + + // rss: shows keep their address; the switch turns it all off. + shared.refresh(RefreshRequest("rss:abc", "https://old.example/feed.xml")) + assertEquals(false, rss.lastRefreshRequest?.detectMove) + enabled = false + shared.refresh(RefreshRequest("123", "https://old.example/feed.xml")) + assertEquals(false, pi.lastRefreshRequest?.detectMove) + assertEquals(emptyList>(), adopted) + } + + @Test fun aFailedAdoptionStillReturnsTheRefresh() = runTest { + val outcome = LocalEpisodeCatalogPort.RefreshOutcome.Success(newest = null, itemCount = 1, ready = true, movedTo = "https://new.example/feed.xml") + val pi = FakeLocalEpisodeCatalogPort().apply { refreshOutcome = outcome } + val shared = + SubscribedEpisodeCatalog( + pi, + FakeLocalEpisodeCatalogPort(), + feedMoves = FeedMoveFollower(enabled = { true }, showTitle = { null }, adopt = { _, _ -> error("database closed") }), + ) + + assertEquals(outcome, shared.refresh(RefreshRequest("123", "https://old.example/feed.xml"))) + } } diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt index 9dd6468e3..ba8686de9 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.core.catalog -import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.advanceTimeBy @@ -548,7 +547,7 @@ class SubscriptionForegroundSyncTest { else -> null } }, - saveAddress = { id, url -> savedAddresses[id] = url }, + saveAddress = { id, candidate -> savedAddresses[id] = candidate.feedUrl }, ), ) assertEquals(setOf("moved", "badnew", "dead"), rechecked.toSet(), "only dead addresses, never rss: shows") @@ -583,34 +582,6 @@ class SubscriptionForegroundSyncTest { assertTrue(piSynced.isEmpty()) } - @Test - fun `a dead address is re-checked once a day, and only once the server answers`() = runTest { - val day = LocalEpisodeCatalogRepository.FEED_URL_LOOKUP_INTERVAL_MS - var stamped: Long? = null - suspend fun check(now: Long, last: Long, confirmed: String?, saved: String? = "https://old.example/f.xml", id: String = "123") = - DirectFeedRecovery.recheckGoneFeedAddress( - id = id, - now = now, - savedFeedUrl = saved, - lastLookupAt = { last }, - markLookup = { stamped = it }, - lookupFeedUrl = { confirmed }, - ) - - assertEquals("https://new.example/f.xml", check(now = 2 * day, last = 0L, confirmed = "https://new.example/f.xml")) - assertEquals(2 * day, stamped) - - stamped = null - assertEquals(null, check(now = 2 * day, last = 2 * day - 1_000L, confirmed = "https://new.example/f.xml"), "within a day") - assertEquals(null, check(now = 3 * day, last = 0L, confirmed = null), "no answer") - assertEquals(null, stamped, "an offline pass doesn't use up the day") - assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "https://old.example/f.xml"), "same address") - assertEquals(3 * day, stamped) - assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "http://new.example/f.xml"), "http only") - assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "https://new.example/f.xml", id = "rss:abc"), "rss: shows keep theirs") - assertEquals(null, check(now = 3 * day, last = 0L, confirmed = "https://new.example/f.xml", saved = null), "no saved address: recovery owns it") - } - @Test fun `a feed that moved for good gets its new address saved, only with the switch on and never for rss shows`() = runTest { val meta = DirectFeedTipMeta("https://old.example/feed.xml", "Moving Show", null, null, null, null) @@ -620,7 +591,10 @@ class SubscriptionForegroundSyncTest { publisherFeedUnchanged = false refreshOutcome = cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Success( - newest = null, itemCount = 1, ready = true, movedTo = "https://new.example/feed.xml", + newest = null, + itemCount = 1, + ready = true, + movedTo = "https://new.example/feed.xml", ) } val adopted = mutableListOf() diff --git a/core/database/README.md b/core/database/README.md index 6b258808a..3bb6f9bd4 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -13,7 +13,7 @@ Owns the main Room database, entities, DAOs, type converters, and migrations for - `BoxLoreDatabase` and its `getDatabase` factory. - Entities: `PodcastEntity`, `ListeningHistoryEntity`, `ListeningSessionEntity`, `ListeningRollupEntity`, `DownloadedEpisodeEntity`, `RssEpisodeEntity`, `EpisodeSupplementEntity`, `EpisodeSupplementItemEntity`, `LocalEpisodeFeedEntity`, `LocalEpisodeEntity`, `FolderEntity`, `PodcastFolderCrossRef`, `AutoDownloadShowEntity`, `AutoDownloadReleaseEntity`, `EpisodeAliasEntity`, `EpisodeGroupStateEntity`, `UpgradeRepairShowEntity`, `entities.QueueItem`, and `entities.QueueMetadataEntity`. String values for alias states, alias provenance and repair states live in `EpisodeAliasState`, `EpisodeAliasProvenance` and `UpgradeRepairState`; stored values are never renamed. - `EpisodeListenerStore` (from `BoxLoreDatabase.episodeListenerStore()`, so the playback service reaches it without `AppContainer`) keeps one listener state per episode across its stored IDs ([`docs/EPISODE_IDS.md`](../../docs/EPISODE_IDS.md) §5). An `EpisodeGroup` is every trusted alias of one (show, catalog key), owner first; any other ID is a group of its own and reads and writes exactly as before aliases existed. It works out the owner (the stored mark while that ID holds listener state, else the v39 upgrade's order through `EpisodeIdentityDao.chooseOwner`) and stores it in the transaction of the write that needs it (`historyId`). Reads project (`EpisodeListenerProjection`): the owner's row is the base, and progress, position and play time come from the most recently played row (the owner's, unless a pull, a restore or older-build data put later listening under another ID; an owner that holds only a like never hides another ID's progress); liked and completed are the latest decision across the rows and `episode_group_state`, a group setting winning a tie, so an older alias row can't bring back a cleared like or completion; an episode with one stored ID reads its own row unchanged. `projectRows`, `projectedRows` and `projectCompletedIds` count each episode once (the shared-alias query is watched as a Flow, so episodes with one ID cost nothing extra per emission); `projectCompletedIds` also counts an episode that only its group settings mark completed. `projectRowsByMember`, `projectedRowsByMember` and `projectedLikedIds` list an episode once per stored ID, for screens that look an episode up by the ID they show. `lastPlayedSession` is the projected last session for restores and Android Auto. Writes: `setLiked` writes an absolute like to every trusted ID and the group settings (an episode with one stored ID keeps the clock's time on its row, as before); `recordLike`, `recordCompletion` and the batched `recordCompletions` record a decision after the caller wrote the owner's row (an episode with one ID and no row records nothing); `historyIds` works out owners in batches; `removeGroupState`, `restoreGroupState` and `clearGroupStates` remove an episode's settings with its history and put them back on Undo; `downloadHolder(s)` names the ID whose completed download plays an episode; `memberIds`, `withAllAliases`, `groupTokens` and `queueIdentities` serve the queue, Smart Queue, Smart Downloads, sync and ranking; `queueRemovalIds` names the IDs a queue removal records: the removed ones and every other trusted ID of their episodes, queued here or not (a cloud sync pull keeps one queue entry per episode, so the cloud can still hold another), except the IDs of an episode that stays queued or plays; `withAllAliasesFlow`, `memberIdsFlow` and `aliasGroupsFlow` expand IDs to every trusted ID of their episodes in memory from the watched shared aliases, so download and queue indicators read an episode under the ID a screen shows. `ownerRow` gives a new history row written under another ID of the playing episode (its owner) the owner's own show (its stored row, else its catalog row, else its alias), and `keepingOwnerMetadata` keeps a stored owner row's show and episode metadata when another ID's page saves progress, so the owner row of a show switched to its feed stays under the Podcast Index show it belongs to. `showReplacementsFlow` maps each Podcast Index show a followed RSS show replaced (and the listener no longer follows) to that RSS show, for listening read by show. Like times in the group settings stay monotonic within an episode even when the clock reads earlier than a recorded decision. -- DAOs: `EpisodeIdentityDao` (alias and group-state reads and writes, `upsertGroupStates`, `getGroupStates`, `completedGroupStates`, `deleteGroupState`, `deleteAllGroupStates`, `sharedTrustedAliases` / `sharedTrustedAliasesFlow`, `chooseOwner` (the upgrade's owner order, read at runtime), `setOwner` and `holdsListenerState`; when ownership may move and which states resolve a lookup belong to `EpisodeListenerStore`), `UpgradeRepairDao` (pending shows and per-show progress), `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `PodcastDao.upsert` and `LocalEpisodeCatalogDao.upsertFeed` keep a stored row's Room v39 columns (see Version 39 below). `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `ListeningHistoryDao.getHistoryItems`, `setLikeStatusForIds` and `setCompletionForIds` and `DownloadedEpisodeDao.getCompletedDownloadIds`, `getDownloads`, `countCompletedFlow` and `countActiveFlow` read and write an episode's rows under all its IDs. `PodcastDao.observeSwitchedRssShows` watches the followed RSS shows that replaced a Podcast Index show the listener no longer follows. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `ListeningHistoryDao.getDirtyListeningHistoryBatch` reads one push batch: up to the given number of unsynced rows, most recently played first, without episode descriptions, so a large backlog is never read in full. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL found on the device (address recovery, auto-download, library restore) and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`); `findOwners` reports which (show, key) holds each episode ID so writers check before `upsertEpisodes` replaces a row. `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. +- DAOs: `EpisodeIdentityDao` (alias and group-state reads and writes, `upsertGroupStates`, `getGroupStates`, `completedGroupStates`, `deleteGroupState`, `deleteAllGroupStates`, `sharedTrustedAliases` / `sharedTrustedAliasesFlow`, `chooseOwner` (the upgrade's owner order, read at runtime), `setOwner` and `holdsListenerState`; when ownership may move and which states resolve a lookup belong to `EpisodeListenerStore`), `UpgradeRepairDao` (pending shows and per-show progress), `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `PodcastDao.upsert` and `LocalEpisodeCatalogDao.upsertFeed` keep a stored row's Room v39 columns (see Version 39 below). `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `ListeningHistoryDao.getHistoryItems`, `setLikeStatusForIds` and `setCompletionForIds` and `DownloadedEpisodeDao.getCompletedDownloadIds`, `getDownloads`, `countCompletedFlow` and `countActiveFlow` read and write an episode's rows under all its IDs. `PodcastDao.observeSwitchedRssShows` watches the followed RSS shows that replaced a Podcast Index show the listener no longer follows. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `ListeningHistoryDao.getDirtyListeningHistoryBatch` reads one push batch: up to the given number of unsynced rows, most recently played first, without episode descriptions, so a large backlog is never read in full. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL found on the device and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. App code replaces a saved address only through `PodcastDao.replaceFeedAddress(FeedAddressWrite)` (P4 PR 14, used by `:core:catalog`'s `FeedAddressUpdater`): one transaction that writes `feedUrl` and `transportUrl`, keeps the old address in `previousFeedUrl`, records `feedUrlSource` and `feedUrlCheckedAt`, clears the show's ETag and Last-Modified on its row, its `local_episode_feeds` row and its `episode_supplements` row, and marks the row dirty unless the address came from the cloud. It is a compare-and-set on the address the caller read (`feedUrl IS :expected`) and never touches `rss:` rows. `PodcastDao.markFeedUrlChecked` stamps the daily address check without marking the row dirty. No schema change. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`); `findOwners` reports which (show, key) holds each episode ID so writers check before `upsertEpisodes` replaces a row. `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. - `EpisodeSupplementDao.replaceAll` / `upsertSupplementAndOptionalItems` keep validator rows and tip items in one Room transaction. - `QueueDao.bumpQueueVersion` keeps `queue_metadata.recentRemovedEpisodes` (a JSON list of `QueueRemovalIdentity`: ID, recipe ID, show and catalog key, written with hand-named fields) in step with the plain `recentRemovedEpisodeIds`. A removal adds one entry per removed ID, with the episode's identity when the caller passes it; restoring the same episode under any ID (same ID, or same show and key) drops its entry, and a removal of an episode that stays queued under another ID isn't recorded there. Both lists keep the newest 50. `QueueDao.richRemovalsWithin` keeps the rich list in step after a pulled queue state replaced the plain list: only entries the plain list still holds and whose episode isn't queued again. Cloud sync pushes the rich list with the queue and pulls the server's (`:core:catalog` `sync.QueueSyncResolver`). - `PodcastScoring.calculateScores` takes an optional `episodeKey`, so `:core:ranking` can count an episode stored under several IDs once; the default keeps each ID its own episode. `ScorablePodcast.listenerShowIds` (filled by `toScorable` from `Podcast.listenerShowIds` and `PodcastEntity.listenerShowIds`) lets an RSS show the listener switched to from a Podcast Index show count that show's history, unless that show is scored itself. `sortedByListening` orders shows for Most listened the same way. @@ -111,6 +111,7 @@ src/main/java/cx/aswin/boxlore/core/database/ - Unit tests live under `core/database/src/test`. - `BriefingArtworkCompatibilityTest` covers upgrade/reopen, restored rows, all region aliases, recursive-trigger safety, unchanged non-Brief artwork and preservation of playback/download/queue state. Opening recreates trigger definitions transactionally, including after a database comes from another application variant, so future restores use the current authority and aliases. - `PodcastDaoExtendedTest.setFeedUrlMarksACorrectedAddressForTheNextPush` covers a corrected address marking the row dirty and an unchanged one staying clean. +- `PodcastFeedAddressDaoTest` covers `replaceFeedAddress`: both addresses, the kept old address and every validator of the show cleared together (other shows untouched), a row changed since it was read and `rss:` rows left alone with nothing cleared, a missing address filled, a cloud address leaving the row clean, undo, and the daily-check stamp. - `PodcastDaoInMemoryTest` verifies the in-memory Room DAO path and RSS notification cleanup selection, including disabled/unsubscribed rows with stale source metadata, and that `setEpNsIfUnset` never replaces a stored namespace or sets one for an `rss:` show. `LocalEpisodeCatalogDaoOwnersTest` covers the catalog's `setEpNsIfUnset` and `upsertFeed` keeping it; `EpisodeIdentityDaoTest` covers `aliasesForShow`. - `ListeningRollupMergeTest` covers session→rollup merge, including the empty-sessions guard. `ListeningHistoryDaoInMemoryTest` covers insert-if-absent conflict safety and playback completion updates preserving listener-owned fields such as likes, and that a push batch reads only its own rows, newest first and without descriptions. `EpisodeListenerStoreTest` also covers which IDs a queue removal records. `ListeningHistoryDao.repairPlaybackArtworkLinks` and `QueueDao.repairPlaybackArtworkLinks` swap player-only `.collage` artwork links saved by older builds for the episode's cover (local, then RSS catalogue), else the show's, else the row's own show cover, else null. A history row is marked dirty only when every dead link found a real cover, so an emptied field never overwrites a cover another device uploaded; `QueueDao.repairPlaybackArtworkLinksForSync` marks a changed queue dirty in the same transaction without changing its sequence, so the cloud copy drops the dead links, and its order is untouched. `ListeningHistoryDaoInMemoryTest` and `QueueDaoInMemoryTest` cover each source, the row's own show cover, emptied rows staying clean, the queue marked dirty only when a row changed, untouched rows and a second run changing nothing. - `RoomV39UpgradeTest` builds databases from the exported 36, 37 and 38 schemas, and a phone that came up through the older migrations (from 30, with the `CHECK` constraints, `queue_metadata` SQL defaults and supplement-copied rows), fills them with every kind of stored ID (Podcast Index, negative, served recipe-range IDs in the catalog, history, queue and downloads, SQLite-trimmed, synthetic and address keys, Brief and `rss:` IDs, reserved IDs from the collision guard), then opens them through `BoxLoreDatabase.builder`, so Room runs the production migrations and its own schema check. It asserts that every pre-existing value is unchanged, the untouched tables keep their exact SQL, the Brief artwork triggers don't interfere, aliases, owners, namespaces and repair rows are seeded as described above (including IDs held under another show, address keys whose saved item is gone, and each owner rule), eviction keeps aliases, later catalog refreshes and podcast writes keep the seeded columns, a fresh install and an upgraded phone have identical tables, columns, defaults and indices, a failure inside the seeding rolls the file back to v38 and the next open succeeds, a second seeding pass changes nothing, a missing upgrade path fails without erasing data, and downgrades and pre-12 versions still open empty. `EpisodeAliasSeedingTest` covers every seeding rule without SQLite, including the recipe trim cases; `EpisodeIdentityDaoTest` covers the new DAOs. `RoomSchemaFixture` and `LegacyRows` build these fixtures; reuse them for later upgrades. diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt new file mode 100644 index 000000000..6e05f24b3 --- /dev/null +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt @@ -0,0 +1,26 @@ +package cx.aswin.boxlore.core.database + +/** + * One saved feed address replacement for [PodcastDao.replaceFeedAddress]. + * + * - [expectedFeedUrl]: the address the caller read; nothing is written when the row holds another + * one by then. It also becomes `previousFeedUrl`, so the change can be undone. + * - [transportUrl]: the address to fetch through when the publisher's can't be read directly; null + * clears an old one. + * - [source]: where [feedUrl] came from (`feedUrlSource`). + * - [checkedAt]: when the address registry confirmed [feedUrl] (`feedUrlCheckedAt`); 0 when it didn't. + * - [markDirty]: true for an address found on this phone, so the next sync push sends it; false for + * one that came from the cloud. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): the one address writer. Permanent: never remove (MIGRATION.md §5). + */ +data class FeedAddressWrite( + val podcastId: String, + val expectedFeedUrl: String?, + val feedUrl: String, + val transportUrl: String?, + val source: String?, + val checkedAt: Long, + val markDirty: Boolean, +) 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 405668f7e..888c29b14 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 @@ -181,6 +181,87 @@ interface PodcastDao { ) suspend fun setFeedUrl(id: String, feedUrl: String,) + /** + * Replaces a show's saved feed address in one transaction: the publisher and transport + * addresses, where the address came from and when the registry last confirmed it, the old + * address kept for undo, every stored ETag and Last-Modified for the show (so the next read + * fetches the new address in full), and the dirty flag when [FeedAddressWrite.markDirty]. + * Compare-and-set: nothing is written unless the row still holds [FeedAddressWrite.expectedFeedUrl]. + * Never for `rss:` shows, whose address is their identity. + * + * @return true when the address was replaced + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): the one address writer. Permanent: never remove (MIGRATION.md §5). + */ + @Transaction + suspend fun replaceFeedAddress(write: FeedAddressWrite): Boolean { + val replaced = + replaceFeedAddressRow( + id = write.podcastId, + expectedFeedUrl = write.expectedFeedUrl, + feedUrl = write.feedUrl, + transportUrl = write.transportUrl, + previousFeedUrl = write.expectedFeedUrl, + source = write.source, + checkedAt = write.checkedAt, + markDirty = write.markDirty, + ) == 1 + if (replaced) { + clearCatalogFeedValidators(write.podcastId) + clearSupplementFeedValidators(write.podcastId) + } + return replaced + } + + /** The row update of [replaceFeedAddress]; use that, which also clears the show's other validators. */ + @Query( + """ + UPDATE podcasts + SET feedUrl = :feedUrl, + transportUrl = :transportUrl, + previousFeedUrl = :previousFeedUrl, + feedUrlSource = :source, + feedUrlCheckedAt = :checkedAt, + feedEtag = NULL, + feedLastModified = NULL, + isDirty = CASE WHEN :markDirty THEN 1 ELSE isDirty END + WHERE podcastId = :id + AND sourceType != 'rss' + AND podcastId NOT LIKE 'rss:%' + AND feedUrl IS :expectedFeedUrl + """, + ) + @Suppress("LongParameterList", "kotlin:S107") // Room binds each column as its own parameter. + suspend fun replaceFeedAddressRow( + id: String, + expectedFeedUrl: String?, + feedUrl: String, + transportUrl: String?, + previousFeedUrl: String?, + source: String?, + checkedAt: Long, + markDirty: Boolean, + ): Int + + /** Clears the catalog read's validators so the next read of a new address is a full fetch. */ + @Query("UPDATE local_episode_feeds SET feedEtag = NULL, feedLastModified = NULL WHERE podcastId = :podcastId") + suspend fun clearCatalogFeedValidators(podcastId: String) + + /** Clears the feed-only extras' validators so the next read of a new address is a full fetch. */ + @Query("UPDATE episode_supplements SET feedEtag = NULL, feedLastModified = NULL WHERE podcastId = :podcastId") + suspend fun clearSupplementFeedValidators(podcastId: String) + + /** + * Records that the show's saved address was checked against the address registry at [atMillis] + * (the daily check, or its `/podcast` fallback). Leaves `isDirty` alone: nothing user-visible changed. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: daily check). + * COMPAT(own-ids): one daily stamp shared by the check and its fallback. Permanent: never remove (MIGRATION.md §5). + */ + @Query("UPDATE podcasts SET feedUrlCheckedAt = :atMillis WHERE podcastId = :id") + suspend fun markFeedUrlChecked(id: String, atMillis: Long,) + /** * The show's stored episode namespace (`epNs`), or null when the show has no row or none was set. * diff --git a/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt b/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt new file mode 100644 index 000000000..b2e7a7f12 --- /dev/null +++ b/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt @@ -0,0 +1,159 @@ +package cx.aswin.boxlore.core.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +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 + +/** + * [PodcastDao.replaceFeedAddress], the one saved-address writer (own-episode-IDs P4 PR 14, plan + * section 11): every column and every stored validator changes together or not at all. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PodcastFeedAddressDaoTest { + private lateinit var database: BoxLoreDatabase + private lateinit var dao: PodcastDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + dao = database.podcastDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `one update writes both addresses, keeps the old one, and clears every validator of the show`() = runTest { + dao.upsertRow(show("920666", feedUrl = OLD).copy(feedEtag = "\"rss\"", feedLastModified = "Mon", isDirty = false)) + database.localEpisodeCatalogDao().upsertFeed(catalogFeed("920666")) + database.localEpisodeCatalogDao().upsertFeed(catalogFeed("other")) + database.episodeSupplementDao().upsertSupplement(EpisodeSupplementEntity("920666", OLD, "rss:ns", "\"sup\"", "Tue", 1L)) + + val replaced = dao.replaceFeedAddress(write("920666", expected = OLD, source = "registry:apple", checkedAt = 42L)) + + assertTrue(replaced) + val row = dao.getPodcast("920666")!! + assertEquals(NEW, row.feedUrl) + assertEquals(OLD, row.previousFeedUrl) + assertEquals("registry:apple", row.feedUrlSource) + assertEquals(42L, row.feedUrlCheckedAt) + assertNull(row.transportUrl) + assertNull(row.feedEtag) + assertNull(row.feedLastModified) + assertTrue(row.isDirty) + val catalog = database.localEpisodeCatalogDao().getFeed("920666")!! + assertNull(catalog.feedEtag) + assertNull(catalog.feedLastModified) + assertEquals("other shows keep theirs", "\"etag\"", database.localEpisodeCatalogDao().getFeed("other")!!.feedEtag) + val supplement = database.episodeSupplementDao().getSupplement("920666")!! + assertNull(supplement.feedEtag) + assertNull(supplement.feedLastModified) + } + + @Test + fun `a row that changed since it was read, or an rss show, is left alone`() = runTest { + dao.upsertRow(show("920666", feedUrl = "https://other.example/feed.xml").copy(isDirty = false)) + dao.upsertRow(show("rss:abc", feedUrl = OLD).copy(sourceType = PodcastEntity.SOURCE_RSS)) + database.localEpisodeCatalogDao().upsertFeed(catalogFeed("920666")) + + assertFalse(dao.replaceFeedAddress(write("920666", expected = OLD))) + assertFalse(dao.replaceFeedAddress(write("rss:abc", expected = OLD))) + + val row = dao.getPodcast("920666")!! + assertEquals("https://other.example/feed.xml", row.feedUrl) + assertNull(row.previousFeedUrl) + assertFalse(row.isDirty) + assertEquals("a refused write clears nothing", "\"etag\"", database.localEpisodeCatalogDao().getFeed("920666")!!.feedEtag) + assertEquals(OLD, dao.getPodcast("rss:abc")!!.feedUrl) + } + + @Test + fun `a missing address can be filled, and an address from the cloud leaves the row clean`() = runTest { + dao.upsertRow(show("920666", feedUrl = null).copy(isDirty = false)) + + assertTrue(dao.replaceFeedAddress(write("920666", expected = null, markDirty = false))) + + val row = dao.getPodcast("920666")!! + assertEquals(NEW, row.feedUrl) + assertNull(row.previousFeedUrl) + assertFalse(row.isDirty) + } + + @Test + fun `undo swaps back and keeps the replaced address`() = runTest { + dao.upsertRow(show("920666", feedUrl = OLD)) + dao.replaceFeedAddress(write("920666", expected = OLD)) + + assertTrue(dao.replaceFeedAddress(write("920666", expected = NEW, feedUrl = OLD))) + + val row = dao.getPodcast("920666")!! + assertEquals(OLD, row.feedUrl) + assertEquals(NEW, row.previousFeedUrl) + } + + @Test + fun `the daily check stamp leaves the row clean`() = runTest { + dao.upsertRow(show("920666", feedUrl = OLD).copy(isDirty = false)) + + dao.markFeedUrlChecked("920666", 99L) + + val row = dao.getPodcast("920666")!! + assertEquals(99L, row.feedUrlCheckedAt) + assertFalse(row.isDirty) + assertEquals(OLD, row.feedUrl) + } + + private fun write( + id: String, + expected: String?, + feedUrl: String = NEW, + source: String? = "feed_move", + checkedAt: Long = 0L, + markDirty: Boolean = true, + ) = FeedAddressWrite(id, expected, feedUrl, transportUrl = null, source = source, checkedAt = checkedAt, markDirty = markDirty) + + private fun show(id: String, feedUrl: String?) = PodcastEntity( + podcastId = id, + title = "Show", + author = "Author", + imageUrl = "", + description = null, + isSubscribed = true, + feedUrl = feedUrl, + ) + + private fun catalogFeed(id: String) = LocalEpisodeFeedEntity( + podcastId = id, + feedUrl = OLD, + feedEtag = "\"etag\"", + feedLastModified = "Wed", + fetchedAt = 1L, + itemCount = 1, + feedOrder = LocalFeedOrder.NEWEST_FIRST, + ttlExpiresAt = null, + needsFullBackfill = false, + copiedExtrasCount = 0, + ready = true, + feedUrlLookupAt = 0L, + ) + + private companion object { + const val OLD = "https://old.example/feed.xml" + const val NEW = "https://new.example/feed.xml" + } +} diff --git a/core/domain/README.md b/core/domain/README.md index 592e7629a..15050fd72 100644 --- a/core/domain/README.md +++ b/core/domain/README.md @@ -20,7 +20,7 @@ Owns thin domain ports and small result types used by ViewModels, repositories, - `DeviceIdentityPort` for cross-device sync attribution. - `FeedTelemetryPort` reports feed reads (`feedFetched`) and saved-address swaps (`feedUrlCorrected`) without a dependency on `:core:analytics`; `AppContainer` wires the analytics-backed one and `NONE` reports nothing. Implementations must not throw. -- `LocalEpisodeCatalogPort.RefreshRequest.reason` defaults to `NORMAL`; automatic refresh and `AUTO_DOWNLOAD` share the persisted six-hour freshness gate. `MANUAL` and `NEW_RELEASE` bypass quiet/HEAD shortcuts. `canProceed` rechecks authorization before network/persistence, `runPostPersistCallback` prevents background metadata recovery from invoking ungated ingest callbacks, and `isRefreshDue` supports bounded foreground batches. `RefreshOutcome.Failure.addressGone` says the saved address looks dead rather than temporarily failing; with `RefreshRequest.detectMove` (and a `PodcastMeta.title` to compare), `RefreshOutcome.Success.movedTo` carries a confirmed new HTTPS address. `RefreshRequest.serverEpNs` passes the episode namespace the server reported for the show; it is only compared with the stored one and never changes an ID. Publisher RSS and Room remain the source of subscribed episodes. +- `LocalEpisodeCatalogPort.RefreshRequest.reason` defaults to `NORMAL`; automatic refresh and `AUTO_DOWNLOAD` share the persisted six-hour freshness gate. `MANUAL` and `NEW_RELEASE` bypass quiet/HEAD shortcuts. `canProceed` rechecks authorization before network/persistence, `runPostPersistCallback` prevents background metadata recovery from invoking ungated ingest callbacks, and `isRefreshDue` supports bounded foreground batches. `RefreshOutcome.Failure.addressGone` says the saved address looks dead rather than temporarily failing, and `RefreshOutcome.Failure.reportStatus` says what the read saw in the words `/v2/feed-report` takes (`http_404`, `timeout`, `dns`, `tls`, `parse`, `network`, `other`; null when no read happened); with `RefreshRequest.detectMove` (and a `PodcastMeta.title` to compare), `RefreshOutcome.Success.movedTo` carries a confirmed new HTTPS address. `RefreshRequest.serverEpNs` passes the episode namespace the server reported for the show; it is only compared with the stored one and never changes an ID. Publisher RSS and Room remain the source of subscribed episodes. ## Internal structure diff --git a/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/LocalEpisodeCatalogPort.kt b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/LocalEpisodeCatalogPort.kt index 1064f13b5..445ffb2ab 100644 --- a/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/LocalEpisodeCatalogPort.kt +++ b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/LocalEpisodeCatalogPort.kt @@ -53,8 +53,13 @@ interface LocalEpisodeCatalogPort { * [addressGone]: the publisher answered in a way that says the saved address no longer * works (gone or refused, no such host, a failing certificate, or not a feed), so it is * worth asking the server for a newer one. False for timeouts, server errors and offline. + * + * [reportStatus]: what the read saw, in the words `/v2/feed-report` takes (`http_404`, + * `timeout`, `dns`, `tls`, `parse`, `network`, `other`); null when no read was made (a gate + * said no, or there was no address to read), so there is nothing to report. + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). */ - data class Failure(val message: String, val addressGone: Boolean = false,) : RefreshOutcome + data class Failure(val message: String, val addressGone: Boolean = false, val reportStatus: String? = null,) : RefreshOutcome } /** True when this show must be served from Room only (no PI ∪ extras merge). */ diff --git a/core/downloads/README.md b/core/downloads/README.md index 9c96241f7..81ab6e863 100644 --- a/core/downloads/README.md +++ b/core/downloads/README.md @@ -22,7 +22,7 @@ Owns offline download orchestration: Media3 offline cache access, download datab - `DownloadTogglePolicy` resolves the deterministic action (`CONFIRM_REMOVAL`, `CANCEL_DOWNLOAD`, or `START_DOWNLOAD`) when toggling episode downloads across surfaces, ensuring downloaded media requires explicit confirmation before removal. - `ports.DownloadServiceLauncher` and `DownloadServiceLauncherHolder` let `:app` provide the Media3 service class without a downloads-to-playback Gradle edge. -- `AutoDownloadCoordinator`, `AutoDownloadDiscoveryWorker`, and `AutoDownloadScheduling` use publisher RSS/Room for release discovery. Discovery refreshes the catalog without a Podcast Index list, so a new release of a Podcast Index show is queued under its recipe ID. Push-triggered and foreground-discovered downloads remain available without polling consent. Additional background discovery is explicitly opt-in, defaults off, and runs about every six hours; per-show auto-download flags cannot enable it. `AutoDownloadBackgroundGate` fails closed on missing consent, unknown/unvalidated connectivity, low/unknown battery, metered networks when restricted, or missing charging when required. WorkManager constraints and a live consent/network/battery watcher protect both discovery and its tagged transfers; admission is rechecked before fetch slots, persistence and enqueue. Forced metadata recovery from a background transfer also rechecks consent and suppresses the ordinary ingest callback. Disabling cancels background work. Background audio obeys both the check network restriction and the normal auto-download network policy. Durable activation boundaries, pending claims, canonical unique work, resumable eight-minute slices, provenance-safe retention and manual-download protection remain in force. +- `AutoDownloadCoordinator`, `AutoDownloadDiscoveryWorker`, and `AutoDownloadScheduling` use publisher RSS/Room for release discovery. A missing address the coordinator recovers is saved through `saveFeedUrl`, the app's one feed address writer (`SubscriptionRepository.ensureHttpsFeedUrl`; `AutoDownloadCoordinatorTest.aRecoveredAddressIsSavedThroughTheAppsAddressWriter`). Discovery refreshes the catalog without a Podcast Index list, so a new release of a Podcast Index show is queued under its recipe ID. Push-triggered and foreground-discovered downloads remain available without polling consent. Additional background discovery is explicitly opt-in, defaults off, and runs about every six hours; per-show auto-download flags cannot enable it. `AutoDownloadBackgroundGate` fails closed on missing consent, unknown/unvalidated connectivity, low/unknown battery, metered networks when restricted, or missing charging when required. WorkManager constraints and a live consent/network/battery watcher protect both discovery and its tagged transfers; admission is rechecked before fetch slots, persistence and enqueue. Forced metadata recovery from a background transfer also rechecks consent and suppresses the ordinary ingest callback. Disabling cancels background work. Background audio obeys both the check network restriction and the normal auto-download network policy. Durable activation boundaries, pending claims, canonical unique work, resumable eight-minute slices, provenance-safe retention and manual-download protection remain in force. - Foreground and push admission supersede unfinished background-gated or legacy transfers under the existing episode work name. Repeated background admission and repeated ordinary admission keep the current request; background cancellation cannot remove its ordinary replacement. Enqueue decisions are serialized separately from the transfer lock. diff --git a/core/downloads/src/main/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinator.kt b/core/downloads/src/main/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinator.kt index 19fb730a0..7f9568ed5 100644 --- a/core/downloads/src/main/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinator.kt +++ b/core/downloads/src/main/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinator.kt @@ -13,7 +13,12 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -/** Publisher RSS + Room own release discovery. Push is only an early wake-up hint. */ +/** + * Publisher RSS + Room own release discovery. Push is only an early wake-up hint. + * + * A recovered address is saved through [saveFeedUrl], the app's one feed address writer (P4 PR 14 + * of the own-episode-IDs plan, plan section 11); the default writes the row directly (tests). + */ class AutoDownloadCoordinator( private val database: BoxLoreDatabase, private val catalog: LocalEpisodeCatalogPort, @@ -21,6 +26,7 @@ class AutoDownloadCoordinator( private val enqueue: suspend (podcastId: String, episodeId: String, wifiOnly: Boolean, background: Boolean) -> Unit, private val recoverFeedUrl: suspend (String) -> String? = { null }, private val nowSeconds: () -> Long = { System.currentTimeMillis() / 1000L }, + private val saveFeedUrl: suspend (podcastId: String, feedUrl: String) -> Unit = { id, url -> database.podcastDao().setFeedUrl(id, url) }, ) { private val gate = Mutex() private val ledger = database.autoDownloadDao() @@ -58,7 +64,7 @@ class AutoDownloadCoordinator( val feed = show.feedUrl?.takeIf { it.startsWith("https://") } ?: if (show.isRss) null else recoverMissingFeed(show.podcastId) var succeeded = feed != null if (feed != null) { - if (feed != show.feedUrl) database.podcastDao().setFeedUrl(show.podcastId, feed) + if (feed != show.feedUrl) saveFeedUrl(show.podcastId, feed) val outcome = catalog.refresh( LocalEpisodeCatalogPort.RefreshRequest( podcastIndexId = show.podcastId, @@ -131,6 +137,7 @@ class AutoDownloadCoordinator( catalog: LocalEpisodeCatalogPort, preferences: UserPreferencesRepository, recoverFeedUrl: suspend (String) -> String?, + saveFeedUrl: suspend (podcastId: String, feedUrl: String) -> Unit, ): AutoDownloadCoordinator = AutoDownloadCoordinator( database, catalog, @@ -142,6 +149,7 @@ class AutoDownloadCoordinator( } }, recoverFeedUrl = recoverFeedUrl, + saveFeedUrl = saveFeedUrl, ) } } diff --git a/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt b/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt index 01cbf194c..62cbfdebb 100644 --- a/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt +++ b/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt @@ -205,6 +205,26 @@ class AutoDownloadCoordinatorTest { assertEquals(1, callbacks) } + @Test fun aRecoveredAddressIsSavedThroughTheAppsAddressWriter() = runBlocking { + val row = database.podcastDao().getPodcast("100")!! + database.podcastDao().upsertRow(row.copy(feedUrl = null)) + val saved = mutableListOf>() + val coordinator = AutoDownloadCoordinator( + database, + catalog, + prefs, + enqueue = { _, episodeId, _, _ -> enqueued += episodeId }, + recoverFeedUrl = { URL }, + nowSeconds = { now }, + saveFeedUrl = { id, url -> saved += id to url }, + ) + + coordinator.discover { true } + + assertEquals(listOf("100" to URL), saved) + assertNull("the coordinator never writes the row itself", database.podcastDao().getPodcast("100")!!.feedUrl) + } + @Test fun pureRssUsesSameReleaseLedgerRetentionBoundAndNeverLoadsCatalogBaseline() = runBlocking { val id = "rss:private-show" val context = ApplicationProvider.getApplicationContext() diff --git a/core/rss/README.md b/core/rss/README.md index fbb791795..ed7630fb9 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -102,7 +102,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - **Test seam:** `RecipeIdHash` is the recipe new IDs are minted with; production always uses `RecipeIdHash.RECIPE`. Collision tests pass a hash that gives two keys the same ID. - **Older duplicate rows stay.** Older builds could store two rows for one GUID-less item (one per parser's audio address) or for a padded and a plain copy of one GUID. The item now updates one of them; the other stays unchanged and is no longer refreshed, with any listener state it holds (local catalogs, true RSS rows and supplements alike). Nothing joins such pairs yet. - **Supplement IDs are kept too.** `EpisodeSupplementRepository` replaces a show's supplement rows on every refresh. Feed-only items take the ID of the stored supplement row they match (`SupplementIdentity`, the same lookup as true RSS rows), and a stored row no item matched that holds one of an item's keys (an older build's second row for the same episode) is written back unchanged. Rows of episodes that left the feed or reached the Podcast Index list are still dropped. The tip `resolveNewestTipFromFeed` writes keeps its stored ID the same way. -- Feed read failures are typed: `FeedHttpException` (status code; message unchanged) and `NotAFeedException` (wrong content type or nothing parseable), both `IllegalArgumentException`s. `FeedReadFailure.isAddressGone` reads them, plus no such host and failing certificates, as a dead address (`RefreshOutcome.Failure.addressGone`); timeouts, 5xx, 429 and dropped connections are passing trouble. An address that answers with no episodes counts as dead too. +- Feed read failures are typed: `FeedHttpException` (status code; message unchanged) and `NotAFeedException` (wrong content type or nothing parseable), both `IllegalArgumentException`s. `FeedReadFailure.isAddressGone` reads them, plus no such host and failing certificates, as a dead address (`RefreshOutcome.Failure.addressGone`); timeouts, 5xx, 429 and dropped connections are passing trouble. An address that answers with no episodes counts as dead too. `FeedReadFailure.reportStatus` names what a failed read saw for `/v2/feed-report` (`RefreshOutcome.Failure.reportStatus`): `http_`, `dns`, `timeout`, `tls`, `parse`, `network` or `other`; an address with no episodes reports `parse`. - Moved feeds: `RssFetchResult.movedTo` is the final address when every redirect was permanent (301, 308; `permanentMoveTarget`); `ParsedRssFeed.newFeedUrl` is the channel's `itunes:new-feed-url` (both parsers). With `RefreshRequest.detectMove`, `confirmedMove` reports a new HTTPS address only when it differs from the saved one and serves the same show by title (case, spacing and punctuation ignored); a declared address is read once to confirm. Temporary redirects never move a feed. - Local catalog writes never replace another episode (`LocalEpisodeCollisionGuard`, [docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md) §4). The write is a REPLACE, which deletes any stored row sharing the episode ID or the unique (show, catalog key). Inside the refresh transaction, for each row: - **Its own stored row first.** When the show already stores the key, the row keeps that stored ID, so it is updated and never deleted through the unique index; an exact stored key wins over one that only matches after trimming. Stored keys are compared after the recipe trim, so a key that Room migration 31→32 left with a trailing newline, tab or no-break space keeps its ID and gets the trimmed key instead of a second row. @@ -116,7 +116,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ ## Testing notes - 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 (`FeedReadFailureTest`, `LocalEpisodeCatalogRepositoryTest.refreshFailureSaysWhetherTheSavedAddressLooksGone`), 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). +- 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. - 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/FeedReadFailure.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt index e0f868c14..34c9da9da 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt @@ -1,7 +1,10 @@ package cx.aswin.boxlore.core.rss +import java.io.IOException +import java.io.InterruptedIOException import java.net.UnknownHostException import java.security.cert.CertificateException +import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException @@ -32,6 +35,39 @@ internal object FeedReadFailure { return false } + /** + * What a failed read saw, as `/v2/feed-report` takes it: `http_`, `timeout`, `dns`, + * `tls`, `parse`, `network` (the connection failed or dropped) or `other`. The first cause that + * says something wins. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). + */ + fun reportStatus(error: Throwable): String { + var cause: Throwable? = error + var depth = 0 + while (cause != null && depth < MAX_CAUSE_DEPTH) { + statusOf(cause)?.let { return it } + cause = cause.cause + depth++ + } + return OTHER + } + + private fun statusOf(cause: Throwable): String? = when (cause) { + is FeedHttpException -> if (cause.code in HTTP_CODES) "http_${cause.code}" else OTHER + is UnknownHostException -> "dns" + is InterruptedIOException -> "timeout" + is SSLException, is CertificateException -> "tls" + is NotAFeedException -> PARSE + is IOException -> "network" + else -> null + } + + /** The `/v2/feed-report` status of a read whose answer held nothing readable as episodes. */ + const val PARSE = "parse" + + private const val OTHER = "other" + private val HTTP_CODES = 100..599 private const val MAX_CAUSE_DEPTH = 6 } diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt index 44ae61053..acac37478 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt @@ -112,7 +112,11 @@ private suspend fun fetchAndPersist( } catch (error: CancellationException) { throw error } catch (error: Exception) { - RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE, addressGone = FeedReadFailure.isAddressGone(error)) + RefreshOutcome.Failure( + LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE, + addressGone = FeedReadFailure.isAddressGone(error), + reportStatus = FeedReadFailure.reportStatus(error), + ) } private suspend fun persistParsed( @@ -138,7 +142,7 @@ private suspend fun persistParsed( val rows = planned.map { it.entity } if (rows.isEmpty()) { // The address answered, but with nothing we can read as episodes. - return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE, addressGone = true) + return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE, addressGone = true, reportStatus = FeedReadFailure.PARSE) } val copied = existing?.copiedExtrasCount ?: 0 val feedOrder = diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedReadFailureTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedReadFailureTest.kt index 24d3134d1..908616fbf 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedReadFailureTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedReadFailureTest.kt @@ -29,6 +29,19 @@ class FeedReadFailureTest { assertFalse(FeedReadFailure.isAddressGone(IllegalArgumentException("Feed is larger than 50 MB"))) } + @Test + fun `a failed read is reported in the words the feed-report route takes`() { + assertEquals("http_404", FeedReadFailure.reportStatus(FeedHttpException(404))) + assertEquals("http_503", FeedReadFailure.reportStatus(IllegalStateException("wrapped", FeedHttpException(503)))) + assertEquals("other", FeedReadFailure.reportStatus(FeedHttpException(700)), "never an out-of-range status") + assertEquals("dns", FeedReadFailure.reportStatus(UnknownHostException("feeds.example"))) + assertEquals("timeout", FeedReadFailure.reportStatus(SocketTimeoutException("slow"))) + assertEquals("tls", FeedReadFailure.reportStatus(SSLPeerUnverifiedException("hostname not verified"))) + assertEquals("parse", FeedReadFailure.reportStatus(NotAFeedException("not a feed"))) + assertEquals("network", FeedReadFailure.reportStatus(ConnectException("refused"))) + assertEquals("other", FeedReadFailure.reportStatus(IllegalArgumentException("Feed is larger than 50 MB"))) + } + @Test fun `typed feed errors keep their messages and IllegalArgumentException type`() { assertEquals("Feed returned HTTP 404", FeedHttpException(404).message) 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 bde918823..428a55701 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 @@ -158,6 +158,7 @@ class LocalEpisodeCatalogRepositoryTest { ) assertTrue(outcome is RefreshOutcome.Failure) assertTrue((outcome as RefreshOutcome.Failure).addressGone, "an address with no episodes is worth re-checking") + assertEquals("parse", outcome.reportStatus) } @Test @@ -199,6 +200,7 @@ class LocalEpisodeCatalogRepositoryTest { ).refresh(LocalEpisodeCatalogPort.RefreshRequest(podcastIndexId = "100", feedUrl = "https://feeds.example/show.xml")) } assertEquals(listOf(true, false, false), outcomes.map { (it as RefreshOutcome.Failure).addressGone }) + assertEquals(listOf("http_410", "http_503", "timeout"), outcomes.map { (it as RefreshOutcome.Failure).reportStatus }) } @Test From 867dc2adbd108a31aed5741eaf00c0700c3fbd3d Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 21:52:31 +0530 Subject: [PATCH 2/9] feat(info): read show pages from the feed --- app/README.md | 1 + .../java/cx/aswin/boxlore/AppContainer.kt | 24 +- core/catalog/README.md | 2 + .../core/catalog/FeedFirstShowPages.kt | 264 ++++++++++++++++ .../boxlore/core/catalog/PodcastRepository.kt | 121 +++++++- .../PodcastRepositoryNetworkLookups.kt | 16 - .../catalog/PodcastRepositoryShowPages.kt | 166 ++++++++++ .../boxlore/core/catalog/RolloutSwitches.kt | 5 +- .../core/catalog/FeedFirstShowPagesTest.kt | 216 +++++++++++++ .../PodcastRepositoryEpisodeSupplementTest.kt | 5 +- .../catalog/PodcastRepositoryShowPagesTest.kt | 290 ++++++++++++++++++ core/database/README.md | 2 + .../core/database/LocalEpisodeCatalogDao.kt | 74 +++++ .../core/database/LocalEpisodeFeedEntity.kt | 23 +- core/domain/README.md | 1 + .../core/domain/ports/ShowPageFeedPort.kt | 86 ++++++ core/downloads/README.md | 1 + .../downloads/AutoDownloadCoordinatorTest.kt | 28 ++ core/rss/README.md | 3 + .../boxlore/core/rss/FeedPrefixReader.kt | 107 +++++++ .../core/rss/LocalEpisodeCatalogReads.kt | 6 +- .../core/rss/LocalEpisodeCatalogRefresh.kt | 66 +++- .../core/rss/LocalEpisodeCatalogRepository.kt | 12 + .../aswin/boxlore/core/rss/RssFeedClient.kt | 35 +++ .../boxlore/core/rss/ShowPageFeedCatalog.kt | 174 +++++++++++ .../boxlore/core/rss/ShowPageFeedRules.kt | 69 +++++ .../boxlore/core/rss/FeedPrefixReaderTest.kt | 119 +++++++ .../rss/LocalEpisodeCatalogRepositoryTest.kt | 18 ++ .../core/rss/ShowPageFeedCatalogTest.kt | 181 +++++++++++ .../boxlore/core/rss/ShowPageFeedRulesTest.kt | 101 ++++++ feature/info/README.md | 4 + .../feature/info/PodcastInfoEpisodeList.kt | 13 + .../feature/info/PodcastInfoFeedFirst.kt | 130 ++++++++ .../info/PodcastInfoSupplementSupport.kt | 6 +- .../feature/info/PodcastInfoUiModels.kt | 27 ++ .../feature/info/PodcastInfoViewModel.kt | 131 +++++++- .../info/components/ShowFeedStatusLine.kt | 82 +++++ .../info/logic/PodcastInfoPullRefreshLogic.kt | 6 + .../feature/info/logic/ShowFeedLogic.kt | 84 +++++ feature/info/src/main/res/values/strings.xml | 4 + .../info/PodcastInfoAdaptiveTransitionTest.kt | 88 +++++- .../feature/info/logic/ShowFeedLogicTest.kt | 105 +++++++ 42 files changed, 2838 insertions(+), 58 deletions(-) create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt create mode 100644 core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt create mode 100644 core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedPrefixReader.kt create mode 100644 core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt create mode 100644 core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedRules.kt create mode 100644 core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedPrefixReaderTest.kt create mode 100644 core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt create mode 100644 core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedRulesTest.kt create mode 100644 feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt create mode 100644 feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt create mode 100644 feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt create mode 100644 feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt diff --git a/app/README.md b/app/README.md index 4275d5d92..8640e22a6 100644 --- a/app/README.md +++ b/app/README.md @@ -72,6 +72,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). - **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`. - **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, and `feedTelemetry`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs; `rss:` shows keep their own journal worker. +- **Feed-first show pages** (P4 PR 16): `AppContainer.podcastRepository` gets `FeedFirstWiring`: the stored copies (`localEpisodeCatalogRepository.showPages`), `connectivityStatus` for the offline rung, the broken-feed report stamps (`boxlore_feed_reports`, now one lazy instance shared with the daily address check so both stay inside one daily limit) and `feedTelemetry` for `feed_fetch_result`. The page reads the feed only while `feed_first` is on (`RolloutSwitches.feedFirst`, fixed per process). - NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 898148c55..591beb3bc 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -9,6 +9,7 @@ import cx.aswin.boxlore.core.catalog.EpisodeSharePreparer import cx.aswin.boxlore.core.catalog.EpisodeShareRegistrar import cx.aswin.boxlore.core.catalog.FeedAddressUpkeep import cx.aswin.boxlore.core.catalog.FeedAddressWiring +import cx.aswin.boxlore.core.catalog.FeedFirstWiring import cx.aswin.boxlore.core.catalog.FeedFixToggles import cx.aswin.boxlore.core.catalog.FeedMoveFollower import cx.aswin.boxlore.core.catalog.FolderRepository @@ -226,6 +227,14 @@ class AppContainer( rolloutSwitches = rolloutSwitches, episodeAliases = episodeAliasReads, discoveryEpisodeIds = discoveryEpisodeIds, + // Feed-first show pages (P4 PR 16): stored copies, the report limits and feed_fetch_result. + feedFirstWiring = + FeedFirstWiring( + copies = localEpisodeCatalogRepository.showPages, + isOnline = { connectivityStatus.isOnline() }, + reportStamps = feedReportStamps, + telemetry = feedTelemetry, + ), ) } @@ -424,15 +433,22 @@ class AppContainer( // Daily address check and broken-feed reports (P4 PR 14), behind address-check-off. addressUpkeep = FeedAddressUpkeep( - reportStamps = - SharedPreferencesFeedReportStamps( - appContext.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE), - ), + reportStamps = feedReportStamps, isOnline = connectivityStatus::isOnline, ), ) } + /** + * Broken-feed report times, shared by the daily address check and the show page so both stay + * inside one daily limit (P4 PR 14 and PR 16). + */ + private val feedReportStamps: SharedPreferencesFeedReportStamps by lazy { + SharedPreferencesFeedReportStamps( + appContext.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE), + ) + } + override val folderRepository: FolderRepository by lazy { RoomFolderRepository( folderDao = database.folderDao(), diff --git a/core/catalog/README.md b/core/catalog/README.md index 6eb021325..c3fd6baf2 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -19,6 +19,7 @@ Failed consent writes keep activation off, and local notification flags prevent - RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. - `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. Followed shows' catalog refreshes never read a Podcast Index list: new episodes take recipe IDs in `:core:rss`. `loadPiEpisodesForSupplement` is the Podcast Index list feed-only extras (`EpisodeSupplementPort`) are compared with: up to 1,000 newest episodes from `episodes/match` (match fields only), falling back to the oldest page (at most 100) when that route fails, returns nothing, or its `useEpisodeMatchBaseline` off switch is set. It is strict, so a failure of both stops the extras refresh instead of comparing the feed with an empty list. Its callers are the show page's "Missing episodes?" refresh, the legacy subscription sync and push paths without a local catalog, and the restore of extras opt-ins; they stay until show pages read feeds for every listener. Full pages keep the 100 cap. `getEpisode` resolves an ID in `EpisodeLinkResolver`'s order without reading a feed: a stored row (local catalog, then the feed-only rows for a negative ID), then the phone's row of the episode the ID is a trusted alias of, then the network. It sends a revcipe-ry canonical positivge ID (`[2^52, 2^53 − 1]`) that no local row or alias holds to `/v2/episode` (with the show when a caller passes `getEpisode(episodeId, showId)` and the show is below 2^52): a recipe-range ID (`[2^52, 2^53 − 1]`)d never reachesto the v1 Podcast Index lookup, and an old Podcast Index ID falls back; to v1 `/episode` only when the v2 route can't answer; other legacy IDs keep v1. The episode keeps the ID asked for and carries the recipe ID and catalog key. A `needsFeed` answer, a 400/404/410 or an error is not found. Podcast Index IDs keep v1 `/episode`. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). `PodcastRepository` also owns the `/v2` client (`apiV2`, a `BoxLoreApiV2` built on first use): same base URL and the same cached OkHttp client as `api`, so the process keeps one HTTP cache. Tests can pass `boxLoreApiV2` the same way they pass `boxLoreApi`. `getEpisode` and the discovery routes below use its only caller so far. In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). +- **Show page episode source (P4 PR 16).** `PodcastRepository.showPageSource(feedId)` is `RSS` (`rss:` shows), `FOLLOWED_CATALOG` (a ready followed catalog), `FEED` (`RolloutSwitches.feedFirst` on, a Podcast Index show ID, and its feed not found unreadable on this page) or `SERVER_LIST`. `getEpisodesPaginated` pages a `FEED` show from its stored copy (`FeedFirstWiring.copies`, a `ShowPageFeedPort`): more pages follow while the copy has rows or isn't the whole archive. Otherwise it reads `/v2/episodes`, its recipe IDs mapped to the phone's owners (`DiscoveryEpisodeIds.toLocalOwners`, so a rollback of the flag shows the same IDs), and v1 `/episodes` only when `/v2/episodes` fails (decision D1). `FeedFirstShowPages` (`openFeedShowPage`, `readShowPageFeed`) reads the feed into the copy, `NEWEST` or `ARCHIVE` deep, down the failure ladder: offline shows the copy with no read and no report (`OFFLINE`); the address is the registry's (`/v2/podcast`), else the saved one, and an http-only feed goes through `/v2/feed/`; a failed read is tried once more (not after a 404 or 410), then reported through `FeedReporter` (limits shared with the daily address check), and a different HTTPS address in the answer is read; then the page uses the server's list with a notice (`FALLBACK`) until a read works. One `feed_fetch_result` per read (source `feed` or `transport`, `partial` or `complete`). Once a day per process it removes unfollowed copies not opened for 30 days, under a size cap (decision D10). In-show search always runs on the phone: the stored copy (bounded to 200), else the newest 1,000 of `/v2/episodes` matched by title or description; v1 `episodes/search` is never called (decision D16). `invalidateEpisodesCache` also drops pages cached under the Podcast Index ID a `url:`, `guid:` or `itunes:` ID resolved to. - Episode links, pushes and Android Auto items that name an episode by ID and maybe its show resolve through `resolveEpisodeLink(episodeId, showId, onReadingFeed)` (`EpisodeLinkResolver`, `PodcastRepositoryEpisodeLinks.kt`). Order: a stored row; a trusted alias of the ID, or for a recipe ID with its show the one episode of that show whose trusted aliases carry it, answered with the phone's own row and ID; the API (an answer for an episode stored under another trusted ID or the same key is translated to the phone's row; a v1 answer, which has neither, by its enclosure address); and, for a `needsFeed` answer, one read of the show's feed through the existing reader (`RssEpisodeFeedReader`: the publisher's HTTPS address with `RssFeedClient`, an http-only address only through `/v2/feed/`), matched by recipe ID in the show's stored namespace. Only items whose key follows the contract match; a feed listing the same item twice gives its first copy in document order, and a recipe ID two different keys share matches nothing. Results are `EpisodeLinkResult.Found`, `NotFound` (nothing lists it: 400, 404 or 410) or `Unavailable` (a source couldn't be reached, including 401, 403, 408 and 429). `storedEpisodeForKey(show, key)` finds a stored row by catalog key (the local catalog by GUID or enclosure, then the show's feed-only rows by enclosure); alias, push and link lookups use it. Ambiguous and unresolved aliases never resolve. `EpisodeAliasReads` / `RoomEpisodeAliasReads` are the alias reads (`trustedKeyOf`, `trustedKeyForRecipe`, `namespaceOf`); `PodcastRepository(episodeAliases = …)` takes them (none by default, as in older tests) and `feedReader` is the test seam for the feed read. `localEpisodeForRecipe(show, recipeId)` is the push match by (show, recipe ID). - Episode share links: `EpisodeSharePreparer` (an `EpisodeShareTargetSource`) names the episode by its trusted alias's recipe ID and Podcast Index show (also for an RSS show linked to that show), else by `EpisodeShareIds.choose` on the episode's fields; phone-only negative or reserved IDs are never shared: an episode held only under one is shared by the recipe ID of its catalog key in the show's namespace, else as the show's link, and `rss:` shows keep today's link. With a known catalog key it registers the episode first through `EpisodeShareRegistrar.registerOnce` (`POST /v2/episodes/register`, `{show, guid, enclosureUrl}` with the audio address so the API can match a GUID that differs from Podcast Index's, or `{show, enclosureUrl}` when the key is the audio address), waiting at most `DEFAULT_TIMEOUT_MS` (2 s); the link goes out whatever the answer, and a timeout, offline, 401, 429 or server error hands the request to `scheduleRetry` (the app's background worker). 400/404 are dropped. - `syncSubscriptions` (`POST /sync`) is PI-only. `SubscriptionForegroundSync` sends it the shows that are **not** catalog-ready and have no saved HTTPS feed address, plus the shows whose own feed failed this pass. The PI ids go out in chunks of 20 (`PI_SYNC_CHUNK_SIZE`), at most two calls at a time (`PI_SYNC_PARALLELISM`), because the API serves only the first 40 ids of a call: whole-library callers (Smart Downloads, backup restore) used to lose every show past the 40th. A failed chunk drops only its own shows' tips. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). @@ -188,6 +189,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - `ShowNotesParserTest`, `CrossPromotionDetectorTest`, and `CrossPromotionResolverTest` cover the Blood Trails announcement, artwork-corroborated presenters, own-series/premium/topic/event/mix refusals, new-show and friends-at naming, colon, host and parenthetical title matches, shorter-name search, malformed/relative anchors, platform-label/handle separation, reserved profile routes, destination purpose and spoof domains, URL deduplication, safe timestamp linking, chapter boundaries, weekday-scheduled full-length introductions, false promotions and shorter-name prefixes, full-title matching, ambiguous/host matches, verified unnamed Apple links, retry, cancellation, and cache expiry. - `RoomFolderRepositoryTest` covers linking shows to a deleted folder. - Cloud sync identity (Robolectric, in-memory Room): `SyncEpisodeIdentityTest` covers which identity a pushed row and a rich removal carry (trusted, unresolved, ambiguous and linked RSS rows; a recipe ID two keys share; a recipe ID whose local ID another show's alias or catalog row holds; a served ID that isn't its recipe ID) and which aliases a pull records (unknown rows, matches by key and by a unique recipe ID, an unknown key winning over a recipe ID, settling an unresolved alias, rows left alone) and the episode tokens. `CloudSyncIdentityTest` runs `UserSyncCoordinator` against `FakeSyncServer`, which applies the sync API's rules (recipe fields kept when omitted, a recipe-range ID naming another show or key refused while the push answers ok, rich removals expanded and stored only when sent): the push and pull fields, a Podcast Index row from an older build plus a recipe owner read as one episode with the owner unchanged, an unknown row owning nothing, a row whose recipe ID names another episode here pushed without it so another device's removal of that episode by recipe ID can't reach its queue row, a served ID stored without a key, a pulled row of the playing episode (stored without touching the owner row; identified before the merge so a stored row keeps its progress), a large backlog pushed one bounded batch per sync, newest first, and the version-3 catch-up: a pull from the start first, then the cloud's own copies with the identity added (the newer progress and its own time, a completion the cloud has at the same last play kept, nothing new in another device's delta pull), every batch in the same sync, a run cut short by a failed push marking no row and finishing on the next sync without overwriting newer progress, a row removed here before the update staying removed, nothing again on the next sync, and an account switch purging the rich removals and catching up the new account. It also covers queue removals across IDs: removing an episode queued here under its RSS ID removes the copy an older build queued under the Podcast Index ID, a removal reaches the cloud copy the merge folded into the phone's item and it never comes back, and a stale rich removal never reaching an episode another device queued again. `QueueSyncIdentityTest` covers a removal under a Podcast Index ID removing the recipe ID's item, this phone's removal dropping the pulled item under another ID, the united plain and rich lists, a stale rich removal, the server's rich list replacing the phone's older one on a clean pull, a removal made stale by the identity a pulled item carries, a removal its own side still queues, one item per episode, the playing ID, and hydration from another ID's catalog row and from `/v2/episode` (MockWebServer). `SyncEpisodeIdentityTest` also covers the catch-up builder: only copies lacking a known identity, only the missing field, another device's key or recipe ID never overwritten. +- Feed-first show pages: `FeedFirstShowPagesTest` (the failure ladder against a scripted copy: offline, one retry but none after a 404 or 410, the report and the reported address, the fallback and the way back, the registry or saved address and the transport, one event per read, opening state, the daily cleanup) and `PodcastRepositoryShowPagesTest` (Robolectric Room, MockWebServer and a real feed parser: the router per state, `/v2/episodes` under local owners and v1 only on failure, paging that stays stable while the copy grows from 100 to 150 episodes, the fallback switching the source, search on the phone that never calls v1 `episodes/search`, a flag rollback that changes no stored ID or alias, and dropping the pages of a resolved `url:` show). ## CI relevance diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt new file mode 100644 index 000000000..5c5b7bbe2 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt @@ -0,0 +1,264 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadOutcome +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadRequest +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.FeedFetchCompleteness +import cx.aswin.boxlore.core.model.FeedFetchOutcome +import cx.aswin.boxlore.core.model.FeedFetchReport +import cx.aswin.boxlore.core.model.FeedFetchSource +import cx.aswin.boxlore.core.network.model.V2EpisodeIds +import cx.aswin.boxlore.core.network.model.V2FeedReadTarget +import cx.aswin.boxlore.core.network.model.V2PodcastResponse +import cx.aswin.boxlore.core.rss.ShowPageFeedRules +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellationException + +/** Where a show page's episodes come from. */ +enum class ShowPageSource { + /** A show added by its feed address: the RSS table. */ + RSS, + + /** A followed show whose stored catalog is complete: Room only. */ + FOLLOWED_CATALOG, + + /** The show's own feed, through the phone's stored copy (`feed_first` on). */ + FEED, + + /** The server's list of the show's episodes (`/v2/episodes`). */ + SERVER_LIST, +} + +/** + * What a feed-first show page holds when it opens: whether a stored copy exists and is the whole + * archive, whether it is old enough to read again ([ShowPageFeedRules.RECHECK_AFTER_MS]), and + * whether the feed lists its oldest items first. + */ +data class ShowPageFeedState( + val cached: Boolean, + val archiveComplete: Boolean, + val recheckDue: Boolean, + val oldestFirst: Boolean, +) + +/** How a show page's feed read ended. */ +enum class ShowPageFeedResult { + /** The feed was read and stored. */ + UPDATED, + + /** The feed answered that the stored copy is current. */ + UNCHANGED, + + /** The phone is offline: the stored copy is shown as it is. */ + OFFLINE, + + /** The feed couldn't be read after a retry and a report: the page shows the server's list with a notice. */ + FALLBACK, +} + +/** [result] of a read, and whether the stored copy now holds the whole archive. */ +data class ShowPageFeedRead(val result: ShowPageFeedResult, val archiveComplete: Boolean) + +/** + * The feed-first show page of a Podcast Index show: it reads the show's own feed into the phone's + * stored copy ([ShowPageFeedPort]) and decides when the page falls back to the server's list. + * + * The failure ladder of one read ([read]): + * 1. Offline: nothing is read and nothing is reported; the stored copy is shown as it is. + * 2. The address comes from `/v2/podcast` (the registry), else the saved one. An http-only address + * is read through boxlore's transport (`/v2/feed/`). + * 3. A failed read is tried once more, except after a 404 or 410. + * 4. Then the failure is reported (`/v2/feed-report`, rate-limited, never offline); when the answer + * names a different HTTPS address, that one is read. + * 5. Then the page uses `/v2/episodes` with a notice ([ShowPageFeedResult.FALLBACK]) until a later + * read of the feed works. + * + * Each read sends one `feed_fetch_result` (decision D11). Once a day it also removes the stored + * copies of unfollowed shows not opened for 30 days, under a size cap (decision D10). + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). + */ +@Suppress("LongParameterList", "kotlin:S107") // Each dependency is one rung of the ladder or a test seam. +class FeedFirstShowPages( + private val copies: ShowPageFeedPort?, + private val registry: suspend (Long) -> V2PodcastResponse?, + private val transport: suspend (Long) -> ShowPageFeedPort.Document?, + private val reporter: FeedReporter?, + private val isOnline: () -> Boolean, + private val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private val fallbackShows = ConcurrentHashMap.newKeySet() + private val metas = ConcurrentHashMap() + + @Volatile + private var lastCleanUpAt: Long? = null + + /** True while [podcastId]'s page uses the server's list because its feed couldn't be read. */ + fun isInFallback(podcastId: String): Boolean = podcastId in fallbackShows + + /** The show details last given to [open] or [read], for episodes listed from the stored copy. */ + fun metaFor(podcastId: String): PodcastMeta? = metas[podcastId] + + suspend fun cached(podcastId: String): ShowPageFeedPort.CachedShow? = safely(null) { copies?.cached(podcastId) } + + /** + * What the page holds as it opens; notes the opening and runs the daily cleanup. A page that + * opens tries the feed again, even after an earlier page fell back to the server's list. + */ + suspend fun open(podcastId: String, meta: PodcastMeta): ShowPageFeedState { + remember(podcastId, meta) + fallbackShows.remove(podcastId) + val cached = cached(podcastId) + val now = nowMillis() + if (cached != null) safely(Unit) { copies?.markOpened(podcastId, now) } + cleanUpDaily(now, podcastId) + return ShowPageFeedState( + cached = cached != null, + archiveComplete = cached?.archiveComplete == true, + recheckDue = cached == null || now - cached.fetchedAt !in 0 until ShowPageFeedRules.RECHECK_AFTER_MS, + oldestFirst = cached?.oldestFirst == true, + ) + } + + /** One read of the show's feed, [scope] deep, down the failure ladder. [savedFeedUrl]: the address the phone has, if any. */ + suspend fun read(podcastId: String, meta: PodcastMeta, scope: Scope, savedFeedUrl: String?): ShowPageFeedRead { + remember(podcastId, meta) + val showId = V2EpisodeIds.parseShowId(podcastId) + if (copies == null || showId == null) return finish(podcastId, ShowPageFeedResult.FALLBACK) + if (!isOnline()) return finish(podcastId, ShowPageFeedResult.OFFLINE) + val answer = safely(null) { registry(showId) } + val target = + answer?.readTarget(showId) + ?: savedFeedUrl?.takeIf(String::isNotBlank)?.let(V2FeedReadTarget::Direct) + ?: return finish(podcastId, ShowPageFeedResult.FALLBACK) + val request = ReadRequest(podcastId, "", scope, meta, answer?.validEpNs) + val attempt = afterReport(podcastId, request, withRetry(request, target), savedFeedUrl) ?: return finish(podcastId, ShowPageFeedResult.OFFLINE) + report(podcastId, attempt) + return when (attempt.outcome) { + is ReadOutcome.Failed -> finish(podcastId, ShowPageFeedResult.FALLBACK) + is ReadOutcome.Stored -> finish(podcastId, ShowPageFeedResult.UPDATED) + ReadOutcome.NotModified -> finish(podcastId, ShowPageFeedResult.UNCHANGED) + } + } + + /** The read, and once more when it failed in a way a second try may not (not a 404 or 410). */ + private suspend fun withRetry(request: ReadRequest, target: V2FeedReadTarget): Attempt { + val first = Attempt(target, readOnce(request, target)) + return if (first.outcome is ReadOutcome.Failed && first.retryable) first.copy(outcome = readOnce(request, target)) else first + } + + /** + * After a failed read, the report and a read of the address it names, when it names a + * different one. Null when the phone went offline meanwhile: nothing is reported then. + */ + private suspend fun afterReport(podcastId: String, request: ReadRequest, attempt: Attempt, savedFeedUrl: String?): Attempt? { + val failed = attempt.outcome as? ReadOutcome.Failed ?: return attempt + if (!isOnline()) return null + val used = (attempt.target as? V2FeedReadTarget.Direct)?.url ?: savedFeedUrl + val reported = safely(null) { reporter?.report(podcastId, failed.reportStatus, used) } ?: return attempt + val next = V2FeedReadTarget.Direct(reported) + return Attempt(next, readOnce(request, next)) + } + + private data class Attempt(val target: V2FeedReadTarget, val outcome: ReadOutcome) { + /** A 404 or 410 says the address is gone: a second try would see the same. */ + val retryable: Boolean get() = (outcome as? ReadOutcome.Failed)?.reportStatus !in GONE_STATUSES + } + + private suspend fun readOnce(request: ReadRequest, target: V2FeedReadTarget): ReadOutcome { + val port = copies ?: return ReadOutcome.Failed(OTHER) + return safely(ReadOutcome.Failed(OTHER)) { + when (target) { + is V2FeedReadTarget.Direct -> port.read(request.copy(feedUrl = target.url)) + is V2FeedReadTarget.ViaApi -> { + val document = transport(target.showId) ?: return@safely ReadOutcome.Failed(OTHER) + port.store(request.copy(feedUrl = document.finalUrl), document) + } + } + } + } + + /** + * The read's result, with whether the copy is now the whole archive. A fallback puts the page on + * the server's list; a read that worked takes it back to the feed; offline changes neither. + */ + private suspend fun finish(podcastId: String, result: ShowPageFeedResult): ShowPageFeedRead { + when (result) { + ShowPageFeedResult.FALLBACK -> fallbackShows.add(podcastId) + ShowPageFeedResult.UPDATED, ShowPageFeedResult.UNCHANGED -> fallbackShows.remove(podcastId) + ShowPageFeedResult.OFFLINE -> Unit + } + return ShowPageFeedRead(result, archiveComplete(podcastId)) + } + + private suspend fun archiveComplete(podcastId: String): Boolean = cached(podcastId)?.archiveComplete == true + + private fun remember(podcastId: String, meta: PodcastMeta) { + if (meta.title != null || meta.imageUrl != null) metas[podcastId] = meta + } + + private suspend fun cleanUpDaily(now: Long, keep: String) { + val last = lastCleanUpAt + if (last != null && now - last in 0 until CLEANUP_INTERVAL_MS) return + lastCleanUpAt = now + safely(0) { copies?.cleanUp(now, keep) ?: 0 } + } + + /** The one `feed_fetch_result` of this read (decision D11). Never carries an address. */ + private fun report(podcastId: String, attempt: Attempt) { + try { + telemetry.feedFetched(showPageFetchReport(podcastId, attempt.target, attempt.outcome)) + } catch (error: IllegalStateException) { + android.util.Log.w(TAG, "feed_fetch_result not sent", error) + } + } + + companion object { + private const val OTHER = "other" + private val GONE_STATUSES = setOf("http_404", "http_410") + private const val CLEANUP_INTERVAL_MS = 24L * 60L * 60L * 1000L + + /** The `feed_fetch_result` outcome of a failed read, from its `/v2/feed-report` status. */ + fun failureOutcome(reportStatus: String): FeedFetchOutcome = when { + reportStatus in GONE_STATUSES -> FeedFetchOutcome.GONE + reportStatus.startsWith(HTTP_PREFIX) -> FeedFetchOutcome.HTTP_ERROR + reportStatus == "timeout" -> FeedFetchOutcome.TIMEOUT + reportStatus == "parse" -> FeedFetchOutcome.PARSE_ERROR + else -> FeedFetchOutcome.NETWORK_ERROR + } + } +} + +private const val TAG = "FeedFirstShowPages" +private const val HTTP_PREFIX = "http_" + +/** The `feed_fetch_result` of one show-page read of [target] that ended in [outcome]. */ +internal fun showPageFetchReport(podcastId: String, target: V2FeedReadTarget, outcome: ReadOutcome): FeedFetchReport.ShowPage { + val source = if (target is V2FeedReadTarget.ViaApi) FeedFetchSource.TRANSPORT else FeedFetchSource.FEED + return when (outcome) { + is ReadOutcome.Stored -> { + val completeness = if (outcome.complete) FeedFetchCompleteness.COMPLETE else FeedFetchCompleteness.PARTIAL + FeedFetchReport.ShowPage(podcastId, source, FeedFetchOutcome.OK, completeness) + } + ReadOutcome.NotModified -> FeedFetchReport.ShowPage(podcastId, source, FeedFetchOutcome.NOT_MODIFIED) + is ReadOutcome.Failed -> { + val status = outcome.reportStatus.takeIf { it.startsWith(HTTP_PREFIX) }?.removePrefix(HTTP_PREFIX)?.toIntOrNull() + FeedFetchReport.ShowPage(podcastId, source, FeedFirstShowPages.failureOutcome(outcome.reportStatus), httpStatus = status) + } + } +} + +/** [block]'s answer, or [fallback] when it fails; cancellation propagates. */ +private suspend fun safely(fallback: T, block: suspend () -> T): T = try { + block() +} catch (error: CancellationException) { + throw error +} catch (error: Exception) { + android.util.Log.w(TAG, "Show page feed step failed", error) + fallback +} 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 66c36e9d2..efba0b3f1 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 @@ -60,8 +60,8 @@ class PodcastRepository( /** * The v2 build's rollout switches (feed-first show pages, v2 discovery, address check, upgrade * repair), from the app's PostHog flags. Added in P4 (plan section 11, Rollback). Discovery - * reads [RolloutSwitches.v2Discovery] per request ([usesV2Discovery]); the other code paths - * that read them land later. + * reads [RolloutSwitches.v2Discovery] per request ([usesV2Discovery]) and + * [RolloutSwitches.feedFirst] for the show-page source ([showPageSource], P4 PR 16). */ val rolloutSwitches: RolloutSwitches = RolloutSwitches(), /** Trusted alias reads for links, pushes, shares and Android Auto (P4 PR 13); none in older tests. */ @@ -74,6 +74,11 @@ class PodcastRepository( * Added in P4 PR 12 (own-IDs plan section 9, v2 discovery routes). */ internal val discoveryEpisodeIds: DiscoveryEpisodeIds = DiscoveryEpisodeIds.Unmapped, + /** + * The feed-first show page's stored copies, connectivity, report limits and telemetry. The + * default stores nothing, so show pages read the server's list (P4 PR 16, plan section 11). + */ + feedFirstWiring: FeedFirstWiring = FeedFirstWiring(), ) : cx.aswin.boxlore.core.domain.ports.PodcastCatalogPort { internal val episodeFeedReader: EpisodeFeedReader by lazy { feedReader ?: RssEpisodeFeedReader(cx.aswin.boxlore.core.rss.RssFeedClient()) { apiV2 } } @@ -88,6 +93,27 @@ class PodcastRepository( */ val apiV2: BoxLoreApiV2 by lazy { boxLoreApiV2 ?: NetworkModule.createBoxLoreApiV2(baseUrl, cachedClient) } + /** + * The feed-first show page: reads a show's own feed into the phone's stored copy, with its + * failure ladder (P4 PR 16, own-IDs plan section 11). + */ + val feedFirst: FeedFirstShowPages by lazy { + FeedFirstShowPages( + copies = feedFirstWiring.copies, + registry = { showId -> v2ShowAddress(showId) }, + transport = { showId -> v2TransportDocument(showId) }, + reporter = + FeedReporter( + send = { request -> FeedReporter.sendWith(apiV2, request) }, + limiter = FeedReportLimiter(feedFirstWiring.reportStamps), + isOnline = feedFirstWiring.isOnline, + enabled = rolloutSwitches.addressCheck, + ), + isOnline = feedFirstWiring.isOnline, + telemetry = feedFirstWiring.telemetry, + ) + } + /** Last good `/v2` discovery answers of this process (P4 PR 12, plan section 9). */ internal val v2LastGood = V2DiscoveryLastGood() @@ -279,6 +305,12 @@ class PodcastRepository( } } + /** + * In-show search, always on the phone: the RSS table, a followed show's catalog, the feed-first + * stored copy (the show page completes its archive first), or else the server's list of the + * show (`/v2/episodes`). Never v1 `episodes/search`, whose answers carry served IDs that would + * mix with recipe IDs (P4 PR 16, decision D16). + */ suspend fun searchEpisodes(feedId: String, query: String,): List = withContext(Dispatchers.IO) { if (feedId.startsWith("rss:")) { return@withContext searchRssEpisodes(feedId, query) @@ -286,7 +318,55 @@ class PodcastRepository( if (isLocalCatalogReady(feedId)) { return@withContext searchLocalCatalog(feedId, query) } - searchNetworkEpisodes(feedId, query) + if (showPageSource(feedId) == ShowPageSource.FEED) { + searchFeedCopy(feedId, query)?.let { return@withContext it } + } + searchServerList(feedId, query) + } + + /** + * Where [feedId]'s show page reads its episodes: the RSS table, a followed show's complete + * catalog, the show's own feed (`feed_first` on, a Podcast Index show whose feed was not found + * unreadable on this page), or the server's list. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page; decision D1). + */ + suspend fun showPageSource(feedId: String): ShowPageSource = withContext(Dispatchers.IO) { + when { + feedId.startsWith("rss:") -> ShowPageSource.RSS + isLocalCatalogReady(feedId) -> ShowPageSource.FOLLOWED_CATALOG + isFeedFirstShow(feedId) && !feedFirst.isInFallback(feedId) -> ShowPageSource.FEED + else -> ShowPageSource.SERVER_LIST + } + } + + /** + * What the feed-first show page of [feedId] holds as it opens ([FeedFirstShowPages.open]), or + * null when the page reads another source (`rss:` shows, a followed show's ready catalog, + * `feed_first` off). A page that opens tries the feed again after an earlier fallback. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ + suspend fun openFeedShowPage(feedId: String, meta: cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta,): ShowPageFeedState? = + withContext(Dispatchers.IO) { + if (feedId.startsWith("rss:") || !isFeedFirstShow(feedId) || isLocalCatalogReady(feedId)) return@withContext null + feedFirst.open(feedId, meta) + } + + /** + * Reads [feedId]'s feed into its stored copy, [scope] deep, down the failure ladder + * ([FeedFirstShowPages.read]), then drops cached pages of the show so the next page reads the + * new copy, or the server's list after a fallback. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ + suspend fun readShowPageFeed( + feedId: String, + meta: cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta, + scope: cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope, + savedFeedUrl: String?, + ): ShowPageFeedRead = withContext(Dispatchers.IO) { + feedFirst.read(feedId, meta, scope, savedFeedUrl).also { invalidateEpisodesCache(feedId) } } // Opened on first use: the migration reads the prefs file from disk, and this repository is @@ -337,13 +417,20 @@ class PodcastRepository( if (isLocalCatalogReady(feedId)) { return@withContext getLocalCatalogPage(feedId, safeLimit, offset, sort) } - getNetworkEpisodesPaginated(feedId, safeLimit, offset, sort, mergeSupplements) + if (showPageSource(feedId) == ShowPageSource.FEED) { + feedCopyPage(feedId, safeLimit, offset, sort)?.let { return@withContext it } + } + getServerListPage(feedId, safeLimit, offset, sort, mergeSupplements) } - /** Drop cached PI episode pages for [feedId] so the next read hits the network. */ + /** + * Drop cached episode pages for [feedId] so the next read hits the network. Pages are cached + * under the Podcast Index ID a `url:`, `guid:` or `itunes:` ID resolved to, so that ID's pages + * go too (before P4 PR 16 only pages under [feedId] itself were dropped). + */ fun invalidateEpisodesCache(feedId: String) { - val prefix = "$feedId|" - episodesCache.invalidateIf { it.startsWith(prefix) } + val prefixes = setOfNotNull(feedId, resolvedFeedIds[feedId]).map { "$it|" } + episodesCache.invalidateIf { key -> prefixes.any(key::startsWith) } } /** @@ -389,7 +476,13 @@ class PodcastRepository( EpisodePage(emptyList(), false) } - private suspend fun getNetworkEpisodesPaginated( + /** + * v1 `/episodes` pages of a Podcast Index show, merged with its cached feed extras. + * + * LEGACY(own-ids): the last rung of the show page's list, used only when `/v2/episodes` fails. + * Remove at sunset step S7 (MIGRATION.md §6) once `feed_first` has been at 100% for 30 days. + */ + internal suspend fun getNetworkEpisodesPaginated( feedId: String, limit: Int, offset: Int, @@ -397,6 +490,7 @@ class PodcastRepository( mergeSupplements: Boolean, ): EpisodePage { val resolvedId = resolvePodcastIndexFeedId(feedId) + rememberResolvedFeedId(feedId, resolvedId) val cacheKey = "$resolvedId|$limit|$offset|$sort" val cached = episodesCache[cacheKey] if (cached != null) { @@ -438,7 +532,7 @@ class PodcastRepository( } } - private suspend fun maybeMergeSupplements( + internal suspend fun maybeMergeSupplements( podcastId: String, page: EpisodePage, offset: Int, @@ -948,7 +1042,14 @@ class PodcastRepository( // Catalog caches are endpoint-versioned so an otherwise-valid v1/v2 response cannot // suppress the v3 fetch and then invalidate grouped v3 section responses. - private val episodesCache = TimedLruCache(maxEntries = 30, ttlMillis = DEFAULT_EPISODE_CACHE_TTL_MS) + internal val episodesCache = TimedLruCache(maxEntries = 30, ttlMillis = DEFAULT_EPISODE_CACHE_TTL_MS) + + /** The Podcast Index ID each `url:`, `guid:` or `itunes:` show ID resolved to, for [invalidateEpisodesCache]. */ + internal val resolvedFeedIds = java.util.concurrent.ConcurrentHashMap() + + internal fun rememberResolvedFeedId(feedId: String, resolvedId: String) { + if (feedId != resolvedId) resolvedFeedIds[feedId] = resolvedId + } internal val recommendationsCache = TimedLruCache>(maxEntries = 20, ttlMillis = DEFAULT_RECOMMENDATIONS_CACHE_TTL_MS) internal val becauseYouLikeCache = TimedLruCache(maxEntries = 20, ttlMillis = DEFAULT_RECOMMENDATIONS_CACHE_TTL_MS) } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt index 9635dd15a..c9e94a06e 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt @@ -14,22 +14,6 @@ internal suspend fun PodcastRepository.searchRssEpisodes(feedId: String, query: emptyList() } -internal suspend fun PodcastRepository.searchNetworkEpisodes(feedId: String, query: String,): List = try { - val resolvedId = resolvePodcastIndexFeedId(feedId) - val response = api.searchEpisodes(publicKey, resolvedId, query).execute() - val network = - if (response.isSuccessful && response.body() != null) { - response.body()!!.items.mapNotNull { mapToEpisode(it) } - } else { - emptyList() - } - unionCachedSupplementSearch(resolvedId, query, network) -} catch (e: kotlinx.coroutines.CancellationException) { - throw e -} catch (e: Exception) { - emptyList() -} - internal suspend fun PodcastRepository.getEpisodesImpl(feedId: String): List = withContext(Dispatchers.IO) { if (feedId.startsWith("rss:")) { return@withContext getRssEpisodeWindow(feedId) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt new file mode 100644 index 000000000..2452eac04 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt @@ -0,0 +1,166 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.network.model.V2EpisodeIds +import cx.aswin.boxlore.core.network.model.V2EpisodesPaging +import cx.aswin.boxlore.core.network.model.V2PodcastResponse +import java.util.Locale +import kotlinx.coroutines.CancellationException +import retrofit2.Response +import retrofit2.awaitResponse + +/** + * What [PodcastRepository]'s feed-first show page needs from the app: the stored copies + * ([ShowPageFeedPort]; null stores nothing), whether the phone is online, the broken-feed report + * limits shared with the daily address check, and `feed_fetch_result` reporting. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ +data class FeedFirstWiring( + val copies: ShowPageFeedPort? = null, + val isOnline: () -> Boolean = { true }, + val reportStamps: FeedReportStamps = MemoryFeedReportStamps(), + val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, +) + +/** In-show search answers at most this many episodes. */ +internal const val MAX_SHOW_SEARCH_RESULTS = 200 + +private const val SHOW_PAGES_TAG = "PodcastRepository" + +/** + * True when [feedId]'s show page reads its feed first: `feed_first` is on and [feedId] is a Podcast + * Index show ID (not `rss:`, nor a `url:`, `guid:` or `itunes:` lookup that hasn't resolved yet). + * + * LEGACY(own-ids): the staged feed-first rollout. Remove the switch at sunset step S7 (MIGRATION.md §6) + * once `feed_first` has been at 100% for 30 days; the show-ID check stays. + */ +internal fun PodcastRepository.isFeedFirstShow(feedId: String): Boolean = V2EpisodeIds.parseShowId(feedId) != null && rolloutSwitches.feedFirst() + +/** + * A page of [feedId]'s stored feed copy, or null when the phone has none yet. More pages follow + * while the copy has more rows, and while it is not the whole archive: the show page then reads + * the rest of the feed before it pages on. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ +internal suspend fun PodcastRepository.feedCopyPage(feedId: String, limit: Int, offset: Int, sort: String,): PodcastRepository.EpisodePage? { + val cached = feedFirst.cached(feedId) ?: return null + val catalog = localEpisodeCatalog ?: return null + val episodes = catalog.getPage(feedId, limit, offset, sort, feedFirst.metaFor(feedId) ?: localCatalogMeta(feedId)) + return PodcastRepository.EpisodePage( + episodes = episodes, + hasMore = offset + episodes.size < cached.itemCount || !cached.archiveComplete, + sourceCount = episodes.size, + ) +} + +/** + * Bounded search over [feedId]'s stored feed copy; null when the phone has none. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, in-show search on the phone). + */ +internal suspend fun PodcastRepository.searchFeedCopy(feedId: String, query: String,): List? { + feedFirst.cached(feedId) ?: return null + val catalog = localEpisodeCatalog ?: return null + return catalog.search(feedId, query, feedFirst.metaFor(feedId) ?: localCatalogMeta(feedId)).take(MAX_SHOW_SEARCH_RESULTS) +} + +/** + * A page of the server's list of the show (`/v2/episodes`), its recipe IDs mapped to the stored IDs + * that own them on this phone, merged with cached feed extras; v1 `/episodes` only when `/v2` fails. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, rollback: `feed_first` off reads + * `/v2/episodes`; decision D1). + * COMPAT(own-ids): the server's list in recipe IDs. Permanent: never remove (MIGRATION.md §5). + */ +internal suspend fun PodcastRepository.getServerListPage( + feedId: String, + limit: Int, + offset: Int, + sort: String, + mergeSupplements: Boolean, +): PodcastRepository.EpisodePage { + val resolvedId = resolvePodcastIndexFeedId(feedId) + PodcastRepository.rememberResolvedFeedId(feedId, resolvedId) + val page = v2EpisodesPage(resolvedId, limit, offset, sort) ?: return getNetworkEpisodesPaginated(feedId, limit, offset, sort, mergeSupplements) + return maybeMergeSupplements(resolvedId, page, offset, sort, mergeSupplements) +} + +/** One `/v2/episodes` page under local owners, cached with the v1 pages; null when the route can't answer. */ +private suspend fun PodcastRepository.v2EpisodesPage(resolvedId: String, limit: Int, offset: Int, sort: String,): PodcastRepository.EpisodePage? { + val showId = V2EpisodeIds.parseShowId(resolvedId)?.takeIf { V2EpisodesPaging.isValid(it, limit, offset) } ?: return null + val cacheKey = "$resolvedId|v2|$limit|$offset|$sort" + PodcastRepository.episodesCache[cacheKey]?.let { return it } + val body = v2Answer("episodes") { apiV2.getEpisodes(showId, limit, offset, sort).awaitResponse() } ?: return null + return PodcastRepository + .EpisodePage( + episodes = discoveryEpisodeIds.toLocalOwners(body.items.mapNotNull(::mapToEpisode)), + hasMore = body.hasMore, + sourceCount = body.items.size, + ).also { PodcastRepository.episodesCache[cacheKey] = it } +} + +/** + * In-show search over the server's list of the show, on the phone: the newest + * [V2EpisodesPaging.LIMIT_MAX] episodes of `/v2/episodes`, matched by title or description, under + * local owners, with matching cached feed extras. Empty when `/v2` fails: never v1 `episodes/search`. + * + * Added in P4 PR 16 of the own-episode-IDs plan (decision D16: search with `feed_first` off). + * COMPAT(own-ids): no served IDs mix with recipe IDs. Permanent: never remove (MIGRATION.md §5). + */ +internal suspend fun PodcastRepository.searchServerList(feedId: String, query: String,): List { + val trimmed = query.trim() + if (trimmed.isEmpty()) return emptyList() + val resolvedId = resolvePodcastIndexFeedId(feedId) + val list = serverSearchList(resolvedId) + val matches = list.filter { it.matches(trimmed) }.take(MAX_SHOW_SEARCH_RESULTS) + return unionCachedSupplementSearch(resolvedId, trimmed, matches) +} + +private suspend fun PodcastRepository.serverSearchList(resolvedId: String): List { + val showId = V2EpisodeIds.parseShowId(resolvedId)?.takeIf(V2EpisodeIds::isEpisodeShowId) ?: return emptyList() + val cacheKey = "$resolvedId|v2search" + PodcastRepository.episodesCache[cacheKey]?.let { return it.episodes } + val body = + v2Answer("episodes search") { + apiV2.getEpisodes(showId, V2EpisodesPaging.LIMIT_MAX, 0, cx.aswin.boxlore.core.network.model.V2EpisodeSort.NEWEST).awaitResponse() + } ?: return emptyList() + val episodes = discoveryEpisodeIds.toLocalOwners(body.items.mapNotNull(::mapToEpisode)) + PodcastRepository.episodesCache[cacheKey] = PodcastRepository.EpisodePage(episodes, body.hasMore, body.items.size) + return episodes +} + +private fun Episode.matches(query: String): Boolean { + val needle = query.lowercase(Locale.ROOT) + return title.lowercase(Locale.ROOT).contains(needle) || description.lowercase(Locale.ROOT).contains(needle) +} + +/** The show's registry entry (`/v2/podcast`): its address, transport and namespace. Null when it can't be read. */ +internal suspend fun PodcastRepository.v2ShowAddress(showId: Long): V2PodcastResponse? = v2Answer("podcast") { apiV2.getPodcast(showId).awaitResponse() } + +/** + * An http-only show's feed document through boxlore's transport (`/v2/feed/`), read whole. + * Null when the route doesn't answer with a document. + */ +internal suspend fun PodcastRepository.v2TransportDocument(showId: Long): ShowPageFeedPort.Document? { + val response = apiV2.getFeed(showId).awaitResponse() + val body = response.body()?.takeIf { response.isSuccessful } ?: return null + val bytes = body.use { it.bytes() } + return ShowPageFeedPort.Document(bytes, response.raw().request.url.toString(), response.headers()["ETag"], complete = true) +} + +/** The body of a successful `/v2` answer, or null; cancellation propagates. */ +private inline fun v2Answer(route: String, call: () -> Response): T? = try { + val response = call() + response.body().takeIf { response.isSuccessful }.also { + if (it == null) android.util.Log.w(SHOW_PAGES_TAG, "v2 $route failed: HTTP ${response.code()}") + } +} catch (error: CancellationException) { + throw error +} catch (error: Exception) { + android.util.Log.w(SHOW_PAGES_TAG, "v2 $route failed", error) + null +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RolloutSwitches.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RolloutSwitches.kt index 75e31142e..0a7aa2efa 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RolloutSwitches.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/RolloutSwitches.kt @@ -5,8 +5,9 @@ package cx.aswin.boxlore.core.catalog * module never reads PostHog itself (`ARCHITECTURE.md`: catalog does not depend on analytics). * * Added in P4 (own episode IDs, plan section 11 "P4: App v2 build", Rollback). [PodcastRepository] - * reads [v2Discovery] for every discovery request (P4 PR 12); the show-page source, the daily - * address check and the one-time upgrade repair each read their switch when they land. The + * reads [v2Discovery] for every discovery request (P4 PR 12) and [feedFirst] for the show-page + * source (P4 PR 16, [PodcastRepository.showPageSource]); the daily address check and the one-time + * upgrade repair read theirs. The * defaults are what a build gets without the app's wiring (tests, previews): the feed-first show * page off, every job on. */ diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt new file mode 100644 index 000000000..9e6d2bb78 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt @@ -0,0 +1,216 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadOutcome +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.FeedFetchCompleteness +import cx.aswin.boxlore.core.model.FeedFetchOutcome +import cx.aswin.boxlore.core.model.FeedFetchReport +import cx.aswin.boxlore.core.model.FeedFetchSource +import cx.aswin.boxlore.core.model.FeedUrlCorrection +import cx.aswin.boxlore.core.network.model.V2FeedReportRequest +import cx.aswin.boxlore.core.network.model.V2FeedReportResponse +import cx.aswin.boxlore.core.network.model.V2PodcastResponse +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The feed-first show page's failure ladder against a scripted stored copy: offline shows the copy + * without reading or reporting, a failed read is tried once more (never after a 404 or 410), then + * reported, then the reported address is read, and only then the page falls back to the server's + * list. One `feed_fetch_result` per read, without addresses. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class FeedFirstShowPagesTest { + private var online = true + private var registry: V2PodcastResponse? = V2PodcastResponse(fetchUrl = PUBLISHER, fetchVia = "direct", epNs = SHOW.toLong()) + private val reports = mutableListOf() + private var reportAnswer: V2FeedReportResponse? = null + private val telemetry = RecordingTelemetry() + private val copies = ScriptedCopies() + private var now = 1_000_000L + + private val pages by lazy { + FeedFirstShowPages( + copies = copies, + registry = { registry }, + transport = { ShowPageFeedPort.Document("".encodeToByteArray(), "https://api.example/v2/feed/$it", null, complete = true) }, + reporter = + FeedReporter( + send = { request -> + reports += request + reportAnswer + }, + limiter = FeedReportLimiter(MemoryFeedReportStamps()), + isOnline = { online }, + enabled = { true }, + ), + isOnline = { online }, + telemetry = telemetry, + nowMillis = { now }, + ) + } + + @Test + fun offlineShowsTheStoredCopyWithoutReadingOrReporting() = runBlocking { + online = false + + assertEquals(ShowPageFeedResult.OFFLINE, pages.read(SHOW, META, Scope.NEWEST, SAVED).result) + assertTrue(copies.reads.isEmpty()) + assertTrue(reports.isEmpty()) + assertFalse(pages.isInFallback(SHOW)) + assertTrue("no read, no event", telemetry.reports.isEmpty()) + } + + @Test + fun theRegistryAddressIsReadAndOneEventSaysHowMuch() = runBlocking { + copies.answers[PUBLISHER] = mutableListOf(ReadOutcome.Stored(100, complete = false)) + + val read = pages.read(SHOW, META, Scope.NEWEST, SAVED) + + assertEquals(ShowPageFeedResult.UPDATED, read.result) + assertEquals(listOf(PUBLISHER to Scope.NEWEST), copies.reads) + assertEquals("the registry's namespace is passed on to be compared", SHOW.toLong(), copies.lastRequest?.serverEpNs) + assertEquals( + listOf(FeedFetchReport.ShowPage(SHOW, FeedFetchSource.FEED, FeedFetchOutcome.OK, FeedFetchCompleteness.PARTIAL)), + telemetry.reports, + ) + } + + @Test + fun aFailedReadIsRetriedOnceThenReportedThenTheReportedAddressIsRead() = runBlocking { + copies.answers[PUBLISHER] = mutableListOf(ReadOutcome.Failed("timeout"), ReadOutcome.Failed("timeout")) + copies.answers[REPORTED] = mutableListOf(ReadOutcome.Stored(40, complete = true)) + reportAnswer = V2FeedReportResponse(queued = true, fetchUrl = REPORTED, fetchVia = "direct") + + val read = pages.read(SHOW, META, Scope.NEWEST, SAVED) + + assertEquals(ShowPageFeedResult.UPDATED, read.result) + assertEquals(listOf(PUBLISHER, PUBLISHER, REPORTED), copies.reads.map { it.first }) + assertEquals(listOf(V2FeedReportRequest(SHOW.toLong(), "timeout")), reports) + assertEquals(1, telemetry.reports.size) + } + + @Test + fun aGoneAddressIsNotRetriedAndWithoutAnotherAddressThePageFallsBack() = runBlocking { + copies.answers[PUBLISHER] = mutableListOf(ReadOutcome.Failed("http_404")) + reportAnswer = V2FeedReportResponse(queued = true, fetchUrl = PUBLISHER, fetchVia = "direct") + + val read = pages.read(SHOW, META, Scope.NEWEST, SAVED) + + assertEquals(ShowPageFeedResult.FALLBACK, read.result) + assertEquals(listOf(PUBLISHER), copies.reads.map { it.first }) + assertEquals(1, reports.size) + assertTrue(pages.isInFallback(SHOW)) + assertEquals( + listOf(FeedFetchReport.ShowPage(SHOW, FeedFetchSource.FEED, FeedFetchOutcome.GONE, httpStatus = 404)), + telemetry.reports, + ) + + // Opening the page again tries the feed again; a read that works keeps it there. + pages.open(SHOW, META) + assertFalse(pages.isInFallback(SHOW)) + copies.answers[PUBLISHER] = mutableListOf(ReadOutcome.NotModified) + assertEquals(ShowPageFeedResult.UNCHANGED, pages.read(SHOW, META, Scope.NEWEST, SAVED).result) + assertFalse(pages.isInFallback(SHOW)) + } + + @Test + fun withoutTheRegistryTheSavedAddressIsReadAndAnHttpOnlyShowGoesThroughTheTransport() = runBlocking { + registry = null + copies.answers[SAVED] = mutableListOf(ReadOutcome.Stored(10, complete = true)) + assertEquals(ShowPageFeedResult.UPDATED, pages.read(SHOW, META, Scope.ARCHIVE, SAVED).result) + assertEquals(listOf(SAVED to Scope.ARCHIVE), copies.reads) + + registry = V2PodcastResponse(fetchUrl = "http://plain.example/feed", fetchVia = "proxy", transportUrl = "https://api.example/v2/feed/$SHOW") + assertEquals(ShowPageFeedResult.UPDATED, pages.read(SHOW, META, Scope.NEWEST, SAVED).result) + assertEquals(1, copies.stores) + assertEquals(FeedFetchSource.TRANSPORT, (telemetry.reports.last() as FeedFetchReport.ShowPage).source) + + registry = null + assertEquals("no address at all", ShowPageFeedResult.FALLBACK, pages.read(SHOW, META, Scope.NEWEST, null).result) + assertEquals("rss: shows never read through here", ShowPageFeedResult.FALLBACK, pages.read("rss:abc", META, Scope.NEWEST, SAVED).result) + } + + @Test + fun openingSaysWhatTheCopyHoldsAndWhenItIsDueAgain() = runBlocking { + assertEquals(ShowPageFeedState(cached = false, archiveComplete = false, recheckDue = true, oldestFirst = false), pages.open(SHOW, META)) + + copies.cachedShow = ShowPageFeedPort.CachedShow(SHOW, PUBLISHER, fetchedAt = now - 10 * 60_000L, itemCount = 100, archiveComplete = false, oldestFirst = false) + assertEquals(ShowPageFeedState(cached = true, archiveComplete = false, recheckDue = false, oldestFirst = false), pages.open(SHOW, META)) + now += 60 * 60_000L + assertTrue("an hour old: read again", pages.open(SHOW, META).recheckDue) + assertEquals(META, pages.metaFor(SHOW)) + assertEquals("the daily cleanup ran once, keeping the show being opened", listOf(SHOW), copies.cleanups) + assertEquals(2, copies.opened.size) + } + + @Test + fun readFailuresMapToTheEventOutcomes() { + assertEquals(FeedFetchOutcome.GONE, FeedFirstShowPages.failureOutcome("http_410")) + assertEquals(FeedFetchOutcome.HTTP_ERROR, FeedFirstShowPages.failureOutcome("http_503")) + assertEquals(FeedFetchOutcome.TIMEOUT, FeedFirstShowPages.failureOutcome("timeout")) + assertEquals(FeedFetchOutcome.PARSE_ERROR, FeedFirstShowPages.failureOutcome("parse")) + assertEquals(FeedFetchOutcome.NETWORK_ERROR, FeedFirstShowPages.failureOutcome("dns")) + } + + private class ScriptedCopies : ShowPageFeedPort { + val answers = mutableMapOf>() + val reads = mutableListOf>() + var lastRequest: ShowPageFeedPort.ReadRequest? = null + var stores = 0 + var cachedShow: ShowPageFeedPort.CachedShow? = null + val opened = mutableListOf() + val cleanups = mutableListOf() + + override suspend fun cached(podcastId: String): ShowPageFeedPort.CachedShow? = cachedShow + + override suspend fun read(request: ShowPageFeedPort.ReadRequest): ReadOutcome { + reads += request.feedUrl to request.scope + lastRequest = request + val queue = answers[request.feedUrl] ?: return ReadOutcome.Failed("other") + return if (queue.size > 1) queue.removeAt(0) else queue.first() + } + + override suspend fun store(request: ShowPageFeedPort.ReadRequest, document: ShowPageFeedPort.Document): ReadOutcome { + stores++ + return ReadOutcome.Stored(5, document.complete) + } + + override suspend fun markOpened(podcastId: String, atMillis: Long) { + opened += podcastId + } + + override suspend fun cleanUp(nowMillis: Long, keepPodcastId: String?): Int { + cleanups += keepPodcastId + return 0 + } + } + + private class RecordingTelemetry : FeedTelemetryPort { + val reports = mutableListOf() + + override fun feedFetched(report: FeedFetchReport) { + reports += report + } + + override fun feedUrlCorrected(correction: FeedUrlCorrection) = Unit + } + + private companion object { + const val SHOW = "920666" + const val PUBLISHER = "https://feeds.example/920666.xml" + const val SAVED = "https://saved.example/920666.xml" + const val REPORTED = "https://moved.example/920666.xml" + val META = PodcastMeta(title = "Show", imageUrl = "https://img.example/a.jpg") + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt index 949e525d6..b31b35a97 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeSupplementTest.kt @@ -71,6 +71,7 @@ class PodcastRepositoryEpisodeSupplementTest { episodeSupplementRepository = fakePort, ioDispatcher = testDispatcher, boxLoreApi = api, + boxLoreApiV2 = NetworkModule.createBoxLoreApiV2(server.url("/").toString(), client), ) } @@ -240,7 +241,7 @@ class PodcastRepositoryEpisodeSupplementTest { } @Test - fun `searchEpisodes unions PI hits with supplement matches`() = runTest(testDispatcher) { + fun `searchEpisodes matches the server's list on the phone and unions supplement matches`() = runTest(testDispatcher) { val podcastId = "900003" fakePort.searchByPodcast[podcastId] = listOf( @@ -275,6 +276,8 @@ class PodcastRepositoryEpisodeSupplementTest { val results = repository.searchEpisodes(podcastId, "match") assertEquals(listOf("-9", "44"), results.map { it.id }) + // Search runs on the phone over `/v2/episodes`; v1 `episodes/search` is never called (P4 PR 16). + assertEquals("/v2/episodes?id=900003&limit=1000&offset=0&sort=newest", server.takeRequest().path) } @Test diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt new file mode 100644 index 000000000..75672e86f --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt @@ -0,0 +1,290 @@ +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.database.BoxLoreDatabase +import cx.aswin.boxlore.core.database.LocalEpisodeEntity +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.EpisodeRecipeId +import cx.aswin.boxlore.core.network.NetworkModule +import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository +import cx.aswin.boxlore.core.rss.RssFeedClient +import cx.aswin.boxlore.core.rss.RssPodcastRepository +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 + +/** + * The show page's episode source through [PodcastRepository] on a real Room database: the + * feed-first stored copy (`feed_first` on), the server's list in recipe IDs mapped to the phone's + * owners (`feed_first` off, and after a fallback), v1 `/episodes` only when `/v2/episodes` fails, + * search on the phone that never calls v1 `episodes/search`, paging that stays stable while the copy + * grows, and a rollback that changes no stored ID or alias. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PodcastRepositoryShowPagesTest { + private lateinit var server: MockWebServer + private lateinit var context: Context + private lateinit var database: BoxLoreDatabase + private val seen = CopyOnWriteArrayList() + private val answers = mutableMapOf MockResponse>() + private var feedFirst = true + private var feedItems = 150 + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + PodcastRepository.episodesCache.clear() + server = MockWebServer() + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + seen += request.path.orEmpty() + return answers[request.path.orEmpty().substringBefore('?')]?.invoke() ?: MockResponse().setResponseCode(404) + } + } + server.start() + answers["/v2/podcast"] = { json("""{"status":"true","fetchUrl":"$FEED_URL","fetchVia":"direct","epNs":$SHOW}""") } + answers["/v2/episodes"] = { json(v2Episodes(listOf("g0", "g1", "Moon g2"))) } + } + + @After + fun tearDown() { + server.shutdown() + database.close() + RssPodcastRepository.clearInstanceForTests() + PodcastRepository.episodesCache.clear() + } + + @Test + fun withTheFlagOffThePageReadsTheServersListUnderLocalOwnersAndV1OnlyWhenItFails() = runBlocking { + feedFirst = false + val repository = repository(FakeIds(mapOf(recipe("g1") to "5001"))) + + assertEquals(ShowPageSource.SERVER_LIST, repository.showPageSource(SHOW)) + val page = repository.getEpisodesPaginated(SHOW, limit = 20, offset = 0, sort = "newest") + + assertEquals(listOf(recipe("g0").toString(), "5001", recipe("Moon g2").toString()), page.episodes.map { it.id }) + assertEquals("the recipe ID stays with its owner", recipe("g1"), page.episodes[1].recipeId) + assertEquals("/v2/episodes?id=$SHOW&limit=20&offset=0&sort=newest", seen.single()) + + answers["/v2/episodes"] = { MockResponse().setResponseCode(503) } + answers["/episodes"] = { json("""{"items":[{"id":777,"title":"v1","enclosureUrl":"https://cdn.example/v1.mp3","feedId":$SHOW}],"hasMore":false}""") } + repository.invalidateEpisodesCache(SHOW) + assertEquals(listOf("777"), repository.getEpisodesPaginated(SHOW, 20, 0, "newest").episodes.map { it.id }) + assertTrue(seen.last().startsWith("/episodes?")) + } + + @Test + fun withTheFlagOnThePageShowsTheStoredCopyAndPagesStayStableWhileItGrows() = runBlocking { + val repository = repository() + assertEquals(ShowPageSource.FEED, repository.showPageSource(SHOW)) + assertFalse(requireNotNull(repository.openFeedShowPage(SHOW, META)).cached) + + val newest = repository.readShowPageFeed(SHOW, META, Scope.NEWEST, savedFeedUrl = null) + assertEquals(ShowPageFeedRead(ShowPageFeedResult.UPDATED, archiveComplete = false), newest) + + val shown = mutableListOf() + var offset = 0 + repeat(5) { + val page = repository.getEpisodesPaginated(SHOW, 20, offset, "newest") + assertTrue(page.hasMore) + shown += page.episodes.map { it.title } + offset += page.sourceCount + } + val pastCopy = repository.getEpisodesPaginated(SHOW, 20, offset, "newest") + assertTrue("the copy ran out, but the archive isn't read yet", pastCopy.episodes.isEmpty() && pastCopy.hasMore) + + assertEquals(ShowPageFeedResult.UPDATED, repository.readShowPageFeed(SHOW, META, Scope.ARCHIVE, null).result) + while (true) { + val page = repository.getEpisodesPaginated(SHOW, 20, offset, "newest") + shown += page.episodes.map { it.title } + offset += page.sourceCount + if (!page.hasMore) break + } + assertEquals("every episode once, in order, across the growth", (0 until 150).map { "Episode $it" }, shown) + assertTrue("no network page was read", seen.none { it.startsWith("/v2/episodes") || it.startsWith("/episodes") }) + } + + @Test + fun searchRunsOnThePhoneAndNeverCallsV1EpisodeSearch() = runBlocking { + feedFirst = false + val offRepository = repository() + assertEquals(listOf("Moon g2"), offRepository.searchEpisodes(SHOW, "moon").map { it.title }) + assertTrue(seen.single().startsWith("/v2/episodes?id=$SHOW&limit=1000")) + + feedFirst = true + feedItems = 30 + val onRepository = repository() + onRepository.readShowPageFeed(SHOW, META, Scope.ARCHIVE, null) + seen.clear() + assertEquals(listOf("Episode 12"), onRepository.searchEpisodes(SHOW, "Episode 12").map { it.title }) + assertTrue(seen.isEmpty()) + assertTrue(seen.none { it.startsWith("/episodes/search") }) + } + + @Test + fun aFeedThatCantBeReadSwitchesThePageToTheServersList() = runBlocking { + val repository = repository() + answers["/v2/podcast"] = { json("""{"status":"true","fetchUrl":"https://feeds.example/gone.xml","fetchVia":"direct"}""") } + + val read = repository.readShowPageFeed(SHOW, META, Scope.NEWEST, null) + + assertEquals(ShowPageFeedResult.FALLBACK, read.result) + assertEquals(ShowPageSource.SERVER_LIST, repository.showPageSource(SHOW)) + assertEquals(3, repository.getEpisodesPaginated(SHOW, 20, 0, "newest").episodes.size) + assertTrue(seen.any { it.startsWith("/v2/feed-report") }) + + // Opening the page again tries the feed again. + requireNotNull(repository.openFeedShowPage(SHOW, META)) + assertEquals(ShowPageSource.FEED, repository.showPageSource(SHOW)) + feedFirst = false + assertEquals("with the flag off the page never reads the feed", null, repository.openFeedShowPage(SHOW, META)) + } + + @Test + fun rollingTheFlagBackChangesNoStoredIdAndTheServersListMapsToTheSameOwners() = runBlocking { + // An episode an older build stored under its Podcast Index ID while the show was followed. + database.localEpisodeCatalogDao().upsertEpisodes(listOf(row("5001", "g1"))) + val repository = repository(RoomDiscoveryEpisodeIds { database }) + repository.readShowPageFeed(SHOW, META, Scope.ARCHIVE, null) + val storedIds = database.localEpisodeCatalogDao().listIdentities(SHOW).associate { it.guid to it.episodeId } + val aliases = database.episodeIdentityDao().aliasesForShow(SHOW).sortedBy { it.aliasId } + assertEquals("5001", storedIds["g1"]) + assertEquals(recipe("g0").toString(), storedIds["g0"]) + + feedFirst = false + repository.invalidateEpisodesCache(SHOW) + val serverList = repository.getEpisodesPaginated(SHOW, 20, 0, "newest").episodes.map { it.id } + + assertEquals(listOf(storedIds["g0"], "5001", storedIds["Moon g2"] ?: recipe("Moon g2").toString()), serverList) + assertEquals(storedIds, database.localEpisodeCatalogDao().listIdentities(SHOW).associate { it.guid to it.episodeId }) + assertEquals(aliases, database.episodeIdentityDao().aliasesForShow(SHOW).sortedBy { it.aliasId }) + } + + @Test + fun droppingAResolvedShowsPagesAlsoDropsThePagesStoredUnderItsShowId() = runBlocking { + feedFirst = false + answers["/podcast"] = { json("""{"status":"true","feed":{"id":$SHOW,"title":"Show","url":"$FEED_URL"}}""") } + val repository = repository() + val byUrl = "url:" + java.net.URLEncoder.encode(FEED_URL, "UTF-8") + + repository.getEpisodesPaginated(byUrl, 20, 0, "newest") + repository.getEpisodesPaginated(byUrl, 20, 0, "newest") + assertEquals("the second page came from the cache", 1, seen.count { it.startsWith("/v2/episodes") }) + repository.invalidateEpisodesCache(byUrl) + repository.getEpisodesPaginated(byUrl, 20, 0, "newest") + assertEquals(2, seen.count { it.startsWith("/v2/episodes") }) + } + + private fun repository(ids: DiscoveryEpisodeIds = DiscoveryEpisodeIds.Unmapped): PodcastRepository { + val client = OkHttpClient.Builder().readTimeout(5, TimeUnit.SECONDS).build() + val base = server.url("/").toString() + val catalog = LocalEpisodeCatalogRepository.create(database, feedClient()) + return PodcastRepository( + baseUrl = base, + publicKey = "test-app-key", + context = context, + rssRepository = RssPodcastRepository.createForTests(context, database), + localEpisodeCatalog = catalog, + boxLoreApi = NetworkModule.createBoxLoreApi(base, client), + boxLoreApiV2 = NetworkModule.createBoxLoreApiV2(base, client), + rolloutSwitches = RolloutSwitches(feedFirst = { feedFirst }), + discoveryEpisodeIds = ids, + feedFirstWiring = FeedFirstWiring(copies = catalog.showPages), + ) + } + + /** Publisher feeds over an interceptor: [FEED_URL] lists [feedItems] episodes, newest first; any other address is gone. */ + private fun feedClient() = RssFeedClient( + OkHttpClient.Builder().addInterceptor { chain -> + val url = chain.request().url.toString() + val code = if (url == FEED_URL) 200 else 404 + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(code).message("test") + .body(feedXml(feedItems).toResponseBody("application/rss+xml".toMediaType())).build() + }.build(), + ) + + private fun row(id: String, key: String) = LocalEpisodeEntity( + episodeId = id, + podcastId = SHOW, + guid = key, + title = "Episode 1", + description = "", + audioUrl = "https://cdn.example/$key.mp3", + imageUrl = null, + duration = 60, + publishedDate = 1_790_000_000_000L - 86_400_000L, + chaptersUrl = null, + transcriptUrl = null, + transcripts = null, + persons = null, + seasonNumber = null, + episodeNumber = null, + episodeType = null, + enclosureType = "audio/mpeg", + ) + + private class FakeIds(private val owners: Map) : DiscoveryEpisodeIds { + override suspend fun recipeIds(episodeIds: Collection): Map = DiscoveryEpisodeIds.ownRecipeIds(episodeIds) + + override suspend fun localOwners(refs: Collection): Map = + refs.mapNotNull { ref -> owners[ref.recipeId]?.let { ref to DiscoveryEpisodeOwner(it, ref.podcastId) } }.toMap() + } + + private companion object { + const val SHOW = "920666" + const val FEED_URL = "https://feeds.example/920666.xml" + val META = PodcastMeta(title = "Show") + + fun recipe(key: String): Long = EpisodeRecipeId.forNamespace(SHOW.toLong(), key) + + fun json(body: String) = MockResponse().setHeader("Content-Type", "application/json").setBody(body) + + /** A `/v2/episodes` answer naming [keys] under their recipe IDs, as the route re-keys Podcast Index's list. */ + fun v2Episodes(keys: List): String { + val items = + keys.mapIndexed { index, key -> + """{"id":${recipe(key)},"title":"$key","guid":"$key","catalogKey":"$key","enclosureUrl":"https://cdn.example/$index.mp3",""" + + """"feedId":$SHOW,"datePublished":${1_790_000_000L - index * 86_400L}}""" + } + return """{"status":"true","fallback":true,"source":"pi","feedId":$SHOW,"items":[${items.joinToString(",")}],"hasMore":false,"offset":0,"limit":20}""" + } + + fun feedXml(count: Int): String { + val items = + (0 until count).joinToString("") { index -> + val key = if (index == 2) "Moon g2" else "g$index" + val date = DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.ofEpochSecond(1_790_000_000L - index * 86_400L).atZone(ZoneOffset.UTC)) + "Episode $index$key$date" + + "" + } + return "Show$items" + } + } +} diff --git a/core/database/README.md b/core/database/README.md index 3bb6f9bd4..acb7f7bb0 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -15,6 +15,7 @@ Owns the main Room database, entities, DAOs, type converters, and migrations for - `EpisodeListenerStore` (from `BoxLoreDatabase.episodeListenerStore()`, so the playback service reaches it without `AppContainer`) keeps one listener state per episode across its stored IDs ([`docs/EPISODE_IDS.md`](../../docs/EPISODE_IDS.md) §5). An `EpisodeGroup` is every trusted alias of one (show, catalog key), owner first; any other ID is a group of its own and reads and writes exactly as before aliases existed. It works out the owner (the stored mark while that ID holds listener state, else the v39 upgrade's order through `EpisodeIdentityDao.chooseOwner`) and stores it in the transaction of the write that needs it (`historyId`). Reads project (`EpisodeListenerProjection`): the owner's row is the base, and progress, position and play time come from the most recently played row (the owner's, unless a pull, a restore or older-build data put later listening under another ID; an owner that holds only a like never hides another ID's progress); liked and completed are the latest decision across the rows and `episode_group_state`, a group setting winning a tie, so an older alias row can't bring back a cleared like or completion; an episode with one stored ID reads its own row unchanged. `projectRows`, `projectedRows` and `projectCompletedIds` count each episode once (the shared-alias query is watched as a Flow, so episodes with one ID cost nothing extra per emission); `projectCompletedIds` also counts an episode that only its group settings mark completed. `projectRowsByMember`, `projectedRowsByMember` and `projectedLikedIds` list an episode once per stored ID, for screens that look an episode up by the ID they show. `lastPlayedSession` is the projected last session for restores and Android Auto. Writes: `setLiked` writes an absolute like to every trusted ID and the group settings (an episode with one stored ID keeps the clock's time on its row, as before); `recordLike`, `recordCompletion` and the batched `recordCompletions` record a decision after the caller wrote the owner's row (an episode with one ID and no row records nothing); `historyIds` works out owners in batches; `removeGroupState`, `restoreGroupState` and `clearGroupStates` remove an episode's settings with its history and put them back on Undo; `downloadHolder(s)` names the ID whose completed download plays an episode; `memberIds`, `withAllAliases`, `groupTokens` and `queueIdentities` serve the queue, Smart Queue, Smart Downloads, sync and ranking; `queueRemovalIds` names the IDs a queue removal records: the removed ones and every other trusted ID of their episodes, queued here or not (a cloud sync pull keeps one queue entry per episode, so the cloud can still hold another), except the IDs of an episode that stays queued or plays; `withAllAliasesFlow`, `memberIdsFlow` and `aliasGroupsFlow` expand IDs to every trusted ID of their episodes in memory from the watched shared aliases, so download and queue indicators read an episode under the ID a screen shows. `ownerRow` gives a new history row written under another ID of the playing episode (its owner) the owner's own show (its stored row, else its catalog row, else its alias), and `keepingOwnerMetadata` keeps a stored owner row's show and episode metadata when another ID's page saves progress, so the owner row of a show switched to its feed stays under the Podcast Index show it belongs to. `showReplacementsFlow` maps each Podcast Index show a followed RSS show replaced (and the listener no longer follows) to that RSS show, for listening read by show. Like times in the group settings stay monotonic within an episode even when the clock reads earlier than a recorded decision. - DAOs: `EpisodeIdentityDao` (alias and group-state reads and writes, `upsertGroupStates`, `getGroupStates`, `completedGroupStates`, `deleteGroupState`, `deleteAllGroupStates`, `sharedTrustedAliases` / `sharedTrustedAliasesFlow`, `chooseOwner` (the upgrade's owner order, read at runtime), `setOwner` and `holdsListenerState`; when ownership may move and which states resolve a lookup belong to `EpisodeListenerStore`), `UpgradeRepairDao` (pending shows and per-show progress), `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `PodcastDao.upsert` and `LocalEpisodeCatalogDao.upsertFeed` keep a stored row's Room v39 columns (see Version 39 below). `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `ListeningHistoryDao.getHistoryItems`, `setLikeStatusForIds` and `setCompletionForIds` and `DownloadedEpisodeDao.getCompletedDownloadIds`, `getDownloads`, `countCompletedFlow` and `countActiveFlow` read and write an episode's rows under all its IDs. `PodcastDao.observeSwitchedRssShows` watches the followed RSS shows that replaced a Podcast Index show the listener no longer follows. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `ListeningHistoryDao.getDirtyListeningHistoryBatch` reads one push batch: up to the given number of unsynced rows, most recently played first, without episode descriptions, so a large backlog is never read in full. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL found on the device and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. App code replaces a saved address only through `PodcastDao.replaceFeedAddress(FeedAddressWrite)` (P4 PR 14, used by `:core:catalog`'s `FeedAddressUpdater`): one transaction that writes `feedUrl` and `transportUrl`, keeps the old address in `previousFeedUrl`, records `feedUrlSource` and `feedUrlCheckedAt`, clears the show's ETag and Last-Modified on its row, its `local_episode_feeds` row and its `episode_supplements` row, and marks the row dirty unless the address came from the cloud. It is a compare-and-set on the address the caller read (`feedUrl IS :expected`) and never touches `rss:` rows. `PodcastDao.markFeedUrlChecked` stamps the daily address check without marking the row dirty. No schema change. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`); `findOwners` reports which (show, key) holds each episode ID so writers check before `upsertEpisodes` replaces a row. `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. - `EpisodeSupplementDao.replaceAll` / `upsertSupplementAndOptionalItems` keep validator rows and tip items in one Room transaction. +- `LocalEpisodeCatalogDao` writes the v39 feed-first columns on their own (P4 PR 16; no schema change): `setArchiveComplete`, `setLastOpenedAt`, and `setTtlAndFollowed`, which follow and unfollow use so `followed` stays in step with the unsubscribe TTL. `listUnfollowedCopies` (`UnfollowedCatalogCopy`: last opened, last read, episode count) lists the copies the show page's cleanup may remove: followed by neither `local_episode_feeds.followed` nor `podcasts.isSubscribed`, and without an unsubscribe TTL. `deleteCatalogIfUnfollowed` checks that again in its transaction and deletes only the show's `local_episodes` and `local_episode_feeds` rows. - `QueueDao.bumpQueueVersion` keeps `queue_metadata.recentRemovedEpisodes` (a JSON list of `QueueRemovalIdentity`: ID, recipe ID, show and catalog key, written with hand-named fields) in step with the plain `recentRemovedEpisodeIds`. A removal adds one entry per removed ID, with the episode's identity when the caller passes it; restoring the same episode under any ID (same ID, or same show and key) drops its entry, and a removal of an episode that stays queued under another ID isn't recorded there. Both lists keep the newest 50. `QueueDao.richRemovalsWithin` keeps the rich list in step after a pulled queue state replaced the plain list: only entries the plain list still holds and whose episode isn't queued again. Cloud sync pushes the rich list with the queue and pulls the server's (`:core:catalog` `sync.QueueSyncResolver`). - `PodcastScoring.calculateScores` takes an optional `episodeKey`, so `:core:ranking` can count an episode stored under several IDs once; the default keeps each ID its own episode. `ScorablePodcast.listenerShowIds` (filled by `toScorable` from `Podcast.listenerShowIds` and `PodcastEntity.listenerShowIds`) lets an RSS show the listener switched to from a Podcast Index show count that show's history, unless that show is scored itself. `sortedByListening` orders shows for Most listened the same way. - `ListeningInsightsMaintenance` for rolling session/rollup maintenance (via `BoxLoreDatabase.listeningInsightsMaintenance()`). @@ -127,6 +128,7 @@ src/main/java/cx/aswin/boxlore/core/database/ ``` - `ListeningHistoryDaoInMemoryTest` reads completed ids across page boundaries. - `AutoDownloadMigrationTest` opens a v37 database through `BoxLoreDatabase.builder`, so it runs 37→38 and 38→39 as a phone does. +- The feed-first DAO queries run on real Room in `:core:rss` `ShowPageFeedCatalogTest` (cleanup keeps followed shows, history, downloads, the queue and aliases; follow and unfollow write `followed`). ## CI relevance 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 a30009ac7..cb474ebae 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 @@ -181,6 +181,80 @@ interface LocalEpisodeCatalogDao { @Query("UPDATE local_episode_feeds SET epNs = :epNs WHERE podcastId = :podcastId AND epNs IS NULL") suspend fun setEpNsIfUnset(podcastId: String, epNs: Long,) + /** + * Sets [LocalEpisodeFeedEntity.archiveComplete]: whether every item of the feed has been read. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + * COMPAT(own-ids): feed-first catalog state. Permanent: never remove (MIGRATION.md §5). + */ + @Query("UPDATE local_episode_feeds SET archiveComplete = :archiveComplete WHERE podcastId = :podcastId") + suspend fun setArchiveComplete(podcastId: String, archiveComplete: Boolean,) + + /** + * Sets [LocalEpisodeFeedEntity.lastOpenedAt], which keeps an unfollowed show's copy from cleanup. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page; decision D10). + * COMPAT(own-ids): feed-first catalog state. Permanent: never remove (MIGRATION.md §5). + */ + @Query("UPDATE local_episode_feeds SET lastOpenedAt = :atMillis WHERE podcastId = :podcastId") + suspend fun setLastOpenedAt(podcastId: String, atMillis: Long,) + + /** + * The unsubscribe TTL and [LocalEpisodeFeedEntity.followed] in one write: a show is followed + * while it has no TTL. Follow clears the TTL, unfollow sets it. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 8, Room v39 feed columns: follow + * and unfollow write `followed`). + * COMPAT(own-ids): feed-first catalog state. Permanent: never remove (MIGRATION.md §5). + */ + @Query("UPDATE local_episode_feeds SET ttlExpiresAt = :ttlExpiresAt, followed = :followed WHERE podcastId = :podcastId") + suspend fun setTtlAndFollowed(podcastId: String, ttlExpiresAt: Long?, followed: Boolean,) + + /** + * Stored copies the automatic cleanup may remove: not followed by either record (the feed row's + * [LocalEpisodeFeedEntity.followed] and the `podcasts` row), and without an unsubscribe TTL, + * which [listExpiredFeedIds] handles. With each show's stored episode count. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, cleanup of unfollowed cached shows; decision D10). + * COMPAT(own-ids): bounded storage for show pages. Permanent: never remove (MIGRATION.md §5). + */ + @Query( + """ + SELECT f.podcastId AS podcastId, f.lastOpenedAt AS lastOpenedAt, f.fetchedAt AS fetchedAt, + (SELECT COUNT(*) FROM local_episodes e WHERE e.podcastId = f.podcastId) AS episodeCount + FROM local_episode_feeds f + WHERE f.followed = 0 AND f.ttlExpiresAt IS NULL + AND NOT EXISTS (SELECT 1 FROM podcasts p WHERE p.podcastId = f.podcastId AND p.isSubscribed = 1) + """, + ) + suspend fun listUnfollowedCopies(): List + + /** + * Deletes [podcastId]'s stored copy when it is still unfollowed, checked again inside the + * transaction so a follow that lands meanwhile keeps it. Only `local_episodes` and + * `local_episode_feeds` rows go. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, cleanup of unfollowed cached shows; decision D10). + */ + @Transaction + suspend fun deleteCatalogIfUnfollowed(podcastId: String): Boolean { + if (!isUnfollowedCopy(podcastId)) return false + deleteCatalog(podcastId) + return true + } + + /** True when [podcastId]'s stored copy is one [listUnfollowedCopies] lists. */ + @Query( + """ + SELECT EXISTS( + SELECT 1 FROM local_episode_feeds f + WHERE f.podcastId = :podcastId AND f.followed = 0 AND f.ttlExpiresAt IS NULL + AND NOT EXISTS (SELECT 1 FROM podcasts p WHERE p.podcastId = f.podcastId AND p.isSubscribed = 1) + ) + """, + ) + suspend fun isUnfollowedCopy(podcastId: String): Boolean + @Query("DELETE FROM local_episodes WHERE podcastId = :podcastId") suspend fun deleteEpisodes(podcastId: String) diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeFeedEntity.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeFeedEntity.kt index 6ad8fa1b7..1b00ac19d 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeFeedEntity.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/LocalEpisodeFeedEntity.kt @@ -13,9 +13,9 @@ import androidx.room.PrimaryKey * A non-null TTL means the catalog is not ready for live lists: unsubscribed * reads use Podcast Index until resubscribe clears the TTL. * - * Room v39 adds the feed-first columns ([followed] through [showMetadataAt]). Nothing reads them - * yet. [LocalEpisodeCatalogDao.upsertFeed] keeps them when it replaces a stored row, so they change - * only through their own updates. + * Room v39 adds the feed-first columns ([followed] through [showMetadataAt]); the feed-first show + * page (P4 PR 16) reads and writes them. [LocalEpisodeCatalogDao.upsertFeed] keeps them when it + * replaces a stored row, so they change only through their own updates. */ @Entity(tableName = "local_episode_feeds") data class LocalEpisodeFeedEntity( @@ -33,8 +33,8 @@ data class LocalEpisodeFeedEntity( val ready: Boolean, val feedUrlLookupAt: Long, /** - * The show is followed. Seeded from `podcasts.isSubscribed` at the v39 upgrade; follow and - * unfollow don't update it yet, so whatever reads it first must also write it. + * The show is followed. Seeded from `podcasts.isSubscribed` at the v39 upgrade; since P4 PR 16 + * follow and unfollow write it with the unsubscribe TTL ([LocalEpisodeCatalogDao.setTtlAndFollowed]). * * Added in P4 of the own-episode-IDs plan (plan section 8, Room v39 feed columns). * COMPAT(own-ids): feed-first catalog state. Permanent: never remove (MIGRATION.md §5). @@ -103,3 +103,16 @@ object LocalFeedOrder { const val OLDEST_FIRST = "oldest_first" const val MIXED = "mixed" } + +/** + * A stored copy of an unfollowed show's feed ([LocalEpisodeCatalogDao.listUnfollowedCopies]), with + * when the show page last opened it and how many episodes it holds. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, cleanup of unfollowed cached shows; decision D10). + */ +data class UnfollowedCatalogCopy( + val podcastId: String, + val lastOpenedAt: Long, + val fetchedAt: Long, + val episodeCount: Int, +) diff --git a/core/domain/README.md b/core/domain/README.md index 15050fd72..9c5668f2e 100644 --- a/core/domain/README.md +++ b/core/domain/README.md @@ -19,6 +19,7 @@ Owns thin domain ports and small result types used by ViewModels, repositories, - `ConnectivityStatusPort` and connectivity status types. - `DeviceIdentityPort` for cross-device sync attribution. - `FeedTelemetryPort` reports feed reads (`feedFetched`) and saved-address swaps (`feedUrlCorrected`) without a dependency on `:core:analytics`; `AppContainer` wires the analytics-backed one and `NONE` reports nothing. Implementations must not throw. +- `ShowPageFeedPort` is the feed-first show page's stored feed copy of a Podcast Index show, followed or not (P4 PR 16): `cached` (episode count, whether it is the whole archive, when it was read, oldest-first), `read` (`NEWEST` or `ARCHIVE` of an HTTPS address; `Stored`, `NotModified` or `Failed` with the `/v2/feed-report` status, never a throw), `store` (a document read through boxlore's transport), `markOpened` and `cleanUp` (unfollowed copies not opened in 30 days, then a size cap; never history, downloads, the queue or aliases). Pages and search of the copy go through `LocalEpisodeCatalogPort.getPage`/`search`. `:core:rss` implements it (`LocalEpisodeCatalogRepository.showPages`). - `LocalEpisodeCatalogPort.RefreshRequest.reason` defaults to `NORMAL`; automatic refresh and `AUTO_DOWNLOAD` share the persisted six-hour freshness gate. `MANUAL` and `NEW_RELEASE` bypass quiet/HEAD shortcuts. `canProceed` rechecks authorization before network/persistence, `runPostPersistCallback` prevents background metadata recovery from invoking ungated ingest callbacks, and `isRefreshDue` supports bounded foreground batches. `RefreshOutcome.Failure.addressGone` says the saved address looks dead rather than temporarily failing, and `RefreshOutcome.Failure.reportStatus` says what the read saw in the words `/v2/feed-report` takes (`http_404`, `timeout`, `dns`, `tls`, `parse`, `network`, `other`; null when no read happened); with `RefreshRequest.detectMove` (and a `PodcastMeta.title` to compare), `RefreshOutcome.Success.movedTo` carries a confirmed new HTTPS address. `RefreshRequest.serverEpNs` passes the episode namespace the server reported for the show; it is only compared with the stored one and never changes an ID. Publisher RSS and Room remain the source of subscribed episodes. diff --git a/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt new file mode 100644 index 000000000..911a30e3b --- /dev/null +++ b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt @@ -0,0 +1,86 @@ +package cx.aswin.boxlore.core.domain.ports + +/** + * The phone's stored copy of a Podcast Index show's feed for its show page, followed or not, and + * the reads that fill it. Pages and search read the copy through [LocalEpisodeCatalogPort.getPage] + * and [LocalEpisodeCatalogPort.search]; this port says what the copy holds and adds to it. + * + * A read never makes an unfollowed show's copy the followed live list ([LocalEpisodeCatalogPort.isReady] + * stays false), and it never runs the post-write work of followed shows (auto-download, new-episode + * delivery). New items take the same IDs a followed show's refresh gives them; stored rows keep theirs. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). + */ +interface ShowPageFeedPort { + /** + * What the phone holds for a show. + * + * - [itemCount]: stored episodes. + * - [archiveComplete]: every item of the feed has been read, not only the newest part. + * - [fetchedAt]: when the copy was last read or confirmed current (epoch ms). + * - [oldestFirst]: the feed lists its oldest items first, so a read of its start is no use. + */ + data class CachedShow( + val podcastId: String, + val feedUrl: String, + val fetchedAt: Long, + val itemCount: Int, + val archiveComplete: Boolean, + val oldestFirst: Boolean, + ) + + /** How much of the feed a read takes: its newest items, or the whole document. */ + enum class Scope { NEWEST, ARCHIVE } + + /** + * One read of [feedUrl] (HTTPS) for show [podcastId]. [serverEpNs] is only compared with the + * stored namespace (decision D12). + */ + data class ReadRequest( + val podcastId: String, + val feedUrl: String, + val scope: Scope, + val meta: LocalEpisodeCatalogPort.PodcastMeta = LocalEpisodeCatalogPort.PodcastMeta(), + val serverEpNs: Long? = null, + ) + + /** + * A feed document read some other way (boxlore's transport address for an http-only feed). + * [complete] is false when it holds only the start of the feed. + */ + class Document(val bytes: ByteArray, val finalUrl: String, val etag: String?, val complete: Boolean,) + + sealed interface ReadOutcome { + /** The read was stored; [complete] when it held the whole feed. */ + data class Stored(val itemCount: Int, val complete: Boolean) : ReadOutcome + + /** The publisher said the copy is current (304). */ + data object NotModified : ReadOutcome + + /** + * The read failed. [reportStatus] is what it saw, in the words `/v2/feed-report` takes + * (`http_404`, `timeout`, `dns`, `tls`, `parse`, `network`, `other`). + */ + data class Failed(val reportStatus: String) : ReadOutcome + } + + suspend fun cached(podcastId: String): CachedShow? + + /** Reads [ReadRequest.feedUrl] and stores what it holds. Never throws for a failed read. */ + suspend fun read(request: ReadRequest): ReadOutcome + + /** Stores [document] as a read of [request]'s show; a document that isn't a feed is [ReadOutcome.Failed]. */ + suspend fun store(request: ReadRequest, document: Document): ReadOutcome + + /** Notes that the show page opened this show now, which keeps its copy from the automatic cleanup. */ + suspend fun markOpened(podcastId: String, atMillis: Long) + + /** + * Removes the copies of unfollowed shows not opened in 30 days, then the least recently opened + * ones over the size cap, never [keepPodcastId]. Only `local_episodes` and `local_episode_feeds` + * rows go: listening history, downloads, the queue and episode aliases stay. Returns how many + * shows were removed. + */ + suspend fun cleanUp(nowMillis: Long, keepPodcastId: String? = null): Int +} diff --git a/core/downloads/README.md b/core/downloads/README.md index 81ab6e863..5aec85753 100644 --- a/core/downloads/README.md +++ b/core/downloads/README.md @@ -84,6 +84,7 @@ Files under `core/data` are compatibility stubs for previously scheduled WorkMan ./gradlew :core:downloads:testDebugUnitTest ``` - `DownloadAudioUriTest` covers episodes whose audio URL is null or blank. +- `AutoDownloadCoordinatorTest.anUnfollowedCachedShowNeverAutoDownloads`: a show page's stored copy of an unfollowed show (P4 PR 16) schedules no post-write work, claims no release and enqueues nothing, even with auto-download left on from when it was followed. ## CI relevance diff --git a/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt b/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt index 62cbfdebb..79061e299 100644 --- a/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt +++ b/core/downloads/src/test/java/cx/aswin/boxlore/core/downloads/AutoDownloadCoordinatorTest.kt @@ -7,10 +7,12 @@ import cx.aswin.boxlore.core.database.AutoDownloadReleaseEntity import cx.aswin.boxlore.core.database.BoxLoreDatabase import cx.aswin.boxlore.core.database.PodcastEntity import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort import cx.aswin.boxlore.core.prefs.UserPreferencesRepository import cx.aswin.boxlore.core.rss.LocalEpisodeCatalogRepository import cx.aswin.boxlore.core.rss.RssFeedClient import cx.aswin.boxlore.core.rss.RssFetchResult +import cx.aswin.boxlore.core.rss.RssPrefixFetch import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter @@ -144,6 +146,31 @@ class AutoDownloadCoordinatorTest { assertNull(database.autoDownloadDao().getShow("100")) } + /** + * A show page's stored copy of an unfollowed show (P4 PR 16) never reaches auto-download: the + * read runs no post-write work, and the show is not eligible however its old settings read. + */ + @Test fun anUnfollowedCachedShowNeverAutoDownloads() = runBlocking { + val persisted = mutableListOf() + val showPages = LocalEpisodeCatalogRepository.create(database, feed, onCatalogPersisted = { persisted += it }) + database.podcastDao().upsert( + PodcastEntity("200", "Cached", "Author", "", null, isSubscribed = false, autoDownloadEnabled = true, feedUrl = URL), + ) + val coordinator = coordinator() + coordinator.synchronizeSubscriptions() + feed.items = listOf("cached-new" to now + 60, "archive" to now - 86_400) + + val read = showPages.showPages.read(ShowPageFeedPort.ReadRequest("200", URL, ShowPageFeedPort.Scope.NEWEST)) + coordinator.scanCached("200") + coordinator.acceptRelease("200", catalog.findByCatalogKey("200", "cached-new", null)!!) + + assertTrue(read is ShowPageFeedPort.ReadOutcome.Stored) + assertTrue("a show page read schedules nothing", persisted.isEmpty()) + assertTrue(enqueued.isEmpty()) + assertNull(database.autoDownloadDao().getShow("200")) + assertFalse("the copy is never the followed live list", catalog.isReady("200")) + } + @Test fun publisherFailureKeepsPendingForRetry() = runBlocking { val coordinator = coordinator() coordinator.synchronizeSubscriptions() @@ -266,6 +293,7 @@ class AutoDownloadCoordinatorTest { var fail = false var afterFetch: () -> Unit = {} override suspend fun fetchConditional(url: String, etag: String?, lastModified: String?) = fetch(url) + override suspend fun fetchNewest(url: String, maxItems: Int, etag: String?, lastModified: String?) = RssPrefixFetch(fetch(url), complete = true) override suspend fun fetch(url: String): RssFetchResult { fetches++ if (fail) throw java.io.IOException("offline") diff --git a/core/rss/README.md b/core/rss/README.md index ed7630fb9..72e4245c7 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -15,6 +15,7 @@ Failed automatic fetches record a six-hour retry boundary while keeping catalogs - `RssPodcastRepository` implements `RssSubscriptionPort` and manages RSS podcast and episode catalog operations. Refresh and subscribe remap parsed rows through `StickyRssEpisodeRemap.prepare` before writing `latestEpisode` and `rss_episodes`, so stored `episodeId`s never remint. `getEpisodesAround` queries forward-continuation episodes via `RssEpisodeDao.getEpisodesAfter` for same-show queue playback. - `RssPodcastRepository.legacySubscriptionRepair` exposes `LegacyRssSubscriptionRepair`. `inspect` returns a `LegacyRssFeedSnapshot` without mutating Room; `upgrade` returns a `LegacyRssUpgradeOutcome` and atomically copies a verified RSS catalog under a Podcast Index id without re-keying episodes, and records a trusted alias (`rss_follow_upgrade`) for every copied ID whose key follows the identity contract, so the IDs survive eviction. The copied IDs own their episodes: a trusted owner of the same (show, key) under another ID, left by an earlier read of the show's catalog, is demoted in the same transaction and stays trusted, so a read after eviction comes back under the copied IDs that hold the listener state. When such an owner's ID holds history, a download or a queue entry under any show, the upgrade writes nothing and returns `TARGET_ALREADY_IN_USE`. Both share the per-podcast refresh lock with `refreshCatalog`. - `LocalEpisodeCatalogRepository` implements `LocalEpisodeCatalogPort`: full publisher-feed catalog under a Podcast Index id, sticky upsert by guid, and a structural per-show ready gate (complete persisted count; a non-null unsubscribe `ttlExpiresAt` is not ready so unsubscribed reads stay on Podcast Index). `getWindow` with `aroundEpisodeId` uses `continuationRows` (`getEpisodesAfter`) to fetch the anchor and subsequent forward chronological episodes. A not-ready catalog always bypasses HEAD validators and performs the repair GET. Listener-id or cached PI-tip mismatches never permanently hide a successfully persisted publisher feed. HEAD/quiet skip and mega GET (capped at 2) remain for ready catalogs. It never creates `rss:` rows; incremental upsert never shrinks existing rows. HEAD validators are not reused after the publisher `feedUrl` changes. `sweepExpired` rechecks TTL before delete so a refresh that cleared `ttlExpiresAt` is kept. `:core:catalog` `SubscriptionForegroundSync` runs that sweep on each launch / Subscriptions refresh pass. +- `LocalEpisodeCatalogRepository.showPages` implements `ShowPageFeedPort` (`ShowPageFeedCatalog`) for the feed-first show page: a stored copy of a Podcast Index show's feed, followed or not, in the same `local_episodes` rows and through the same write (sticky IDs, recipe IDs for new items, aliases, the collision guard) under the same per-show lock. A first read takes the newest 100 items with `RssFeedClient.fetchNewest`, a streaming read that stops early (`FeedPrefixReader`, RSS `` or Atom ``, cut documents closed so they parse); the archive read takes the whole feed. `FeedRowPolicy` records `archiveComplete` apart from `ready`: a show-page read never makes a copy the followed live list and never runs the post-write work (auto-download, delivery). An oldest-first feed (classified in document order) is always read whole. `ShowPageFeedRules`: the newest read sends the copy's validators; an archive read of a partial copy goes out unconditional, so a 304 that covers only the stored prefix is never taken as the archive; a copy older than an hour is read again; cleanup removes unfollowed copies not opened in 30 days, then the least recently opened over 40 shows or 20,000 episodes. A document read through boxlore's transport (`store`) never records the transport address in the row. Followed shows' refreshes now also set `archiveComplete`. P4 PR 16. - `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). @@ -112,6 +113,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - 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. - 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. ## Testing notes @@ -127,6 +129,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - `RssParsePolicyTest` checks the size limit and that a large valid feed never reaches the library parser. - `FeedDocumentOrderTest` and the `feed_identity.xml` fixture (`IdentityFixtureFeed`, with the rows older builds stored for it, read by both parsers and by the streaming parser alone): a duplicate GUID whose later copy is newer keeps the first copy; GUID-less items where the parsers pick different addresses are one episode keyed on the enclosure; a feed crossing 4 MB keeps every stored ID in both directions, from rows the new code wrote and from rows older builds wrote on either side; a library-only read finds the rows a joined read stored; NBSP and U+180E padding trimmed with the recipe set (written as `\u` escapes); which item updates which stored row (ID and key per title), and which older rows are no longer refreshed, for local catalogs; every stored ID kept for true RSS rows; exact keys win over older keys. `feed_shared_enclosure.xml`: GUID-less items sharing a cover-image or trailer enclosure stay apart. `LocalEpisodeCatalogRepositoryTest` runs both fixtures through the refresh and the collision guard (rows from older builds on either side of 4 MB with the leftover duplicates unchanged; shared enclosures through repeated refreshes, a new release and a read over 4 MB). `EpisodeSupplementRepositoryTest` refreshes supplements small, large, small from older rows and from none: no ID is deleted or changes. `LegacyRssSubscriptionUpgradeLogicTest` checks the upgrade's copied catalog keeps every stored RSS ID and gives a shared key to the feed item, which owners the copied IDs displace (trusted owners of copied contract keys under other IDs), and the ownership hand-over (demoted once a copied ID owns the key, the copied ID's trusted alias promoted, an owner kept when no copied ID can own). - `EpisodeRecipeIdTest` runs every case in the shared recipe fixture against `:core:model`'s `EpisodeRecipeId` and asserts the fixture's SHA-256, so a changed copy fails here and in the scripts test. +- `FeedPrefixReaderTest` (early stop, tags split across reads, Atom, byte cap, `fetchNewest` validators and 304), `ShowPageFeedRulesTest` (the prefix 304 rule, oldest-first, cleanup age and caps) and `ShowPageFeedCatalogTest` (real Room and parser: newest 100 then the archive with the same IDs, a 304 to the stored newest never taken as the archive, oldest-first feeds read whole, transport documents, cleanup that keeps history, downloads, the queue and aliases, and follow/unfollow writing `followed`). ## CI relevance diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedPrefixReader.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedPrefixReader.kt new file mode 100644 index 000000000..a8c11082c --- /dev/null +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedPrefixReader.kt @@ -0,0 +1,107 @@ +package cx.aswin.boxlore.core.rss + +import java.io.InputStream + +/** + * The start of a feed document read from a stream, for [RssFeedClient.fetchNewest]. + * + * [bytes] is a parseable document: when the read stopped early it is cut after the last whole item + * and closed with the root's end tags. [complete] is true when the stream ended on its own, so + * [bytes] is the whole document. [itemCount] counts the items whose end tag was read. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page: the newest + * 100 come from a streaming read that can stop early). + */ +class FeedPrefix(val bytes: ByteArray, val complete: Boolean, val itemCount: Int) + +/** + * Reads a feed document until [read]'s `maxItems` items have ended, then stops, so a show page can + * show a show's newest episodes without downloading a feed of thousands. Items are RSS `` or + * Atom `` elements, matched by their end tag without regard to case. A document that ends + * before that is returned whole. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ +internal object FeedPrefixReader { + private val ITEM_END = "".toByteArray(Charsets.US_ASCII) + private val ENTRY_END = "".toByteArray(Charsets.US_ASCII) + private val RSS_CLOSE = "\n\n\n".toByteArray(Charsets.US_ASCII) + private val ATOM_CLOSE = "\n\n".toByteArray(Charsets.US_ASCII) + private const val CHUNK = 16 * 1024 + + /** Reads at most [maxBytes]; throws [IllegalArgumentException] past it, as a whole read does. */ + fun read(input: InputStream, maxItems: Int, maxBytes: Long): FeedPrefix { + require(maxItems > 0) { "maxItems must be positive" } + val buffer = GrowableBytes() + val chunk = ByteArray(CHUNK) + val scan = ItemScan() + while (true) { + val read = input.read(chunk) + if (read < 0) return FeedPrefix(buffer.toArray(), complete = true, itemCount = scan.count) + buffer.append(chunk, read) + require(buffer.size <= maxBytes) { "Feed is larger than ${maxBytes / (1024 * 1024)} MB" } + val cut = scan.advance(buffer, maxItems) + if (cut != null) { + val close = if (scan.atom) ATOM_CLOSE else RSS_CLOSE + return FeedPrefix(buffer.toArray(cut) + close, complete = false, itemCount = scan.count) + } + } + } + + /** Finds item end tags across chunk boundaries; [advance] returns the cut offset once [count] reaches the limit. */ + private class ItemScan { + var count = 0 + var atom = false + private var position = 0 + + fun advance(buffer: GrowableBytes, maxItems: Int): Int? { + // The last few bytes wait for the next chunk, so a tag split across chunks is still found. + while (position <= buffer.size - ENTRY_END.size) { + val end = + when { + buffer.matchesAt(position, ITEM_END) -> position + ITEM_END.size + buffer.matchesAt(position, ENTRY_END) -> { + atom = true + position + ENTRY_END.size + } + else -> null + } + if (end == null) { + position++ + continue + } + count++ + position = end + if (count >= maxItems) return end + } + return null + } + } + + private class GrowableBytes { + private var data = ByteArray(CHUNK) + var size = 0 + private set + + fun append(source: ByteArray, length: Int) { + if (size + length > data.size) data = data.copyOf(maxOf(data.size * 2, size + length)) + System.arraycopy(source, 0, data, size, length) + size += length + } + + fun matchesAt(offset: Int, tag: ByteArray): Boolean { + for (index in tag.indices) { + if (lower(data[offset + index]) != tag[index]) return false + } + return true + } + + fun toArray(length: Int = size): ByteArray = data.copyOf(length) + + private fun lower(byte: Byte): Byte = if (byte in UPPER_A..UPPER_Z) (byte + CASE_OFFSET).toByte() else byte + } + + private const val UPPER_A: Byte = 'A'.code.toByte() + private const val UPPER_Z: Byte = 'Z'.code.toByte() + private const val CASE_OFFSET = 'a'.code - 'A'.code +} diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogReads.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogReads.kt index a484b7da3..2ac312320 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogReads.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogReads.kt @@ -119,9 +119,13 @@ internal class LocalEpisodeCatalogReads( dao.getFeed(podcastId)?.feedUrlLookupAt ?: 0L } + /** + * Follow clears the TTL and unfollow sets it; since P4 PR 16 the same write keeps + * `local_episode_feeds.followed` in step, which the cleanup of unfollowed copies reads. + */ override suspend fun setUnsubscribedTtl(podcastId: String, ttlExpiresAt: Long?,) = withContext(Dispatchers.IO) { if (dao.getFeed(podcastId) == null) return@withContext - dao.setTtl(podcastId, ttlExpiresAt) + dao.setTtlAndFollowed(podcastId, ttlExpiresAt, followed = ttlExpiresAt == null) } override suspend fun sweepExpired(nowMillis: Long) = withContext(Dispatchers.IO) { diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt index acac37478..9e7ca9a8e 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt @@ -119,13 +119,64 @@ private suspend fun fetchAndPersist( ) } -private suspend fun persistParsed( +/** + * How a write records the show's feed row besides its episodes. + * + * - [complete]: the read held the whole feed, so the copy is the complete archive + * ([LocalEpisodeFeedEntity.archiveComplete]); a read of the newest items only leaves it as it was. + * - [showPage]: a show-page read ([ShowPageFeedCatalog]). It never makes a copy the followed live + * list: `ready` stays what the stored row already allowed. + * - [documentOrder]: the feed's order classified in document order, for a row that has none yet. + * - [openedAt]: when the show page opened the show (epoch ms), kept from the automatic cleanup. + * - [recordedAddress]: the address the row records instead of the one read; boxlore's transport + * address of an http-only feed is never recorded. + * + * The defaults are a followed show's refresh, which reads the whole feed. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page: `archiveComplete` + * is separate from ready). + * COMPAT(own-ids): feed-first catalog state. Permanent: never remove (MIGRATION.md §5). + */ +internal data class FeedRowPolicy( + val complete: Boolean = true, + val showPage: Boolean = false, + val documentOrder: String? = null, + val openedAt: Long? = null, + val recordedAddress: String? = null, +) { + /** A stored order other than mixed stays; else the document order, else the parsed dates. */ + fun feedOrder(existing: LocalEpisodeFeedEntity?, parsed: ParsedRssFeed): String = existing + ?.feedOrder + ?.takeIf { it != LocalFeedOrder.MIXED } + ?: documentOrder + ?: FeedOrderLogic.classify(parsed.episodes.map { it.publishedDate }) + + /** A show-page read keeps what the stored row allowed; a followed show's refresh flips once the count is complete. */ + fun ready(existing: LocalEpisodeFeedEntity?, storedCount: Int, copied: Int): Boolean = if (showPage) { + LocalCatalogReadyLogic.isReady(existing) + } else { + LocalCatalogReadyLogic.isReadyToFlip(feedReady = storedCount >= copied) + } + + /** A read of the newest items leaves a pending backfill pending. */ + fun needsFullBackfill(existing: LocalEpisodeFeedEntity?): Boolean = !complete && (existing?.needsFullBackfill ?: true) + + /** The v39 columns [LocalEpisodeCatalogDao.upsertFeed] keeps, written on their own. */ + suspend fun writeFeedFirstColumns(dao: LocalEpisodeCatalogDao, podcastId: String) { + if (complete) dao.setArchiveComplete(podcastId, true) + openedAt?.let { dao.setLastOpenedAt(podcastId, it) } + } +} + +@Suppress("LongParameterList", "kotlin:S107") // One write: the read, its parse and how the row is recorded. +internal suspend fun persistParsed( deps: LocalCatalogRefreshDeps, request: RefreshRequest, existing: LocalEpisodeFeedEntity?, fetched: RssFetchResult, rssNamespaceId: String, parsed: ParsedRssFeed, + policy: FeedRowPolicy = FeedRowPolicy(), ): RefreshOutcome { val identities = deps.dao.listIdentities(request.podcastIndexId) val newIds = newEpisodeIds(deps, request, existing) @@ -145,11 +196,7 @@ private suspend fun persistParsed( return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE, addressGone = true, reportStatus = FeedReadFailure.PARSE) } val copied = existing?.copiedExtrasCount ?: 0 - val feedOrder = - existing - ?.feedOrder - ?.takeIf { it != LocalFeedOrder.MIXED } - ?: FeedOrderLogic.classify(parsed.episodes.map { it.publishedDate }) + val feedOrder = policy.feedOrder(existing, parsed) var written = rows var resolution: LocalEpisodeCollisionGuard.Resolution? = null var storedCount = rows.size @@ -178,18 +225,18 @@ private suspend fun persistParsed( recordAliases(deps, request.podcastIndexId, planned, resolved, newIds) deps.reconcileListenerState(request.podcastIndexId, written) storedCount = maxOf(deps.dao.count(request.podcastIndexId), written.size) - ready = LocalCatalogReadyLogic.isReadyToFlip(feedReady = storedCount >= copied) + ready = policy.ready(existing, storedCount, copied) deps.dao.upsertFeed( LocalEpisodeFeedEntity( podcastId = request.podcastIndexId, - feedUrl = fetched.finalUrl, + feedUrl = policy.recordedAddress ?: fetched.finalUrl, feedEtag = fetched.etag, feedLastModified = fetched.lastModified, fetchedAt = System.currentTimeMillis(), itemCount = storedCount, feedOrder = feedOrder, ttlExpiresAt = null, - needsFullBackfill = false, + needsFullBackfill = policy.needsFullBackfill(existing), copiedExtrasCount = copied, ready = ready, feedUrlLookupAt = existing?.feedUrlLookupAt ?: 0L, @@ -201,6 +248,7 @@ private suspend fun persistParsed( deps.dao.setEpNsIfUnset(request.podcastIndexId, epNs) deps.identity.keepShowNamespace(request.podcastIndexId, epNs) } + policy.writeFeedFirstColumns(deps.dao, request.podcastIndexId) } resolution?.let { logResolution(request.podcastIndexId, it, written = !incomplete) } if (incomplete) return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE) 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 bd6f41471..505a2862f 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 @@ -9,6 +9,7 @@ import cx.aswin.boxlore.core.database.LocalFeedOrder import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshRequest +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.Dispatchers @@ -92,6 +93,17 @@ class LocalEpisodeCatalogRepository private constructor( private val refreshLocks = ConcurrentHashMap() + /** + * The show page's stored feed copies, followed or not, written under the same per-show lock as + * [refresh] ([ShowPageFeedCatalog]). + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). + */ + val showPages: ShowPageFeedPort by lazy { + ShowPageFeedCatalog(refreshDeps) { podcastId -> refreshLocks.getOrPut(podcastId) { Mutex() } } + } + override suspend fun refresh(request: RefreshRequest): RefreshOutcome = withContext(Dispatchers.IO) { val lock = refreshLocks.getOrPut(request.podcastIndexId) { Mutex() } val outcome = lock.withLock { refreshLocalCatalogLocked(refreshDeps, request) } diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt index 6210e2361..627644a07 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt @@ -57,6 +57,14 @@ data class RssFetchResult( val movedTo: String? = null, ) +/** + * A read of the start of a feed ([RssFeedClient.fetchNewest]). [complete]: the whole document was + * read, so [result] holds every item. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ +class RssPrefixFetch(val result: RssFetchResult, val complete: Boolean) + sealed interface RssFreshnessResult { data class Unchanged(val etag: String?, val lastModified: String?,) : RssFreshnessResult @@ -116,6 +124,33 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien ) } + /** + * Reads the first [maxItems] items of a feed and stops, instead of downloading the whole + * document ([FeedPrefixReader]). Sends [etag] and [lastModified] when given; null means a 304, + * so the stored copy is current. [RssPrefixFetch.complete] says whether the whole document was + * read. The validators in the answer describe the whole document, not the part read. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ + open suspend fun fetchNewest(url: String, maxItems: Int, etag: String? = null, lastModified: String? = null,): RssPrefixFetch? = + execute(conditionalGetRequest(url, etag, lastModified)) { response -> + if (response.code == HTTP_NOT_MODIFIED) return@execute null + if (!response.isSuccessful) throw FeedHttpException(response.code) + validateContentType(response) + val prefix = FeedPrefixReader.read(response.body.byteStream(), maxItems, MAX_FEED_BYTES) + RssPrefixFetch( + result = + RssFetchResult( + finalUrl = response.request.url.toString(), + etag = response.header("ETag"), + lastModified = response.header(LAST_MODIFIED_HEADER), + body = prefix.bytes, + movedTo = permanentMoveTarget(response), + ), + complete = prefix.complete, + ) + } + suspend fun confirmHeadValidators(url: String, etag: String?, lastModified: String?,): Boolean = confirmUnchanged(url, etag, lastModified) /** diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt new file mode 100644 index 000000000..2445e3d6c --- /dev/null +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt @@ -0,0 +1,174 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity +import cx.aswin.boxlore.core.database.LocalFeedOrder +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshRequest +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadOutcome +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadRequest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +/** + * [ShowPageFeedPort] on the local episode catalog: a show page's reads go into the same + * `local_episodes` rows a followed show's refresh writes, through the same write + * ([persistParsed]: sticky IDs, recipe IDs for new items, aliases, the collision guard), under the + * same per-show lock. What differs is recorded in the feed row ([FeedRowPolicy]): a read of the + * newest items leaves the archive incomplete, and a show-page read never makes an unfollowed copy + * the followed live list. No post-write work runs (auto-download, delivery). + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page; decision D10). + * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). + */ +internal class ShowPageFeedCatalog( + private val deps: LocalCatalogRefreshDeps, + private val lockFor: (String) -> Mutex, +) : ShowPageFeedPort { + override suspend fun cached(podcastId: String): ShowPageFeedPort.CachedShow? = withContext(Dispatchers.IO) { + val feed = deps.dao.getFeed(podcastId) ?: return@withContext null + val count = deps.dao.count(podcastId) + if (count <= 0) return@withContext null + ShowPageFeedPort.CachedShow( + podcastId = podcastId, + feedUrl = feed.feedUrl, + fetchedAt = feed.fetchedAt, + itemCount = count, + archiveComplete = feed.archiveComplete, + oldestFirst = feed.feedOrder == LocalFeedOrder.OLDEST_FIRST, + ) + } + + override suspend fun read(request: ReadRequest): ReadOutcome = withContext(Dispatchers.IO) { + if (!isFeedShow(request.podcastId)) return@withContext ReadOutcome.Failed(OTHER) + val url = LocalEpisodeCatalogRepository.resolveHttps(request.feedUrl, null) ?: return@withContext ReadOutcome.Failed(OTHER) + lockFor(request.podcastId).withLock { + failSafe { + val existing = deps.dao.getFeed(request.podcastId) + val wholeFeed = ShowPageFeedRules.readsWholeFeed(request.scope, existing) + val fetched = fetch(url, wholeFeed, existing) ?: return@failSafe notModified(request.podcastId, existing) + persist(request, existing, fetched, url) + } + } + } + + override suspend fun store(request: ReadRequest, document: ShowPageFeedPort.Document): ReadOutcome = withContext(Dispatchers.IO) { + if (!isFeedShow(request.podcastId)) return@withContext ReadOutcome.Failed(OTHER) + lockFor(request.podcastId).withLock { + failSafe { + val existing = deps.dao.getFeed(request.podcastId) + val result = RssFetchResult(document.finalUrl, document.etag, null, document.bytes) + // The transport address needs the API session: the row keeps the address it had, if any. + persist(request, existing, Fetched(result, document.complete), url = null, recordedAddress = existing?.feedUrl.orEmpty()) + } + } + } + + override suspend fun markOpened(podcastId: String, atMillis: Long) = withContext(Dispatchers.IO) { + deps.dao.setLastOpenedAt(podcastId, atMillis) + } + + override suspend fun cleanUp(nowMillis: Long, keepPodcastId: String?): Int = withContext(Dispatchers.IO) { + val victims = ShowPageFeedRules.cleanupVictims(deps.dao.listUnfollowedCopies(), nowMillis, keepPodcastId) + victims.count { podcastId -> lockFor(podcastId).withLock { deps.dao.deleteCatalogIfUnfollowed(podcastId) } } + } + + private class Fetched(val result: RssFetchResult, val complete: Boolean) + + /** The read, or null for a 304 that answers it. */ + private suspend fun fetch(url: String, wholeFeed: Boolean, existing: LocalEpisodeFeedEntity?): Fetched? { + val validators = ShowPageFeedRules.validators(wholeFeed, existing) + if (!wholeFeed) { + val prefix = deps.feedClient.fetchNewest(url, ShowPageFeedRules.NEWEST_ITEMS, validators?.first, validators?.second) ?: return null + return Fetched(prefix.result, prefix.complete) + } + val conditional = deps.megaGetGate.withPermit { deps.feedClient.fetchConditional(url, validators?.first, validators?.second) } + if (conditional != null) return Fetched(conditional, complete = true) + if (ShowPageFeedRules.acceptsNotModified(wholeFeed = true, cachedArchiveComplete = existing?.archiveComplete == true)) return null + // A 304 that covers only the stored newest items is not the archive: read it whole. + return Fetched(deps.megaGetGate.withPermit { deps.feedClient.fetch(url) }, complete = true) + } + + private suspend fun notModified(podcastId: String, existing: LocalEpisodeFeedEntity?): ReadOutcome { + existing?.let { deps.dao.upsertFeed(it.copy(fetchedAt = deps.nowMillis())) } + deps.dao.setLastOpenedAt(podcastId, deps.nowMillis()) + return ReadOutcome.NotModified + } + + private suspend fun persist( + request: ReadRequest, + existing: LocalEpisodeFeedEntity?, + first: Fetched, + url: String?, + recordedAddress: String? = null, + ): ReadOutcome { + var fetched = first + var parsed = parseOrNull(fetched) + if (parsed == null && !fetched.complete && url != null) { + // The cut-short document didn't parse (an unusual root): read the whole feed instead. + fetched = Fetched(deps.megaGetGate.withPermit { deps.feedClient.fetch(url) }, complete = true) + parsed = parseOrNull(fetched) + } + if (parsed == null) return ReadOutcome.Failed(FeedReadFailure.PARSE) + var order = documentOrder(parsed) + if (!fetched.complete && order == LocalFeedOrder.OLDEST_FIRST && url != null) { + // The start of an oldest-first feed holds its oldest items, not its newest. + fetched = Fetched(deps.megaGetGate.withPermit { deps.feedClient.fetch(url) }, complete = true) + parsed = parseOrNull(fetched) ?: return ReadOutcome.Failed(FeedReadFailure.PARSE) + order = documentOrder(parsed) + } + val refresh = + RefreshRequest( + podcastIndexId = request.podcastId, + feedUrl = url ?: fetched.result.finalUrl, + meta = request.meta, + runPostPersistCallback = false, + serverEpNs = request.serverEpNs, + ) + val policy = + FeedRowPolicy( + complete = fetched.complete, + showPage = true, + documentOrder = order, + openedAt = deps.nowMillis(), + recordedAddress = recordedAddress, + ) + val namespace = RssIdGenerator.podcastId(fetched.result.finalUrl) + return when (val outcome = persistParsed(deps, refresh, existing, fetched.result, namespace, parsed, policy)) { + is RefreshOutcome.Success -> ReadOutcome.Stored(outcome.itemCount, fetched.complete) + is RefreshOutcome.Unchanged -> ReadOutcome.NotModified + is RefreshOutcome.Failure -> ReadOutcome.Failed(outcome.reportStatus ?: OTHER) + } + } + + private suspend fun parseOrNull(fetched: Fetched): ParsedRssFeed? = try { + deps.feedClient.parse(fetched.result.finalUrl, fetched.result.body, RssIdGenerator.podcastId(fetched.result.finalUrl)) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } + + private suspend fun failSafe(block: suspend () -> ReadOutcome): ReadOutcome = try { + block() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + ReadOutcome.Failed(FeedReadFailure.reportStatus(error)) + } + + private companion object { + const val OTHER = "other" + + /** Show pages store copies for Podcast Index shows; `rss:` shows have their own table. */ + fun isFeedShow(podcastId: String): Boolean = podcastId.isNotBlank() && !podcastId.startsWith("rss:") + + /** The feed's order as its document lists the items, not as the parser sorted them. */ + fun documentOrder(parsed: ParsedRssFeed): String = + FeedOrderLogic.classify(parsed.items.sortedBy(ParsedFeedItem::documentIndex).map { it.episode.publishedDate }) + } +} diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedRules.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedRules.kt new file mode 100644 index 000000000..23170a049 --- /dev/null +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedRules.kt @@ -0,0 +1,69 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity +import cx.aswin.boxlore.core.database.LocalFeedOrder +import cx.aswin.boxlore.core.database.UnfollowedCatalogCopy +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope + +/** + * The rules of a show page's stored feed copy, kept apart from I/O so they can be tested alone. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page; decision D10). + * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). + */ +object ShowPageFeedRules { + /** How many of a feed's newest items a first read takes. */ + const val NEWEST_ITEMS = 100 + + /** A stored copy is shown at once and read again with its validators once it is this old. */ + const val RECHECK_AFTER_MS = 60L * 60L * 1000L + + /** An unfollowed show's copy not opened for this long is removed. */ + const val UNUSED_AFTER_MS = 30L * 24L * 60L * 60L * 1000L + + /** At most this many unfollowed shows keep a copy… */ + const val MAX_UNFOLLOWED_SHOWS = 40 + + /** …and at most this many episodes between them. */ + const val MAX_UNFOLLOWED_EPISODES = 20_000 + + /** True when a read of the start of the feed can't give its newest items (oldest-first feeds). */ + fun readsWholeFeed(scope: Scope, cached: LocalEpisodeFeedEntity?): Boolean = scope == Scope.ARCHIVE || cached?.feedOrder == LocalFeedOrder.OLDEST_FIRST + + /** + * The validators to send with a read, or null to read without them. A copy that holds only the + * newest items has validators for the whole document, so a 304 to an archive read would only + * say the start is current: archive reads of such a copy go out without validators, and a 304 + * to them is never taken as the archive ([acceptsNotModified]). + */ + fun validators(wholeFeed: Boolean, cached: LocalEpisodeFeedEntity?): Pair? { + if (cached == null || cached.itemCount <= 0) return null + if (wholeFeed && !cached.archiveComplete) return null + if (cached.feedEtag.isNullOrBlank() && cached.feedLastModified.isNullOrBlank()) return null + return cached.feedEtag to cached.feedLastModified + } + + /** Whether a 304 answers this read: always for the newest items, for the archive only when the copy is already complete. */ + fun acceptsNotModified(wholeFeed: Boolean, cachedArchiveComplete: Boolean): Boolean = !wholeFeed || cachedArchiveComplete + + /** Copies to remove: not opened in [UNUSED_AFTER_MS], then the least recently opened over the caps. Never [keep]. */ + fun cleanupVictims(copies: List, nowMillis: Long, keep: String?): List { + val candidates = copies.filter { it.podcastId != keep } + val (stale, fresh) = candidates.partition { nowMillis - lastUse(it) > UNUSED_AFTER_MS } + val keptEpisodes = copies.filter { it.podcastId == keep }.sumOf { it.episodeCount } + var shows = copies.size - candidates.size + var episodes = keptEpisodes + val overCap = mutableListOf() + for (copy in fresh.sortedByDescending(::lastUse)) { + if (shows + 1 > MAX_UNFOLLOWED_SHOWS || episodes + copy.episodeCount > MAX_UNFOLLOWED_EPISODES) { + overCap += copy.podcastId + } else { + shows++ + episodes += copy.episodeCount + } + } + return stale.map { it.podcastId } + overCap + } + + private fun lastUse(copy: UnfollowedCatalogCopy): Long = maxOf(copy.lastOpenedAt, copy.fetchedAt) +} diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedPrefixReaderTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedPrefixReaderTest.kt new file mode 100644 index 000000000..90a3f3ebe --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedPrefixReaderTest.kt @@ -0,0 +1,119 @@ +package cx.aswin.boxlore.core.rss + +import java.io.ByteArrayInputStream +import java.io.InputStream +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** The streaming read of a feed's newest items stops early and leaves a document that still parses as a whole. */ +class FeedPrefixReaderTest { + @Test + fun stopsAfterTheItemLimitAndClosesTheRssDocument() { + val prefix = FeedPrefixReader.read(input(rss(150)), maxItems = 100, maxBytes = MAX) + + assertFalse(prefix.complete) + assertEquals(100, prefix.itemCount) + val text = prefix.bytes.decodeToString() + assertTrue(text.contains("g99")) + assertFalse(text.contains("g100")) + assertTrue(text.trimEnd().endsWith("\n")) + } + + @Test + fun aShortFeedIsReadWhole() { + val document = rss(30) + val prefix = FeedPrefixReader.read(input(document), maxItems = 100, maxBytes = MAX) + + assertTrue(prefix.complete) + assertEquals(30, prefix.itemCount) + assertEquals(document, prefix.bytes.decodeToString()) + } + + @Test + fun tagsSplitAcrossReadsAndAnyCaseAreFound() { + val document = rss(5).replace("", "") + val prefix = FeedPrefixReader.read(TrickleStream(document.encodeToByteArray(), chunk = 3), maxItems = 3, maxBytes = MAX) + + assertFalse(prefix.complete) + assertEquals(3, prefix.itemCount) + assertTrue(prefix.bytes.decodeToString().contains("g2")) + assertFalse(prefix.bytes.decodeToString().contains("g3")) + } + + @Test + fun atomEntriesCloseTheFeedElement() { + val entries = (0 until 4).joinToString("") { "e$it" } + val prefix = FeedPrefixReader.read(input("$entries"), maxItems = 2, maxBytes = MAX) + + assertEquals(2, prefix.itemCount) + assertTrue(prefix.bytes.decodeToString().trimEnd().endsWith("e1\n")) + } + + @Test + fun aDocumentPastTheByteCapFails() { + assertThrows(IllegalArgumentException::class.java) { + FeedPrefixReader.read(input(rss(50)), maxItems = 100, maxBytes = 200) + } + } + + @Test + fun fetchNewestSendsValidatorsTreatsA304AsCurrentAndReportsCompleteness() = runTest { + val requests = mutableListOf() + var code = 304 + val client = + RssFeedClient( + OkHttpClient.Builder().addInterceptor { chain -> + requests += chain.request() + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(code).message("test") + .header("ETag", "whole-doc").body(rss(150).toResponseBody("application/rss+xml".toMediaType())).build() + }.build(), + ) + + assertNull(client.fetchNewest("https://example.com/feed", 100, "etag-1", null)) + assertEquals("etag-1", requests.single().header("If-None-Match")) + + code = 200 + val read = requireNotNull(client.fetchNewest("https://example.com/feed", 100)) + assertFalse(read.complete) + assertEquals("whole-doc", read.result.etag) + assertNull(requests.last().header("If-None-Match")) + assertTrue(requireNotNull(client.fetchNewest("https://example.com/feed", 500)).complete) + } + + private fun input(text: String): InputStream = ByteArrayInputStream(text.encodeToByteArray()) + + /** Hands out at most [chunk] bytes per read, like a slow connection. */ + private class TrickleStream(private val bytes: ByteArray, private val chunk: Int) : InputStream() { + private var position = 0 + + override fun read(): Int = if (position < bytes.size) bytes[position++].toInt() and 0xFF else -1 + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= bytes.size) return -1 + val count = minOf(chunk, length, bytes.size - position) + System.arraycopy(bytes, position, buffer, offset, count) + position += count + return count + } + } + + private companion object { + const val MAX = 25L * 1024L * 1024L + + fun rss(items: Int): String { + val body = (0 until items).joinToString("") { "T$itg$it" } + return "Show$body" + } + } +} 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 428a55701..92ff9f76a 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 @@ -809,6 +809,24 @@ class LocalEpisodeCatalogRepositoryTest { feeds[podcastId] = feeds.getValue(podcastId).copy(feedUrlLookupAt = atMillis) } + override suspend fun setArchiveComplete(podcastId: String, archiveComplete: Boolean,) { + feeds[podcastId]?.let { feeds[podcastId] = it.copy(archiveComplete = archiveComplete) } + } + + override suspend fun setLastOpenedAt(podcastId: String, atMillis: Long,) { + feeds[podcastId]?.let { feeds[podcastId] = it.copy(lastOpenedAt = atMillis) } + } + + override suspend fun setTtlAndFollowed(podcastId: String, ttlExpiresAt: Long?, followed: Boolean,) { + feeds[podcastId]?.let { feeds[podcastId] = it.copy(ttlExpiresAt = ttlExpiresAt, followed = followed) } + } + + override suspend fun listUnfollowedCopies(): List = feeds.values + .filter { !it.followed && it.ttlExpiresAt == null } + .map { feed -> cx.aswin.boxlore.core.database.UnfollowedCatalogCopy(feed.podcastId, feed.lastOpenedAt, feed.fetchedAt, episodes.count { it.podcastId == feed.podcastId }) } + + override suspend fun isUnfollowedCopy(podcastId: String): Boolean = feeds[podcastId]?.let { !it.followed && it.ttlExpiresAt == null } == true + override suspend fun setEpNsIfUnset(podcastId: String, epNs: Long,) { feeds[podcastId]?.takeIf { it.epNs == null }?.let { feeds[podcastId] = it.copy(epNs = epNs) } } diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt new file mode 100644 index 000000000..f1653a7bc --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt @@ -0,0 +1,181 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.LocalFeedOrder +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadOutcome +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.EpisodeRecipeId +import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +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 show page's stored feed copy through the real catalog write on a real Room database, with + * feed documents served over an interceptor and parsed by the real parser: the newest 100 first, + * the archive later with the same IDs, the prefix 304 rule, oldest-first feeds, and the cleanup that + * never touches history, downloads, the queue or aliases. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ShowPageFeedCatalogTest : RoomCatalogTestBase() { + private val requests = CopyOnWriteArrayList() + private var document: String = feedXml(150) + private var status = 200 + private val persisted = mutableListOf() + + private val client = + RssFeedClient( + OkHttpClient.Builder().addInterceptor { chain -> + requests += chain.request() + val answer = if (chain.request().header("If-None-Match") == ETAG && status == 200) 304 else status + Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(answer).message("test") + .header("ETag", ETAG).body(document.toResponseBody("application/rss+xml".toMediaType())).build() + }.build(), + ) + + private val catalog by lazy { + LocalEpisodeCatalogRepository(database, client, DownloadCacheRelinker { _, _ -> false }, { persisted += it }, RecipeIdHash.RECIPE) + } + + private val pages: ShowPageFeedPort get() = catalog.showPages + + @Test + fun theNewestHundredComeFirstAndTheArchiveKeepsTheirIds() = runBlocking { + val newest = pages.read(request(Scope.NEWEST)) + + assertEquals(ReadOutcome.Stored(100, complete = false), newest) + val first = rows(SHOW) + assertEquals(100, first.size) + assertEquals("new items take their recipe IDs", EpisodeRecipeId.forNamespace(SHOW.toLong(), "g0").toString(), first["g0"]) + val cached = requireNotNull(pages.cached(SHOW)) + assertFalse(cached.archiveComplete) + assertFalse("an unfollowed copy is never the live list", catalog.isReady(SHOW)) + assertTrue("a show page read schedules nothing", persisted.isEmpty()) + + val archive = pages.read(request(Scope.ARCHIVE)) + + assertEquals(ReadOutcome.Stored(150, complete = true), archive) + val all = rows(SHOW) + assertEquals(150, all.size) + for ((key, id) in first) assertEquals("$key keeps its ID", id, all[key]) + assertTrue(requireNotNull(pages.cached(SHOW)).archiveComplete) + assertFalse(catalog.isReady(SHOW)) + assertTrue(database.localEpisodeCatalogDao().getFeed(SHOW)!!.lastOpenedAt > 0L) + } + + @Test + fun a304ToTheStoredNewestIsNeverTakenAsTheArchive() = runBlocking { + pages.read(request(Scope.NEWEST)) + assertEquals("the validators of the whole document were kept", ETAG, database.localEpisodeCatalogDao().getFeed(SHOW)!!.feedEtag) + + assertEquals(ReadOutcome.NotModified, pages.read(request(Scope.NEWEST))) + assertEquals(ETAG, requests.last().header("If-None-Match")) + + val archive = pages.read(request(Scope.ARCHIVE)) + + assertNull("an archive read of a partial copy goes out without validators", requests.last().header("If-None-Match")) + assertEquals(ReadOutcome.Stored(150, complete = true), archive) + // Once the archive is whole, its validators answer an archive read too. + assertEquals(ReadOutcome.NotModified, pages.read(request(Scope.ARCHIVE))) + } + + @Test + fun theStartOfAnOldestFirstFeedIsNotItsNewestSoTheWholeFeedIsRead() = runBlocking { + document = feedXml(150, oldestFirst = true) + + assertEquals(ReadOutcome.Stored(150, complete = true), pages.read(request(Scope.NEWEST))) + assertEquals(LocalFeedOrder.OLDEST_FIRST, database.localEpisodeCatalogDao().getFeed(SHOW)!!.feedOrder) + assertTrue(requireNotNull(pages.cached(SHOW)).oldestFirst) + } + + @Test + fun aFailedReadSaysWhatItSawAndStoresNothing() = runBlocking { + status = 404 + assertEquals(ReadOutcome.Failed("http_404"), pages.read(request(Scope.NEWEST))) + assertNull(pages.cached(SHOW)) + assertEquals(ReadOutcome.Failed("other"), pages.read(request(Scope.NEWEST).copy(podcastId = "rss:abc"))) + } + + @Test + fun aTransportDocumentIsStoredWithoutRecordingTheTransportAddress() = runBlocking { + val transport = ShowPageFeedPort.Document(feedXml(20).encodeToByteArray(), "https://api.example/v2/feed/$SHOW", "t-etag", complete = true) + + assertEquals(ReadOutcome.Stored(20, complete = true), pages.store(request(Scope.NEWEST), transport)) + assertEquals("", database.localEpisodeCatalogDao().getFeed(SHOW)!!.feedUrl) + } + + @Test + fun cleanupRemovesOnlyUnfollowedCopiesAndKeepsListenerState() = runBlocking { + document = feedXml(5) + pages.read(request(Scope.ARCHIVE)) + pages.read(request(Scope.ARCHIVE, show = OTHER_SHOW)) + pages.read(request(Scope.ARCHIVE, show = THIRD_SHOW)) + follow(THIRD_SHOW) + val played = rows(SHOW).getValue("g1") + history(played, SHOW) + completedDownload(played, SHOW, "Ep 1") + queue(played, SHOW, "https://cdn.example/g1.mp3", "Ep 1") + val aliasesBefore = database.episodeIdentityDao().aliasesForShow(SHOW).size + + val removed = pages.cleanUp(nowMillis = System.currentTimeMillis() + 31L * DAY, keepPodcastId = OTHER_SHOW) + + assertEquals(1, removed) + assertNull(pages.cached(SHOW)) + assertNotNull("the show being opened stays", pages.cached(OTHER_SHOW)) + assertNotNull("a followed show's catalog stays", pages.cached(THIRD_SHOW)) + assertNotNull(database.listeningHistoryDao().getHistoryItem(played)) + assertNotNull(database.downloadedEpisodeDao().getDownload(played)) + assertNotNull(database.queueDao().getQueueItemByEpisodeId(played)) + assertEquals("aliases outlive the copy", aliasesBefore, database.episodeIdentityDao().aliasesForShow(SHOW).size) + } + + @Test + fun followAndUnfollowWriteTheFollowedColumn() = runBlocking { + pages.read(request(Scope.NEWEST)) + catalog.setUnsubscribedTtl(SHOW, null) + assertTrue(database.localEpisodeCatalogDao().getFeed(SHOW)!!.followed) + catalog.setUnsubscribedTtl(SHOW, 5L) + assertFalse(database.localEpisodeCatalogDao().getFeed(SHOW)!!.followed) + database.podcastDao().upsert(PodcastEntity(podcastId = SHOW, title = "Show", author = "", imageUrl = "", description = null, isSubscribed = false)) + assertEquals("a copy under an unsubscribe TTL is left to the TTL sweep", 0, pages.cleanUp(Long.MAX_VALUE / 2)) + } + + private fun request(scope: Scope, show: String = SHOW) = ShowPageFeedPort.ReadRequest(show, feedUrl(show), scope, LocalEpisodeCatalogPort.PodcastMeta(title = "Show")) + + private companion object { + const val ETAG = "\"whole-doc\"" + const val DAY = 24L * 60L * 60L * 1000L + + /** [count] items dated a day apart, newest first unless [oldestFirst]. */ + fun feedXml(count: Int, oldestFirst: Boolean = false): String { + val order = if (oldestFirst) (count - 1 downTo 0) else (0 until count) + val items = + order.joinToString("") { index -> + val date = DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.ofEpochSecond(1_790_000_000L - index * 86_400L).atZone(ZoneOffset.UTC)) + "Episode $indexg$index$date" + + "" + } + return "Show$items" + } + } +} diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedRulesTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedRulesTest.kt new file mode 100644 index 000000000..afd068681 --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedRulesTest.kt @@ -0,0 +1,101 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity +import cx.aswin.boxlore.core.database.LocalFeedOrder +import cx.aswin.boxlore.core.database.UnfollowedCatalogCopy +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ShowPageFeedRulesTest { + @Test + fun aCopyOfTheNewestItemsNeverAnswersAnArchiveReadWithA304() { + val partial = copy(archiveComplete = false) + + assertEquals("etag" to "lm", ShowPageFeedRules.validators(wholeFeed = false, cached = partial)) + assertNull(ShowPageFeedRules.validators(wholeFeed = true, cached = partial), "an archive read of a partial copy goes out unconditional") + assertFalse(ShowPageFeedRules.acceptsNotModified(wholeFeed = true, cachedArchiveComplete = false)) + assertTrue(ShowPageFeedRules.acceptsNotModified(wholeFeed = false, cachedArchiveComplete = false)) + } + + @Test + fun aCompleteCopySendsItsValidatorsForEitherRead() { + val complete = copy(archiveComplete = true) + + assertEquals("etag" to "lm", ShowPageFeedRules.validators(wholeFeed = true, cached = complete)) + assertTrue(ShowPageFeedRules.acceptsNotModified(wholeFeed = true, cachedArchiveComplete = true)) + } + + @Test + fun noCopyOrNoValidatorsReadsUnconditionally() { + assertNull(ShowPageFeedRules.validators(wholeFeed = false, cached = null)) + assertNull(ShowPageFeedRules.validators(wholeFeed = false, cached = copy(itemCount = 0))) + assertNull(ShowPageFeedRules.validators(wholeFeed = false, cached = copy(etag = null, lastModified = null))) + } + + @Test + fun anOldestFirstFeedIsAlwaysReadWhole() { + assertTrue(ShowPageFeedRules.readsWholeFeed(Scope.NEWEST, copy(order = LocalFeedOrder.OLDEST_FIRST))) + assertFalse(ShowPageFeedRules.readsWholeFeed(Scope.NEWEST, copy(order = LocalFeedOrder.NEWEST_FIRST))) + assertTrue(ShowPageFeedRules.readsWholeFeed(Scope.ARCHIVE, null)) + } + + @Test + fun cleanupRemovesCopiesUnusedForThirtyDaysButNeverTheOneOpening() { + val now = 100L * DAY + val copies = + listOf( + UnfollowedCatalogCopy("old", lastOpenedAt = now - 31 * DAY, fetchedAt = now - 40 * DAY, episodeCount = 10), + UnfollowedCatalogCopy("recent", lastOpenedAt = now - 2 * DAY, fetchedAt = now - 2 * DAY, episodeCount = 10), + UnfollowedCatalogCopy("refetched", lastOpenedAt = 0L, fetchedAt = now - DAY, episodeCount = 10), + UnfollowedCatalogCopy("opening", lastOpenedAt = now - 60 * DAY, fetchedAt = now - 60 * DAY, episodeCount = 10), + ) + + assertEquals(listOf("old"), ShowPageFeedRules.cleanupVictims(copies, now, keep = "opening")) + } + + @Test + fun cleanupKeepsTheMostRecentlyOpenedCopiesUnderTheCaps() { + val now = 100L * DAY + val many = (0 until ShowPageFeedRules.MAX_UNFOLLOWED_SHOWS + 3).map { UnfollowedCatalogCopy("s$it", now - it * 1_000L, 0L, 1) } + assertEquals(listOf("s40", "s41", "s42"), ShowPageFeedRules.cleanupVictims(many, now, keep = null)) + + val big = + listOf( + UnfollowedCatalogCopy("newest", now - 1, 0L, 15_000), + UnfollowedCatalogCopy("middle", now - 2, 0L, 6_000), + UnfollowedCatalogCopy("small", now - 3, 0L, 4_000), + ) + // 15,000 + 6,000 is over the 20,000 episode cap; the next copy that fits still stays. + assertEquals(listOf("middle"), ShowPageFeedRules.cleanupVictims(big, now, keep = null)) + } + + private fun copy( + archiveComplete: Boolean = false, + itemCount: Int = 100, + etag: String? = "etag", + lastModified: String? = "lm", + order: String = LocalFeedOrder.NEWEST_FIRST, + ) = LocalEpisodeFeedEntity( + podcastId = "920666", + feedUrl = "https://feeds.example/920666.xml", + feedEtag = etag, + feedLastModified = lastModified, + fetchedAt = 1L, + itemCount = itemCount, + feedOrder = order, + ttlExpiresAt = null, + needsFullBackfill = !archiveComplete, + copiedExtrasCount = 0, + ready = false, + feedUrlLookupAt = 0L, + archiveComplete = archiveComplete, + ) + + private companion object { + const val DAY = 24L * 60L * 60L * 1000L + } +} diff --git a/feature/info/README.md b/feature/info/README.md index d46580456..67dc4573d 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -29,6 +29,8 @@ Disclosure recognizes canonical RSS IDs even with stale source metadata and uses - Detail UI uses centralized Google Sans Flex weight tokens from `:core:designsystem`. - `EpisodeInfoSeekLogic` builds the progress-save payload when seeking an episode that is not the current player item. - For PI-owned shows whose local catalog is **not ready**, unsubscribed Podcast Info may still show **Missing episodes?** (opt-in extras path). The confirm dialog explains that publisher-feed access is allowed once and refreshes on future visits, while subscribing automatically keeps the latest episodes up to date. Subscribe kicks `SubscriptionForegroundSync.requestCatalogIngest` on the application scope so leaving Podcast Info does not cancel the publisher-feed persist (no PI-vs-feed extras compare). The screen only waits on `catalogIngestFinished` to remount if it is still open. Unsubscribe stamps a 14-day Room TTL; that catalog is **not** ready, so the page uses Podcast Index again (a new PI episode still appears). Resubscribe clears the TTL. Once `LocalEpisodeCatalogPort.isReady` is true, the pill is hidden and the episode list comes from Room only. Successful publisher-feed refreshes treat that feed's newest row as authoritative, including same-date/different-id and older-than-PI cross-promo cases. Pull-to-refresh: true RSS uses `refreshCatalog`; subscribed non-pure RSS shows route to a direct-feed catalog refresh via `LocalEpisodeCatalogPort.refresh` (preserving notification, auto-download, skip overrides, artwork fallback, and sort preferences); not-ready opted-in unsubscribed PI still runs the extras refresh; other unsubscribed PI shows reload from `PodcastRepository` (Room or PI). NEW badges still follow newer `publishedDate` only. +- **Feed-first show pages (P4 PR 16, behind `feed_first`).** When `PodcastRepository.showPageSource` is `FEED`, `PodcastInfoFeedFirst` runs the page: a stored copy shows at once (status `CACHED`, no line); without one the first page waits for the feed. Opening reads the newest episodes when the copy is missing or an hour old (`READING_NEWEST`: "Checking the feed for new episodes…"), and the whole archive for an oldest-first page (serial shows) or an oldest-first feed (`COMPLETING_ARCHIVE`: "Loading all episodes from the feed…"). Search, switching to oldest first and scrolling past the stored newest episodes read the archive first; a search the listener keeps typing into waits on the same read. When the feed can't be read the list becomes boxlore's list of the show with `FALLBACK` ("Can’t reach this show’s feed. Showing boxlore’s episode list instead."); offline keeps the stored copy with `OFFLINE` ("You’re offline. Showing saved episodes."). The line (`ShowFeedStatusLine`, a solid `surfaceContainerHigh` card, polite live region) sits above the episodes. After each read the page reloads as many episodes as showed (plus a page when scrolling on), so rows keep their IDs and the scroll position and selection stay while the list grows or switches source. Pull-to-refresh on a show that isn't followed reads the newest episodes at the registry's address (`Target.FEED_FIRST`); once followed, the show's own catalog refresh takes over. A page that opens tries the feed again after an earlier fallback. These pages never offer **Missing episodes?**: the chip and its dialog stay hidden, and search skips the feed extras. Decisions live in `logic/ShowFeedLogic`. With `feed_first` off, a show that isn't followed lists `/v2/episodes` and in-show search filters that list on the phone; v1 `episodes/search` is no longer used. +- `loadMoreEpisodes` and `toggleSort` rethrow `CancellationException` instead of swallowing it. - Show auto-download can be enabled with notifications off or system notification permission denied. Turning the bell off preserves auto-download; the cloud-dirty preference and activation boundary are persisted by the catalog repository. Pure RSS auto-download eligibility remains unchanged. @@ -92,7 +94,9 @@ src/main/java/cx/aswin/boxlore/feature/info/ - Tablet detail content is centered in the shared Reading width (720dp); the app palette still paints the full viewport. Poster rails keep fixed card sizes and Podroll rail items use the shared RailItem cap. Phone modifiers return unchanged. - `PodcastInfoAdaptiveTransitionTest` drives one live `PodcastInfoScreen` (Robolectric/Compose JVM, mocked `PodcastInfoViewModel`) across 599↔600dp width, 479↔480dp height, 839↔840dp in landscape, portrait↔landscape and a flat↔tabletop fold, in both directions, and recreates it with `StateRestorationTester` while the window changes, which is also what a back-stack return does. It covers search query, results and scroll anchor, and the query run again after process death; sort, hide-completed and the visible refresh indicator (no reload or refresh from a window change); selection, including a full-range selection rebuilt after process death, an oldest-first selection that waits for the reloaded page and is looked up in its own order, actions held while the lookup runs, a failed lookup that keeps every ID and retries, a lookup cut short by a reload, Clear during a lookup, and IDs dropped when the page can no longer find them; each of the five dialogs and sheets on both the compact and the tablet host, keeping its content across window changes and recreation, with confirm or save, cancel, Back and outside tap each running its action once or not at all; a restored Missing episodes? dialog that waits for the offer; and the loading indicator, error, retry and empty states, also after recreation. The episode list, loading state and refresh indicator carry test tags. `PodcastEpisodeSelectionLogicTest` covers the restore lookup and its gate; `PodcastInfoLoadGuardTest` covers the repeat-load check for `rss:`-linked shows and catalog IDs that differ from the route. +- `PodcastInfoAdaptiveTransitionTest` also covers the feed status line in each state across every window gate and recreation, and a list that grows (older episodes below, newer ones above, the archive read) and switches source (stored copy → server's list → back) in the same frame as each window change, keeping the scrolled-to row, the selection and the episodes it plays. `ShowFeedLogicTest` covers read depth, the status line, archive gating, reload size, show details on stored-copy episodes and the `FEED_FIRST` pull target. - **Manual acceptance (show page).** Open a show with many episodes. (1) Phone: tap Search, type a word, scroll the results, rotate twice; the field, query, results and scroll stay. (2) Long-press an episode, choose Select all, rotate; the count stays and Play acts on the same episodes. (3) Open each overflow item (Mark all as played, Mark all as unplayed, Playback for this show, Change tag / genre with a typed draft) and the Missing episodes? chip on an unfollowed show; rotate, then confirm, press Back or tap outside, and check each acts once. (4) Tablet or desktop window: repeat (1) to (3) while resizing across 600dp and 840dp wide and 480dp tall, and in split-screen. (5) Foldable: repeat while folding to tabletop and unfolding, which recreates the activity. (6) Developer options → Don't keep activities: leave and return; open search with its query, dialogs and selection come back. (7) Repeat (1), (2) and (6) on a show you follow through its feed, and on a serial show sorted oldest first. Device runs are not part of CI. +- **Manual acceptance (feed-first show page).** Turn `feed_first` on for the device (PostHog), restart the app, then open a show you don't follow. (1) Phone: the line "Checking the feed for new episodes…" shows, then disappears as the list fills; scroll to the end of the first 100 and "Loading all episodes from the feed…" shows while older episodes appear below without the list jumping. (2) Long-press an episode, then rotate and resize split-screen while the archive loads: the selection count and scroll stay. (3) Tap Search and type a word on a show opened for the first time: the archive line shows, then the results. (4) Sort oldest first on a long show: the oldest episode appears after the archive line. (5) Open the show again within an hour: the saved list shows with no line; pull to refresh reads the feed. (6) Airplane mode, reopen: the saved list shows with "You’re offline. Showing saved episodes." (7) A show whose feed is down: after a moment the line says "Can’t reach this show’s feed. Showing boxlore’s episode list instead." and episodes still play. Repeat (1)–(2) on a tablet (landscape split at 840dp) and a foldable (flat ↔ tabletop). With `feed_first` off, the same show lists its episodes from boxlore's list, and search still finds them. - `InfoPhoneGoldenTest` captures the actual podcast and episode screens at both portrait phone sizes and short landscape in light/dark. Mocked ViewModel flows avoid constructing Application-backed ViewModels or making network requests. These local-only captures use `-PphoneGoldens=verify --tests '*PhoneGoldenTest'`; see [capture instructions](../../docs/screenshots/README.md). diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeList.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeList.kt index fdf7c6287..6f22a75df 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeList.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeList.kt @@ -36,7 +36,9 @@ import cx.aswin.boxlore.feature.info.components.EpisodeListIndicators import cx.aswin.boxlore.feature.info.components.EpisodeSelectionUi import cx.aswin.boxlore.feature.info.components.EpisodeToolbar import cx.aswin.boxlore.feature.info.components.PodcastInfoEpisodeSearch +import cx.aswin.boxlore.feature.info.components.ShowFeedStatusLine import cx.aswin.boxlore.feature.info.components.ToolbarWarningBanner +import cx.aswin.boxlore.feature.info.components.showFeedStatusText import cx.aswin.boxlore.feature.info.logic.FeedItem import cx.aswin.boxlore.feature.info.logic.ToolbarWarning import cx.aswin.boxlore.feature.info.logic.podcastSearchHasNoResults @@ -156,6 +158,8 @@ internal fun PodcastInfoEpisodeList( onAction = callbacks.onWarningAction, ) + feedStatusItem(contentState.state.feedStatus) + episodesSection( contentState = contentState, callbacks = callbacks, @@ -190,6 +194,15 @@ internal fun PodcastInfoEpisodeList( } } +/** Feed-first show pages (P4 PR 16): reading the feed, the server's list instead, or offline. */ +private fun LazyListScope.feedStatusItem(status: ShowFeedStatus) { + if (showFeedStatusText(status) != null) { + item(key = "feed_status") { + ShowFeedStatusLine(status = status, modifier = Modifier.animateItem()) + } + } +} + private fun LazyListScope.toolbarWarningItem( warning: ToolbarWarning, onDismiss: () -> Unit, diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt new file mode 100644 index 000000000..042211c97 --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt @@ -0,0 +1,130 @@ +package cx.aswin.boxlore.feature.info + +import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.catalog.ShowPageFeedRead +import cx.aswin.boxlore.core.catalog.ShowPageFeedState +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.logic.ShowFeedLogic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async + +/** + * The feed-first part of [PodcastInfoViewModel]: which show reads its feed, the reads (with the + * status line) and the page reloads after them. The rows keep their IDs, so a reload grows or + * switches the list (stored copy → feed → server's list) without moving the scroll position or + * dropping a selection. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). + */ +internal class PodcastInfoFeedFirst( + private val repository: PodcastRepository, + private val ui: Ui, +) { + /** How the helper reads and writes the page it serves. */ + interface Ui { + fun state(): PodcastInfoUiState.Success? + + fun set(state: PodcastInfoUiState.Success) + + fun currentPodcastId(): String + + fun setOffset(offset: Int) + + suspend fun remount(state: PodcastInfoUiState.Success, episodes: List, hasMore: Boolean): PodcastInfoUiState.Success + } + + /** The show whose page reads its feed; null when the page reads another source. */ + @Volatile + var podcastId: String? = null + private set + + @Volatile + var archiveComplete: Boolean = false + private set + + fun isActive(id: String?): Boolean = id != null && id == podcastId + + /** Whether [podcastId]'s page reads the feed, and what it holds; null for any other source. */ + suspend fun open(podcastId: String, podcast: Podcast?): ShowPageFeedState? { + val opened = repository.openFeedShowPage(podcastId, podcast?.let(ShowFeedLogic::metaOf) ?: PodcastMeta()) + this.podcastId = podcastId.takeIf { opened != null } + if (opened == null) return null + archiveComplete = opened.archiveComplete + return opened + } + + /** The status line and the read a page starts with once its first page shows. */ + suspend fun begin(podcast: Podcast, sort: EpisodeSort, opened: ShowPageFeedState) { + val scope = ShowFeedLogic.openingScope(sort, opened) + if (!ShowFeedLogic.readsOnOpen(opened, scope)) { + setStatus(podcast.id, ShowFeedLogic.openingStatus(opened)) + return + } + read(podcast, scope) + } + + /** + * Reads [podcast]'s feed [scope] deep, then reloads as many episodes as were showing plus + * [extra]. [showStatus] false keeps the status line out of a pull-to-refresh, which has its own + * indicator. Null when the page moved to another show meanwhile. + */ + suspend fun read(podcast: Podcast, scope: Scope, showStatus: Boolean = true, extra: Int = 0): ShowPageFeedRead? { + if (showStatus) setStatus(podcast.id, ShowFeedLogic.readingStatus(scope)) + val read = repository.readShowPageFeed(podcast.id, ShowFeedLogic.metaOf(podcast), scope, podcast.feedUrl) + if (ui.currentPodcastId() != podcast.id) return null + archiveComplete = read.archiveComplete + reload(podcast, extra) + setStatus(podcast.id, ShowFeedLogic.statusAfter(read.result)) + return read + } + + /** + * Reads the rest of the feed before an action that needs every episode, when the copy holds + * only the newest. The read runs in [scope], so a search the listener keeps typing into waits + * on the same read instead of starting it again. + */ + suspend fun completeArchive(scope: CoroutineScope, podcast: Podcast, extra: Int = 0): ShowPageFeedRead? { + if (archiveComplete) return null + val running = archiveRead?.takeIf { it.isActive } ?: scope.async { read(podcast, Scope.ARCHIVE, extra = extra) }.also { archiveRead = it } + return running.await() + } + + @Volatile + private var archiveRead: Deferred? = null + + /** Pages from the start until the list holds as many episodes as it showed, plus [extra]. */ + suspend fun reload(podcast: Podcast, extra: Int = 0) { + val state = ui.state()?.takeIf { it.podcast.id == podcast.id } ?: return + val oldest = state.currentSort == EpisodeSort.OLDEST + val pageSize = if (oldest) OLDEST_PAGE_SIZE else PAGE_SIZE + val wanted = ShowFeedLogic.reloadCount(state.piEpisodes.size, pageSize, extra) + val episodes = mutableListOf() + var offset = 0 + var hasMore = true + while (episodes.size < wanted && hasMore) { + val page = repository.getEpisodesPaginated(podcast.id, minOf(MAX_PAGE, wanted - episodes.size), offset, if (oldest) "oldest" else "newest") + episodes += page.episodes + offset += page.sourceCount + hasMore = page.hasMore && page.sourceCount > 0 + } + val latest = ui.state()?.takeIf { it.podcast.id == podcast.id && it.currentSort == state.currentSort } ?: return + ui.setOffset(offset) + ui.set(ui.remount(latest, ShowFeedLogic.withShow(episodes, latest.podcast), hasMore).copy(isLoadingMore = false)) + } + + private fun setStatus(podcastId: String, status: ShowFeedStatus) { + val state = ui.state()?.takeIf { it.podcast.id == podcastId } ?: return + if (state.feedStatus != status) ui.set(state.copy(feedStatus = status)) + } + + companion object { + const val PAGE_SIZE = 20 + const val OLDEST_PAGE_SIZE = 50 + private const val MAX_PAGE = 100 + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt index e369ad05b..95cbdf28e 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt @@ -55,7 +55,11 @@ internal class PodcastInfoSupplementSupport( repository.getEpisodesPaginated(id, limit, offset, sort) }, { id -> repository.loadPiEpisodesForSupplement(id) }, - { id -> repository.localEpisodeCatalog?.isReady(id) == true }, + // A feed-first page reads the feed itself, so it never offers "Missing episodes?" (P4 PR 16). + { id -> + repository.localEpisodeCatalog?.isReady(id) == true || + repository.showPageSource(id) == cx.aswin.boxlore.core.catalog.ShowPageSource.FEED + }, ) data class MissingEpisodesRefresh( diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt index 70c1538ce..c5ccbade6 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt @@ -21,6 +21,31 @@ enum class DirectFeedChipState { Updated, } +/** + * The status line of a feed-first show page, above its episodes. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ +enum class ShowFeedStatus { + /** Nothing to say: the list is current, or the page doesn't read the feed. */ + NONE, + + /** The phone's stored copy shows; it is recent, so the feed isn't read again yet. No line. */ + CACHED, + + /** The newest episodes are being read from the feed. */ + READING_NEWEST, + + /** Every episode is being read from the feed (search, oldest first, scrolling on). */ + COMPLETING_ARCHIVE, + + /** The feed couldn't be read: the list is boxlore's list of the show. */ + FALLBACK, + + /** No connection: the list is the stored copy, as it is. */ + OFFLINE, +} + @Immutable sealed interface PodcastInfoUiState { data object Loading : PodcastInfoUiState @@ -41,6 +66,8 @@ sealed interface PodcastInfoUiState { val directFeedChip: DirectFeedChipState = DirectFeedChipState.Hidden, /** One-shot toast/snackbar text; cleared via [PodcastInfoViewModel.consumeUserMessage]. */ val userMessage: String? = null, + /** The feed-first show page's status line (P4 PR 16). */ + val feedStatus: ShowFeedStatus = ShowFeedStatus.NONE, ) : PodcastInfoUiState data object Error : PodcastInfoUiState 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 bacbda09d..3b5aa0278 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 @@ -24,6 +24,7 @@ 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.PodcastInfoPullRefreshLogic +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 @@ -77,6 +78,28 @@ class PodcastInfoViewModel( private val scrollDepth = routeArgs.scrollDepth private val searchQuery = routeArgs.searchQuery private val supplementSupport = PodcastInfoSupplementSupport(repository, episodeSupplementPort) + + /** The feed-first show page (P4 PR 16): reads, the status line and reloads as the list grows. */ + private val feedFirst = + PodcastInfoFeedFirst( + repository, + object : PodcastInfoFeedFirst.Ui { + override fun state(): PodcastInfoUiState.Success? = _uiState.value as? PodcastInfoUiState.Success + + override fun set(state: PodcastInfoUiState.Success) { + _uiState.value = state + } + + override fun currentPodcastId(): String = currentPodcastId + + override fun setOffset(offset: Int) { + currentOffset = offset + } + + override suspend fun remount(state: PodcastInfoUiState.Success, episodes: List, hasMore: Boolean): PodcastInfoUiState.Success = + supplementSupport.remountWithSupplements(state = state, piEpisodes = episodes, hasMoreEpisodes = hasMore, isLoadingMore = false) + }, + ) private val customGenreMutex = Mutex() private val _uiState = MutableStateFlow(PodcastInfoUiState.Loading) @@ -659,8 +682,10 @@ class PodcastInfoViewModel( rssRepository.refreshCatalogIfNeeded(effectivePodcastId) } + // Feed-first show pages (P4 PR 16): without a stored copy the first page waits for the feed. + val feedOpened = if (currentPodcast?.isRss == true) null else feedFirst.open(effectivePodcastId, currentPodcast) val (apiPodcast, page) = - fetchPodcastAndEpisodes(effectivePodcastId, limit, sortParam) + fetchPodcastAndEpisodes(effectivePodcastId, limit, sortParam, skipEpisodes = feedOpened?.cached == false) if (apiPodcast != null) { val apiPodcastWithFallback = @@ -684,11 +709,12 @@ class PodcastInfoViewModel( } currentOffset = page.sourceCount + val pageEpisodes = if (feedOpened != null) ShowFeedLogic.withShow(page.episodes, apiPodcastWithFallback) else page.episodes val baseSuccess = PodcastInfoUiState.Success( podcast = apiPodcastWithFallback, - episodes = page.episodes, - piEpisodes = page.episodes, + episodes = pageEpisodes, + piEpisodes = pageEpisodes, isSubscribed = latestIsSubscribed, hasMoreEpisodes = page.hasMore, currentSort = initialSort, @@ -697,10 +723,11 @@ class PodcastInfoViewModel( _uiState.value = supplementSupport.remountWithSupplements( state = baseSuccess, - piEpisodes = page.episodes, + piEpisodes = pageEpisodes, hasMoreEpisodes = page.hasMore, - isLoadingMore = false, + isLoadingMore = feedOpened?.cached == false, ) + feedOpened?.let { opened -> launch { beginFeedFirst(apiPodcastWithFallback, initialSort, opened) } } if (supplementSupport.shouldRefreshOnOpen( apiPodcastWithFallback.id, @@ -796,6 +823,8 @@ class PodcastInfoViewModel( } else { recovered } + val podcastShown = currentPodcast + feedOpened?.let { opened -> launch { beginFeedFirst(podcastShown, initialSort, opened) } } } } } @@ -822,11 +851,20 @@ class PodcastInfoViewModel( } } + /** + * Show details and the first page together. [skipEpisodes]: a feed-first page with no stored + * copy yet, whose first page comes from the feed read that follows (P4 PR 16). + */ private suspend fun fetchPodcastAndEpisodes( podcastId: String, limit: Int, sortParam: String, + skipEpisodes: Boolean = false, ): Pair = kotlinx.coroutines.coroutineScope { + if (skipEpisodes) { + return@coroutineScope repository.getPodcastDetails(podcastId) to + cx.aswin.boxlore.core.catalog.PodcastRepository.EpisodePage(emptyList(), hasMore = true) + } val apiPodcast: Podcast? val page: cx.aswin.boxlore.core.catalog.PodcastRepository.EpisodePage @@ -850,6 +888,40 @@ class PodcastInfoViewModel( Pair(apiPodcast, page) } + /** + * True when [feedId]'s page reads its feed (P4 PR 16). Search then runs on the phone over every + * episode, so a copy that holds only the newest is completed first. + */ + private suspend fun prepareFeedFirstSearch(feedId: String, podcast: Podcast): Boolean { + if (!feedFirst.isActive(feedId)) return false + if (ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.SEARCH)) { + feedFirst.completeArchive(viewModelScope, podcast) + } + return true + } + + /** Clears the loading row when a feed-first read ended without reloading the page (it moved on, or the archive was already whole). */ + private fun stopLoadingMore() { + val state = _uiState.value as? PodcastInfoUiState.Success ?: return + if (state.isLoadingMore) _uiState.value = state.copy(isLoadingMore = false) + } + + /** + * Starts the feed-first page's opening read once its first page shows. A failure leaves the + * stored copy showing and clears the status line. + */ + private suspend fun beginFeedFirst(podcast: Podcast, sort: EpisodeSort, opened: cx.aswin.boxlore.core.catalog.ShowPageFeedState) { + try { + feedFirst.begin(podcast, sort, opened) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Feed-first read failed for ${podcast.id}", e) + val state = _uiState.value as? PodcastInfoUiState.Success ?: return + if (state.podcast.id == podcast.id) _uiState.value = state.copy(isLoadingMore = false, feedStatus = ShowFeedStatus.NONE) + } + } + private fun trackScreenViewed( podcastId: String, podcastName: String?, @@ -924,6 +996,14 @@ class PodcastInfoViewModel( val limit = if (currentState.currentSort == EpisodeSort.OLDEST) OLDEST_PAGE_SIZE else PAGE_SIZE val sortParam = if (currentState.currentSort == EpisodeSort.OLDEST) "oldest" else "newest" val page = repository.getEpisodesPaginated(currentPodcastId, limit, currentOffset, sortParam) + if (feedFirst.isActive(currentPodcastId) && + ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.LOAD_MORE, limit, page.episodes.size) + ) { + // Past the stored newest episodes: read the whole feed, then show one more page. + feedFirst.completeArchive(viewModelScope, currentState.podcast, extra = limit) + stopLoadingMore() + return@launch + } currentOffset += page.sourceCount val latest = _uiState.value as? PodcastInfoUiState.Success ?: return@launch val nextPi = latest.piEpisodes + page.episodes @@ -934,6 +1014,8 @@ class PodcastInfoViewModel( hasMoreEpisodes = page.hasMore, isLoadingMore = false, ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { e.printStackTrace() _uiState.value = currentState.copy(isLoadingMore = false) @@ -949,8 +1031,11 @@ class PodcastInfoViewModel( isRss = currentState.podcast.isRss, isSubscribed = currentState.isSubscribed, chip = currentState.directFeedChip, + // Once followed, the show's own catalog refresh (with auto-download) takes over. + feedFirst = feedFirst.isActive(currentState.podcast.id) && !currentState.isSubscribed, ) ) { + PodcastInfoPullRefreshLogic.Target.FEED_FIRST -> refreshFeedFirstFromPull(currentState) PodcastInfoPullRefreshLogic.Target.RSS_CATALOG -> refreshTrueRssCatalog(currentState) PodcastInfoPullRefreshLogic.Target.SUBSCRIBED_DIRECT_FEED -> refreshSubscribedDirectFeedCatalogFromPull(currentState) PodcastInfoPullRefreshLogic.Target.DIRECT_FEED -> refreshDirectFeedFromPull(currentState) @@ -959,6 +1044,25 @@ class PodcastInfoViewModel( } } + /** + * Pull-to-refresh of a feed-first page: reads the newest episodes at the registry's address + * into the stored copy (whether it is partial or the whole archive), then reloads the pages. + */ + private fun refreshFeedFirstFromPull(currentState: PodcastInfoUiState.Success) { + val podcast = currentState.podcast + _uiState.value = currentState.copy(isRssRefreshing = true) + viewModelScope.launch { + try { + feedFirst.read(podcast, cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope.NEWEST, showStatus = false) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Pull-to-refresh feed read failed", e) + } + clearPullRefreshing(podcast.id) + } + } + private fun refreshTrueRssCatalog(currentState: PodcastInfoUiState.Success) { val targetPodcastId = currentState.podcast.id _uiState.value = currentState.copy(isLoadingMore = true, isRssRefreshing = true) @@ -1239,6 +1343,15 @@ class PodcastInfoViewModel( } try { + if (newSort == EpisodeSort.OLDEST && + feedFirst.isActive(currentPodcastId) && + ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.SORT_OLDEST) + ) { + // The oldest episodes are only known once the whole feed is read. + feedFirst.completeArchive(viewModelScope, currentState.podcast) + stopLoadingMore() + return@launch + } val limit = if (newSort == EpisodeSort.OLDEST) OLDEST_PAGE_SIZE else PAGE_SIZE val sortParam = if (newSort == EpisodeSort.OLDEST) "oldest" else "newest" val page = repository.getEpisodesPaginated(currentPodcastId, limit, 0, sortParam) @@ -1251,6 +1364,8 @@ class PodcastInfoViewModel( hasMoreEpisodes = page.hasMore, isLoadingMore = false, ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { e.printStackTrace() val latestState = _uiState.value as? PodcastInfoUiState.Success ?: return@launch @@ -1291,12 +1406,13 @@ class PodcastInfoViewModel( val feedId = podcastContext?.podcast?.id ?: return@launch val podcast = podcastContext.podcast + val readsFeed = prepareFeedFirstSearch(feedId, podcast) val networkResults = repository.searchEpisodes(feedId, query) val results = supplementSupport.unionSearch( feedId = feedId, query = query, - networkResults = networkResults, + networkResults = ShowFeedLogic.withShow(networkResults, podcast), meta = PodcastListMeta( title = podcast.title, @@ -1304,7 +1420,8 @@ class PodcastInfoViewModel( genre = podcast.genre, artist = podcast.artist, ), - isRss = podcast.isRss, + // The feed's own copy holds every episode the feed has: no extras to add. + isRss = podcast.isRss || readsFeed, ) // Ensure we are still in a valid state to update diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt new file mode 100644 index 000000000..f0810e88f --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt @@ -0,0 +1,82 @@ +package cx.aswin.boxlore.feature.info.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily +import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.ShowFeedStatus + +internal const val SHOW_FEED_STATUS_TAG = "podcast_info_feed_status" + +/** The text of [status]'s line, or null when the page shows no line. */ +internal fun showFeedStatusText(status: ShowFeedStatus): Int? = when (status) { + ShowFeedStatus.NONE, ShowFeedStatus.CACHED -> null + ShowFeedStatus.READING_NEWEST -> R.string.podcast_info_feed_reading + ShowFeedStatus.COMPLETING_ARCHIVE -> R.string.podcast_info_feed_completing + ShowFeedStatus.FALLBACK -> R.string.podcast_info_feed_fallback + ShowFeedStatus.OFFLINE -> R.string.podcast_info_feed_offline +} + +/** + * The feed-first show page's status line above its episodes: a feed read in progress, the + * server's list in place of an unreadable feed, or no connection. A solid tonal surface, announced + * politely to screen readers when it changes. Nothing for a current list or a recent stored copy. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ +@Composable +internal fun ShowFeedStatusLine(status: ShowFeedStatus, modifier: Modifier = Modifier) { + val text = showFeedStatusText(status) ?: return + val reading = status == ShowFeedStatus.READING_NEWEST || status == ShowFeedStatus.COMPLETING_ARCHIVE + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(16.dp), + modifier = + modifier + .fillMaxWidth() + .boxloreCardWidth(BoxloreCardFamily.Utility) + .padding(horizontal = 16.dp) + .testTag(SHOW_FEED_STATUS_TAG) + .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (reading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon( + imageVector = if (status == ShowFeedStatus.OFFLINE) Icons.Rounded.CloudOff else Icons.Rounded.Info, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + Text(text = stringResource(text), style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/PodcastInfoPullRefreshLogic.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/PodcastInfoPullRefreshLogic.kt index 1de4985fe..c6166f949 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/PodcastInfoPullRefreshLogic.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/PodcastInfoPullRefreshLogic.kt @@ -7,18 +7,24 @@ import cx.aswin.boxlore.feature.info.PodcastInfoUiState internal object PodcastInfoPullRefreshLogic { enum class Target { RSS_CATALOG, + + /** A feed-first show page: read the newest episodes into its stored copy (P4 PR 16). */ + FEED_FIRST, SUBSCRIBED_DIRECT_FEED, DIRECT_FEED, PI_CATALOG, NONE, } + /** [feedFirst]: the page reads the show's own feed (P4 PR 16); it replaces the Missing episodes? path. */ fun target( isRss: Boolean, isSubscribed: Boolean = false, chip: DirectFeedChipState, + feedFirst: Boolean = false, ): Target = when { isRss -> Target.RSS_CATALOG + feedFirst -> Target.FEED_FIRST chip == DirectFeedChipState.Fetching -> Target.NONE isSubscribed -> Target.SUBSCRIBED_DIRECT_FEED chip == DirectFeedChipState.Updated -> Target.DIRECT_FEED diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt new file mode 100644 index 000000000..8c4db760d --- /dev/null +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt @@ -0,0 +1,84 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.catalog.ShowPageFeedResult +import cx.aswin.boxlore.core.catalog.ShowPageFeedState +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.model.Podcast +import cx.aswin.boxlore.feature.info.EpisodeSort +import cx.aswin.boxlore.feature.info.ShowFeedStatus + +/** + * Decisions of the feed-first show page, apart from the ViewModel so they can be tested alone: + * how deep a read goes, what the status line says, and how pages join as the list grows. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). + */ +internal object ShowFeedLogic { + /** + * The read a page starts as it opens: the whole archive for an oldest-first page (serial shows + * open oldest first) and for a feed that lists its oldest items first, else the newest items. + */ + fun openingScope(sort: EpisodeSort, opened: ShowPageFeedState): Scope = if (sort == EpisodeSort.OLDEST || opened.oldestFirst) Scope.ARCHIVE else Scope.NEWEST + + /** + * Whether opening reads the feed: always without a stored copy or once it is an hour old, and + * for an archive read the copy doesn't hold yet. + */ + fun readsOnOpen(opened: ShowPageFeedState, scope: Scope): Boolean = opened.recheckDue || (scope == Scope.ARCHIVE && !opened.archiveComplete) + + /** The status line while a read of [scope] runs. */ + fun readingStatus(scope: Scope): ShowFeedStatus = if (scope == Scope.ARCHIVE) ShowFeedStatus.COMPLETING_ARCHIVE else ShowFeedStatus.READING_NEWEST + + /** The status line once a read ended with [result]. */ + fun statusAfter(result: ShowPageFeedResult): ShowFeedStatus = when (result) { + ShowPageFeedResult.UPDATED, ShowPageFeedResult.UNCHANGED -> ShowFeedStatus.NONE + ShowPageFeedResult.OFFLINE -> ShowFeedStatus.OFFLINE + ShowPageFeedResult.FALLBACK -> ShowFeedStatus.FALLBACK + } + + /** The status line as a page opens without reading: the stored copy, or nothing yet. */ + fun openingStatus(opened: ShowPageFeedState): ShowFeedStatus = if (opened.cached) ShowFeedStatus.CACHED else ShowFeedStatus.NONE + + /** + * Whether an action must wait for the whole archive: in-show search, the oldest-first sort, and + * a page that ran past the stored newest items ([pageSize] asked, [pageCount] came back). + */ + fun needsArchive(archiveComplete: Boolean, action: ArchiveAction, pageSize: Int = 0, pageCount: Int = 0): Boolean { + if (archiveComplete) return false + return when (action) { + ArchiveAction.SEARCH, ArchiveAction.SORT_OLDEST -> true + ArchiveAction.LOAD_MORE -> pageCount < pageSize + } + } + + /** + * How many episodes a page reloaded after a read holds: as many as were showing (so the rows, + * the scroll position and any selection stay), plus [extra] when the listener was scrolling on. + */ + fun reloadCount(showing: Int, pageSize: Int, extra: Int = 0): Int = maxOf(showing + extra, pageSize) + + /** Stored-copy episodes with the show's details filled in where the copy has none. */ + fun withShow(episodes: List, podcast: Podcast): List = episodes.map { episode -> + if (!episode.podcastTitle.isNullOrBlank() && !episode.podcastImageUrl.isNullOrBlank()) return@map episode + episode.copy( + podcastTitle = episode.podcastTitle?.takeIf(String::isNotBlank) ?: podcast.title, + podcastImageUrl = episode.podcastImageUrl?.takeIf(String::isNotBlank) ?: podcast.imageUrl.takeIf(String::isNotBlank), + podcastId = episode.podcastId ?: podcast.id, + podcastGenre = episode.podcastGenre ?: podcast.genre, + podcastArtist = episode.podcastArtist ?: podcast.artist, + ) + } + + /** The show details stored-copy episodes are listed with. */ + fun metaOf(podcast: Podcast): PodcastMeta = PodcastMeta( + title = podcast.title.takeIf(String::isNotBlank), + imageUrl = podcast.imageUrl.takeIf(String::isNotBlank), + genre = podcast.genre, + artist = podcast.artist, + ) + + enum class ArchiveAction { SEARCH, SORT_OLDEST, LOAD_MORE } +} diff --git a/feature/info/src/main/res/values/strings.xml b/feature/info/src/main/res/values/strings.xml index 156aa1a14..89d2c76a8 100644 --- a/feature/info/src/main/res/values/strings.xml +++ b/feature/info/src/main/res/values/strings.xml @@ -118,6 +118,10 @@ Subscribed shows automatically get the latest episodes from the publisher’s feed. This check is only needed if you prefer to stay unsubscribed.\n\nAllow boxlore to look for missing episodes and refresh the list whenever you open this show. Large feeds may use more data. Allow feed checks Not now + Checking the feed for new episodes… + Loading all episodes from the feed… + Can’t reach this show’s feed. Showing boxlore’s episode list instead. + You’re offline. Showing saved episodes. Play trailer Support Select episode 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 daa77ce09..38eb7c02c 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 @@ -86,6 +86,7 @@ import org.robolectric.shadows.ShadowDialog @RunWith(RobolectricTestRunner::class) @Config(sdk = [33], application = Application::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) +@Suppress("LargeClass") // One live page fixture drives every window gate; splitting it would copy the fixture. class PodcastInfoAdaptiveTransitionTest { @get:Rule val fixture = PhoneGoldenRule(PhoneGoldenConfiguration(1_280, 800, false)) @@ -524,6 +525,79 @@ class PodcastInfoAdaptiveTransitionTest { } } + @Test + fun `the feed status line shows each state across window changes and recreation`() { + uiState.value = success().copy(feedStatus = ShowFeedStatus.READING_NEWEST) + render() + val statuses = + listOf( + ShowFeedStatus.READING_NEWEST to "Checking the feed for new episodes…", + ShowFeedStatus.COMPLETING_ARCHIVE to "Loading all episodes from the feed…", + ShowFeedStatus.FALLBACK to "Can’t reach this show’s feed. Showing boxlore’s episode list instead.", + ShowFeedStatus.OFFLINE to "You’re offline. Showing saved episodes.", + ) + for ((status, text) in statuses) { + uiState.update { (it as PodcastInfoUiState.Success).copy(feedStatus = status) } + acrossWindowChanges { + // On a short window the hero fills the screen: the line sits right below it. + fixture.compose.onNodeWithTag(LIST_TAG).performScrollToIndex(RESULT_ROW) + fixture.compose.onNodeWithText(text).assertIsDisplayed() + } + } + recreate(FOLD_TABLETOP) + fixture.compose.onNodeWithTag(LIST_TAG).performScrollToIndex(RESULT_ROW) + fixture.compose.onNodeWithText("You’re offline. Showing saved episodes.").assertIsDisplayed() + fixture.compose.onNodeWithTag(FEED_STATUS_TAG).assertIsDisplayed() + + // A current list and a recent stored copy show no line. + for (quiet in listOf(ShowFeedStatus.NONE, ShowFeedStatus.CACHED)) { + uiState.update { (it as PodcastInfoUiState.Success).copy(feedStatus = quiet) } + acrossWindowChanges { fixture.compose.onNodeWithTag(FEED_STATUS_TAG).assertDoesNotExist() } + } + verify(model, never()).refreshRssFeed() + } + + @Test + fun `scroll and selection hold while the list grows and switches source during window changes`() { + uiState.value = success().copy(feedStatus = ShowFeedStatus.READING_NEWEST) + render() + // Rows: hero, toolbar, the status line, then the episodes. + fixture.compose.onNodeWithTag(LIST_TAG).performScrollToIndex(RESULT_ROW + 1 + 5) + fixture.compose.onNodeWithText("Episode 5").performTouchInput { longClick() } + selectionCount("1") + fixture.compose.onNodeWithTag(LIST_TAG).performScrollToIndex(RESULT_ROW + 1 + 12) + val anchor = "Episode 12" + fixture.compose.onNodeWithText(anchor).assertIsDisplayed() + + val newer = List(3) { index -> + TestFixtures.episode(id = "n$index", title = "New $index", podcastId = "1", podcastTitle = podcast.title, publishedDate = 1_700_000_000L + (index + 1) * 86_400L) + } + // Each window step lands one stage of a feed-first page: the stored copy grows with older + // episodes, newer ones arrive above, the archive is read, and the source switches to the + // server's list and back, every row keeping its ID. + val stages: List<(PodcastInfoUiState.Success) -> PodcastInfoUiState.Success> = + listOf( + { it.copy(episodes = episodes.take(40), piEpisodes = episodes.take(40), feedStatus = ShowFeedStatus.NONE) }, + { it.copy(feedStatus = ShowFeedStatus.COMPLETING_ARCHIVE, isLoadingMore = true) }, + { it.copy(episodes = newer + episodes, piEpisodes = newer + episodes, feedStatus = ShowFeedStatus.NONE, isLoadingMore = false) }, + { it.copy(feedStatus = ShowFeedStatus.FALLBACK) }, + { it.copy(episodes = episodes, piEpisodes = episodes, feedStatus = ShowFeedStatus.READING_NEWEST) }, + { it.copy(feedStatus = ShowFeedStatus.OFFLINE) }, + ) + acrossWindowChanges(onStep = { step -> uiState.update { stages[step % stages.size](it as PodcastInfoUiState.Success) } }) { + fixture.compose.onNodeWithText(anchor).assertIsDisplayed() + selectionCount("1") + } + recreate(FOLD_TABLETOP) + fixture.compose.onNodeWithText(anchor).assertIsDisplayed() + selectionCount("1") + + fixture.compose.onNodeWithContentDescription("Play selected episodes").performClick() + assertEquals(listOf(listOf("e5")), playedIds()) + verify(model, times(2)).loadPodcast("1") + verify(model, never()).refreshRssFeed() + } + @Test fun `loading, error, retry and empty states hold across window changes and recreation`() { uiState.value = PodcastInfoUiState.Loading @@ -682,10 +756,19 @@ class PodcastInfoAdaptiveTransitionTest { private fun playedIds(): List> = played.toList() /** Moves the same live page across every gate and back, asserting after each step. */ - private fun acrossWindowChanges(assertion: () -> Unit) { + private fun acrossWindowChanges(assertion: () -> Unit) = acrossWindowChanges(onStep = {}, assertion = assertion) + + /** + * [acrossWindowChanges], with [onStep] changing the page's state in the same frame as each + * window change, as a feed read that lands mid-resize does. + */ + private fun acrossWindowChanges(onStep: (Int) -> Unit, assertion: () -> Unit) { val start = window.value (TRANSITIONS + start).forEachIndexed { step, size -> - fixture.compose.runOnIdle { window.value = size } + fixture.compose.runOnIdle { + window.value = size + onStep(step) + } fixture.compose.runOnUiThread { Snapshot.sendApplyNotifications() } fixture.compose.waitForIdle() try { @@ -783,6 +866,7 @@ class PodcastInfoAdaptiveTransitionTest { const val LIST_TAG = "podcast_info_episode_list" const val LOADING_TAG = "podcast_info_loading" const val REFRESH_TAG = "podcast_info_refresh_indicator" + const val FEED_STATUS_TAG = "podcast_info_feed_status" const val RESULT_ROW = 2 val ALERT_EXITS = listOf(Exit.CONFIRM, Exit.CANCEL, Exit.BACK, Exit.OUTSIDE) val SELECTION_ACTIONS = listOf( diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt new file mode 100644 index 000000000..f74852150 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt @@ -0,0 +1,105 @@ +package cx.aswin.boxlore.feature.info.logic + +import cx.aswin.boxlore.core.catalog.ShowPageFeedResult +import cx.aswin.boxlore.core.catalog.ShowPageFeedState +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.testing.TestFixtures +import cx.aswin.boxlore.feature.info.DirectFeedChipState +import cx.aswin.boxlore.feature.info.EpisodeSort +import cx.aswin.boxlore.feature.info.R +import cx.aswin.boxlore.feature.info.ShowFeedStatus +import cx.aswin.boxlore.feature.info.components.showFeedStatusText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** The feed-first show page's decisions: read depth, the status line, archive gating and page growth. */ +class ShowFeedLogicTest { + private val fresh = ShowPageFeedState(cached = true, archiveComplete = false, recheckDue = false, oldestFirst = false) + + @Test + fun `a page opens on the newest items unless it lists oldest first`() { + assertEquals(Scope.NEWEST, ShowFeedLogic.openingScope(EpisodeSort.NEWEST, fresh)) + assertEquals(Scope.ARCHIVE, ShowFeedLogic.openingScope(EpisodeSort.OLDEST, fresh), "serial shows open oldest first") + assertEquals(Scope.ARCHIVE, ShowFeedLogic.openingScope(EpisodeSort.NEWEST, fresh.copy(oldestFirst = true))) + } + + @Test + fun `a recent copy is shown without a read, an old one or a missing archive is read`() { + assertFalse(ShowFeedLogic.readsOnOpen(fresh, Scope.NEWEST)) + assertEquals(ShowFeedStatus.CACHED, ShowFeedLogic.openingStatus(fresh)) + assertTrue(ShowFeedLogic.readsOnOpen(fresh.copy(recheckDue = true), Scope.NEWEST)) + assertTrue(ShowFeedLogic.readsOnOpen(fresh, Scope.ARCHIVE)) + assertFalse(ShowFeedLogic.readsOnOpen(fresh.copy(archiveComplete = true), Scope.ARCHIVE)) + assertEquals(ShowFeedStatus.NONE, ShowFeedLogic.openingStatus(fresh.copy(cached = false))) + } + + @Test + fun `the status line follows the read`() { + assertEquals(ShowFeedStatus.READING_NEWEST, ShowFeedLogic.readingStatus(Scope.NEWEST)) + assertEquals(ShowFeedStatus.COMPLETING_ARCHIVE, ShowFeedLogic.readingStatus(Scope.ARCHIVE)) + assertEquals(ShowFeedStatus.NONE, ShowFeedLogic.statusAfter(ShowPageFeedResult.UPDATED)) + assertEquals(ShowFeedStatus.NONE, ShowFeedLogic.statusAfter(ShowPageFeedResult.UNCHANGED)) + assertEquals(ShowFeedStatus.OFFLINE, ShowFeedLogic.statusAfter(ShowPageFeedResult.OFFLINE)) + assertEquals(ShowFeedStatus.FALLBACK, ShowFeedLogic.statusAfter(ShowPageFeedResult.FALLBACK)) + } + + @Test + fun `only the reading, fallback and offline states show a line`() { + assertNull(showFeedStatusText(ShowFeedStatus.NONE)) + assertNull(showFeedStatusText(ShowFeedStatus.CACHED)) + assertEquals(R.string.podcast_info_feed_reading, showFeedStatusText(ShowFeedStatus.READING_NEWEST)) + assertEquals(R.string.podcast_info_feed_completing, showFeedStatusText(ShowFeedStatus.COMPLETING_ARCHIVE)) + assertEquals(R.string.podcast_info_feed_fallback, showFeedStatusText(ShowFeedStatus.FALLBACK)) + assertEquals(R.string.podcast_info_feed_offline, showFeedStatusText(ShowFeedStatus.OFFLINE)) + } + + @Test + fun `search, the oldest sort and scrolling past the stored newest wait for the archive`() { + assertTrue(ShowFeedLogic.needsArchive(false, ShowFeedLogic.ArchiveAction.SEARCH)) + assertTrue(ShowFeedLogic.needsArchive(false, ShowFeedLogic.ArchiveAction.SORT_OLDEST)) + assertTrue(ShowFeedLogic.needsArchive(false, ShowFeedLogic.ArchiveAction.LOAD_MORE, pageSize = 20, pageCount = 0)) + assertFalse(ShowFeedLogic.needsArchive(false, ShowFeedLogic.ArchiveAction.LOAD_MORE, pageSize = 20, pageCount = 20)) + assertFalse(ShowFeedLogic.needsArchive(true, ShowFeedLogic.ArchiveAction.SEARCH)) + } + + @Test + fun `a reload after a read keeps every row that showed`() { + assertEquals(20, ShowFeedLogic.reloadCount(showing = 0, pageSize = 20)) + assertEquals(60, ShowFeedLogic.reloadCount(showing = 60, pageSize = 20)) + assertEquals(120, ShowFeedLogic.reloadCount(showing = 100, pageSize = 20, extra = 20)) + } + + @Test + fun `stored-copy episodes take the show's details only where they have none`() { + val podcast = TestFixtures.podcast(id = "920666", title = "Curious world", imageUrl = "https://img.example/show.jpg") + val bare = TestFixtures.episode(id = "e1", title = "One").copy(podcastTitle = null, podcastImageUrl = null, podcastId = null) + val own = TestFixtures.episode(id = "e2", title = "Two").copy(podcastTitle = "Own", podcastImageUrl = "https://img.example/own.jpg") + + val filled = ShowFeedLogic.withShow(listOf(bare, own), podcast) + + assertEquals("Curious world", filled[0].podcastTitle) + assertEquals("https://img.example/show.jpg", filled[0].podcastImageUrl) + assertEquals("920666", filled[0].podcastId) + assertEquals(own, filled[1]) + assertEquals("Curious world", ShowFeedLogic.metaOf(podcast).title) + } + + @Test + fun `pull to refresh on a feed-first page reads the feed`() { + assertEquals( + PodcastInfoPullRefreshLogic.Target.FEED_FIRST, + PodcastInfoPullRefreshLogic.target(isRss = false, chip = DirectFeedChipState.Hidden, feedFirst = true), + ) + assertEquals( + PodcastInfoPullRefreshLogic.Target.FEED_FIRST, + PodcastInfoPullRefreshLogic.target(isRss = false, isSubscribed = true, chip = DirectFeedChipState.Hidden, feedFirst = true), + ) + assertEquals( + PodcastInfoPullRefreshLogic.Target.RSS_CATALOG, + PodcastInfoPullRefreshLogic.target(isRss = true, chip = DirectFeedChipState.Hidden, feedFirst = true), + ) + } +} From 1fc69f872a84e03dea09d430291b503dfbcbab7f Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 21:52:57 +0530 Subject: [PATCH 3/9] feat(catalog): update feed addresses in one place and check them daily --- ARCHITECTURE.md | 1 + app/README.md | 2 +- .../java/cx/aswin/boxlore/AppContainer.kt | 7 +- .../PodcastNotificationRegistrationWorker.kt | 10 +- ...dcastNotificationRegistrationWorkerTest.kt | 46 ++++++- core/catalog/README.md | 10 +- .../core/catalog/DirectFeedRecovery.kt | 30 ++++- .../core/catalog/FeedAddressDailyCheck.kt | 64 ++++++--- .../core/catalog/FeedAddressUpdater.kt | 106 +++++++++++---- .../aswin/boxlore/core/catalog/FeedReports.kt | 33 +++-- .../PendingNotificationRegistrations.kt | 68 ++++++++++ .../catalog/PodcastNotificationRegistrar.kt | 12 +- .../core/catalog/SubscribedEpisodeCatalog.kt | 10 +- .../catalog/SubscriptionForegroundSync.kt | 29 ++-- .../core/catalog/SubscriptionRepository.kt | 18 ++- .../catalog/sync/SubscriptionSyncResolver.kt | 44 ++++-- .../core/catalog/DirectFeedRecoveryTest.kt | 3 + .../core/catalog/FeedAddressDailyCheckTest.kt | 76 ++++++++++- .../catalog/FeedAddressRecordedAnswersTest.kt | 127 ++++++++++++++++++ .../core/catalog/FeedAddressUpdaterTest.kt | 72 +++++++++- .../boxlore/core/catalog/FeedReportsTest.kt | 23 +++- .../PodcastNotificationRegistrarTest.kt | 52 +++++++ .../SharedPreferencesFeedReportStampsTest.kt | 8 +- ...cesPendingNotificationRegistrationsTest.kt | 32 +++++ .../catalog/SubscribedEpisodeCatalogTest.kt | 6 +- .../catalog/SubscriptionForegroundSyncTest.kt | 2 +- .../src/test/resources/fixtures/v2/README.md | 9 ++ .../resources/fixtures/v2/feed-report.json | 1 + .../v2/shows-feeds-registry-unavailable.json | 1 + .../resources/fixtures/v2/shows-feeds.json | 1 + core/database/README.md | 2 +- .../boxlore/core/database/FeedAddressWrite.kt | 7 +- .../aswin/boxlore/core/database/PodcastDao.kt | 39 +++++- .../database/PodcastFeedAddressDaoTest.kt | 41 ++++++ core/rss/README.md | 3 +- .../core/rss/LocalEpisodeCatalogPersist.kt | 25 ++-- .../core/rss/LocalEpisodeCatalogRefresh.kt | 27 +++- .../core/rss/LocalEpisodeCollisionGuard.kt | 15 ++- .../core/rss/LegacyIdentityRoomTest.kt | 49 +++++++ 39 files changed, 976 insertions(+), 135 deletions(-) create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressRecordedAnswersTest.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt create mode 100644 core/catalog/src/test/resources/fixtures/v2/feed-report.json create mode 100644 core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-unavailable.json create mode 100644 core/catalog/src/test/resources/fixtures/v2/shows-feeds.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 011f861e0..0c03df78d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -253,6 +253,7 @@ Historical FQCNs and preference file names remain reachable so upgrades from old | — | `boxlore_library_widget` | `LibraryWidgetSnapshotStore` (`:feature:widgets`; last rendered Subscriptions / New Episodes snapshot JSON; do not rename) | | — | `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) | | `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 4275d5d92..b0a168ab2 100644 --- a/app/README.md +++ b/app/README.md @@ -71,7 +71,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the other switches have no reader yet. - **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). - **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`. -- **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, and `feedTelemetry`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs; `rss:` shows keep their own journal worker. +- **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, `feedTelemetry`, and pending registrations in SharedPreferences `boxlore_notification_reregistrations`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs. It records each outcome (`settle`): a show it gives up on stays pending and the next foreground pass queues it again; `rss:` shows keep their own journal worker. - NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 898148c55..ad145acd5 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -26,6 +26,7 @@ import cx.aswin.boxlore.core.catalog.RoomFolderRepository import cx.aswin.boxlore.core.catalog.RoomLocalCatalog import cx.aswin.boxlore.core.catalog.SharedAppDependencies import cx.aswin.boxlore.core.catalog.SharedPreferencesFeedReportStamps +import cx.aswin.boxlore.core.catalog.SharedPreferencesPendingNotificationRegistrations import cx.aswin.boxlore.core.catalog.SubscriptionForegroundSync import cx.aswin.boxlore.core.catalog.SubscriptionRepository import cx.aswin.boxlore.core.catalog.ports.SmartDownloadSyncPort @@ -180,7 +181,7 @@ class AppContainer( FeedMoveFollower( enabled = { FeedFixSwitches.isOn(FeedFixSwitches.MOVED_FEED_OFF) }, showTitle = { id -> database.podcastDao().getPodcast(id)?.title }, - adopt = { id, movedTo -> subscriptionRepository.adoptMovedFeed(id, movedTo) }, + adopt = { id, readFrom, movedTo -> subscriptionRepository.adoptMovedFeed(id, readFrom, movedTo) }, ), ) } @@ -404,6 +405,10 @@ class AppContainer( FeedAddressWiring( requestNotificationRegistration = { id -> PodcastNotificationRegistrationWorker.enqueue(appContext, id) }, telemetry = feedTelemetry, + pendingRegistrations = + SharedPreferencesPendingNotificationRegistrations( + appContext.getSharedPreferences(SharedPreferencesPendingNotificationRegistrations.FILE_NAME, Context.MODE_PRIVATE), + ), ), ) } diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt b/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt index 2b3dd46e1..c50139277 100644 --- a/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt +++ b/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt @@ -21,7 +21,9 @@ import kotlinx.coroutines.CancellationException * changed, and retries until Firebase acknowledges it: the same `tracked_podcasts/` node and * topic as turning notifications on ([cx.aswin.boxlore.core.catalog.PodcastNotificationRegistrar]). * Network-gated, exponential backoff from 30 s, at most [MAX_ATTEMPTS] runs, so it never retries - * forever like the `rss:` journal worker ([RssNotificationSyncWorker]). + * forever like the `rss:` journal worker ([RssNotificationSyncWorker]). A show it gives up on stays + * pending ([cx.aswin.boxlore.core.catalog.PendingNotificationRegistrations]) and is queued again + * by the next foreground sync pass, so a registration is never silently dropped. * * The unique work name ([workName], prefix [WORK_NAME_PREFIX]) is a stable identity. REPLACE: the * worker reads the show's row when it runs, so a newer address change restarts it with fresh attempts. @@ -34,6 +36,11 @@ class PodcastNotificationRegistrationWorker(context: Context, params: WorkerPara SharedAppDependenciesHolder.require().subscriptionRepository.registerPodcastNotifications(podcastId) } + /** Records each run's outcome: only an acknowledgement (or a refusal) clears the show's pending mark. */ + internal var settle: (podcastId: String, outcome: NotificationRegistrationOutcome) -> Unit = { podcastId, outcome -> + SharedAppDependenciesHolder.require().subscriptionRepository.feedAddresses.registrationSettled(podcastId, outcome) + } + override suspend fun doWork(): Result { val podcastId = inputData.getString(KEY_PODCAST_ID)?.takeIf { it.isNotBlank() && !it.startsWith("rss:") } ?: return Result.failure() val outcome = @@ -44,6 +51,7 @@ class PodcastNotificationRegistrationWorker(context: Context, params: WorkerPara } catch (_: Exception) { NotificationRegistrationOutcome.RETRY } + settle(podcastId, outcome) return when (outcome) { NotificationRegistrationOutcome.DONE, NotificationRegistrationOutcome.DROPPED -> Result.success() NotificationRegistrationOutcome.RETRY -> if (runAttemptCount + 1 < MAX_ATTEMPTS) Result.retry() else Result.failure() diff --git a/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt b/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt index f8f30d429..a19bc5b4d 100644 --- a/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.fcm import android.app.Application import android.content.Context +import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.work.BackoffPolicy import androidx.work.Configuration @@ -12,7 +13,10 @@ import androidx.work.WorkManager import androidx.work.testing.SynchronousExecutor import androidx.work.testing.TestListenableWorkerBuilder import androidx.work.testing.WorkManagerTestInitHelper +import cx.aswin.boxlore.core.catalog.FeedAddressUpdater +import cx.aswin.boxlore.core.catalog.MemoryPendingNotificationRegistrations import cx.aswin.boxlore.core.catalog.NotificationRegistrationOutcome +import cx.aswin.boxlore.core.database.BoxLoreDatabase import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -24,7 +28,8 @@ import org.robolectric.annotation.Config /** * Acknowledged notification registration after a Podcast Index show's address changed (own-episode-IDs - * P4 PR 14, plan section 11): one permanent unique work item per show, network-gated, capped retries. + * P4 PR 14, plan section 11): one permanent unique work item per show, network-gated, capped retries, + * and a show the job gave up on queued again by the next pass. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class) @@ -72,10 +77,45 @@ class PodcastNotificationRegistrationWorkerTest { ) } + @Test + fun `a show the job gave up on stays pending and is queued again by the next pass`() = runBlocking { + val database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + try { + val pending = MemoryPendingNotificationRegistrations().apply { add("920666") } + val updater = + FeedAddressUpdater( + database.podcastDao(), + requestNotificationRegistration = { id -> PodcastNotificationRegistrationWorker.enqueue(context, id) }, + pendingRegistrations = pending, + ) + val last = worker(NotificationRegistrationOutcome.RETRY, attempt = PodcastNotificationRegistrationWorker.MAX_ATTEMPTS - 1) + last.settle = updater::registrationSettled + + assertEquals(ListenableWorker.Result.failure(), last.doWork()) + assertEquals("given up, not forgotten", setOf("920666"), pending.all()) + + updater.resumePendingRegistrations() + val queued = WorkManager.getInstance(context).getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("920666")).get() + assertEquals(1, queued.count { it.state == WorkInfo.State.ENQUEUED }) + + val acknowledged = worker(NotificationRegistrationOutcome.DONE).apply { settle = updater::registrationSettled } + assertEquals(ListenableWorker.Result.success(), acknowledged.doWork()) + assertTrue(pending.all().isEmpty()) + } finally { + database.close() + } + } + @Test fun `an error counts as a retry, and work without a show fails without registering`() = runBlocking { - val failing = worker(NotificationRegistrationOutcome.DONE).apply { register = { error("Firebase not ready") } } + val settled = mutableListOf() + val failing = + worker(NotificationRegistrationOutcome.DONE).apply { + register = { error("Firebase not ready") } + settle = { _, outcome -> settled += outcome } + } assertEquals(ListenableWorker.Result.retry(), failing.doWork()) + assertEquals(listOf(NotificationRegistrationOutcome.RETRY), settled) var calls = 0 val empty = TestListenableWorkerBuilder(context).build().apply { @@ -83,6 +123,7 @@ class PodcastNotificationRegistrationWorkerTest { calls++ NotificationRegistrationOutcome.DONE } + settle = { _, _ -> calls++ } } assertEquals(ListenableWorker.Result.failure(), empty.doWork()) assertEquals(0, calls) @@ -98,5 +139,6 @@ class PodcastNotificationRegistrationWorkerTest { assertEquals("920666", id) outcome } + settle = { _, _ -> } } } diff --git a/core/catalog/README.md b/core/catalog/README.md index 6eb021325..13d40c3d2 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -11,10 +11,10 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su Failed consent writes keep activation off, and local notification flags prevent a failed revoke from implicitly re-enabling after restart. RSS sync isolates each feed and runs alongside PI sync; the source adapter explicitly implements every catalog operation. - `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 the row for the next sync push (not for an address that came from the cloud). It is a compare-and-set on the address it read. It then asks the app to register the show's notifications again when they are on (`FeedAddressWiring.requestNotificationRegistration`) and 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. 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). -- `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. `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 a confirmed feed move left. `fetchUrl: null` keeps the saved address. Each show the registry answered for is stamped (`podcasts.feedUrlCheckedAt`). 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 (`FeedReportLimiter`; times kept in `FeedReportStamps`, the app's `SharedPreferencesFeedReportStamps`). Its answer is an address to try only when it differs, is HTTPS and the saved address looks dead. +- **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, 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`); 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). +- `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 15 a day in all, below the API's 20 so the show page has room (`FeedReportLimiter`; 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. - RSS notification activation requires explicit public-feed disclosure acceptance through `setNotificationsEnabled(..., acceptRssDisclosure = true, disclosedFeedUrl = savedUrl)`. `DeviceRssNotificationConsent` stores only URL fingerprints in `noBackupFilesDir`; changed URLs, restore and remote cloud settings cannot supply acceptance. Canonical `rss:` IDs require disclosure even with stale source metadata. Refused or revoked activation also removes this device's old registration; a library cleared during acceptance cannot publish a deleted show. Notification settings remain independent of auto-download. The shared checker receives only explicitly accepted public feeds. `TrackedPodcastRtdbLogic.topic` maps `rss:` to FCM-safe `rss_` while retaining canonical IDs in payloads. JSON/cloud RSS restore retains the original saved show ID across redirects. - RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. @@ -165,7 +165,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit ## Testing notes -- 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; a failed registration request keeps the address), `FeedAddressDailyCheckTest` (501 followed shows make two calls, nothing again the same day; only confirmed moves; 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), `FeedReportsTest` and `SharedPreferencesFeedReportStampsTest` (once per show a day, 20 a day in all, 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, nothing without notifications, retry and refused outcomes, and a cloud pull that changes only the address re-registering on the same node and topic), `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). +- 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; 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, 15 a day in all, 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`. - `PodcastRepositoryApiV2Test` (Robolectric, MockWebServer): the production `api` and `apiV2` reach the same base URL and open the HTTP cache once; test overrides open none. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt index 647a3de7c..dcb8c1077 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt @@ -1,6 +1,7 @@ package cx.aswin.boxlore.core.catalog import android.util.Log +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep import kotlinx.coroutines.CancellationException @@ -8,15 +9,17 @@ import kotlinx.coroutines.CancellationException /** * Another address to try for a show whose saved one failed, and the path that offered it * ([FeedUrlCorrectionStep.FEED_REPORT] or the daily check's `/podcast` fallback, - * [FeedUrlCorrectionStep.DAILY_CHECK]). + * [FeedUrlCorrectionStep.DAILY_CHECK]). [replacing] is the saved address that failed: the candidate + * replaces only that one, so a newer address saved meanwhile (a cloud pull, the daily check) stays. */ -internal data class AddressCandidate(val feedUrl: String, val step: FeedUrlCorrectionStep) { +internal data class AddressCandidate(val feedUrl: String, val step: FeedUrlCorrectionStep, val replacing: String? = null) { /** The address change saved once [feedUrl] read successfully. */ fun change(podcastId: String, nowMillis: Long): FeedAddressChange = FeedAddressChange( podcastId = podcastId, feedUrl = feedUrl, source = if (step == FeedUrlCorrectionStep.FEED_REPORT) FeedUrlSources.FEED_REPORT else FeedUrlSources.PODCAST_LOOKUP, checkedAt = nowMillis, + onlyReplacing = replacing, reason = FeedUrlCorrectionReason.DEAD, step = step, ) @@ -70,9 +73,9 @@ internal object DirectFeedRecovery { private suspend fun candidate(directFeed: DirectFeedSyncSeams, id: String, result: DirectFeedResolveResult): AddressCandidate? { val reported = guarded("Feed report", id) { directFeed.reportFeed(id, result.reportStatus) } if (!result.addressGone) return null - reported?.let { return AddressCandidate(it, FeedUrlCorrectionStep.FEED_REPORT) } + reported?.let { return AddressCandidate(it, FeedUrlCorrectionStep.FEED_REPORT, result.readFeedUrl) } return guarded("Feed address re-check", id) { directFeed.recheckAddress(id) } - ?.let { AddressCandidate(it, FeedUrlCorrectionStep.DAILY_CHECK) } + ?.let { AddressCandidate(it, FeedUrlCorrectionStep.DAILY_CHECK, result.readFeedUrl) } } private suspend fun guarded(what: String, id: String, block: suspend () -> String?): String? = try { @@ -84,6 +87,25 @@ internal object DirectFeedRecovery { null } + /** A failed read remembers the saved address it used, so a recovered one replaces only that address. */ + fun withReadAddress(result: DirectFeedResolveResult, feedUrl: String?): DirectFeedResolveResult = + if (result.failed) result.copy(readFeedUrl = feedUrl?.trim()?.takeIf(String::isNotEmpty)) else result + + /** + * The production read of [FeedAddressDailyCheck] (P4 PR 14): the show's catalog is refreshed + * from the registry's address before that address is saved, and the address counts as readable + * when the read worked. Without a local catalog nothing is read, so nothing is swapped. + */ + fun addressRead(subscriptionRepository: SubscriptionRepository, catalog: LocalEpisodeCatalogPort?): suspend (String, String) -> Boolean = { id, url -> + val row = subscriptionRepository.getPodcastEntity(id) + if (catalog == null || row == null) { + false + } else { + val meta = DirectFeedTipMeta(feedUrl = url, title = row.title, imageUrl = row.imageUrl, genre = row.genre, artist = row.author, knownTip = row.latestEpisode) + !SubscriptionForegroundSync.resolveCatalogRefresh(id, meta, catalog).failed + } + } + /** * The production [DirectFeedSyncSeams.recheckAddress]: [recheckGoneFeedAddress] behind its * switch, stamped with the daily address check's per-show stamp (`podcasts.feedUrlCheckedAt`). 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 8541981d6..1dba5a5da 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 @@ -16,9 +16,13 @@ import retrofit2.awaitResponse /** * The daily address check of followed shows: asks `/v2/shows/feeds` for every followed Podcast * Index show not checked in the last day, in chunks of at most [V2ShowsFeedsRequest.MAX_IDS], and - * swaps a saved address only for a confirmed move ([confirmedMove]) through [FeedAddressUpdater]. + * swaps a saved address only for a confirmed move ([confirmedMove]) through [FeedAddressUpdater], + * and only once the phone has read the new address ([readsAt]), the rule every dead-address path + * follows: an address the phone can't read (blocked, an old TLS stack, a wrong registry row) never + * replaces one it can, and the next day's check tries again. * - * Each show checked is stamped (`podcasts.feedUrlCheckedAt`) once the registry answers for it. The + * Each show checked is stamped (`podcasts.feedUrlCheckedAt`) once the registry answers for it (a + * swap writes the stamp with the address, so a show is never stamped and left unswapped). The * per-show `/podcast` re-check of a dead address (`DirectFeedRecovery`) reads the same stamp, so it * runs only for shows this check couldn't reach (the call failed, or `registryAvailable` was false) * and a show is never checked twice a day. Rows the phone changed and hasn't pushed yet @@ -32,6 +36,7 @@ import retrofit2.awaitResponse class FeedAddressDailyCheck( private val followedShows: suspend () -> List, private val fetchAddresses: suspend (List) -> V2ShowsFeedsResponse?, + private val readsAt: suspend (podcastId: String, feedUrl: String) -> Boolean, private val replaceAddress: suspend (FeedAddressChange) -> FeedAddressUpdate, private val markChecked: suspend (podcastId: String, atMillis: Long) -> Unit, private val enabled: () -> Boolean, @@ -80,22 +85,41 @@ class FeedAddressDailyCheck( private suspend fun apply(results: List, due: Map, now: Long): Int = results.count { result -> due[result.id]?.let { row -> applyOne(row, result, now) } == true } - /** Stamps one show the registry answered for; true when its address was swapped. */ + /** 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 { - markChecked(row.podcastId, now) noteNamespace(row, result) - val target = confirmedMove(row, result) ?: return false - val change = - FeedAddressChange( - podcastId = row.podcastId, - feedUrl = target, - source = result.feedSource ?: FeedUrlSources.REGISTRY, - checkedAt = now, - onlyReplacing = row.feedUrl, - reason = FeedUrlCorrectionReason.REGISTRY, - step = FeedUrlCorrectionStep.DAILY_CHECK, - ) - return replaceAddress(change) == FeedAddressUpdate.UPDATED + val target = confirmedMove(row, result) + val swapped = + target != null && + readable(row.podcastId, target) && + replaceAddress(change(row, result, target, now)) == FeedAddressUpdate.UPDATED + // A swap stamps the show with its address; anything else is stamped here, after the swap had its chance. + if (!swapped) markChecked(row.podcastId, now) + return swapped + } + + private fun change(row: PodcastEntity, result: V2ShowAddress, target: String, now: Long) = FeedAddressChange( + podcastId = row.podcastId, + feedUrl = target, + source = result.feedSource ?: FeedUrlSources.REGISTRY, + checkedAt = now, + onlyReplacing = row.feedUrl, + reason = FeedUrlCorrectionReason.REGISTRY, + step = FeedUrlCorrectionStep.DAILY_CHECK, + ) + + private suspend fun readable(podcastId: String, feedUrl: String): Boolean { + val read = + try { + readsAt(podcastId, feedUrl) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Reading the registry address of $podcastId failed", e) + false + } + if (!read) Log.i(TAG, "Show $podcastId keeps its address: the registry's can't be read from this phone") + return read } /** The phone keeps its own namespace (decision D12); a different one is only logged. */ @@ -130,8 +154,9 @@ class FeedAddressDailyCheck( * The registry's address when it is a confirmed move away from the saved one: the registry * flags the show for address repair, the address is the registry's own (`registry:` source), * the phone can read it directly (HTTPS), it differs from the saved one, and it isn't the - * address a confirmed feed move left behind (which would swap back and forth). `fetchUrl: - * null` keeps the saved address. + * address the saved one replaced, whatever replaced it (a feed move, dead-address recovery + * or a cloud pull): going back would swap back and forth. `fetchUrl: null` keeps the saved + * address. The caller still reads the address before it is saved. */ internal fun confirmedMove(row: PodcastEntity, result: V2ShowAddress): String? { if (result.flags?.addressRepair != true) return null @@ -139,8 +164,7 @@ class FeedAddressDailyCheck( if (result.fetchVia != null && result.fetchVia != V2FetchVia.DIRECT) return null val https = TrackedPodcastRtdbLogic.httpsFeedUrl(result.fetchUrl) ?: return null if (https == row.feedUrl?.trim()) return null - val leftByMove = row.feedUrlSource == FeedUrlSources.FEED_MOVE && row.previousFeedUrl?.trim() == https - return https.takeUnless { leftByMove } + return https.takeUnless { it == row.previousFeedUrl?.trim() } } /** The production fetch: one `POST /v2/shows/feeds`; null for any answer but 200. */ 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 4771b0632..662f433d9 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 @@ -3,10 +3,12 @@ package cx.aswin.boxlore.core.catalog import android.util.Log import cx.aswin.boxlore.core.database.FeedAddressWrite import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.database.PodcastEntity import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort import cx.aswin.boxlore.core.model.FeedUrlCorrection import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep +import kotlinx.coroutines.CancellationException /** * Where a saved feed address came from, stored in `podcasts.feedUrlSource`. An address the @@ -34,6 +36,25 @@ object FeedUrlSources { /** The registry answered without naming its source. */ const val REGISTRY = "registry" + + /** + * True when [source] marks an address this phone confirmed as a correction (the registry, a feed + * move, a broken-feed report or the `/podcast` re-check), which a cloud pull of the address it + * replaced never undoes. + */ + fun isConfirmedCorrection(source: String?): Boolean = + source != null && (source.startsWith(REGISTRY) || source == FEED_MOVE || source == FEED_REPORT || source == PODCAST_LOOKUP) + + /** + * The address a follow writes with the row: a Podcast Index show's saved HTTPS address stays + * (it may have been corrected since the show was last followed; only confirmed moves replace it, + * through [FeedAddressUpdater]), else the [supplied] one. `rss:` shows keep the address they + * were opened with. + */ + fun followedFeedUrl(isRss: Boolean, saved: String?, supplied: String?): String? { + if (isRss) return supplied + return saved?.takeIf { TrackedPodcastRtdbLogic.httpsFeedUrl(it) == it } ?: supplied + } } /** @@ -79,20 +100,31 @@ enum class FeedAddressUpdate { /** The saved address changed after it was read; nothing was written. */ CHANGED_MEANWHILE, + + /** + * A pulled address is the one a confirmed correction on this phone replaced: the correction is + * kept and the follow is marked for the next push, so the account gets it back. + */ + KEPT_CORRECTION, } /** * The one writer of a show's saved feed address. In one transaction ([PodcastDao.replaceFeedAddress]) * it writes the publisher and transport addresses and keeps the old one for undo, resets every - * stored ETag and Last-Modified of the show, and marks the row for the next sync push (not for - * addresses that came from the cloud). It then asks for the show's new-episode notifications to be - * registered again, acknowledged, when they are on ([requestNotificationRegistration]; the same + * stored ETag and Last-Modified of the show, and marks a followed show for the next sync push (not + * for addresses that came from the cloud). It then asks for the show's new-episode notifications to + * be registered again, acknowledged, when they are on ([requestNotificationRegistration]; the same * `tracked_podcasts/` node and topic), and reports a correction of a followed show's address * as `feed_url_corrected`. * + * A registration is recorded as pending ([pendingRegistrations]) before the address is written and + * cleared only once Firebase acknowledged it ([registrationSettled]), so one the process lost or + * the background job gave up on is asked for again ([resumePendingRegistrations]). + * * Every address writer goes through it: recovery of a missing address, the dead-address re-check, * feeds that moved, broken-feed reports, the daily check, auto-download, backup restores and cloud - * pulls. `rss:` shows are refused: their address is their identity and notification consent. + * pulls. `rss:` shows are refused: their address is their identity and notification consent. A + * cloud pull never puts back the address a confirmed correction replaced ([FeedAddressUpdate.KEPT_CORRECTION]). * * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). * COMPAT(own-ids): saved addresses stay reachable. Permanent: never remove (MIGRATION.md §5). @@ -101,14 +133,15 @@ class FeedAddressUpdater( private val podcastDao: PodcastDao, private val requestNotificationRegistration: (podcastId: String) -> Unit = {}, private val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, + private val pendingRegistrations: PendingNotificationRegistrations = MemoryPendingNotificationRegistrations(), ) { suspend fun update(change: FeedAddressChange): FeedAddressUpdate { if (change.podcastId.startsWith("rss:")) return FeedAddressUpdate.REFUSED val https = TrackedPodcastRtdbLogic.httpsFeedUrl(change.feedUrl) ?: return FeedAddressUpdate.REFUSED val row = podcastDao.getPodcast(change.podcastId) ?: return FeedAddressUpdate.NO_SHOW - if (row.isRss) return FeedAddressUpdate.REFUSED - if (change.onlyReplacing != null && row.feedUrl != change.onlyReplacing) return FeedAddressUpdate.CHANGED_MEANWHILE - if (row.feedUrl == https) return FeedAddressUpdate.UNCHANGED + notWritten(row, change, https)?.let { return it } + val notificationsOn = row.isSubscribed && row.notificationsEnabled + if (notificationsOn) pendingRegistrations.add(row.podcastId) val written = podcastDao.replaceFeedAddress( FeedAddressWrite( @@ -118,33 +151,60 @@ class FeedAddressUpdater( transportUrl = change.transportUrl, source = change.source, checkedAt = change.checkedAt, - markDirty = !change.fromCloud, + markDirty = !change.fromCloud && row.isSubscribed, ), ) if (!written) return FeedAddressUpdate.CHANGED_MEANWHILE - afterReplace( - podcastId = row.podcastId, - followed = row.isSubscribed, - notificationsOn = row.isSubscribed && row.notificationsEnabled, - hadAddress = !row.feedUrl.isNullOrBlank(), - change = change, - ) + if (notificationsOn) requestRegistration(row.podcastId) + reportCorrection(row, change) return FeedAddressUpdate.UPDATED } - private fun afterReplace(podcastId: String, followed: Boolean, notificationsOn: Boolean, hadAddress: Boolean, change: FeedAddressChange) { - if (notificationsOn) { - try { - requestNotificationRegistration(podcastId) - } catch (e: IllegalStateException) { - Log.e(TAG, "Couldn't ask to register notifications again for $podcastId", e) - } + /** + * Asks again for every registration still pending: one whose request was lost with the process, + * or that the background job gave up on. Run once per foreground sync pass. + */ + fun resumePendingRegistrations() { + pendingRegistrations.all().filterNot { it.startsWith("rss:") }.forEach(::requestRegistration) + } + + /** Records what an acknowledged registration achieved: only a retry stays pending. */ + fun registrationSettled(podcastId: String, outcome: NotificationRegistrationOutcome) { + if (outcome != NotificationRegistrationOutcome.RETRY) pendingRegistrations.remove(podcastId) + } + + private fun requestRegistration(podcastId: String) { + try { + requestNotificationRegistration(podcastId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Couldn't ask to register notifications again for $podcastId", e) } + } + + private fun reportCorrection(row: PodcastEntity, change: FeedAddressChange) { val reason = change.reason ?: return val step = change.step ?: return - if (followed && hadAddress) telemetry.feedUrlCorrected(FeedUrlCorrection(podcastId, reason, step)) + if (row.isSubscribed && !row.feedUrl.isNullOrBlank()) telemetry.feedUrlCorrected(FeedUrlCorrection(row.podcastId, reason, step)) } + /** Why [change] isn't written to [row], or null when it is. */ + private suspend fun notWritten(row: PodcastEntity, change: FeedAddressChange, https: String): FeedAddressUpdate? = when { + row.isRss -> FeedAddressUpdate.REFUSED + change.onlyReplacing != null && row.feedUrl?.trim() != change.onlyReplacing.trim() -> FeedAddressUpdate.CHANGED_MEANWHILE + row.feedUrl == https -> FeedAddressUpdate.UNCHANGED + change.fromCloud && undoesCorrection(row, https) -> { + podcastDao.markFollowedDirty(row.podcastId) + FeedAddressUpdate.KEPT_CORRECTION + } + else -> null + } + + /** The pulled address is the one a confirmed correction on this phone replaced. */ + private fun undoesCorrection(row: PodcastEntity, pulled: String): Boolean = + FeedUrlSources.isConfirmedCorrection(row.feedUrlSource) && row.previousFeedUrl?.trim() == pulled + private companion object { const val TAG = "FeedAddressUpdater" } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt index d03267921..947b7d856 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt @@ -61,24 +61,36 @@ class SharedPreferencesFeedReportStamps(private val prefs: SharedPreferences) : /** * Limits broken-feed reports: one per show per [PER_SHOW_INTERVAL_MS], and at most [DAILY_CAP] a - * day in all (the API takes 20 a day from one phone). + * day in all. The API takes 20 a day from one phone; the background pass keeps 5 of them for the + * show page. Only a report the API answered counts ([record]): a failed or refused send uses up + * nothing. * * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). */ class FeedReportLimiter(private val stamps: FeedReportStamps) { - /** Claims a report of [podcastId] at [nowMillis]; false when the show or the day is used up. */ + /** True when a report of [podcastId] may be sent at [nowMillis]. */ @Synchronized - fun tryClaim(podcastId: String, nowMillis: Long): Boolean { + fun allows(podcastId: String, nowMillis: Long): Boolean { + val recent = recent(nowMillis) + return podcastId !in recent && recent.size < DAILY_CAP + } + + /** Counts a report of [podcastId] the API answered at [nowMillis]. */ + @Synchronized + fun record(podcastId: String, nowMillis: Long) { + stamps.mark(podcastId, nowMillis, keepSince = nowMillis - PER_SHOW_INTERVAL_MS) + } + + private fun recent(nowMillis: Long): Map { val since = nowMillis - PER_SHOW_INTERVAL_MS - val recent = stamps.all().filterValues { it > since && it <= nowMillis } - if (podcastId in recent || recent.size >= DAILY_CAP) return false - stamps.mark(podcastId, nowMillis, keepSince = since) - return true + return stamps.all().filterValues { it > since && it <= nowMillis } } companion object { const val PER_SHOW_INTERVAL_MS = 24L * 60L * 60L * 1000L - const val DAILY_CAP = 20 + + /** Below the API's 20 a day per phone, so the show page's own reports still go through. */ + const val DAILY_CAP = 15 } } @@ -106,7 +118,7 @@ class FeedReporter( suspend fun report(podcastId: String, status: String?, savedFeedUrl: String?): String? { if (status == null || !V2FeedReportStatus.isValid(status) || !enabled()) return null val showId = V2EpisodeIds.parseShowId(podcastId) ?: return null - if (!isOnline() || !limiter.tryClaim(podcastId, nowMillis())) return null + if (!isOnline() || !limiter.allows(podcastId, nowMillis())) return null val answer = try { send(V2FeedReportRequest(id = showId, status = status)) @@ -116,6 +128,7 @@ class FeedReporter( Log.w(TAG, "Feed report for $podcastId failed", e) null } ?: return null + limiter.record(podcastId, nowMillis()) if (answer.fetchVia != null && answer.fetchVia != V2FetchVia.DIRECT) return null val https = TrackedPodcastRtdbLogic.httpsFeedUrl(answer.fetchUrl) ?: return null return https.takeIf { it != savedFeedUrl?.trim() } @@ -124,7 +137,7 @@ class FeedReporter( companion object { private const val TAG = "FeedReporter" - /** The production [send]: one `POST /v2/feed-report`; null for any answer but 200. */ + /** The production [send]: one `POST /v2/feed-report`; null for any answer but 2xx (a 429 included), which counts nothing. */ suspend fun sendWith(api: BoxLoreApiV2, request: V2FeedReportRequest): V2FeedReportResponse? { val response = api.reportFeed(request).awaitResponse() return if (response.isSuccessful) response.body() else null diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt new file mode 100644 index 000000000..2c930011d --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt @@ -0,0 +1,68 @@ +package cx.aswin.boxlore.core.catalog + +import android.annotation.SuppressLint +import android.content.SharedPreferences + +/** + * Podcast Index shows whose new-episode notifications must be registered again after an address + * change and that Firebase hasn't acknowledged yet. Kept across launches, so a request lost with + * the process, or one the capped background job gave up on, is asked for again + * ([FeedAddressUpdater.resumePendingRegistrations]). + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): notifications follow a corrected address. Permanent: never remove (MIGRATION.md §5). + */ +interface PendingNotificationRegistrations { + fun all(): Set + + fun add(podcastId: String) + + fun remove(podcastId: String) +} + +/** [PendingNotificationRegistrations] for one process only (tests, and callers built without the app's wiring). */ +class MemoryPendingNotificationRegistrations : PendingNotificationRegistrations { + private val ids = LinkedHashSet() + + @Synchronized + override fun all(): Set = ids.toSet() + + @Synchronized + override fun add(podcastId: String) { + ids += podcastId + } + + @Synchronized + override fun remove(podcastId: String) { + ids -= podcastId + } +} + +/** + * [PendingNotificationRegistrations] in SharedPreferences file [FILE_NAME]: one key per show ID, + * the time it was asked for (epoch ms) as its value. A restored copy only registers those shows + * again, which is harmless. Stable storage name: never rename. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): notifications follow a corrected address. Permanent: never remove (MIGRATION.md §5). + */ +class SharedPreferencesPendingNotificationRegistrations( + private val prefs: SharedPreferences, + private val nowMillis: () -> Long = System::currentTimeMillis, +) : PendingNotificationRegistrations { + override fun all(): Set = prefs.all.keys.toSet() + + @SuppressLint("ApplySharedPref") + override fun add(podcastId: String) { + // Written to disk at once: the address change that needs it follows in the same call. + prefs.edit().putLong(podcastId, nowMillis()).commit() + } + + override fun remove(podcastId: String) { + prefs.edit().remove(podcastId).apply() + } + + companion object { + const val FILE_NAME = "boxlore_notification_reregistrations" + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt index a70231686..4691c12fd 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrar.kt @@ -21,7 +21,8 @@ enum class NotificationRegistrationOutcome { /** * Registers a Podcast Index show's new-episode notifications again after its saved address * changed, and waits for Firebase to acknowledge both writes: the `tracked_podcasts/` node - * (title, artwork, and the HTTPS address once the show has a latest episode) and the + * (title, artwork and the HTTPS address; written only once the show has a latest episode, so the + * shared node never loses its address) and the * [TrackedPodcastRtdbLogic.topic] subscription. Same node and topic as turning notifications on, * so nothing old builds or the checker read changes. The app retries [NotificationRegistrationOutcome.RETRY] * with a capped background job. `rss:` shows have their own journal ([RssNotificationReconciler]). @@ -56,9 +57,12 @@ class PodcastNotificationRegistrar internal constructor( } private suspend fun publish(row: PodcastEntity) { - // A stub row from a cloud pull has no details yet: subscribe the topic only, as turning notifications on does. - if (row.title.isNotBlank() && row.title != STUB_TITLE) { - val feedUrl = TrackedPodcastRtdbLogic.attachableFeedUrl(row.feedUrl, row.latestEpisode?.id) + // The node is one per show for every follower and its write replaces it whole: it is written + // only with an address. A stub row from a cloud pull, or a row with no latest episode yet, + // subscribes the topic only, as turning notifications on does; the latest-episode save + // attaches the address later. + val feedUrl = TrackedPodcastRtdbLogic.attachableFeedUrl(row.feedUrl, row.latestEpisode?.id) + if (row.title.isNotBlank() && row.title != STUB_TITLE && feedUrl != null) { firebase.set(row.podcastId, TrackedPodcastRtdbLogic.payload(row.title, row.imageUrl, feedUrl)) } firebase.subscribe(TrackedPodcastRtdbLogic.topic(row.podcastId)) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt index d3958a2f7..2f5d77f03 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalog.kt @@ -11,7 +11,9 @@ import kotlinx.coroutines.CancellationException * Follows feeds that moved for good on every catalog refresh path (the show page, pushes, * auto-download, restores, the ingest after a follow), not only the background pass: a refresh of * a Podcast Index show checks for a confirmed move ([RefreshRequest.detectMove]), and a move it - * finds is saved through [adopt] (the one address writer). [enabled] is the + * finds is saved through [adopt] (the one address writer) only while the show still holds the + * address that was read ([RefreshRequest.feedUrl]): a push or a restore may read another address, + * and a redirect from it is no move of the saved one. [enabled] is the * `feed-fix-moved-feed-off` switch; [showTitle] gives the stored title a move is compared with * when the caller sent none. Requests that already ask for move detection are left to their caller. * @@ -21,11 +23,11 @@ import kotlinx.coroutines.CancellationException class FeedMoveFollower( val enabled: () -> Boolean, val showTitle: suspend (podcastId: String) -> String?, - val adopt: suspend (podcastId: String, movedTo: String) -> Unit, + val adopt: suspend (podcastId: String, readFrom: String, movedTo: String) -> Unit, ) { companion object { /** Follows no moves (tests, and callers built without the app's wiring). */ - val NONE = FeedMoveFollower(enabled = { false }, showTitle = { null }, adopt = { _, _ -> }) + val NONE = FeedMoveFollower(enabled = { false }, showTitle = { null }, adopt = { _, _, _ -> }) } } @@ -60,7 +62,7 @@ class SubscribedEpisodeCatalog( val outcome = catalog.refresh(request.copy(detectMove = true, meta = request.meta.copy(title = title))) val movedTo = (outcome as? RefreshOutcome.Success)?.movedTo ?: return outcome try { - feedMoves.adopt(id, movedTo) + feedMoves.adopt(id, request.feedUrl.trim(), movedTo) } catch (e: CancellationException) { throw e } catch (e: Exception) { 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 bea713575..792d54727 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 @@ -279,7 +279,8 @@ class SubscriptionForegroundSync( feedNetworkDelayMs = feedNetworkDelayMs, chunkSize = chunkSize, feedFixes = feedFixes, - addressJobs = addressJobs(podcastRepository, subscriptionRepository, addressUpkeep), + addressJobs = + addressJobs(podcastRepository, subscriptionRepository, addressUpkeep, DirectFeedRecovery.addressRead(subscriptionRepository, localEpisodeCatalog)), ), ) } @@ -289,6 +290,7 @@ class SubscriptionForegroundSync( podcastRepository: PodcastRepository, subscriptionRepository: SubscriptionRepository, upkeep: FeedAddressUpkeep, + readsAt: suspend (podcastId: String, feedUrl: String) -> Boolean, ): FeedAddressJobs { val enabled = podcastRepository.rolloutSwitches.addressCheck return FeedAddressJobs( @@ -296,6 +298,7 @@ class SubscriptionForegroundSync( FeedAddressDailyCheck( followedShows = subscriptionRepository::followedShowRows, fetchAddresses = { ids -> FeedAddressDailyCheck.fetchWith(podcastRepository.apiV2, ids) }, + readsAt = readsAt, replaceAddress = subscriptionRepository::updateFeedAddress, markChecked = subscriptionRepository::markFeedAddressChecked, enabled = enabled, @@ -326,7 +329,7 @@ class SubscriptionForegroundSync( val refreshBatch = DirectFeedRefreshBatch() return { SubscriptionForegroundSyncIngest.sweepExpiredLocalCatalogs(localEpisodeCatalog) - runDailyAddressCheck(addressJobs.dailyCheck) + runDailyAddressCheck(addressJobs.dailyCheck, subscriptionRepository) val ids = subscriptionRepository.subscribedPodcastIds.first() val recoveryNow = System.currentTimeMillis() recoverMissingFeedUrls( @@ -374,7 +377,7 @@ class SubscriptionForegroundSync( episodeSupplementPort = episodeSupplementPort, podcastRepository = podcastRepository, followMoves = feedFixes.movedFeed(), - adoptMovedFeed = { url -> subscriptionRepository.adoptMovedFeed(id, url) }, + adoptMovedFeed = { readFrom, url -> subscriptionRepository.adoptMovedFeed(id, readFrom, url) }, ) }, saveDirectFeedLatest = { id, episode -> @@ -424,9 +427,13 @@ class SubscriptionForegroundSync( ) } - /** Runs the daily address check before the pass reads feeds, so a swapped address is read at once. */ - private suspend fun runDailyAddressCheck(dailyCheck: FeedAddressDailyCheck) { + /** + * Runs the daily address check before the pass reads feeds, so a swapped address is read at + * once, after asking again for notification registrations still waiting for Firebase. + */ + private suspend fun runDailyAddressCheck(dailyCheck: FeedAddressDailyCheck, subscriptionRepository: SubscriptionRepository) { try { + subscriptionRepository.feedAddresses.resumePendingRegistrations() dailyCheck.run() } catch (e: CancellationException) { throw e @@ -443,7 +450,7 @@ class SubscriptionForegroundSync( episodeSupplementPort: EpisodeSupplementPort, podcastRepository: PodcastRepository, followMoves: Boolean, - adoptMovedFeed: suspend (String) -> Unit, + adoptMovedFeed: suspend (readFrom: String, movedTo: String) -> Unit, ): DirectFeedResolveResult { val catalog = localEpisodeCatalog if (catalog != null) { @@ -462,7 +469,7 @@ class SubscriptionForegroundSync( meta: DirectFeedTipMeta, catalog: LocalEpisodeCatalogPort, followMoves: Boolean = false, - adoptMovedFeed: suspend (String) -> Unit = {}, + adoptMovedFeed: suspend (readFrom: String, movedTo: String) -> Unit = { _, _ -> }, ): DirectFeedResolveResult { val podcastMeta = catalogMeta(meta) if (catalog.isPublisherFeedUnchanged(podcastId, meta.feedUrl.orEmpty())) { @@ -485,7 +492,7 @@ class SubscriptionForegroundSync( is LocalEpisodeCatalogPort.RefreshOutcome.Success -> { outcome.movedTo?.takeIf { followMoves && !podcastId.startsWith("rss:") }?.let { moved -> Log.i(TAG, "Feed for $podcastId moved; saving its new address") - adoptMovedFeed(moved) + adoptMovedFeed(meta.feedUrl.orEmpty().trim(), moved) } DirectFeedResolveResult(tip = outcome.newest, persisted = true) } @@ -812,7 +819,7 @@ class SubscriptionForegroundSync( "No Room feedUrl for opted-in $podcastId; port may still use the stored supplement URL", ) } - val resolved = resolveFeedTip(podcastId, meta) + val resolved = DirectFeedRecovery.withReadAddress(resolveFeedTip(podcastId, meta), meta.feedUrl) if (resolved.persisted) { onFeedRefreshed(podcastId) } @@ -871,7 +878,8 @@ internal data class DirectFeedTipMeta( * Result of an opted-in publisher-feed resolve (tip plus whether extras were persisted). * [failed]: the feed couldn't be read this pass; [addressGone]: and its saved address looks dead * ([LocalEpisodeCatalogPort.RefreshOutcome.Failure.addressGone]); [reportStatus]: what the read saw, - * for `/v2/feed-report` ([LocalEpisodeCatalogPort.RefreshOutcome.Failure.reportStatus]). + * for `/v2/feed-report` ([LocalEpisodeCatalogPort.RefreshOutcome.Failure.reportStatus]); + * [readFeedUrl]: the saved address the failed read used, the only one a recovered address may replace. */ internal data class DirectFeedResolveResult( val tip: Episode?, @@ -879,6 +887,7 @@ internal data class DirectFeedResolveResult( val failed: Boolean = false, val addressGone: Boolean = false, val reportStatus: String? = null, + val readFeedUrl: String? = null, ) /** 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 5a9600b34..0e6a9b8ec 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 @@ -19,12 +19,14 @@ import kotlinx.coroutines.flow.map /** * What the one feed address writer needs from the app: how to ask for a show's notifications to be - * registered again in the background, and where `feed_url_corrected` goes. + * registered again in the background, where `feed_url_corrected` goes, and where registrations + * not yet acknowledged are kept across launches. * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows). */ data class FeedAddressWiring( val requestNotificationRegistration: (podcastId: String) -> Unit = {}, val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, + val pendingRegistrations: PendingNotificationRegistrations = MemoryPendingNotificationRegistrations(), ) @Suppress("TooManyFunctions", "LongParameterList", "kotlin:S107") // Constructor injection from AppContainer; no DI framework. @@ -42,7 +44,7 @@ class SubscriptionRepository( ) : PodcastNotificationSyncPort { /** The one writer of saved feed addresses (P4 PR 14). */ val feedAddresses: FeedAddressUpdater = - FeedAddressUpdater(podcastDao, feedAddressWiring.requestNotificationRegistration, feedAddressWiring.telemetry) + feedAddressWiring.run { FeedAddressUpdater(podcastDao, requestNotificationRegistration, telemetry, pendingRegistrations) } private val notificationRegistrar by lazy { PodcastNotificationRegistrar(podcastDao) } @@ -116,7 +118,7 @@ class SubscriptionRepository( existing?.skipEndingOverrideMs ?: podcast.skipEndingOverrideMs, sourceType = podcast.sourceType, - feedUrl = podcast.feedUrl, + feedUrl = FeedUrlSources.followedFeedUrl(podcast.isRss, existing?.feedUrl, podcast.feedUrl), rssRefreshCapability = podcast.rssRefreshCapability, rssCatalogStale = podcast.rssCatalogStale, rssHasNewEpisodes = podcast.rssHasNewEpisodes, @@ -680,15 +682,18 @@ class SubscriptionRepository( /** * Saves the new address of a feed that moved for good (permanent redirects or - * `itunes:new-feed-url`, same show) and reports the correction. + * `itunes:new-feed-url`, same show) and reports the correction. Only a move of the saved + * address counts: nothing is written unless the show still holds [readFrom], the address that + * was read (a push or a restore may read another one). * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: redirects). */ - suspend fun adoptMovedFeed(podcastId: String, movedTo: String) { + suspend fun adoptMovedFeed(podcastId: String, readFrom: String, movedTo: String) { feedAddresses.update( FeedAddressChange( podcastId = podcastId, feedUrl = movedTo, source = FeedUrlSources.FEED_MOVE, + onlyReplacing = readFrom, reason = FeedUrlCorrectionReason.MOVED, step = FeedUrlCorrectionStep.FEED_REFRESH, ), @@ -703,7 +708,8 @@ class SubscriptionRepository( /** * Registers a Podcast Index show's notifications again and waits for Firebase to acknowledge it; - * run by the app's capped background job after an address change ([PodcastNotificationRegistrar]). + * run by the app's capped background job after an address change ([PodcastNotificationRegistrar]), + * which records the outcome with [FeedAddressUpdater.registrationSettled]. * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows). */ suspend fun registerPodcastNotifications(podcastId: String): NotificationRegistrationOutcome = notificationRegistrar.register(podcastId) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt index 8372ef3f7..138031b34 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt @@ -20,7 +20,8 @@ import cx.aswin.boxlore.core.rss.RssPodcastRepository * * A pulled address for a Podcast Index show goes through [feedAddresses], the one address writer * (P4 PR 14): validators reset, notifications registered again with acknowledgement, the row left - * clean. Without it (older tests) the address is written with the row as before. `rss:` shows keep + * clean. A pull never puts back the address a confirmed correction on this phone replaced, and a + * merge never writes an older copy's address over one saved meanwhile. Without it (older tests) the address is written with the row as before. `rss:` shows keep * their own path: their address is their identity. */ class SubscriptionSyncResolver( @@ -124,16 +125,18 @@ class SubscriptionSyncResolver( val details = runCatching { podRepo.getPodcastDetails(podcastId) }.getOrNull() ?: return val current = podcastDao.getPodcast(podcastId) ?: return - podcastDao.upsert( + val routed = routesAddress(current) + val enriched = current.copy( title = details.title.takeIf { it.isNotBlank() } ?: current.title, author = details.artist.takeIf { it.isNotBlank() } ?: current.author, imageUrl = details.imageUrl.takeIf { it.isNotBlank() } ?: current.imageUrl, description = details.description ?: current.description, genre = details.genre.takeIf { it.isNotBlank() } ?: current.genre, - feedUrl = current.feedUrl ?: details.feedUrl, - ), - ) + feedUrl = if (routed) current.feedUrl else current.feedUrl ?: details.feedUrl, + ) + upsertMerged(enriched) + if (routed) fillMissingAddress(current, details.feedUrl) if (current.notificationsEnabled) { notificationSyncPort?.setNotificationTopicSubscribed(podcastId, true) } @@ -214,7 +217,7 @@ class SubscriptionSyncResolver( customGenreIcon = if (remote.customGenre == null) null else local.customGenreIcon, feedUrl = rowFeedUrl(local, remote), ) - podcastDao.upsert(updated) + upsertMerged(updated) applyPulledAddress(local, remote) if (!local.isRss) { if (remote.notificationsEnabled) { @@ -246,7 +249,7 @@ class SubscriptionSyncResolver( isDirty = false, syncedAt = syncedAt, ) - podcastDao.upsert(updated) + upsertMerged(updated) applyPulledAddress(local, remote) if (updated.notificationsEnabled != local.notificationsEnabled) { notificationSyncPort?.setNotificationTopicSubscribed(local.podcastId, updated.notificationsEnabled) @@ -259,11 +262,36 @@ class SubscriptionSyncResolver( private fun routesAddress(local: PodcastEntity): Boolean = feedAddresses != null && !local.isRss && !local.podcastId.startsWith("rss:") + /** + * Writes a merged row built from a copy read earlier. With [feedAddresses], an address another + * writer saved meanwhile (the daily check, a feed move) and its pending push are kept instead + * of the copy's ([PodcastDao.upsertKeepingFeedAddress]). + */ + private suspend fun upsertMerged(row: PodcastEntity) { + if (routesAddress(row)) podcastDao.upsertKeepingFeedAddress(row) else podcastDao.upsert(row) + } + + /** A fill of a missing address from the show's details, through the one address writer (P4 PR 14). */ + private suspend fun fillMissingAddress(current: PodcastEntity, detailsFeedUrl: String?) { + if (!current.feedUrl.isNullOrBlank()) return + val found = detailsFeedUrl?.takeIf(String::isNotBlank) ?: return + feedAddresses?.update(FeedAddressChange(podcastId = current.podcastId, feedUrl = found, source = FeedUrlSources.PODCAST_LOOKUP, fromCloud = true)) + } + /** A pulled address that differs from the stored one, saved through the one address writer (P4 PR 14). */ private suspend fun applyPulledAddress(local: PodcastEntity, remote: UserSubscriptionSyncDto) { val writer = feedAddresses?.takeIf { routesAddress(local) } ?: return val pulled = remote.feedUrl?.takeIf { it.isNotBlank() && it != local.feedUrl } ?: return - val result = writer.update(FeedAddressChange(podcastId = local.podcastId, feedUrl = pulled, source = FeedUrlSources.CLOUD_SYNC, fromCloud = true)) + val change = + FeedAddressChange( + podcastId = local.podcastId, + feedUrl = pulled, + source = FeedUrlSources.CLOUD_SYNC, + fromCloud = true, + onlyReplacing = local.feedUrl, + ) + // KEPT_CORRECTION: the pull would undo a confirmed correction; it stays and is pushed back. + val result = writer.update(change) if (result != FeedAddressUpdate.UPDATED) Log.d(TAG, "Pulled address for ${local.podcastId} not saved: $result") } diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt index 5dab7a4f7..41a3bb909 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/DirectFeedRecoveryTest.kt @@ -62,6 +62,9 @@ class DirectFeedRecoveryTest { assertEquals(FeedUrlCorrectionStep.FEED_REPORT, saved["reported"]?.step) assertEquals(FeedUrlCorrectionStep.DAILY_CHECK, saved["fallback"]?.step) assertFalse(saved.containsKey("slow"), "a passing failure keeps its address, whatever the report answered") + val change = saved.getValue("reported").change("reported", nowMillis = 5L) + assertEquals("https://old.example/reported.xml", change.onlyReplacing, "only the address that failed is replaced") + assertEquals("https://old.example/fallback.xml", saved.getValue("fallback").change("fallback", 5L).onlyReplacing) } @Test 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 3c4c1446a..b1b42e225 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 @@ -22,6 +22,8 @@ class FeedAddressDailyCheckTest { private val rows = mutableMapOf() private val calls = mutableListOf>() private val changes = mutableListOf() + private val events = mutableListOf() + private val unreadable = mutableSetOf() private var enabled = true private var answer: (List) -> V2ShowsFeedsResponse? = { ids -> V2ShowsFeedsResponse(true, ids.map { address(it) }) } @@ -32,11 +34,22 @@ class FeedAddressDailyCheckTest { calls += ids answer(ids) }, + readsAt = { id, url -> + events += "read $id $url" + url !in unreadable + }, replaceAddress = { change -> changes += change + events += "swap ${change.podcastId}" + val row = rows.getValue(change.podcastId) + rows[change.podcastId] = + row.copy(feedUrl = change.feedUrl, previousFeedUrl = row.feedUrl, feedUrlSource = change.source, feedUrlCheckedAt = change.checkedAt) FeedAddressUpdate.UPDATED }, - markChecked = { id, at -> rows[id] = rows.getValue(id).copy(feedUrlCheckedAt = at) }, + markChecked = { id, at -> + events += "stamp $id" + rows[id] = rows.getValue(id).copy(feedUrlCheckedAt = at) + }, enabled = { enabled }, nowMillis = { now }, ) @@ -85,6 +98,7 @@ class FeedAddressDailyCheckTest { val outcome = check.run() assertEquals(listOf("1"), changes.map { it.podcastId }, "no flag, a dump address, http only, no address, the same address, or back to where a move left: kept") + assertEquals(listOf("read 1 $NEW", "swap 1"), events.filterNot { it.startsWith("stamp") }, "read before it is saved") val change = changes.single() assertEquals(NEW, change.feedUrl) assertEquals(OLD, change.onlyReplacing) @@ -96,6 +110,66 @@ class FeedAddressDailyCheckTest { assertTrue(rows.values.all { it.feedUrlCheckedAt == now }, "every show the registry answered for is checked") } + @Test + fun `a registry address the phone can't read is never saved, and the next day's check tries again`() = runTest { + add(show("1")) + unreadable += NEW + + val outcome = check.run() + + assertTrue(changes.isEmpty(), "the working address stays") + assertEquals(0, outcome.swapped) + assertEquals(OLD, rows.getValue("1").feedUrl) + assertEquals(now, rows.getValue("1").feedUrlCheckedAt, "checked today, so the show isn't asked about twice a day") + events.clear() + check.run() + assertTrue(events.isEmpty(), "never twice a day") + + unreadable.clear() + val tomorrow = + FeedAddressDailyCheck( + followedShows = { rows.values.toList() }, + fetchAddresses = { ids -> answer(ids) }, + readsAt = { _, _ -> true }, + replaceAddress = { change -> + changes += change + FeedAddressUpdate.UPDATED + }, + markChecked = { _, _ -> }, + enabled = { true }, + nowMillis = { now + DAY }, + ) + tomorrow.run() + assertEquals(listOf(NEW), changes.map { it.feedUrl }) + } + + @Test + fun `a swapped show is stamped by the swap itself, never stamped first and left unswapped`() = runTest { + add(show("1")) + add(show("2")) + answer = { ids -> V2ShowsFeedsResponse(true, ids.map { address(it, repair = it == 1L) }) } + + check.run() + + assertEquals(listOf("read 1 $NEW", "swap 1", "stamp 2"), events) + assertEquals(now, rows.getValue("1").feedUrlCheckedAt) + } + + @Test + fun `the address the saved one replaced is never swapped back, whatever replaced it`() = runTest { + // Dead-address recovery left a dead registry address X for a readable Z; the registry still flags X. + add(show("1").copy(feedUrl = MOVED, previousFeedUrl = NEW, feedUrlSource = FeedUrlSources.FEED_REPORT)) + add(show("2").copy(feedUrl = MOVED, previousFeedUrl = NEW, feedUrlSource = FeedUrlSources.PODCAST_LOOKUP)) + // A move reached this phone by a cloud pull; its previous address is this phone's old one. + add(show("3").copy(feedUrl = MOVED, previousFeedUrl = NEW, feedUrlSource = FeedUrlSources.CLOUD_SYNC)) + + check.run() + + assertTrue(changes.isEmpty()) + assertTrue(events.none { it.startsWith("read") }, "nothing is even read") + assertTrue(rows.values.all { it.feedUrl == MOVED && it.feedUrlCheckedAt == now }) + } + @Test fun `follows changed on the phone wait for their push, other shows are left out`() = runTest { add(show("1").copy(isDirty = true, syncedAt = 5L)) diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressRecordedAnswersTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressRecordedAnswersTest.kt new file mode 100644 index 000000000..2d555ab57 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressRecordedAnswersTest.kt @@ -0,0 +1,127 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.network.BoxLoreApiV2 +import cx.aswin.boxlore.core.network.NetworkModule +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * The daily address check and broken-feed reports on the API's own answers (`fixtures/v2`, see its + * README), decoded by the app's client: a registry move, a dump address, a show the API doesn't + * know, an http-only show, an unavailable registry, and a report's answer (own-episode-IDs P4 PR 14). + */ +class FeedAddressRecordedAnswersTest { + private lateinit var server: MockWebServer + private lateinit var api: BoxLoreApiV2 + private val rows = mutableMapOf() + private val changes = mutableListOf() + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + api = NetworkModule.createBoxLoreApiV2(server.url("/").toString(), OkHttpClient()) + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + @Test + fun `only the recorded registry move is swapped, and every show the registry answered for is checked`() = runTest { + listOf("3391039", "920666", "9999999999", "7").forEach { id -> rows[id] = show(id) } + server.enqueue(json(fixture("shows-feeds.json"))) + + val outcome = check().run() + + assertEquals("""{"ids":[3391039,920666,9999999999,7]}""", server.takeRequest().body.readUtf8()) + val change = changes.single() + assertEquals("3391039", change.podcastId) + assertEquals("https://www.spreaker.com/show/4688572/episodes/feed", change.feedUrl) + assertEquals("registry:apple", change.source) + assertEquals(1, outcome.swapped) + assertEquals(4, outcome.checked) + assertTrue(rows.values.all { it.feedUrlCheckedAt == NOW }, "a dump address, an unknown show and an http-only show keep their address") + } + + @Test + fun `an unavailable registry, or an answer without the field, checks nothing`() = runTest { + rows["3391039"] = show("3391039") + server.enqueue(json(fixture("shows-feeds-registry-unavailable.json"))) + server.enqueue(json("""{"results":[]}""")) + + assertEquals(1, check().run().unchecked) + assertEquals(1, check().run().unchecked) + + assertTrue(changes.isEmpty()) + assertEquals(0L, rows.getValue("3391039").feedUrlCheckedAt, "the /podcast fallback may still run") + } + + @Test + fun `a report's recorded answer offers the registry address once, and a refused report counts nothing`() = runTest { + val stamps = MemoryFeedReportStamps() + val reporter = + FeedReporter( + send = { request -> FeedReporter.sendWith(api, request) }, + limiter = FeedReportLimiter(stamps), + isOnline = { true }, + enabled = { true }, + nowMillis = { NOW }, + ) + server.enqueue(MockResponse().setResponseCode(429).setBody("""{"error":"report_limit"}""")) + server.enqueue(json(fixture("feed-report.json"))) + + assertNull(reporter.report("3391039", "http_404", OLD)) + assertTrue(stamps.all().isEmpty()) + assertEquals("https://www.spreaker.com/show/4688572/episodes/feed", reporter.report("3391039", "http_404", OLD)) + + server.takeRequest() + assertEquals("""{"id":3391039,"status":"http_404"}""", server.takeRequest().body.readUtf8()) + assertEquals(setOf("3391039"), stamps.all().keys) + assertFalse(FeedReportLimiter(stamps).allows("3391039", NOW)) + } + + private fun check() = FeedAddressDailyCheck( + followedShows = { rows.values.toList() }, + fetchAddresses = { ids -> FeedAddressDailyCheck.fetchWith(api, ids) }, + readsAt = { _, _ -> true }, + replaceAddress = { change -> + changes += change + rows[change.podcastId] = rows.getValue(change.podcastId).copy(feedUrl = change.feedUrl, feedUrlCheckedAt = change.checkedAt) + FeedAddressUpdate.UPDATED + }, + markChecked = { id, at -> rows[id] = rows.getValue(id).copy(feedUrlCheckedAt = at) }, + enabled = { true }, + nowMillis = { NOW }, + ) + + private fun fixture(name: String): String = requireNotNull(javaClass.classLoader?.getResource("fixtures/v2/$name")) { name }.readText() + + private fun json(body: String) = MockResponse().setHeader("Content-Type", "application/json").setBody(body) + + private fun show(id: String) = PodcastEntity( + podcastId = id, + title = "Show $id", + author = "Author", + imageUrl = "", + description = null, + isSubscribed = true, + feedUrl = OLD, + ) + + private companion object { + const val NOW = 50 * FeedAddressDailyCheck.CHECK_INTERVAL_MS + const val OLD = "https://old.example/feed.xml" + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt index 6121d760f..4171b10fb 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt @@ -13,6 +13,7 @@ import cx.aswin.boxlore.core.model.FeedFetchReport import cx.aswin.boxlore.core.model.FeedUrlCorrection import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep +import cx.aswin.boxlore.core.model.Podcast import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -124,7 +125,7 @@ class FeedAddressUpdaterTest { fun `a filled missing address, a cloud address and an unfollowed show are not corrections`() = runTest { dao.upsertRow(show("1", null, notifications = true)) dao.upsertRow(show("2", OLD, notifications = true).copy(isDirty = false)) - dao.upsertRow(show("3", OLD).copy(isSubscribed = false)) + dao.upsertRow(show("3", OLD).copy(isSubscribed = false, isDirty = false)) updater.update(correction("1")) updater.update(correction("2").copy(fromCloud = true, reason = null, step = null)) @@ -134,15 +135,84 @@ class FeedAddressUpdaterTest { assertEquals(listOf("1", "2"), registrations) assertFalse("an address from the cloud is not pushed back", dao.getPodcast("2")!!.isDirty) assertEquals(NEW, dao.getPodcast("3")!!.feedUrl) + assertFalse("an unfollowed show is never pushed: its push could end a follow on another device", dao.getPodcast("3")!!.isDirty) + } + + @Test + fun `a registration stays pending until Firebase acknowledges it, and is asked for again`() = runTest { + val pending = MemoryPendingNotificationRegistrations() + val tracking = FeedAddressUpdater(dao, requestNotificationRegistration = { registrations += it }, pendingRegistrations = pending) + dao.upsertRow(show("1", OLD, notifications = true)) + dao.upsertRow(show("2", OLD, notifications = true)) + dao.upsertRow(show("3", OLD)) + + listOf("1", "2", "3").forEach { tracking.update(correction(it)) } + assertEquals(setOf("1", "2"), pending.all()) + tracking.registrationSettled("1", NotificationRegistrationOutcome.DONE) + tracking.registrationSettled("2", NotificationRegistrationOutcome.RETRY) // the job gave up after its last run + registrations.clear() + + tracking.resumePendingRegistrations() + + assertEquals(listOf("2"), registrations) + tracking.registrationSettled("2", NotificationRegistrationOutcome.DROPPED) + assertTrue(pending.all().isEmpty()) + } + + @Test + fun `a registration lost with the process is asked for again on the next launch`() = runTest { + val pending = MemoryPendingNotificationRegistrations() + dao.upsertRow(show("920666", OLD, notifications = true)) + // The process dies before the background job is queued. + FeedAddressUpdater(dao, requestNotificationRegistration = { throw RuntimeException("process gone") }, pendingRegistrations = pending) + .update(correction("920666")) + + FeedAddressUpdater(dao, requestNotificationRegistration = { registrations += it }, pendingRegistrations = pending).resumePendingRegistrations() + + assertEquals(NEW, dao.getPodcast("920666")!!.feedUrl) + assertEquals(listOf("920666"), registrations) + } + + @Test + fun `a push or restore address that redirects never replaces a different saved address`() = runTest { + dao.upsertRow(show("920666", NEW)) + val repository = SubscriptionRepository(dao) + + // A push read the shared node's stale address, which redirects somewhere else. + repository.adoptMovedFeed("920666", readFrom = OLD, movedTo = "https://elsewhere.example/feed.xml") + assertEquals(NEW, dao.getPodcast("920666")!!.feedUrl) + + repository.adoptMovedFeed("920666", readFrom = NEW, movedTo = "https://moved.example/feed.xml") + val row = dao.getPodcast("920666")!! + assertEquals("https://moved.example/feed.xml", row.feedUrl) + assertEquals(NEW, row.previousFeedUrl) + assertEquals(FeedUrlSources.FEED_MOVE, row.feedUrlSource) + } + + @Test + fun `following a show again keeps its corrected address`() = runTest { + dao.upsertRow(show("920666", OLD).copy(isSubscribed = false)) + FeedAddressUpdater(dao).update(correction("920666")) + val repository = SubscriptionRepository(dao) + + repository.toggleSubscription(Podcast(id = "920666", title = "Show", artist = "Author", imageUrl = "", feedUrl = OLD)) + + val row = dao.getPodcast("920666")!! + assertTrue(row.isSubscribed) + assertEquals(NEW, row.feedUrl) + assertEquals("registry:apple", row.feedUrlSource) } @Test fun `a failed request for registration never undoes the saved address`() = runTest { val failing = FeedAddressUpdater(dao, requestNotificationRegistration = { throw IllegalStateException("WorkManager not ready") }) + val broken = FeedAddressUpdater(dao, requestNotificationRegistration = { throw IllegalArgumentException("bad request") }) dao.upsertRow(show("920666", OLD, notifications = true)) + dao.upsertRow(show("1", OLD, notifications = true)) assertEquals(FeedAddressUpdate.UPDATED, failing.update(correction("920666"))) assertEquals(NEW, dao.getPodcast("920666")!!.feedUrl) + assertEquals("any failure, not only WorkManager's", FeedAddressUpdate.UPDATED, broken.update(correction("1"))) } private fun correction(id: String) = FeedAddressChange( diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt index 035afda46..858c47a77 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt @@ -44,15 +44,29 @@ class FeedReportsTest { } @Test - fun `at most twenty reports a day in all`() = runTest { + fun `at most fifteen reports a day in all, leaving the show page room under the API's twenty`() = runTest { repeat(25) { reporter.report((it + 1).toString(), "dns", OLD) } + assertEquals(15, FeedReportLimiter.DAILY_CAP) assertEquals(FeedReportLimiter.DAILY_CAP, sent.size) now += DAY reporter.report("25", "dns", OLD) assertEquals(FeedReportLimiter.DAILY_CAP + 1, sent.size, "a new day") } + @Test + fun `a failed or refused send uses up neither the show's day nor the daily cap`() = runTest { + answer = null // a 429, a 503 or any answer but 2xx + repeat(FeedReportLimiter.DAILY_CAP + 5) { reporter.report("920666", "http_404", OLD) } + val failing = FeedReporter(send = { error("offline after all") }, limiter = FeedReportLimiter(stamps), isOnline = { true }, enabled = { true }, nowMillis = { now }) + failing.report("920666", "http_404", OLD) + + assertTrue(stamps.all().isEmpty()) + answer = V2FeedReportResponse(queued = true, fetchUrl = NEW, fetchVia = "direct", feedSource = "registry:apple") + assertEquals(NEW, reporter.report("920666", "http_404", OLD), "the show is reported once the API answers") + assertNull(reporter.report("920666", "http_404", OLD)) + } + @Test fun `nothing is sent offline, for rss shows, without a read, or with the switch off`() = runTest { online = false @@ -83,12 +97,13 @@ class FeedReportsTest { @Test fun `old report times are dropped as new ones are written`() { val limiter = FeedReportLimiter(stamps) - assertTrue(limiter.tryClaim("1", now)) + limiter.record("1", now) now += 2 * DAY - assertTrue(limiter.tryClaim("2", now)) + assertTrue(limiter.allows("1", now)) + limiter.record("2", now) assertEquals(setOf("2"), stamps.all().keys) - assertFalse(limiter.tryClaim("2", now + 1)) + assertFalse(limiter.allows("2", now + 1)) } private companion object { diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt index 2fd1428ce..f2798bf5a 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt @@ -56,6 +56,19 @@ class PodcastNotificationRegistrarTest { assertEquals(listOf("new_ep_920666"), firebase.topics) } + @Test + fun `a show with no latest episode never writes the shared node without its address`() = runTest { + dao.upsertRow(show("920666", notifications = true).copy(latestEpisode = null)) + dao.upsertRow(show("75075", notifications = true).copy(feedUrl = "http://plain.example/rss")) + + val registrar = PodcastNotificationRegistrar(dao, firebase) + assertEquals(NotificationRegistrationOutcome.DONE, registrar.register("920666")) + assertEquals(NotificationRegistrationOutcome.DONE, registrar.register("75075")) + + assertTrue("the node is one per show for every follower: never replaced without feedUrl", firebase.nodes.isEmpty()) + assertEquals(listOf("new_ep_920666", "new_ep_75075"), firebase.topics) + } + @Test fun `nothing is registered for a show without notifications, and rss shows are dropped`() = runTest { dao.upsertRow(show("1", notifications = false)) @@ -106,6 +119,45 @@ class PodcastNotificationRegistrarTest { assertEquals(listOf("new_ep_920666"), firebase.topics) } + @Test + fun `a pulled previous address never undoes a registry correction, which goes back to the account`() = runTest { + dao.upsertRow(show("920666", notifications = true).copy(feedUrl = OLD, isDirty = false, syncedAt = 1L, subscribedAt = 1L)) + val queued = mutableListOf() + val updater = FeedAddressUpdater(dao, requestNotificationRegistration = { queued += it }) + updater.update(FeedAddressChange("920666", NEW, source = "registry:apple", checkedAt = 5L, onlyReplacing = OLD)) + dao.markPodcastsSynced(listOf("920666"), 6L) + queued.clear() + + // Another device, still on the old address, pushed later. + SubscriptionSyncResolver(podcastDao = dao, feedAddresses = updater).resolveSubscription( + UserSubscriptionSyncDto(podcastId = "920666", isSubscribed = true, subscribedAt = 1L, notificationsEnabled = true, feedUrl = OLD, updatedAt = 9L), + syncedAt = 10L, + ) + + val row = dao.getPodcast("920666")!! + assertEquals(NEW, row.feedUrl) + assertEquals("registry:apple", row.feedUrlSource) + assertEquals(OLD, row.previousFeedUrl) + assertTrue("the corrected address is pushed back", row.isDirty) + assertTrue("notifications stay on the corrected address", queued.isEmpty()) + } + + @Test + fun `a merge never writes its older copy's address over one saved meanwhile`() = runTest { + dao.upsertRow(show("920666", notifications = false).copy(feedUrl = OLD, isDirty = false, syncedAt = 1L, subscribedAt = 1L)) + val stale = dao.getPodcast("920666")!! + FeedAddressUpdater(dao).update(FeedAddressChange("920666", NEW, source = "registry:apple", checkedAt = 5L)) + + dao.upsertKeepingFeedAddress(stale.copy(customGenre = "Comedy", isDirty = false, syncedAt = 10L)) + + val row = dao.getPodcast("920666")!! + assertEquals(NEW, row.feedUrl) + assertEquals("registry:apple", row.feedUrlSource) + assertEquals(5L, row.feedUrlCheckedAt) + assertEquals("Comedy", row.customGenre) + assertTrue("the swap's pending push survives", row.isDirty) + } + @Test fun `a pull that brings the same address registers nothing`() = runTest { dao.upsertRow(show("920666", notifications = true).copy(isDirty = false, syncedAt = 1L, subscribedAt = 1L)) diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt index 5c728be20..b428aecc2 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesFeedReportStampsTest.kt @@ -16,15 +16,15 @@ class SharedPreferencesFeedReportStampsTest { private val context get() = ApplicationProvider.getApplicationContext() @Test - fun `a report claimed in one process holds in the next, and old times are dropped`() { + fun `a report sent in one process holds in the next, and old times are dropped`() { val day = FeedReportLimiter.PER_SHOW_INTERVAL_MS val prefs = context.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE) - FeedReportLimiter(SharedPreferencesFeedReportStamps(prefs)).tryClaim("920666", 10 * day) + FeedReportLimiter(SharedPreferencesFeedReportStamps(prefs)).record("920666", 10 * day) val nextProcess = SharedPreferencesFeedReportStamps(context.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE)) - assertFalse(FeedReportLimiter(nextProcess).tryClaim("920666", 10 * day + 1)) + assertFalse(FeedReportLimiter(nextProcess).allows("920666", 10 * day + 1)) - FeedReportLimiter(nextProcess).tryClaim("1", 12 * day) + FeedReportLimiter(nextProcess).record("1", 12 * day) assertEquals(mapOf("1" to 12 * day), nextProcess.all()) assertEquals("boxlore_feed_reports", SharedPreferencesFeedReportStamps.FILE_NAME) } diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt new file mode 100644 index 000000000..3c095ff5a --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt @@ -0,0 +1,32 @@ +package cx.aswin.boxlore.core.catalog + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Registrations not yet acknowledged survive a new process, so they are asked for again (P4 PR 14). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SharedPreferencesPendingNotificationRegistrationsTest { + private val context get() = ApplicationProvider.getApplicationContext() + + private fun store() = SharedPreferencesPendingNotificationRegistrations( + context.getSharedPreferences(SharedPreferencesPendingNotificationRegistrations.FILE_NAME, Context.MODE_PRIVATE), + ) + + @Test + fun `a pending show holds in the next process until it is acknowledged`() { + store().add("920666") + store().add("75075") + + val nextProcess = store() + assertEquals(setOf("920666", "75075"), nextProcess.all()) + nextProcess.remove("920666") + assertEquals(setOf("75075"), store().all()) + assertEquals("boxlore_notification_reregistrations", SharedPreferencesPendingNotificationRegistrations.FILE_NAME) + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt index aa0d0765a..487b5bf6e 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscribedEpisodeCatalogTest.kt @@ -61,14 +61,14 @@ class SubscribedEpisodeCatalogTest { SubscribedEpisodeCatalog( pi, rss, - feedMoves = FeedMoveFollower(enabled = { enabled }, showTitle = { "Stored title" }, adopt = { id, url -> adopted += id to url }), + feedMoves = FeedMoveFollower(enabled = { enabled }, showTitle = { "Stored title" }, adopt = { id, readFrom, url -> adopted += id to "$readFrom>$url" }), ) // A push, the show page or auto-download: no move check of its own, no title. shared.refresh(RefreshRequest("123", "https://old.example/feed.xml", reason = LocalEpisodeCatalogPort.RefreshReason.NEW_RELEASE)) assertEquals(true, pi.lastRefreshRequest?.detectMove) assertEquals("Stored title", pi.lastRefreshRequest?.meta?.title) - assertEquals(listOf("123" to "https://new.example/feed.xml"), adopted) + assertEquals("the move is adopted only from the address that was read", listOf("123" to "https://old.example/feed.xml>https://new.example/feed.xml"), adopted) // A caller that asks for move detection itself saves the move itself. adopted.clear() @@ -92,7 +92,7 @@ class SubscribedEpisodeCatalogTest { SubscribedEpisodeCatalog( pi, FakeLocalEpisodeCatalogPort(), - feedMoves = FeedMoveFollower(enabled = { true }, showTitle = { null }, adopt = { _, _ -> error("database closed") }), + feedMoves = FeedMoveFollower(enabled = { true }, showTitle = { null }, adopt = { _, _, _ -> error("database closed") }), ) assertEquals(outcome, shared.refresh(RefreshRequest("123", "https://old.example/feed.xml"))) diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt index ba8686de9..c923168ad 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SubscriptionForegroundSyncTest.kt @@ -598,7 +598,7 @@ class SubscriptionForegroundSyncTest { ) } val adopted = mutableListOf() - SubscriptionForegroundSync.resolveCatalogRefresh(id, meta, catalog, followMoves) { adopted += it } + SubscriptionForegroundSync.resolveCatalogRefresh(id, meta, catalog, followMoves) { _, movedTo -> adopted += movedTo } return adopted to catalog.lastRefreshRequest?.detectMove } diff --git a/core/catalog/src/test/resources/fixtures/v2/README.md b/core/catalog/src/test/resources/fixtures/v2/README.md index c0b9b7886..6309be98f 100644 --- a/core/catalog/src/test/resources/fixtures/v2/README.md +++ b/core/catalog/src/test/resources/fixtures/v2/README.md @@ -15,3 +15,12 @@ recorded. | `curiosity.json` | `GET /v2/curated/curiosity-v3?page=1` | | `briefing-metadata.json` | `GET /v2/briefings/metadata/us` | | `briefing-chapters.json` | the `chaptersUrl` of that briefing (`/v2/briefings/chapters/us`) | +| `shows-feeds.json` | `POST /v2/shows/feeds` with `{"ids":[3391039,920666,9999999999,7]}`, recorded 2026-10-27: a registry move flagged for repair, a dump address, a show the API doesn't know (`fetchUrl: null`) and an http-only show read through the API (`fetchVia: proxy`) | + +Two answers can't be recorded on demand, so they are written field for field in the shape the +Worker answers with (the same fields, types and nesting as `shows-feeds.json`): + +| File | Answer | +|---|---| +| `shows-feeds-registry-unavailable.json` | `POST /v2/shows/feeds` while the address registry is down: `registryAvailable: false`, addresses from the dump | +| `feed-report.json` | `POST /v2/feed-report` for show 3391039: the registry's address. A live report would queue a real re-check | diff --git a/core/catalog/src/test/resources/fixtures/v2/feed-report.json b/core/catalog/src/test/resources/fixtures/v2/feed-report.json new file mode 100644 index 000000000..7efe28307 --- /dev/null +++ b/core/catalog/src/test/resources/fixtures/v2/feed-report.json @@ -0,0 +1 @@ +{"queued":true,"fetchUrl":"https://www.spreaker.com/show/4688572/episodes/feed","fetchVia":"direct","transportUrl":null,"feedSource":"registry:apple"} \ No newline at end of file diff --git a/core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-unavailable.json b/core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-unavailable.json new file mode 100644 index 000000000..2f5c27948 --- /dev/null +++ b/core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-unavailable.json @@ -0,0 +1 @@ +{"registryAvailable":false,"results":[{"id":3391039,"fetchUrl":"https://www.spreaker.com/show/4688572/episodes/feed","fetchVia":"direct","transportUrl":null,"feedSource":"pidump","epNs":3391039,"flags":{"addressRepair":false,"episodeStale":false},"verifiedAt":null}]} \ No newline at end of file diff --git a/core/catalog/src/test/resources/fixtures/v2/shows-feeds.json b/core/catalog/src/test/resources/fixtures/v2/shows-feeds.json new file mode 100644 index 000000000..e510661dd --- /dev/null +++ b/core/catalog/src/test/resources/fixtures/v2/shows-feeds.json @@ -0,0 +1 @@ +{"registryAvailable":true,"results":[{"id":3391039,"fetchUrl":"https://www.spreaker.com/show/4688572/episodes/feed","fetchVia":"direct","transportUrl":null,"feedSource":"registry:apple","epNs":3391039,"flags":{"addressRepair":true,"episodeStale":false},"verifiedAt":1793094459},{"id":920666,"fetchUrl":"https://feeds.podcastindex.org/pc20.xml","fetchVia":"direct","transportUrl":null,"feedSource":"pidump","epNs":920666,"flags":{"addressRepair":false,"episodeStale":false},"verifiedAt":null},{"id":9999999999,"fetchUrl":null,"fetchVia":null,"transportUrl":null,"feedSource":null,"epNs":9999999999,"flags":{"addressRepair":false,"episodeStale":false},"verifiedAt":null},{"id":7,"fetchUrl":"http://feeds.feedburner.com/DirtBikePodcast","fetchVia":"proxy","transportUrl":"https://api.aswin.cx/v2/feed/7","feedSource":"pidump","epNs":7,"flags":{"addressRepair":false,"episodeStale":false},"verifiedAt":null}]} \ No newline at end of file diff --git a/core/database/README.md b/core/database/README.md index 3bb6f9bd4..8b54792e6 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -111,7 +111,7 @@ src/main/java/cx/aswin/boxlore/core/database/ - Unit tests live under `core/database/src/test`. - `BriefingArtworkCompatibilityTest` covers upgrade/reopen, restored rows, all region aliases, recursive-trigger safety, unchanged non-Brief artwork and preservation of playback/download/queue state. Opening recreates trigger definitions transactionally, including after a database comes from another application variant, so future restores use the current authority and aliases. - `PodcastDaoExtendedTest.setFeedUrlMarksACorrectedAddressForTheNextPush` covers a corrected address marking the row dirty and an unchanged one staying clean. -- `PodcastFeedAddressDaoTest` covers `replaceFeedAddress`: both addresses, the kept old address and every validator of the show cleared together (other shows untouched), a row changed since it was read and `rss:` rows left alone with nothing cleared, a missing address filled, a cloud address leaving the row clean, undo, and the daily-check stamp. +- `PodcastFeedAddressDaoTest` covers `replaceFeedAddress`: both addresses, the kept old address and every validator of the show cleared together (other shows untouched), a row changed since it was read and `rss:` rows left alone with nothing cleared, a missing address filled, a cloud address leaving the row clean, undo, the daily-check stamp (kept by writes the registry didn't confirm), `markFollowedDirty` for followed rows only, and `upsertKeepingFeedAddress` keeping an address saved after the merged copy was read. - `PodcastDaoInMemoryTest` verifies the in-memory Room DAO path and RSS notification cleanup selection, including disabled/unsubscribed rows with stale source metadata, and that `setEpNsIfUnset` never replaces a stored namespace or sets one for an `rss:` show. `LocalEpisodeCatalogDaoOwnersTest` covers the catalog's `setEpNsIfUnset` and `upsertFeed` keeping it; `EpisodeIdentityDaoTest` covers `aliasesForShow`. - `ListeningRollupMergeTest` covers session→rollup merge, including the empty-sessions guard. `ListeningHistoryDaoInMemoryTest` covers insert-if-absent conflict safety and playback completion updates preserving listener-owned fields such as likes, and that a push batch reads only its own rows, newest first and without descriptions. `EpisodeListenerStoreTest` also covers which IDs a queue removal records. `ListeningHistoryDao.repairPlaybackArtworkLinks` and `QueueDao.repairPlaybackArtworkLinks` swap player-only `.collage` artwork links saved by older builds for the episode's cover (local, then RSS catalogue), else the show's, else the row's own show cover, else null. A history row is marked dirty only when every dead link found a real cover, so an emptied field never overwrites a cover another device uploaded; `QueueDao.repairPlaybackArtworkLinksForSync` marks a changed queue dirty in the same transaction without changing its sequence, so the cloud copy drops the dead links, and its order is untouched. `ListeningHistoryDaoInMemoryTest` and `QueueDaoInMemoryTest` cover each source, the row's own show cover, emptied rows staying clean, the queue marked dirty only when a row changed, untouched rows and a second run changing nothing. - `RoomV39UpgradeTest` builds databases from the exported 36, 37 and 38 schemas, and a phone that came up through the older migrations (from 30, with the `CHECK` constraints, `queue_metadata` SQL defaults and supplement-copied rows), fills them with every kind of stored ID (Podcast Index, negative, served recipe-range IDs in the catalog, history, queue and downloads, SQLite-trimmed, synthetic and address keys, Brief and `rss:` IDs, reserved IDs from the collision guard), then opens them through `BoxLoreDatabase.builder`, so Room runs the production migrations and its own schema check. It asserts that every pre-existing value is unchanged, the untouched tables keep their exact SQL, the Brief artwork triggers don't interfere, aliases, owners, namespaces and repair rows are seeded as described above (including IDs held under another show, address keys whose saved item is gone, and each owner rule), eviction keeps aliases, later catalog refreshes and podcast writes keep the seeded columns, a fresh install and an upgraded phone have identical tables, columns, defaults and indices, a failure inside the seeding rolls the file back to v38 and the next open succeeds, a second seeding pass changes nothing, a missing upgrade path fails without erasing data, and downgrades and pre-12 versions still open empty. `EpisodeAliasSeedingTest` covers every seeding rule without SQLite, including the recipe trim cases; `EpisodeIdentityDaoTest` covers the new DAOs. `RoomSchemaFixture` and `LegacyRows` build these fixtures; reuse them for later upgrades. diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt index 6e05f24b3..c1decf6d8 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/FeedAddressWrite.kt @@ -8,9 +8,10 @@ package cx.aswin.boxlore.core.database * - [transportUrl]: the address to fetch through when the publisher's can't be read directly; null * clears an old one. * - [source]: where [feedUrl] came from (`feedUrlSource`). - * - [checkedAt]: when the address registry confirmed [feedUrl] (`feedUrlCheckedAt`); 0 when it didn't. - * - [markDirty]: true for an address found on this phone, so the next sync push sends it; false for - * one that came from the cloud. + * - [checkedAt]: when the address registry confirmed [feedUrl] (`feedUrlCheckedAt`); 0 when it didn't, + * which keeps the stored stamp so the daily check still runs at most once a day. + * - [markDirty]: true for an address found on this phone for a followed show, so the next sync + * push sends it; false for one that came from the cloud, or a show that isn't followed. * * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). * COMPAT(own-ids): the one address writer. Permanent: never remove (MIGRATION.md §5). 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 888c29b14..5791970e2 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 @@ -183,8 +183,8 @@ interface PodcastDao { /** * Replaces a show's saved feed address in one transaction: the publisher and transport - * addresses, where the address came from and when the registry last confirmed it, the old - * address kept for undo, every stored ETag and Last-Modified for the show (so the next read + * addresses, where the address came from and when the registry last confirmed it (a write the + * registry didn't confirm keeps the stored daily-check stamp), the old address kept for undo, every stored ETag and Last-Modified for the show (so the next read * fetches the new address in full), and the dirty flag when [FeedAddressWrite.markDirty]. * Compare-and-set: nothing is written unless the row still holds [FeedAddressWrite.expectedFeedUrl]. * Never for `rss:` shows, whose address is their identity. @@ -222,7 +222,7 @@ interface PodcastDao { transportUrl = :transportUrl, previousFeedUrl = :previousFeedUrl, feedUrlSource = :source, - feedUrlCheckedAt = :checkedAt, + feedUrlCheckedAt = CASE WHEN :checkedAt > 0 THEN :checkedAt ELSE feedUrlCheckedAt END, feedEtag = NULL, feedLastModified = NULL, isDirty = CASE WHEN :markDirty THEN 1 ELSE isDirty END @@ -262,6 +262,39 @@ interface PodcastDao { @Query("UPDATE podcasts SET feedUrlCheckedAt = :atMillis WHERE podcastId = :id") suspend fun markFeedUrlChecked(id: String, atMillis: Long,) + /** + * Marks a followed show for the next sync push, for a saved address this phone keeps over an + * older one a pull brought, so the account gets the corrected address back. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + */ + @Query("UPDATE podcasts SET isDirty = 1 WHERE podcastId = :id AND isSubscribed = 1") + suspend fun markFollowedDirty(id: String) + + /** + * [upsert] for a whole-row write built from a row read earlier (sync merges): when another writer + * replaced the saved address since, that address, its bookkeeping and its pending push are kept + * instead of being written back over. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + * COMPAT(own-ids): the one address writer stays the only one. Permanent: never remove (MIGRATION.md §5). + */ + @Transaction + suspend fun upsertKeepingFeedAddress(podcast: PodcastEntity) { + val stored = getPodcast(podcast.podcastId) + if (stored == null) { + upsertRow(podcast) + return + } + val row = + if (stored.feedUrl == podcast.feedUrl) { + podcast + } else { + podcast.copy(feedUrl = stored.feedUrl, isDirty = podcast.isDirty || stored.isDirty) + } + upsertRow(row.keepingFeedIdentityColumnsOf(stored)) + } + /** * The show's stored episode namespace (`epNs`), or null when the show has no row or none was set. * diff --git a/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt b/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt index b2e7a7f12..239925f3e 100644 --- a/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt +++ b/core/database/src/test/java/cx/aswin/boxlore/core/database/PodcastFeedAddressDaoTest.kt @@ -118,6 +118,47 @@ class PodcastFeedAddressDaoTest { assertEquals(OLD, row.feedUrl) } + @Test + fun `a write the registry didn't confirm keeps the daily check stamp`() = runTest { + dao.upsertRow(show("920666", feedUrl = OLD).copy(feedUrlCheckedAt = 50L)) + + assertTrue(dao.replaceFeedAddress(write("920666", OLD, checkedAt = 0L))) + assertEquals("a cloud pull, a feed move or a fill never re-opens today's check", 50L, dao.getPodcast("920666")!!.feedUrlCheckedAt) + + assertTrue(dao.replaceFeedAddress(write("920666", NEW, feedUrl = OLD, checkedAt = 70L))) + assertEquals(70L, dao.getPodcast("920666")!!.feedUrlCheckedAt) + } + + @Test + fun `only a followed show is marked for a push to keep its address`() = runTest { + dao.upsertRow(show("1", feedUrl = OLD).copy(isDirty = false)) + dao.upsertRow(show("2", feedUrl = OLD).copy(isDirty = false, isSubscribed = false)) + + dao.markFollowedDirty("1") + dao.markFollowedDirty("2") + + assertTrue(dao.getPodcast("1")!!.isDirty) + assertFalse(dao.getPodcast("2")!!.isDirty) + } + + @Test + fun `a merged row built from an older copy keeps an address saved meanwhile`() = runTest { + dao.upsertRow(show("920666", feedUrl = OLD).copy(isDirty = false)) + val older = dao.getPodcast("920666")!! + dao.replaceFeedAddress(write("920666", OLD, source = "registry:apple", checkedAt = 9L)) + + dao.upsertKeepingFeedAddress(older.copy(title = "Renamed", isDirty = false)) + val kept = dao.getPodcast("920666")!! + assertEquals(NEW, kept.feedUrl) + assertEquals(OLD, kept.previousFeedUrl) + assertEquals("registry:apple", kept.feedUrlSource) + assertEquals("Renamed", kept.title) + assertTrue(kept.isDirty) + + dao.upsertKeepingFeedAddress(kept.copy(title = "Again", isDirty = false)) + assertFalse("an unchanged address writes the row as given", dao.getPodcast("920666")!!.isDirty) + } + private fun write( id: String, expected: String?, diff --git a/core/rss/README.md b/core/rss/README.md index ed7630fb9..67566949d 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -103,7 +103,8 @@ src/main/java/cx/aswin/boxlore/core/rss/ - **Older duplicate rows stay.** Older builds could store two rows for one GUID-less item (one per parser's audio address) or for a padded and a plain copy of one GUID. The item now updates one of them; the other stays unchanged and is no longer refreshed, with any listener state it holds (local catalogs, true RSS rows and supplements alike). Nothing joins such pairs yet. - **Supplement IDs are kept too.** `EpisodeSupplementRepository` replaces a show's supplement rows on every refresh. Feed-only items take the ID of the stored supplement row they match (`SupplementIdentity`, the same lookup as true RSS rows), and a stored row no item matched that holds one of an item's keys (an older build's second row for the same episode) is written back unchanged. Rows of episodes that left the feed or reached the Podcast Index list are still dropped. The tip `resolveNewestTipFromFeed` writes keeps its stored ID the same way. - Feed read failures are typed: `FeedHttpException` (status code; message unchanged) and `NotAFeedException` (wrong content type or nothing parseable), both `IllegalArgumentException`s. `FeedReadFailure.isAddressGone` reads them, plus no such host and failing certificates, as a dead address (`RefreshOutcome.Failure.addressGone`); timeouts, 5xx, 429 and dropped connections are passing trouble. An address that answers with no episodes counts as dead too. `FeedReadFailure.reportStatus` names what a failed read saw for `/v2/feed-report` (`RefreshOutcome.Failure.reportStatus`): `http_`, `dns`, `timeout`, `tls`, `parse`, `network` or `other`; an address with no episodes reports `parse`. -- Moved feeds: `RssFetchResult.movedTo` is the final address when every redirect was permanent (301, 308; `permanentMoveTarget`); `ParsedRssFeed.newFeedUrl` is the channel's `itunes:new-feed-url` (both parsers). With `RefreshRequest.detectMove`, `confirmedMove` reports a new HTTPS address only when it differs from the saved one and serves the same show by title (case, spacing and punctuation ignored); a declared address is read once to confirm. Temporary redirects never move a feed. +- Moved feeds: `RssFetchResult.movedTo` is the final address when every redirect was permanent (301, 308; `permanentMoveTarget`); `ParsedRssFeed.newFeedUrl` is the channel's `itunes:new-feed-url` (both parsers). With `RefreshRequest.detectMove`, `confirmedMove` reports a new HTTPS address only when it differs from the saved one and serves the same show by title (case, spacing and punctuation ignored); a declared address is read once to confirm, only while `RefreshRequest.canProceed` still allows a fetch. Temporary redirects never move a feed. +- Address changes keep legacy rows: a catalog read also tries the URL namespaces of the address the catalog was last read from and the address the read was asked for (`olderNamespaces`), so a legacy row minted under an old address (synthetic `id:` or audio-address key) still heals to its own ID after the saved address changed or the read was redirected (`LocalEpisodeCollisionGuard.resolve(olderNamespaces)`, `LocalEpisodeCatalogPersist.plan(olderNamespaces)`; `LegacyIdentityRoomTest`). - Local catalog writes never replace another episode (`LocalEpisodeCollisionGuard`, [docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md) §4). The write is a REPLACE, which deletes any stored row sharing the episode ID or the unique (show, catalog key). Inside the refresh transaction, for each row: - **Its own stored row first.** When the show already stores the key, the row keeps that stored ID, so it is updated and never deleted through the unique index; an exact stored key wins over one that only matches after trimming. Stored keys are compared after the recipe trim, so a key that Room migration 31→32 left with a trailing newline, tab or no-break space keeps its ID and gets the trimmed key instead of a second row. - **Legacy heal.** A same-show row that holds the item's own minted ID (`StickyEpisodeIdentity.ownMintedId`) and whose key is the synthetic `id:`, or its audio address while the item has a GUID, is the same episode: it keeps its ID and takes the feed's key. The own mint is looked up whatever ID the row arrives with, so a new item that arrives with its recipe ID heals the legacy row instead of writing a second row under the recipe ID. diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersist.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersist.kt index ff6e85195..3646c8b3d 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersist.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersist.kt @@ -98,9 +98,12 @@ object LocalEpisodeCatalogPersist { * when one belongs to another episode. * * Document order and the two-key lookup were added in P4 PR 8; aliases and recipe IDs replaced - * the Podcast Index match in P4 PR 9 (own-IDs plan section 4). + * the Podcast Index match in P4 PR 9 (own-IDs plan section 4). [olderNamespaces] (the URL + * namespaces of addresses the show was read from before) were added in P4 PR 14: an item's own + * mint in them also proves listener state its own after the saved address changed. * COMPAT(own-ids): keeps every stored episode ID and decides new ones. Permanent: never remove (MIGRATION.md §5). */ + @Suppress("LongParameterList", "kotlin:S107") // Each input is its own part of the identity contract. fun plan( podcastIndexId: String, rssNamespaceId: String, @@ -109,12 +112,13 @@ object LocalEpisodeCatalogPersist { newIds: NewEpisodeIds, channelImageUrl: String?, showImageUrl: String?, + olderNamespaces: List = emptyList(), ): List { val occupiedIds = existing.map { it.episodeId }.toSet() val stored = StoredRowMatch.StoredRows.of(existing.map { Triple(it.episodeId, it.guid, it.audioUrl) }) val matches = StoredRowMatch.resolve(parsed, stored) val aliasMatches = aliasMatches(parsed, matches, newIds, occupiedIds) - val adopted = listenerMatches(parsed, matches, aliasMatches, newIds, rssNamespaceId, occupiedIds) + val adopted = listenerMatches(parsed, matches, aliasMatches, newIds, listOf(rssNamespaceId) + olderNamespaces, occupiedIds) val ctx = PersistContext( podcastIndexId = podcastIndexId, @@ -195,7 +199,7 @@ object LocalEpisodeCatalogPersist { matches: Array, aliasMatches: Array, newIds: NewEpisodeIds, - rssNamespaceId: String, + namespaces: List, occupiedIds: Set, ): Map { if (newIds.epNs == null || newIds.listeners.isEmpty) return emptyMap() @@ -215,7 +219,7 @@ object LocalEpisodeCatalogPersist { ListenerIdAdoption.Candidate( position = position, open = open, - mints = if (open) ownMints(item, match.key, rssNamespaceId) else emptyList(), + mints = if (open) ownMints(item, match.key, namespaces) else emptyList(), audioKeys = (listOf(item.episode.audioUrl) + item.enclosureUrls + item.audioUrls) .mapNotNullTo(HashSet()) { url -> url.trim().takeIf(String::isNotEmpty) }, @@ -226,10 +230,15 @@ object LocalEpisodeCatalogPersist { return ListenerIdAdoption.adopt(candidates, newIds.listeners, claimed) } - /** The IDs older builds could have minted for [item]: the parser's, then the mint of its key and of each older key. */ - private fun ownMints(item: ParsedFeedItem, key: String, rssNamespaceId: String,): List { - if (!rssNamespaceId.startsWith(RSS_NAMESPACE_PREFIX)) return listOf(item.episode.episodeId) - val mints = (listOf(key) + item.legacyKeys).map { StickyEpisodeIdentity.ownMintedId(rssNamespaceId, it) } + /** + * The IDs older builds could have minted for [item]: the parser's, then the mint of its key and + * of each older key in each of [namespaces] (this read's first, then addresses read before). + */ + private fun ownMints(item: ParsedFeedItem, key: String, namespaces: List,): List { + val urlNamespaces = namespaces.filter { it.startsWith(RSS_NAMESPACE_PREFIX) } + if (urlNamespaces.isEmpty()) return listOf(item.episode.episodeId) + val keys = listOf(key) + item.legacyKeys + val mints = urlNamespaces.flatMap { namespace -> keys.map { StickyEpisodeIdentity.ownMintedId(namespace, it) } } return (listOf(item.episode.episodeId) + mints).distinct() } diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt index acac37478..4c8321a83 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt @@ -119,6 +119,25 @@ private suspend fun fetchAndPersist( ) } +/** + * The URL namespaces this show's stored rows may have been minted under besides [rssNamespaceId] + * (the address this read ended at): the address the catalog was last read from, and the address + * this read was asked for. They differ after a saved address changed (a registry correction, a + * feed move) or when the read was redirected; legacy rows minted under the old address still heal + * to their own IDs ([LocalEpisodeCollisionGuard], [LocalEpisodeCatalogPersist.plan]). + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 4, legacy keys resolve when the feed + * is read again; section 11, followed shows). + * COMPAT(own-ids): legacy rows keep their IDs across an address change. Permanent: never remove (MIGRATION.md §5). + */ +internal fun olderNamespaces(rssNamespaceId: String, request: RefreshRequest, existing: LocalEpisodeFeedEntity?): List = + listOfNotNull(existing?.feedUrl, request.feedUrl) + .map(String::trim) + .filter(String::isNotEmpty) + .mapNotNull { url -> runCatching { RssIdGenerator.podcastId(url) }.getOrNull() } // An address that can't be one mints nothing. + .filter { it != rssNamespaceId } + .distinct() + private suspend fun persistParsed( deps: LocalCatalogRefreshDeps, request: RefreshRequest, @@ -129,6 +148,7 @@ private suspend fun persistParsed( ): RefreshOutcome { val identities = deps.dao.listIdentities(request.podcastIndexId) val newIds = newEpisodeIds(deps, request, existing) + val olderNamespaces = olderNamespaces(rssNamespaceId, request, existing) val planned = LocalEpisodeCatalogPersist.plan( podcastIndexId = request.podcastIndexId, @@ -138,6 +158,7 @@ private suspend fun persistParsed( newIds = newIds, channelImageUrl = parsed.imageUrl, showImageUrl = request.meta.imageUrl, + olderNamespaces = olderNamespaces, ) val rows = planned.map { it.entity } if (rows.isEmpty()) { @@ -162,6 +183,7 @@ private suspend fun persistParsed( LocalEpisodeCollisionGuard.resolve( rows = rows, rssNamespaceId = rssNamespaceId, + olderNamespaces = olderNamespaces, findOwners = { ids -> deps.dao.findOwners(ids) }, listShowRows = { podcastId -> deps.dao.listIdentities(podcastId) }, findAliases = { ids -> deps.identity.aliases(ids) }, @@ -342,7 +364,8 @@ private fun logResolution(podcastId: String, resolution: LocalEpisodeCollisionGu * Adopted only when the new address is HTTPS, differs from the saved one and serves the same show * (same title as [RefreshRequest.meta]), as the server's address checks do: from redirects that * were all permanent ([RssFetchResult.movedTo]), or from `itunes:new-feed-url`, read once to - * confirm. Temporary redirects (302, 307) never move a feed. + * confirm, and only while [RefreshRequest.canProceed] still allows a fetch. Temporary redirects + * (302, 307) never move a feed. */ internal suspend fun confirmedMove(feedClient: RssFeedClient, request: RefreshRequest, fetched: RssFetchResult, parsed: ParsedRssFeed,): String? { val showTitle = request.meta.title?.takeIf(String::isNotBlank) ?: return null @@ -355,6 +378,8 @@ internal suspend fun confirmedMove(feedClient: RssFeedClient, request: RefreshRe ?.trim() ?.takeIf { isNewHttpsAddress(it, saved) && isNewHttpsAddress(it, fetched.finalUrl) } ?: return null + // The confirming read is a second fetch: a gated caller (auto-download's background or transfer gate) may refuse it. + if (!request.canProceed()) return null return try { val next = feedClient.fetch(declared) val nextParsed = feedClient.parse(next.finalUrl, next.body, RssIdGenerator.podcastId(next.finalUrl)) diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt index 8172dd8f8..5ad33d978 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCollisionGuard.kt @@ -147,8 +147,10 @@ internal object LocalEpisodeCollisionGuard { * @param findAliases alias rows of up to 500 episode IDs across every show * @param fallbackIds per row of [rows] (same index), the IDs to try in order when the row's own * ID is taken, before a reserved one + * @param olderNamespaces URL namespaces of addresses the show was read from before (P4 PR 14): + * a legacy row minted under one of them heals too, after the saved address changed */ - @Suppress("LongParameterList") + @Suppress("LongParameterList", "kotlin:S107") suspend fun resolve( rows: List, rssNamespaceId: String, @@ -156,12 +158,14 @@ internal object LocalEpisodeCollisionGuard { listShowRows: suspend (String) -> List, findAliases: suspend (List) -> List = { emptyList() }, fallbackIds: List> = emptyList(), + olderNamespaces: List = emptyList(), ): Resolution { if (rows.isEmpty()) return Resolution(rows, emptyList(), emptyList()) val shows = rows.map { it.podcastId }.distinct().associateWith { ShowRows(listShowRows(it)) } val holders = Holders(findOwners, findAliases) holders.load(rows.map { it.episodeId }) val state = BatchState() + val namespaces = (listOf(rssNamespaceId) + olderNamespaces).distinct() val resolved = arrayOfNulls(rows.size) // Rows whose key the show already stores go first, so no other row can take their IDs. rows.forEachIndexed { index, row -> @@ -172,7 +176,7 @@ internal object LocalEpisodeCollisionGuard { val mine = ownerOf(row) if (mine in state.claimedKeys) return@forEachIndexed // A repeated key: the first row wins. val fallbacks = fallbackIds.getOrNull(index).orEmpty() - val id = placeNewRow(row, mine, rssNamespaceId, shows.getValue(row.podcastId), holders, state, fallbacks) + val id = placeNewRow(row, mine, namespaces, shows.getValue(row.podcastId), holders, state, fallbacks) if (id == null) { state.unplaced += row } else { @@ -244,13 +248,13 @@ internal object LocalEpisodeCollisionGuard { private suspend fun placeNewRow( row: LocalEpisodeEntity, mine: Owner, - rssNamespaceId: String, + namespaces: List, show: ShowRows, holders: Holders, state: BatchState, fallbacks: List, ): String? { - healLegacyRow(row, mine, rssNamespaceId, show, state)?.let { healed -> + namespaces.firstNotNullOfOrNull { namespace -> healLegacyRow(row, mine, namespace, show, state) }?.let { healed -> state.repaired += healed return healed.episodeId } @@ -274,7 +278,8 @@ internal object LocalEpisodeCollisionGuard { * a recipe ID or a listener-held ID (before P4 PR 9, on a catalog's first read, a Podcast Index match), and writing * that would leave the legacy row beside a second row of the same episode. A row with any other * key, or whose ID an earlier row of the batch took (for example a GUID-less item still keyed by - * that audio address), stays a different episode. + * that audio address), stays a different episode. The caller tries this read's namespace first, + * then those of addresses the show was read from before (P4 PR 14). */ private fun healLegacyRow( row: LocalEpisodeEntity, diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt index 38974e21e..28c0450f6 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt @@ -4,6 +4,9 @@ import androidx.test.core.app.ApplicationProvider 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.LocalEpisodeFeedEntity +import cx.aswin.boxlore.core.database.LocalFeedOrder +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome import cx.aswin.boxlore.core.model.EpisodeRecipeId import kotlinx.coroutines.runBlocking @@ -80,6 +83,52 @@ class LegacyIdentityRoomTest : RoomCatalogTestBase() { assertEquals(1L, alias.createdAt) } + @Test + fun aSyntheticLegacyRowMintedUnderTheOldAddressHealsWhenTheShowIsReadAtItsNewAddress() = runBlocking { + // The saved address changed (a registry correction or a feed move): the catalog was last + // read at the old address, and its legacy rows were minted in that address's namespace. + follow(SHOW) + val old = feedUrl(SHOW) + val minted = StickyEpisodeIdentity.ownMintedId(namespace(old), "g1") + database.localEpisodeCatalogDao().upsertEpisodes(listOf(row(minted, SHOW, "id:$minted"))) + database.localEpisodeCatalogDao().upsertFeed( + LocalEpisodeFeedEntity( + podcastId = SHOW, + feedUrl = old, + feedEtag = null, + feedLastModified = null, + fetchedAt = 1L, + itemCount = 1, + feedOrder = LocalFeedOrder.NEWEST_FIRST, + ttlExpiresAt = null, + needsFullBackfill = false, + copiedExtrasCount = 0, + ready = true, + feedUrlLookupAt = 0L, + ), + ) + history(minted, SHOW) + feed.items = listOf(Item("g1")) + + assertTrue(refresh(SHOW, url = "https://moved.example/$SHOW.xml") is RefreshOutcome.Success) + + assertEquals("one row, under its own ID: never a second copy of the episode", mapOf("g1" to minted), rows(SHOW)) + assertNotNull(database.listeningHistoryDao().getHistoryItem(minted)) + } + + @Test + fun theNamespacesOfTheAddressesReadBeforeAreTriedAfterThisReadsOwn() { + val request = LocalEpisodeCatalogPort.RefreshRequest(podcastIndexId = SHOW, feedUrl = " https://requested.example/a.xml ") + val current = namespace("https://final.example/a.xml") + + assertEquals( + "a redirected read also tries the address it was asked for", + listOf(namespace("https://requested.example/a.xml")), + olderNamespaces(current, request, existing = null), + ) + assertEquals(emptyList(), olderNamespaces(namespace("https://requested.example/a.xml"), request, existing = null)) + } + @Test fun aPaddedGuidWithItsSeededTrustedAliasRecordsTheHealAndKeepsItsIdAcrossEviction() = runBlocking { // As the v39 upgrade leaves it: key "g1\n" from Room migration 31→32, alias re-trimmed and trusted. From 9ea28b10aa71f4ba3a84eaa6e5bb1c632b2d81dd Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 22:43:03 +0530 Subject: [PATCH 4/9] feat(info): read show pages from the feed --- app/README.md | 2 +- .../java/cx/aswin/boxlore/AppContainer.kt | 13 +- core/catalog/README.md | 4 +- .../core/catalog/FeedFirstShowPages.kt | 83 ++++--- .../aswin/boxlore/core/catalog/FeedReports.kt | 59 +++-- .../boxlore/core/catalog/PodcastRepository.kt | 39 +++- .../catalog/PodcastRepositoryEpisodeLinks.kt | 3 +- .../PodcastRepositoryNetworkLookups.kt | 11 +- .../PodcastRepositoryShowPageTransport.kt | 53 +++++ .../catalog/PodcastRepositoryShowPages.kt | 74 +++++-- .../catalog/SubscriptionForegroundSync.kt | 5 +- .../core/catalog/FeedFirstShowPagesTest.kt | 74 ++++++- .../boxlore/core/catalog/FeedReportsTest.kt | 26 +++ .../catalog/PodcastRepositoryShowPagesTest.kt | 107 ++++++++- .../src/test/resources/fixtures/v2/README.md | 13 ++ .../test/resources/fixtures/v2/episodes.json | 155 +++++++++++++ .../fixtures/v2/feed-not-proxied.json | 4 + .../resources/fixtures/v2/podcast-direct.json | 42 ++++ .../resources/fixtures/v2/podcast-proxy.json | 46 ++++ core/database/README.md | 2 +- .../core/database/LocalEpisodeCatalogDao.kt | 34 +++ core/domain/README.md | 2 +- .../core/domain/ports/ShowPageFeedPort.kt | 47 +++- core/rss/README.md | 4 +- .../aswin/boxlore/core/rss/FeedReadFailure.kt | 6 + .../core/rss/LocalEpisodeCatalogRefresh.kt | 48 +++- .../aswin/boxlore/core/rss/RssFeedClient.kt | 37 ++-- .../boxlore/core/rss/ShowPageFeedCatalog.kt | 59 ++++- .../rss/LocalEpisodeCatalogRepositoryTest.kt | 4 + .../core/rss/ShowPageFeedCatalogTest.kt | 116 +++++++++- feature/info/README.md | 6 +- .../feature/info/PodcastInfoFeedFirst.kt | 105 +++++++-- .../info/PodcastInfoSupplementSupport.kt | 8 +- .../feature/info/PodcastInfoUiModels.kt | 3 + .../feature/info/PodcastInfoViewModel.kt | 55 +++-- .../info/components/ShowFeedStatusLine.kt | 3 +- .../feature/info/logic/ShowFeedLogic.kt | 38 +++- feature/info/src/main/res/values/strings.xml | 1 + .../feature/info/PodcastInfoFeedFirstTest.kt | 208 ++++++++++++++++++ .../feature/info/logic/ShowFeedLogicTest.kt | 44 +++- 40 files changed, 1439 insertions(+), 204 deletions(-) create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPageTransport.kt create mode 100644 core/catalog/src/test/resources/fixtures/v2/episodes.json create mode 100644 core/catalog/src/test/resources/fixtures/v2/feed-not-proxied.json create mode 100644 core/catalog/src/test/resources/fixtures/v2/podcast-direct.json create mode 100644 core/catalog/src/test/resources/fixtures/v2/podcast-proxy.json create mode 100644 feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirstTest.kt diff --git a/app/README.md b/app/README.md index 8640e22a6..87379c611 100644 --- a/app/README.md +++ b/app/README.md @@ -72,7 +72,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). - **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`. - **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, and `feedTelemetry`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs; `rss:` shows keep their own journal worker. -- **Feed-first show pages** (P4 PR 16): `AppContainer.podcastRepository` gets `FeedFirstWiring`: the stored copies (`localEpisodeCatalogRepository.showPages`), `connectivityStatus` for the offline rung, the broken-feed report stamps (`boxlore_feed_reports`, now one lazy instance shared with the daily address check so both stay inside one daily limit) and `feedTelemetry` for `feed_fetch_result`. The page reads the feed only while `feed_first` is on (`RolloutSwitches.feedFirst`, fixed per process). +- **Feed-first show pages** (P4 PR 16): `AppContainer.podcastRepository` gets `FeedFirstWiring`: the stored copies (`localEpisodeCatalogRepository.showPages`), `connectivityStatus` for the offline rung, one broken-feed report limiter (`feedReportLimiter` over the `boxlore_feed_reports` stamps, the same instance `FeedAddressUpkeep.reportLimiter` gives the daily address check, so both claim inside one daily limit), `feedTelemetry` for `feed_fetch_result`, and `isFollowed` (`podcasts.isSubscribed`), so the show page leaves followed shows' reports to their own refresh. The page reads the feed only while `feed_first` is on (`RolloutSwitches.feedFirst`, fixed per process). - NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 591beb3bc..070144466 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -12,6 +12,7 @@ import cx.aswin.boxlore.core.catalog.FeedAddressWiring import cx.aswin.boxlore.core.catalog.FeedFirstWiring import cx.aswin.boxlore.core.catalog.FeedFixToggles import cx.aswin.boxlore.core.catalog.FeedMoveFollower +import cx.aswin.boxlore.core.catalog.FeedReportLimiter import cx.aswin.boxlore.core.catalog.FolderRepository import cx.aswin.boxlore.core.catalog.InstallReferrerManager import cx.aswin.boxlore.core.catalog.LegacyRssRepair @@ -227,13 +228,14 @@ class AppContainer( rolloutSwitches = rolloutSwitches, episodeAliases = episodeAliasReads, discoveryEpisodeIds = discoveryEpisodeIds, - // Feed-first show pages (P4 PR 16): stored copies, the report limits and feed_fetch_result. + // Feed-first show pages (P4 PR 16): stored copies, the shared report limiter and feed_fetch_result. feedFirstWiring = FeedFirstWiring( copies = localEpisodeCatalogRepository.showPages, isOnline = { connectivityStatus.isOnline() }, - reportStamps = feedReportStamps, + reportLimiter = feedReportLimiter, telemetry = feedTelemetry, + isFollowed = { id -> database.podcastDao().getPodcast(id)?.isSubscribed == true }, ), ) } @@ -435,6 +437,7 @@ class AppContainer( FeedAddressUpkeep( reportStamps = feedReportStamps, isOnline = connectivityStatus::isOnline, + reportLimiter = feedReportLimiter, ), ) } @@ -449,6 +452,12 @@ class AppContainer( ) } + /** + * The app's one broken-feed report limiter over [feedReportStamps]: the daily address check and + * the show page claim through the same instance, so they never pass the caps together (P4 PR 16). + */ + private val feedReportLimiter: FeedReportLimiter by lazy { FeedReportLimiter(feedReportStamps) } + override val folderRepository: FolderRepository by lazy { RoomFolderRepository( folderDao = database.folderDao(), diff --git a/core/catalog/README.md b/core/catalog/README.md index c3fd6baf2..75594cc6a 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -19,7 +19,7 @@ Failed consent writes keep activation off, and local notification flags prevent - RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. - `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. Followed shows' catalog refreshes never read a Podcast Index list: new episodes take recipe IDs in `:core:rss`. `loadPiEpisodesForSupplement` is the Podcast Index list feed-only extras (`EpisodeSupplementPort`) are compared with: up to 1,000 newest episodes from `episodes/match` (match fields only), falling back to the oldest page (at most 100) when that route fails, returns nothing, or its `useEpisodeMatchBaseline` off switch is set. It is strict, so a failure of both stops the extras refresh instead of comparing the feed with an empty list. Its callers are the show page's "Missing episodes?" refresh, the legacy subscription sync and push paths without a local catalog, and the restore of extras opt-ins; they stay until show pages read feeds for every listener. Full pages keep the 100 cap. `getEpisode` resolves an ID in `EpisodeLinkResolver`'s order without reading a feed: a stored row (local catalog, then the feed-only rows for a negative ID), then the phone's row of the episode the ID is a trusted alias of, then the network. It sends a revcipe-ry canonical positivge ID (`[2^52, 2^53 − 1]`) that no local row or alias holds to `/v2/episode` (with the show when a caller passes `getEpisode(episodeId, showId)` and the show is below 2^52): a recipe-range ID (`[2^52, 2^53 − 1]`)d never reachesto the v1 Podcast Index lookup, and an old Podcast Index ID falls back; to v1 `/episode` only when the v2 route can't answer; other legacy IDs keep v1. The episode keeps the ID asked for and carries the recipe ID and catalog key. A `needsFeed` answer, a 400/404/410 or an error is not found. Podcast Index IDs keep v1 `/episode`. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). `PodcastRepository` also owns the `/v2` client (`apiV2`, a `BoxLoreApiV2` built on first use): same base URL and the same cached OkHttp client as `api`, so the process keeps one HTTP cache. Tests can pass `boxLoreApiV2` the same way they pass `boxLoreApi`. `getEpisode` and the discovery routes below use its only caller so far. In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). -- **Show page episode source (P4 PR 16).** `PodcastRepository.showPageSource(feedId)` is `RSS` (`rss:` shows), `FOLLOWED_CATALOG` (a ready followed catalog), `FEED` (`RolloutSwitches.feedFirst` on, a Podcast Index show ID, and its feed not found unreadable on this page) or `SERVER_LIST`. `getEpisodesPaginated` pages a `FEED` show from its stored copy (`FeedFirstWiring.copies`, a `ShowPageFeedPort`): more pages follow while the copy has rows or isn't the whole archive. Otherwise it reads `/v2/episodes`, its recipe IDs mapped to the phone's owners (`DiscoveryEpisodeIds.toLocalOwners`, so a rollback of the flag shows the same IDs), and v1 `/episodes` only when `/v2/episodes` fails (decision D1). `FeedFirstShowPages` (`openFeedShowPage`, `readShowPageFeed`) reads the feed into the copy, `NEWEST` or `ARCHIVE` deep, down the failure ladder: offline shows the copy with no read and no report (`OFFLINE`); the address is the registry's (`/v2/podcast`), else the saved one, and an http-only feed goes through `/v2/feed/`; a failed read is tried once more (not after a 404 or 410), then reported through `FeedReporter` (limits shared with the daily address check), and a different HTTPS address in the answer is read; then the page uses the server's list with a notice (`FALLBACK`) until a read works. One `feed_fetch_result` per read (source `feed` or `transport`, `partial` or `complete`). Once a day per process it removes unfollowed copies not opened for 30 days, under a size cap (decision D10). In-show search always runs on the phone: the stored copy (bounded to 200), else the newest 1,000 of `/v2/episodes` matched by title or description; v1 `episodes/search` is never called (decision D16). `invalidateEpisodesCache` also drops pages cached under the Podcast Index ID a `url:`, `guid:` or `itunes:` ID resolved to. +- **Show page episode source (P4 PR 16).** `PodcastRepository.showPageSource(feedId)` is `RSS` (`rss:` shows), `FOLLOWED_CATALOG` (a ready followed catalog), `FEED` (`RolloutSwitches.feedFirst` on, a Podcast Index show ID, and its feed not found unreadable on this page) or `SERVER_LIST`. Only the show page reads the stored copy: `getShowPageEpisodes` pages a `FEED` show from it (`FeedFirstWiring.copies`, a `ShowPageFeedPort`), more pages following while the copy has rows or isn't the whole archive, and an oldest-first page of a partial copy comes from the server's list. `getEpisodesPaginated` (Smart Queue, Android Auto, Home, downloads, the episode page) never reads the copy, which only the show page reads again. For a show whose catalog isn't ready it reads `/v2/episodes`, its recipe IDs mapped to the phone's owners (`DiscoveryEpisodeIds.toLocalOwners`, so a rollback of the flag shows the same IDs), and v1 `/episodes` only when `/v2/episodes` fails (decision D1). `getEpisodeWindow` and `getEpisodes` use the same IDs: around an episode the feed-first copy holds, the copy's window; else the newest 200 of `/v2/episodes` under local owners; v1 only when that fails (`LEGACY(own-ids)`). `FeedFirstShowPages` (`openFeedShowPage`, `readShowPageFeed`) reads the feed into the copy, `NEWEST` or `ARCHIVE` deep, down the failure ladder: offline shows the copy with no read and no report (`OFFLINE`); the address is the registry's (`/v2/podcast`), else the saved one, and an http-only feed goes through `/v2/feed/` (`v2Transport`: the copy's ETag as `If-None-Match`, the body capped like a direct read with `RssFeedClient.readFeedBody`, a `404 not_proxied` with an HTTPS `fetchUrl` read directly, under the download gate and per-show lock); a failed read is tried once more (not after a 404 or 410), then reported through `FeedReporter.reportTarget` (the app's one `FeedReportLimiter`, leaving `FOLLOWED_SHOWS_RESERVE` of the daily cap to followed shows; never for a followed show, whose own refresh reports and saves addresses), and a different address in the answer is read, through the transport when it is http-only; then the page uses the server's list with a notice (`FALLBACK`) until it is opened again. One `feed_fetch_result` per read (source `feed` or `transport`, `partial` or `complete`). Once a day per process, in the background so a page's first paint never waits, it removes unfollowed copies not opened for 30 days, under a size cap (decision D10). In-show search always runs on the phone: the stored copy (`LIMIT 200` in the query), else the newest 1,000 of `/v2/episodes` matched by title or description; v1 `episodes/search` is never called (decision D16). `invalidateEpisodesCache` also drops pages cached under the Podcast Index ID a `url:`, `guid:` or `itunes:` ID resolved to. - Episode links, pushes and Android Auto items that name an episode by ID and maybe its show resolve through `resolveEpisodeLink(episodeId, showId, onReadingFeed)` (`EpisodeLinkResolver`, `PodcastRepositoryEpisodeLinks.kt`). Order: a stored row; a trusted alias of the ID, or for a recipe ID with its show the one episode of that show whose trusted aliases carry it, answered with the phone's own row and ID; the API (an answer for an episode stored under another trusted ID or the same key is translated to the phone's row; a v1 answer, which has neither, by its enclosure address); and, for a `needsFeed` answer, one read of the show's feed through the existing reader (`RssEpisodeFeedReader`: the publisher's HTTPS address with `RssFeedClient`, an http-only address only through `/v2/feed/`), matched by recipe ID in the show's stored namespace. Only items whose key follows the contract match; a feed listing the same item twice gives its first copy in document order, and a recipe ID two different keys share matches nothing. Results are `EpisodeLinkResult.Found`, `NotFound` (nothing lists it: 400, 404 or 410) or `Unavailable` (a source couldn't be reached, including 401, 403, 408 and 429). `storedEpisodeForKey(show, key)` finds a stored row by catalog key (the local catalog by GUID or enclosure, then the show's feed-only rows by enclosure); alias, push and link lookups use it. Ambiguous and unresolved aliases never resolve. `EpisodeAliasReads` / `RoomEpisodeAliasReads` are the alias reads (`trustedKeyOf`, `trustedKeyForRecipe`, `namespaceOf`); `PodcastRepository(episodeAliases = …)` takes them (none by default, as in older tests) and `feedReader` is the test seam for the feed read. `localEpisodeForRecipe(show, recipeId)` is the push match by (show, recipe ID). - Episode share links: `EpisodeSharePreparer` (an `EpisodeShareTargetSource`) names the episode by its trusted alias's recipe ID and Podcast Index show (also for an RSS show linked to that show), else by `EpisodeShareIds.choose` on the episode's fields; phone-only negative or reserved IDs are never shared: an episode held only under one is shared by the recipe ID of its catalog key in the show's namespace, else as the show's link, and `rss:` shows keep today's link. With a known catalog key it registers the episode first through `EpisodeShareRegistrar.registerOnce` (`POST /v2/episodes/register`, `{show, guid, enclosureUrl}` with the audio address so the API can match a GUID that differs from Podcast Index's, or `{show, enclosureUrl}` when the key is the audio address), waiting at most `DEFAULT_TIMEOUT_MS` (2 s); the link goes out whatever the answer, and a timeout, offline, 401, 429 or server error hands the request to `scheduleRetry` (the app's background worker). 400/404 are dropped. - `syncSubscriptions` (`POST /sync`) is PI-only. `SubscriptionForegroundSync` sends it the shows that are **not** catalog-ready and have no saved HTTPS feed address, plus the shows whose own feed failed this pass. The PI ids go out in chunks of 20 (`PI_SYNC_CHUNK_SIZE`), at most two calls at a time (`PI_SYNC_PARALLELISM`), because the API serves only the first 40 ids of a call: whole-library callers (Smart Downloads, backup restore) used to lose every show past the 40th. A failed chunk drops only its own shows' tips. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). @@ -189,7 +189,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - `ShowNotesParserTest`, `CrossPromotionDetectorTest`, and `CrossPromotionResolverTest` cover the Blood Trails announcement, artwork-corroborated presenters, own-series/premium/topic/event/mix refusals, new-show and friends-at naming, colon, host and parenthetical title matches, shorter-name search, malformed/relative anchors, platform-label/handle separation, reserved profile routes, destination purpose and spoof domains, URL deduplication, safe timestamp linking, chapter boundaries, weekday-scheduled full-length introductions, false promotions and shorter-name prefixes, full-title matching, ambiguous/host matches, verified unnamed Apple links, retry, cancellation, and cache expiry. - `RoomFolderRepositoryTest` covers linking shows to a deleted folder. - Cloud sync identity (Robolectric, in-memory Room): `SyncEpisodeIdentityTest` covers which identity a pushed row and a rich removal carry (trusted, unresolved, ambiguous and linked RSS rows; a recipe ID two keys share; a recipe ID whose local ID another show's alias or catalog row holds; a served ID that isn't its recipe ID) and which aliases a pull records (unknown rows, matches by key and by a unique recipe ID, an unknown key winning over a recipe ID, settling an unresolved alias, rows left alone) and the episode tokens. `CloudSyncIdentityTest` runs `UserSyncCoordinator` against `FakeSyncServer`, which applies the sync API's rules (recipe fields kept when omitted, a recipe-range ID naming another show or key refused while the push answers ok, rich removals expanded and stored only when sent): the push and pull fields, a Podcast Index row from an older build plus a recipe owner read as one episode with the owner unchanged, an unknown row owning nothing, a row whose recipe ID names another episode here pushed without it so another device's removal of that episode by recipe ID can't reach its queue row, a served ID stored without a key, a pulled row of the playing episode (stored without touching the owner row; identified before the merge so a stored row keeps its progress), a large backlog pushed one bounded batch per sync, newest first, and the version-3 catch-up: a pull from the start first, then the cloud's own copies with the identity added (the newer progress and its own time, a completion the cloud has at the same last play kept, nothing new in another device's delta pull), every batch in the same sync, a run cut short by a failed push marking no row and finishing on the next sync without overwriting newer progress, a row removed here before the update staying removed, nothing again on the next sync, and an account switch purging the rich removals and catching up the new account. It also covers queue removals across IDs: removing an episode queued here under its RSS ID removes the copy an older build queued under the Podcast Index ID, a removal reaches the cloud copy the merge folded into the phone's item and it never comes back, and a stale rich removal never reaching an episode another device queued again. `QueueSyncIdentityTest` covers a removal under a Podcast Index ID removing the recipe ID's item, this phone's removal dropping the pulled item under another ID, the united plain and rich lists, a stale rich removal, the server's rich list replacing the phone's older one on a clean pull, a removal made stale by the identity a pulled item carries, a removal its own side still queues, one item per episode, the playing ID, and hydration from another ID's catalog row and from `/v2/episode` (MockWebServer). `SyncEpisodeIdentityTest` also covers the catch-up builder: only copies lacking a known identity, only the missing field, another device's key or recipe ID never overwritten. -- Feed-first show pages: `FeedFirstShowPagesTest` (the failure ladder against a scripted copy: offline, one retry but none after a 404 or 410, the report and the reported address, the fallback and the way back, the registry or saved address and the transport, one event per read, opening state, the daily cleanup) and `PodcastRepositoryShowPagesTest` (Robolectric Room, MockWebServer and a real feed parser: the router per state, `/v2/episodes` under local owners and v1 only on failure, paging that stays stable while the copy grows from 100 to 150 episodes, the fallback switching the source, search on the phone that never calls v1 `episodes/search`, a flag rollback that changes no stored ID or alias, and dropping the pages of a resolved `url:` show). +- Feed-first show pages: `FeedFirstShowPagesTest` (the failure ladder against a scripted copy: offline, one retry but none after a 404 or 410, the report and the reported address, an http-only reported address read through the transport, no report for a followed show, the reserve for followed shows, the fallback and the way back, the registry or saved address and the transport, one event per read, opening state, the daily cleanup that never holds up the opening) and `PodcastRepositoryShowPagesTest` (Robolectric Room, MockWebServer and a real feed parser: the router per state, `/v2/episodes` under local owners and v1 only on failure, paging that stays stable while the copy grows from 100 to 150 episodes, the fallback switching the source, search on the phone that never calls v1 `episodes/search`, a flag rollback that changes no stored ID, alias or saved show row and calls no registration route, other callers reading the server's list while a followed http-only show's page reads its transported copy with its ETag, oldest-first pages of a partial copy, playback windows under the page's IDs, recorded `/v2/episodes`, `/v2/podcast` and `/v2/feed` answers (`src/test/resources/fixtures/v2`), and dropping the pages of a resolved `url:` show). ## CI relevance diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt index 5c5b7bbe2..5234e760d 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPages.kt @@ -6,6 +6,8 @@ import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadOutcome import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadRequest import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Transported +import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.model.FeedFetchCompleteness import cx.aswin.boxlore.core.model.FeedFetchOutcome import cx.aswin.boxlore.core.model.FeedFetchReport @@ -16,6 +18,10 @@ import cx.aswin.boxlore.core.network.model.V2PodcastResponse import cx.aswin.boxlore.core.rss.ShowPageFeedRules import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** Where a show page's episodes come from. */ enum class ShowPageSource { @@ -59,8 +65,11 @@ enum class ShowPageFeedResult { FALLBACK, } -/** [result] of a read, and whether the stored copy now holds the whole archive. */ -data class ShowPageFeedRead(val result: ShowPageFeedResult, val archiveComplete: Boolean) +/** + * [result] of a read, whether the stored copy now holds the whole archive, and whether the phone + * has a copy at all ([hasCopy]): an offline page without one has nothing saved to show. + */ +data class ShowPageFeedRead(val result: ShowPageFeedResult, val archiveComplete: Boolean, val hasCopy: Boolean = false,) /** * The feed-first show page of a Podcast Index show: it reads the show's own feed into the phone's @@ -69,15 +78,17 @@ data class ShowPageFeedRead(val result: ShowPageFeedResult, val archiveComplete: * The failure ladder of one read ([read]): * 1. Offline: nothing is read and nothing is reported; the stored copy is shown as it is. * 2. The address comes from `/v2/podcast` (the registry), else the saved one. An http-only address - * is read through boxlore's transport (`/v2/feed/`). + * is read through boxlore's transport (`/v2/feed/`), with the copy's ETag. * 3. A failed read is tried once more, except after a 404 or 410. - * 4. Then the failure is reported (`/v2/feed-report`, rate-limited, never offline); when the answer - * names a different HTTPS address, that one is read. - * 5. Then the page uses `/v2/episodes` with a notice ([ShowPageFeedResult.FALLBACK]) until a later - * read of the feed works. + * 4. Then the failure is reported (`/v2/feed-report`, rate-limited, never offline, and not for a + * followed show, whose own refresh reports and saves addresses); when the answer names a + * different address, that one is read, through the transport when it is http-only. + * 5. Then the page uses `/v2/episodes` with a notice ([ShowPageFeedResult.FALLBACK]) until the page + * is opened again ([open]). * - * Each read sends one `feed_fetch_result` (decision D11). Once a day it also removes the stored - * copies of unfollowed shows not opened for 30 days, under a size cap (decision D10). + * Each read sends one `feed_fetch_result` (decision D11). Once a day, away from the page's first + * paint ([background]), it also removes the stored copies of unfollowed shows not opened for 30 + * days, under a size cap (decision D10). * * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). @@ -86,11 +97,13 @@ data class ShowPageFeedRead(val result: ShowPageFeedResult, val archiveComplete: class FeedFirstShowPages( private val copies: ShowPageFeedPort?, private val registry: suspend (Long) -> V2PodcastResponse?, - private val transport: suspend (Long) -> ShowPageFeedPort.Document?, + private val transport: suspend (showId: Long, ifNoneMatch: String?) -> Transported, private val reporter: FeedReporter?, private val isOnline: () -> Boolean, private val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, private val nowMillis: () -> Long = System::currentTimeMillis, + private val background: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val isFollowed: suspend (String) -> Boolean = { false }, ) { private val fallbackShows = ConcurrentHashMap.newKeySet() private val metas = ConcurrentHashMap() @@ -101,14 +114,25 @@ class FeedFirstShowPages( /** True while [podcastId]'s page uses the server's list because its feed couldn't be read. */ fun isInFallback(podcastId: String): Boolean = podcastId in fallbackShows + /** Whether the phone is online now, as the ladder sees it. */ + fun online(): Boolean = isOnline() + /** The show details last given to [open] or [read], for episodes listed from the stored copy. */ fun metaFor(podcastId: String): PodcastMeta? = metas[podcastId] suspend fun cached(podcastId: String): ShowPageFeedPort.CachedShow? = safely(null) { copies?.cached(podcastId) } + /** Bounded in-show search over [podcastId]'s stored copy; null when the phone has none. */ + suspend fun search(podcastId: String, query: String, meta: PodcastMeta, limit: Int): List? { + val port = copies ?: return null + cached(podcastId) ?: return null + return safely(null) { port.search(podcastId, query, limit, meta) } + } + /** - * What the page holds as it opens; notes the opening and runs the daily cleanup. A page that - * opens tries the feed again, even after an earlier page fell back to the server's list. + * What the page holds as it opens; notes the opening and starts the daily cleanup in the + * background. A page that opens tries the feed again, even after an earlier page fell back to + * the server's list. */ suspend fun open(podcastId: String, meta: PodcastMeta): ShowPageFeedState { remember(podcastId, meta) @@ -116,7 +140,7 @@ class FeedFirstShowPages( val cached = cached(podcastId) val now = nowMillis() if (cached != null) safely(Unit) { copies?.markOpened(podcastId, now) } - cleanUpDaily(now, podcastId) + scheduleCleanUp(now, podcastId) return ShowPageFeedState( cached = cached != null, archiveComplete = cached?.archiveComplete == true, @@ -132,12 +156,15 @@ class FeedFirstShowPages( if (copies == null || showId == null) return finish(podcastId, ShowPageFeedResult.FALLBACK) if (!isOnline()) return finish(podcastId, ShowPageFeedResult.OFFLINE) val answer = safely(null) { registry(showId) } + val saved = savedFeedUrl?.takeIf(String::isNotBlank) val target = answer?.readTarget(showId) - ?: savedFeedUrl?.takeIf(String::isNotBlank)?.let(V2FeedReadTarget::Direct) + ?: saved?.let(V2FeedReadTarget::Direct) ?: return finish(podcastId, ShowPageFeedResult.FALLBACK) - val request = ReadRequest(podcastId, "", scope, meta, answer?.validEpNs) - val attempt = afterReport(podcastId, request, withRetry(request, target), savedFeedUrl) ?: return finish(podcastId, ShowPageFeedResult.OFFLINE) + // The publisher's own address, for the read's request; a transport read never records it. + val publisher = answer?.fetchUrl?.takeIf(String::isNotBlank) ?: saved.orEmpty() + val request = ReadRequest(podcastId, publisher, scope, meta, answer?.validEpNs) + val attempt = afterReport(podcastId, request, withRetry(request, target), saved) ?: return finish(podcastId, ShowPageFeedResult.OFFLINE) report(podcastId, attempt) return when (attempt.outcome) { is ReadOutcome.Failed -> finish(podcastId, ShowPageFeedResult.FALLBACK) @@ -154,14 +181,17 @@ class FeedFirstShowPages( /** * After a failed read, the report and a read of the address it names, when it names a - * different one. Null when the phone went offline meanwhile: nothing is reported then. + * different one. Null when the phone went offline meanwhile: nothing is reported then. A + * followed show isn't reported here: its own refresh reports it and saves a working address + * (P4 PR 14), and the one report a day it may send stays for that. */ private suspend fun afterReport(podcastId: String, request: ReadRequest, attempt: Attempt, savedFeedUrl: String?): Attempt? { val failed = attempt.outcome as? ReadOutcome.Failed ?: return attempt if (!isOnline()) return null + if (safely(false) { isFollowed(podcastId) }) return attempt val used = (attempt.target as? V2FeedReadTarget.Direct)?.url ?: savedFeedUrl - val reported = safely(null) { reporter?.report(podcastId, failed.reportStatus, used) } ?: return attempt - val next = V2FeedReadTarget.Direct(reported) + val next = safely(null) { reporter?.reportTarget(podcastId, failed.reportStatus, used) } ?: return attempt + if (next == attempt.target) return attempt return Attempt(next, readOnce(request, next)) } @@ -175,10 +205,7 @@ class FeedFirstShowPages( return safely(ReadOutcome.Failed(OTHER)) { when (target) { is V2FeedReadTarget.Direct -> port.read(request.copy(feedUrl = target.url)) - is V2FeedReadTarget.ViaApi -> { - val document = transport(target.showId) ?: return@safely ReadOutcome.Failed(OTHER) - port.store(request.copy(feedUrl = document.finalUrl), document) - } + is V2FeedReadTarget.ViaApi -> port.readTransport(request) { etag -> transport(target.showId, etag) } } } } @@ -193,20 +220,20 @@ class FeedFirstShowPages( ShowPageFeedResult.UPDATED, ShowPageFeedResult.UNCHANGED -> fallbackShows.remove(podcastId) ShowPageFeedResult.OFFLINE -> Unit } - return ShowPageFeedRead(result, archiveComplete(podcastId)) + val cached = cached(podcastId) + return ShowPageFeedRead(result, archiveComplete = cached?.archiveComplete == true, hasCopy = cached != null) } - private suspend fun archiveComplete(podcastId: String): Boolean = cached(podcastId)?.archiveComplete == true - private fun remember(podcastId: String, meta: PodcastMeta) { if (meta.title != null || meta.imageUrl != null) metas[podcastId] = meta } - private suspend fun cleanUpDaily(now: Long, keep: String) { + /** Starts the daily cleanup in [background], at most once a day, keeping the show [keep] being opened. */ + private fun scheduleCleanUp(now: Long, keep: String) { val last = lastCleanUpAt if (last != null && now - last in 0 until CLEANUP_INTERVAL_MS) return lastCleanUpAt = now - safely(0) { copies?.cleanUp(now, keep) ?: 0 } + background.launch { safely(0) { copies?.cleanUp(now, keep) ?: 0 } } } /** The one `feed_fetch_result` of this read (decision D11). Never carries an address. */ diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt index d03267921..5be6c3780 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/FeedReports.kt @@ -4,6 +4,7 @@ import android.content.SharedPreferences import android.util.Log import cx.aswin.boxlore.core.network.BoxLoreApiV2 import cx.aswin.boxlore.core.network.model.V2EpisodeIds +import cx.aswin.boxlore.core.network.model.V2FeedReadTarget import cx.aswin.boxlore.core.network.model.V2FeedReportRequest import cx.aswin.boxlore.core.network.model.V2FeedReportResponse import cx.aswin.boxlore.core.network.model.V2FeedReportStatus @@ -66,12 +67,16 @@ class SharedPreferencesFeedReportStamps(private val prefs: SharedPreferences) : * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: reports). */ class FeedReportLimiter(private val stamps: FeedReportStamps) { - /** Claims a report of [podcastId] at [nowMillis]; false when the show or the day is used up. */ - @Synchronized - fun tryClaim(podcastId: String, nowMillis: Long): Boolean { + /** + * Claims a report of [podcastId] at [nowMillis]; false when the show or the day is used up. + * [keepFree] reports of the day stay for other callers: the show page keeps + * [FOLLOWED_SHOWS_RESERVE] free for followed shows (P4 PR 16). Claims lock on the stamps, so + * limiters sharing one store never claim past the caps together. + */ + fun tryClaim(podcastId: String, nowMillis: Long, keepFree: Int = 0): Boolean = synchronized(stamps) { val since = nowMillis - PER_SHOW_INTERVAL_MS val recent = stamps.all().filterValues { it > since && it <= nowMillis } - if (podcastId in recent || recent.size >= DAILY_CAP) return false + if (podcastId in recent || recent.size >= DAILY_CAP - keepFree.coerceIn(0, DAILY_CAP)) return false stamps.mark(podcastId, nowMillis, keepSince = since) return true } @@ -79,6 +84,9 @@ class FeedReportLimiter(private val stamps: FeedReportStamps) { companion object { const val PER_SHOW_INTERVAL_MS = 24L * 60L * 60L * 1000L const val DAILY_CAP = 20 + + /** Reports of the day the show page leaves to followed shows (P4 PR 16). */ + const val FOLLOWED_SHOWS_RESERVE = 5 } } @@ -98,27 +106,44 @@ class FeedReporter( private val isOnline: () -> Boolean, private val enabled: () -> Boolean, private val nowMillis: () -> Long = System::currentTimeMillis, + private val keepFree: Int = 0, ) { /** * @param status what the failed read saw, as the route takes it; null when no read happened * @return the API's HTTPS address when it differs from [savedFeedUrl], else null */ - suspend fun report(podcastId: String, status: String?, savedFeedUrl: String?): String? { - if (status == null || !V2FeedReportStatus.isValid(status) || !enabled()) return null + suspend fun report(podcastId: String, status: String?, savedFeedUrl: String?): String? = + (reportTarget(podcastId, status, savedFeedUrl) as? V2FeedReadTarget.Direct)?.url + + /** + * [report] for a caller that can also read through boxlore's transport: the answer's address + * as a read target, [V2FeedReadTarget.ViaApi] when it is http-only (`fetchVia: proxy`, + * `V2-ROUTES.md` `/v2/feed-report`), or a different HTTPS address. Null when nothing was sent or + * the answer names the address already read. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, the show page's failure ladder). + */ + suspend fun reportTarget(podcastId: String, status: String?, savedFeedUrl: String?): V2FeedReadTarget? { val showId = V2EpisodeIds.parseShowId(podcastId) ?: return null - if (!isOnline() || !limiter.tryClaim(podcastId, nowMillis())) return null - val answer = - try { - send(V2FeedReportRequest(id = showId, status = status)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Feed report for $podcastId failed", e) - null - } ?: return null + val answer = sendReport(podcastId, showId, status) ?: return null + if (answer.fetchVia == V2FetchVia.PROXY) return answer.readTarget(showId) if (answer.fetchVia != null && answer.fetchVia != V2FetchVia.DIRECT) return null val https = TrackedPodcastRtdbLogic.httpsFeedUrl(answer.fetchUrl) ?: return null - return https.takeIf { it != savedFeedUrl?.trim() } + return https.takeIf { it != savedFeedUrl?.trim() }?.let(V2FeedReadTarget::Direct) + } + + /** One report within the limits; null when none was sent or it got no answer. */ + private suspend fun sendReport(podcastId: String, showId: Long, status: String?): V2FeedReportResponse? { + if (status == null || !V2FeedReportStatus.isValid(status) || !enabled()) return null + if (!isOnline() || !limiter.tryClaim(podcastId, nowMillis(), keepFree)) return null + return try { + send(V2FeedReportRequest(id = showId, status = status)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Feed report for $podcastId failed", e) + null + } } companion object { 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 efba0b3f1..2016375d0 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 @@ -101,16 +101,18 @@ class PodcastRepository( FeedFirstShowPages( copies = feedFirstWiring.copies, registry = { showId -> v2ShowAddress(showId) }, - transport = { showId -> v2TransportDocument(showId) }, + transport = { showId, etag -> v2Transport(showId, etag) }, reporter = FeedReporter( send = { request -> FeedReporter.sendWith(apiV2, request) }, - limiter = FeedReportLimiter(feedFirstWiring.reportStamps), + limiter = feedFirstWiring.reportLimiter, isOnline = feedFirstWiring.isOnline, enabled = rolloutSwitches.addressCheck, + keepFree = FeedReportLimiter.FOLLOWED_SHOWS_RESERVE, ), isOnline = feedFirstWiring.isOnline, telemetry = feedFirstWiring.telemetry, + isFollowed = feedFirstWiring.isFollowed, ) } @@ -417,12 +419,38 @@ class PodcastRepository( if (isLocalCatalogReady(feedId)) { return@withContext getLocalCatalogPage(feedId, safeLimit, offset, sort) } + getServerListPage(feedId, safeLimit, offset, sort, mergeSupplements) + } + + /** + * [getEpisodesPaginated] for the show page itself: the feed-first stored copy when the page + * reads the feed ([ShowPageSource.FEED]), else the same sources. Only the show page reads the + * copy, because only it reads the feed again (on open, pull to refresh and archive actions); + * Smart Queue, Android Auto, Home and downloads keep reading the server's list + * ([getEpisodesPaginated]), so a copy that stops being read never goes stale under them. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page; decision D1). + */ + suspend fun getShowPageEpisodes( + feedId: String, + limit: Int = 20, + offset: Int = 0, + sort: String = "newest", + ): EpisodePage = withContext(Dispatchers.IO) { if (showPageSource(feedId) == ShowPageSource.FEED) { - feedCopyPage(feedId, safeLimit, offset, sort)?.let { return@withContext it } + feedCopyPage(feedId, clampPageLimit(limit), offset, sort)?.let { return@withContext it } } - getServerListPage(feedId, safeLimit, offset, sort, mergeSupplements) + getEpisodesPaginated(feedId, limit, offset, sort) } + /** + * True when [feedId]'s show page reads its feed first (`feed_first` on, a Podcast Index show), + * whether or not this page has fallen back: such a page never offers "Missing episodes?". + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, refresh replaces the chip). + */ + fun isFeedFirstShowPage(feedId: String): Boolean = !feedId.startsWith("rss:") && isFeedFirstShow(feedId) + /** * Drop cached episode pages for [feedId] so the next read hits the network. Pages are cached * under the Podcast Index ID a `url:`, `guid:` or `itunes:` ID resolved to, so that ID's pages @@ -1021,7 +1049,8 @@ class PodcastRepository( if (isLocalCatalogReady(feedId)) { return@withContext getLocalCatalogWindow(feedId, sort, aroundEpisodeId) } - getNetworkEpisodeWindow(feedId) + // The show page's IDs (P4 PR 16): the feed-first copy around its episode, else /v2/episodes. + getShowEpisodeWindow(feedId, aroundEpisodeId, sort) } override suspend fun getEpisode(episodeId: String): Episode? = getEpisodeImpl(episodeId, showId = null) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeLinks.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeLinks.kt index b528d7036..f5630109d 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeLinks.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryEpisodeLinks.kt @@ -201,7 +201,8 @@ internal class RssEpisodeFeedReader( is V2FeedReadTarget.ViaApi -> { val response = api().getFeed(target.showId).awaitResponse() val body = response.body()?.takeIf { response.isSuccessful } ?: error("feed proxy answered ${response.code()}") - val bytes = body.use { it.bytes() } + // The same size cap as a direct read (P4 PR 16): a huge document never fills memory. + val bytes = body.use { RssFeedClient.readFeedBody(it.byteStream()) } feedClient.parse(requireNotNull(address.transportUrl), bytes) } null -> null diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt index c9e94a06e..d355b4f5a 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryNetworkLookups.kt @@ -21,7 +21,8 @@ internal suspend fun PodcastRepository.getEpisodesImpl(feedId: String): List = try { @@ -33,6 +34,10 @@ internal suspend fun PodcastRepository.getRssEpisodeWindow(feedId: String, aroun emptyList() } +/** + * LEGACY(own-ids): v1 `/episodes` window, served IDs. Since P4 PR 16 only when `/v2/episodes` fails + * ([getShowEpisodeWindow]). Remove at sunset step S7 (MIGRATION.md §6). + */ internal suspend fun PodcastRepository.getNetworkEpisodeWindow(feedId: String): List = try { val resolvedId = resolvePodcastIndexFeedId(feedId) val response = @@ -59,6 +64,10 @@ internal suspend fun PodcastRepository.getAllRssEpisodes(feedId: String): List = try { val resolvedId = resolvePodcastIndexFeedId(feedId) val page1Response = diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPageTransport.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPageTransport.kt new file mode 100644 index 000000000..f702fb32a --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPageTransport.kt @@ -0,0 +1,53 @@ +package cx.aswin.boxlore.core.catalog + +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort +import cx.aswin.boxlore.core.rss.FeedTooLargeException +import cx.aswin.boxlore.core.rss.RssFeedClient +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import retrofit2.awaitResponse + +/** + * One read of an http-only show's feed through boxlore's transport (`/v2/feed/`), with the + * copy's ETag ([ifNoneMatch]): the document, read at most [RssFeedClient.readFeedBody]'s cap; a + * 304; a `404 not_proxied` naming an HTTPS address to read directly; or what failed, as + * `/v2/feed-report` words it. Network errors propagate to the ladder, which words them. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page; `V2-ROUTES.md` `/v2/feed/`). + */ +internal suspend fun PodcastRepository.v2Transport(showId: Long, ifNoneMatch: String?): ShowPageFeedPort.Transported { + val response = apiV2.getFeed(showId, ifNoneMatch).awaitResponse() + return when { + response.code() == HTTP_NOT_MODIFIED -> ShowPageFeedPort.Transported.NotModified + response.isSuccessful -> { + val body = response.body() ?: return ShowPageFeedPort.Transported.Failed(OTHER_STATUS) + try { + ShowPageFeedPort.Transported.Document( + bytes = body.use { RssFeedClient.readFeedBody(it.byteStream()) }, + etag = response.headers()["ETag"], + ) + } catch (_: FeedTooLargeException) { + ShowPageFeedPort.Transported.Failed(OTHER_STATUS) + } + } + else -> transportFailure(response.code(), response.errorBody()?.use { it.string() }) + } +} + +/** A failed transport answer: a `404 not_proxied` with an HTTPS `fetchUrl` is read directly; anything else is `http_`. */ +internal fun transportFailure(code: Int, errorBody: String?): ShowPageFeedPort.Transported { + if (code == HTTP_NOT_FOUND && errorBody != null) { + val answer = runCatching { Json.parseToJsonElement(errorBody).jsonObject }.getOrNull() + val error = answer?.get("error")?.jsonPrimitive?.contentOrNull + val fetchUrl = TrackedPodcastRtdbLogic.httpsFeedUrl(answer?.get("fetchUrl")?.jsonPrimitive?.contentOrNull) + if (error == "not_proxied" && fetchUrl != null) return ShowPageFeedPort.Transported.ReadDirect(fetchUrl) + } + return ShowPageFeedPort.Transported.Failed(if (code in HTTP_CODES) "http_$code" else OTHER_STATUS) +} + +private const val HTTP_NOT_MODIFIED = 304 +private const val HTTP_NOT_FOUND = 404 +private val HTTP_CODES = 100..599 +private const val OTHER_STATUS = "other" diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt index 2452eac04..b5c09163a 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPages.kt @@ -13,22 +13,27 @@ import retrofit2.awaitResponse /** * What [PodcastRepository]'s feed-first show page needs from the app: the stored copies - * ([ShowPageFeedPort]; null stores nothing), whether the phone is online, the broken-feed report - * limits shared with the daily address check, and `feed_fetch_result` reporting. + * ([ShowPageFeedPort]; null stores nothing), whether the phone is online, the app's one broken-feed + * report limiter (shared with the daily address check, [FeedAddressUpkeep.reportLimiter]), + * `feed_fetch_result` reporting, and whether the listener follows a show ([isFollowed]: followed + * shows are reported by their own refresh, not by the show page). * * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). */ data class FeedFirstWiring( val copies: ShowPageFeedPort? = null, val isOnline: () -> Boolean = { true }, - val reportStamps: FeedReportStamps = MemoryFeedReportStamps(), + val reportLimiter: FeedReportLimiter = FeedReportLimiter(MemoryFeedReportStamps()), val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, + val isFollowed: suspend (String) -> Boolean = { false }, ) /** In-show search answers at most this many episodes. */ internal const val MAX_SHOW_SEARCH_RESULTS = 200 private const val SHOW_PAGES_TAG = "PodcastRepository" +private const val OLDEST_SORT = "oldest" +private const val NEWEST_SORT = "newest" /** * True when [feedId]'s show page reads its feed first: `feed_first` is on and [feedId] is a Podcast @@ -40,14 +45,16 @@ private const val SHOW_PAGES_TAG = "PodcastRepository" internal fun PodcastRepository.isFeedFirstShow(feedId: String): Boolean = V2EpisodeIds.parseShowId(feedId) != null && rolloutSwitches.feedFirst() /** - * A page of [feedId]'s stored feed copy, or null when the phone has none yet. More pages follow - * while the copy has more rows, and while it is not the whole archive: the show page then reads - * the rest of the feed before it pages on. + * A page of [feedId]'s stored feed copy, or null when the phone has none yet, or when an oldest-first + * page is asked of a copy that holds only the newest items (its oldest rows aren't the show's first + * episodes). More pages follow while the copy has more rows, and while it is not the whole archive: + * the show page then reads the rest of the feed before it pages on. * * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). */ internal suspend fun PodcastRepository.feedCopyPage(feedId: String, limit: Int, offset: Int, sort: String,): PodcastRepository.EpisodePage? { val cached = feedFirst.cached(feedId) ?: return null + if (sort == OLDEST_SORT && !cached.archiveComplete) return null val catalog = localEpisodeCatalog ?: return null val episodes = catalog.getPage(feedId, limit, offset, sort, feedFirst.metaFor(feedId) ?: localCatalogMeta(feedId)) return PodcastRepository.EpisodePage( @@ -58,14 +65,48 @@ internal suspend fun PodcastRepository.feedCopyPage(feedId: String, limit: Int, } /** - * Bounded search over [feedId]'s stored feed copy; null when the phone has none. + * Bounded search over [feedId]'s stored feed copy ([MAX_SHOW_SEARCH_RESULTS] rows, limited in the + * query); null when the phone has none. * * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, in-show search on the phone). */ -internal suspend fun PodcastRepository.searchFeedCopy(feedId: String, query: String,): List? { - feedFirst.cached(feedId) ?: return null - val catalog = localEpisodeCatalog ?: return null - return catalog.search(feedId, query, feedFirst.metaFor(feedId) ?: localCatalogMeta(feedId)).take(MAX_SHOW_SEARCH_RESULTS) +internal suspend fun PodcastRepository.searchFeedCopy(feedId: String, query: String,): List? = + feedFirst.search(feedId, query, feedFirst.metaFor(feedId) ?: localCatalogMeta(feedId), MAX_SHOW_SEARCH_RESULTS) + +/** + * The bounded episode window (Smart Queue, same-show continuation, Android Auto) of a Podcast Index + * show whose catalog isn't ready, under the same IDs its show page lists: + * - around an episode the feed-first copy holds ([aroundEpisodeId]), the copy's window, so playback + * that started on a feed-first page continues under the copy's IDs; + * - else the newest [LOCAL_CATALOG_WINDOW_BOUND] of `/v2/episodes`, mapped to the IDs this phone + * stores, as the show page's server list is; + * - v1 `/episodes` only when `/v2/episodes` fails. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page; decision D1). + */ +internal suspend fun PodcastRepository.getShowEpisodeWindow(feedId: String, aroundEpisodeId: String?, sort: String,): List { + if (aroundEpisodeId != null && isFeedFirstShow(feedId) && feedFirst.cached(feedId) != null) { + val catalog = localEpisodeCatalog + val window = catalog?.getWindow(feedId, sort, LOCAL_CATALOG_WINDOW_BOUND, aroundEpisodeId, feedFirst.metaFor(feedId) ?: localCatalogMeta(feedId)) + if (window != null && window.any { it.id == aroundEpisodeId }) return window + } + return serverListWindow(feedId) ?: getNetworkEpisodeWindow(feedId) +} + +/** + * The newest [LOCAL_CATALOG_WINDOW_BOUND] episodes of the show for callers that list a whole show + * ([PodcastRepository.getEpisodes]): `/v2/episodes` mapped to the IDs this phone stores, then v1. + * + * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11; decision D1). + */ +internal suspend fun PodcastRepository.getShowEpisodesNewest(feedId: String): List = serverListWindow(feedId) ?: getAllNetworkEpisodes(feedId) + +/** The newest [LOCAL_CATALOG_WINDOW_BOUND] of `/v2/episodes` under local owners, with cached feed extras; null when `/v2` can't answer. */ +private suspend fun PodcastRepository.serverListWindow(feedId: String): List? { + val resolvedId = resolvePodcastIndexFeedId(feedId) + PodcastRepository.rememberResolvedFeedId(feedId, resolvedId) + val page = v2EpisodesPage(resolvedId, LOCAL_CATALOG_WINDOW_BOUND, 0, NEWEST_SORT) ?: return null + return mergeCachedSupplementsNewest(resolvedId, page.episodes).take(LOCAL_CATALOG_WINDOW_BOUND) } /** @@ -141,17 +182,6 @@ private fun Episode.matches(query: String): Boolean { /** The show's registry entry (`/v2/podcast`): its address, transport and namespace. Null when it can't be read. */ internal suspend fun PodcastRepository.v2ShowAddress(showId: Long): V2PodcastResponse? = v2Answer("podcast") { apiV2.getPodcast(showId).awaitResponse() } -/** - * An http-only show's feed document through boxlore's transport (`/v2/feed/`), read whole. - * Null when the route doesn't answer with a document. - */ -internal suspend fun PodcastRepository.v2TransportDocument(showId: Long): ShowPageFeedPort.Document? { - val response = apiV2.getFeed(showId).awaitResponse() - val body = response.body()?.takeIf { response.isSuccessful } ?: return null - val bytes = body.use { it.bytes() } - return ShowPageFeedPort.Document(bytes, response.raw().request.url.toString(), response.headers()["ETag"], complete = true) -} - /** The body of a successful `/v2` answer, or null; cancellation propagates. */ private inline fun v2Answer(route: String, call: () -> Response): T? = try { val response = call() 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 bea713575..a4262e08a 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 @@ -303,7 +303,7 @@ class SubscriptionForegroundSync( reporter = FeedReporter( send = { request -> FeedReporter.sendWith(podcastRepository.apiV2, request) }, - limiter = FeedReportLimiter(upkeep.reportStamps), + limiter = upkeep.reportLimiter, isOnline = upkeep.isOnline, enabled = enabled, ), @@ -922,10 +922,13 @@ internal data class DirectFeedSyncSeams( /** * What the daily address check and broken-feed reports need from the app (P4 PR 14): where report * times are kept across launches, and whether the phone is online (reports are never sent offline). + * [reportLimiter] is the app's one limiter, shared with the show page (P4 PR 16) so both stay inside + * one daily limit; the default builds one on [reportStamps]. */ data class FeedAddressUpkeep( val reportStamps: FeedReportStamps = MemoryFeedReportStamps(), val isOnline: () -> Boolean = { true }, + val reportLimiter: FeedReportLimiter = FeedReportLimiter(reportStamps), ) /** The daily address check and the broken-feed reporter of one [SubscriptionForegroundSync] (P4 PR 14). */ diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt index 9e6d2bb78..303d4658e 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedFirstShowPagesTest.kt @@ -5,6 +5,7 @@ import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadOutcome import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.Episode import cx.aswin.boxlore.core.model.FeedFetchCompleteness import cx.aswin.boxlore.core.model.FeedFetchOutcome import cx.aswin.boxlore.core.model.FeedFetchReport @@ -13,6 +14,9 @@ import cx.aswin.boxlore.core.model.FeedUrlCorrection import cx.aswin.boxlore.core.network.model.V2FeedReportRequest import cx.aswin.boxlore.core.network.model.V2FeedReportResponse import cx.aswin.boxlore.core.network.model.V2PodcastResponse +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -43,7 +47,7 @@ class FeedFirstShowPagesTest { FeedFirstShowPages( copies = copies, registry = { registry }, - transport = { ShowPageFeedPort.Document("".encodeToByteArray(), "https://api.example/v2/feed/$it", null, complete = true) }, + transport = { _, _ -> ShowPageFeedPort.Transported.Document("".encodeToByteArray(), null) }, reporter = FeedReporter( send = { request -> @@ -57,8 +61,11 @@ class FeedFirstShowPagesTest { isOnline = { online }, telemetry = telemetry, nowMillis = { now }, + background = CoroutineScope(Dispatchers.Unconfined), + isFollowed = { it in followed }, ) } + private val followed = mutableSetOf() @Test fun offlineShowsTheStoredCopyWithoutReadingOrReporting() = runBlocking { @@ -154,6 +161,52 @@ class FeedFirstShowPagesTest { assertEquals(2, copies.opened.size) } + @Test + fun aReportNamingAnHttpOnlyAddressIsReadThroughTheTransport() = runBlocking { + copies.answers[PUBLISHER] = mutableListOf(ReadOutcome.Failed("http_404")) + reportAnswer = V2FeedReportResponse(queued = true, fetchUrl = "http://plain.example/feed", fetchVia = "proxy", transportUrl = "https://api.example/v2/feed/$SHOW") + + val read = pages.read(SHOW, META, Scope.NEWEST, SAVED) + + assertEquals(ShowPageFeedResult.UPDATED, read.result) + assertEquals(1, copies.stores) + assertEquals(FeedFetchSource.TRANSPORT, (telemetry.reports.single() as FeedFetchReport.ShowPage).source) + } + + @Test + fun aFollowedShowIsLeftToItsOwnRefreshToReport() = runBlocking { + followed += SHOW + copies.answers[PUBLISHER] = mutableListOf(ReadOutcome.Failed("http_404")) + reportAnswer = V2FeedReportResponse(queued = true, fetchUrl = REPORTED, fetchVia = "direct") + + assertEquals(ShowPageFeedResult.FALLBACK, pages.read(SHOW, META, Scope.NEWEST, SAVED).result) + assertTrue("the followed show's one report a day stays for its refresh", reports.isEmpty()) + } + + @Test + fun theShowPageLeavesPartOfTheDailyReportsToFollowedShows() = runBlocking { + val limiter = FeedReportLimiter(MemoryFeedReportStamps()) + val reporter = FeedReporter({ reportAnswer }, limiter, { true }, { true }, { now }, keepFree = FeedReportLimiter.FOLLOWED_SHOWS_RESERVE) + val showPageCap = FeedReportLimiter.DAILY_CAP - FeedReportLimiter.FOLLOWED_SHOWS_RESERVE + repeat(showPageCap) { assertTrue(limiter.tryClaim("${100 + it}", now, keepFree = FeedReportLimiter.FOLLOWED_SHOWS_RESERVE)) } + + assertEquals(null, reporter.reportTarget("920700", "timeout", SAVED)) + assertTrue("followed shows still have theirs", limiter.tryClaim("920701", now)) + } + + @Test + fun theOpeningShowsCopyNeverWaitsForTheDailyCleanup() = runBlocking { + val held = CompletableDeferred() + copies.cleanupGate = held + + val opened = pages.open(SHOW, META) + + assertTrue("the page opened while the cleanup still runs", opened.recheckDue) + assertEquals(listOf(SHOW), copies.cleanups) + held.complete(Unit) + Unit + } + @Test fun readFailuresMapToTheEventOutcomes() { assertEquals(FeedFetchOutcome.GONE, FeedFirstShowPages.failureOutcome("http_410")) @@ -168,9 +221,12 @@ class FeedFirstShowPagesTest { val reads = mutableListOf>() var lastRequest: ShowPageFeedPort.ReadRequest? = null var stores = 0 + val transportRequests = mutableListOf() + var transportAnswer: ShowPageFeedPort.Transported? = null var cachedShow: ShowPageFeedPort.CachedShow? = null val opened = mutableListOf() val cleanups = mutableListOf() + var cleanupGate: CompletableDeferred? = null override suspend fun cached(podcastId: String): ShowPageFeedPort.CachedShow? = cachedShow @@ -181,17 +237,29 @@ class FeedFirstShowPagesTest { return if (queue.size > 1) queue.removeAt(0) else queue.first() } - override suspend fun store(request: ShowPageFeedPort.ReadRequest, document: ShowPageFeedPort.Document): ReadOutcome { + override suspend fun readTransport( + request: ShowPageFeedPort.ReadRequest, + fetch: suspend (ifNoneMatch: String?) -> ShowPageFeedPort.Transported, + ): ReadOutcome { stores++ - return ReadOutcome.Stored(5, document.complete) + transportRequests += request + return when (val answer = transportAnswer ?: fetch(null)) { + is ShowPageFeedPort.Transported.Document -> ReadOutcome.Stored(5, complete = true) + ShowPageFeedPort.Transported.NotModified -> ReadOutcome.NotModified + is ShowPageFeedPort.Transported.ReadDirect -> read(request.copy(feedUrl = answer.url)) + is ShowPageFeedPort.Transported.Failed -> ReadOutcome.Failed(answer.reportStatus) + } } + override suspend fun search(podcastId: String, query: String, limit: Int, meta: PodcastMeta): List = emptyList() + override suspend fun markOpened(podcastId: String, atMillis: Long) { opened += podcastId } override suspend fun cleanUp(nowMillis: Long, keepPodcastId: String?): Int { cleanups += keepPodcastId + cleanupGate?.await() return 0 } } diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt index 035afda46..8e1bb68a3 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedReportsTest.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.core.catalog +import cx.aswin.boxlore.core.network.model.V2FeedReadTarget import cx.aswin.boxlore.core.network.model.V2FeedReportRequest import cx.aswin.boxlore.core.network.model.V2FeedReportResponse import kotlinx.coroutines.test.runTest @@ -80,6 +81,31 @@ class FeedReportsTest { assertNull(failing.report("4", "http_404", OLD)) } + @Test + fun `the show page reads an http-only answer through the transport`() = runTest { + answer = V2FeedReportResponse(queued = true, fetchUrl = "http://new.example/feed.xml", fetchVia = "proxy", transportUrl = "https://api.example/v2/feed/2") + assertEquals(V2FeedReadTarget.ViaApi(2), reporter.reportTarget("2", "http_404", OLD)) + answer = V2FeedReportResponse(queued = true, fetchUrl = NEW, fetchVia = "direct") + assertEquals(V2FeedReadTarget.Direct(NEW), reporter.reportTarget("3", "http_404", OLD)) + } + + @Test + fun `limiters sharing one store never pass the caps together`() { + val first = FeedReportLimiter(stamps) + val second = FeedReportLimiter(stamps) + val claims = java.util.concurrent.atomic.AtomicInteger() + val threads = + (0 until 8).map { worker -> + Thread { + repeat(10) { index -> if ((if (worker % 2 == 0) first else second).tryClaim("${worker * 10 + index}", now)) claims.incrementAndGet() } + } + } + threads.forEach(Thread::start) + threads.forEach(Thread::join) + + assertEquals(FeedReportLimiter.DAILY_CAP, claims.get()) + } + @Test fun `old report times are dropped as new ones are written`() { val limiter = FeedReportLimiter(stamps) diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt index 75672e86f..3adeac7a1 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastRepositoryShowPagesTest.kt @@ -5,7 +5,9 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import cx.aswin.boxlore.core.database.BoxLoreDatabase import cx.aswin.boxlore.core.database.LocalEpisodeEntity +import cx.aswin.boxlore.core.database.PodcastEntity import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope import cx.aswin.boxlore.core.model.EpisodeRecipeId import cx.aswin.boxlore.core.network.NetworkModule @@ -51,6 +53,7 @@ class PodcastRepositoryShowPagesTest { private lateinit var context: Context private lateinit var database: BoxLoreDatabase private val seen = CopyOnWriteArrayList() + private val ifNoneMatch = CopyOnWriteArrayList() private val answers = mutableMapOf MockResponse>() private var feedFirst = true private var feedItems = 150 @@ -65,6 +68,7 @@ class PodcastRepositoryShowPagesTest { object : Dispatcher() { override fun dispatch(request: RecordedRequest): MockResponse { seen += request.path.orEmpty() + request.getHeader("If-None-Match")?.let { ifNoneMatch += it } return answers[request.path.orEmpty().substringBefore('?')]?.invoke() ?: MockResponse().setResponseCode(404) } } @@ -107,22 +111,22 @@ class PodcastRepositoryShowPagesTest { assertFalse(requireNotNull(repository.openFeedShowPage(SHOW, META)).cached) val newest = repository.readShowPageFeed(SHOW, META, Scope.NEWEST, savedFeedUrl = null) - assertEquals(ShowPageFeedRead(ShowPageFeedResult.UPDATED, archiveComplete = false), newest) + assertEquals(ShowPageFeedRead(ShowPageFeedResult.UPDATED, archiveComplete = false, hasCopy = true), newest) val shown = mutableListOf() var offset = 0 repeat(5) { - val page = repository.getEpisodesPaginated(SHOW, 20, offset, "newest") + val page = repository.getShowPageEpisodes(SHOW, 20, offset, "newest") assertTrue(page.hasMore) shown += page.episodes.map { it.title } offset += page.sourceCount } - val pastCopy = repository.getEpisodesPaginated(SHOW, 20, offset, "newest") + val pastCopy = repository.getShowPageEpisodes(SHOW, 20, offset, "newest") assertTrue("the copy ran out, but the archive isn't read yet", pastCopy.episodes.isEmpty() && pastCopy.hasMore) assertEquals(ShowPageFeedResult.UPDATED, repository.readShowPageFeed(SHOW, META, Scope.ARCHIVE, null).result) while (true) { - val page = repository.getEpisodesPaginated(SHOW, 20, offset, "newest") + val page = repository.getShowPageEpisodes(SHOW, 20, offset, "newest") shown += page.episodes.map { it.title } offset += page.sourceCount if (!page.hasMore) break @@ -157,7 +161,8 @@ class PodcastRepositoryShowPagesTest { assertEquals(ShowPageFeedResult.FALLBACK, read.result) assertEquals(ShowPageSource.SERVER_LIST, repository.showPageSource(SHOW)) - assertEquals(3, repository.getEpisodesPaginated(SHOW, 20, 0, "newest").episodes.size) + assertTrue("still a feed-first page: no Missing episodes? chip", repository.isFeedFirstShowPage(SHOW)) + assertEquals(3, repository.getShowPageEpisodes(SHOW, 20, 0, "newest").episodes.size) assertTrue(seen.any { it.startsWith("/v2/feed-report") }) // Opening the page again tries the feed again. @@ -171,6 +176,9 @@ class PodcastRepositoryShowPagesTest { fun rollingTheFlagBackChangesNoStoredIdAndTheServersListMapsToTheSameOwners() = runBlocking { // An episode an older build stored under its Podcast Index ID while the show was followed. database.localEpisodeCatalogDao().upsertEpisodes(listOf(row("5001", "g1"))) + // The show's saved row, which notification registration and the tracked-shows sync read. + database.podcastDao().upsert(PodcastEntity(podcastId = SHOW, title = "Show", author = "", imageUrl = "", description = null, isSubscribed = false, feedUrl = SAVED_URL)) + val savedRow = database.podcastDao().getPodcast(SHOW) val repository = repository(RoomDiscoveryEpisodeIds { database }) repository.readShowPageFeed(SHOW, META, Scope.ARCHIVE, null) val storedIds = database.localEpisodeCatalogDao().listIdentities(SHOW).associate { it.guid to it.episodeId } @@ -185,6 +193,82 @@ class PodcastRepositoryShowPagesTest { assertEquals(listOf(storedIds["g0"], "5001", storedIds["Moon g2"] ?: recipe("Moon g2").toString()), serverList) assertEquals(storedIds, database.localEpisodeCatalogDao().listIdentities(SHOW).associate { it.guid to it.episodeId }) assertEquals(aliases, database.episodeIdentityDao().aliasesForShow(SHOW).sortedBy { it.aliasId }) + val after = requireNotNull(database.podcastDao().getPodcast(SHOW)) + // The namespace the read kept (decision D12) is the one column it may fill. + assertEquals("no address, transport or sync state of the show changed", savedRow?.copy(epNs = after.epNs), after) + assertTrue("no registration or address route was called", seen.none { it.startsWith("/v2/episodes/register") || it.startsWith("/v2/shows/feeds") }) + } + + @Test + fun onlyTheShowPageReadsTheCopyAndAFollowedHttpOnlyShowKeepsReadingTheServersList() = runBlocking { + answers["/v2/podcast"] = { fixture("fixtures/v2/podcast-proxy.json") } + answers["/v2/feed/$PROXIED"] = { MockResponse().setHeader("ETag", "\"t1\"").setBody(feedXml(30)) } + val repository = repository() + // Followed, but its catalog can never be ready: the address is http-only. + database.podcastDao().upsert(PodcastEntity(podcastId = PROXIED, title = "Proxied", author = "", imageUrl = "", description = null, isSubscribed = true)) + + assertEquals(ShowPageFeedResult.UPDATED, repository.readShowPageFeed(PROXIED, META, Scope.NEWEST, null).result) + assertEquals(ShowPageSource.FEED, repository.showPageSource(PROXIED)) + seen.clear() + + assertEquals(20, repository.getShowPageEpisodes(PROXIED, 20, 0, "newest").episodes.size) + assertTrue("the show page reads the copy", seen.isEmpty()) + repository.getEpisodesPaginated(PROXIED, 20, 0, "newest") + assertEquals("Smart Queue, Auto, Home and downloads read the server's list", "/v2/episodes?id=$PROXIED&limit=20&offset=0&sort=newest", seen.single()) + + // The next read sends the copy's ETag; the 304 keeps it. + answers["/v2/feed/$PROXIED"] = { MockResponse().setResponseCode(304) } + assertEquals(ShowPageFeedResult.UNCHANGED, repository.readShowPageFeed(PROXIED, META, Scope.ARCHIVE, null).result) + assertEquals(listOf("\"t1\""), ifNoneMatch.toList()) + } + + @Test + fun anOldestFirstPageOfAPartialCopyComesFromTheServersList() = runBlocking { + val repository = repository() + repository.readShowPageFeed(SHOW, META, Scope.NEWEST, null) + seen.clear() + + repository.getShowPageEpisodes(SHOW, 50, 0, "oldest") + + assertEquals("/v2/episodes?id=$SHOW&limit=50&offset=0&sort=oldest", seen.single()) + } + + @Test + fun playbackWindowsListTheEpisodesUnderTheIdsTheShowPageShowed() = runBlocking { + val repository = repository() + repository.readShowPageFeed(SHOW, META, Scope.ARCHIVE, null) + val playing = recipe("g1").toString() + val next = recipe("g0").toString() + + val fromCopy = repository.getEpisodeWindow(SHOW, aroundEpisodeId = playing).map { it.id } + assertTrue(playing in fromCopy && next in fromCopy) + assertTrue("no v1 window", seen.none { it.startsWith("/episodes") }) + + feedFirst = false + val fromServerList = repository.getEpisodeWindow(SHOW, aroundEpisodeId = playing).map { it.id } + assertTrue(playing in fromServerList && next in fromServerList) + assertEquals(listOf(next, playing, recipe("Moon g2").toString()), repository.getEpisodes(SHOW).map { it.id }) + assertTrue("v1 /episodes only when /v2/episodes fails", seen.none { it.startsWith("/episodes") }) + } + + @Test + fun recordedAnswersOfTheV2RoutesReadAsTheRoutesSendThem() = runBlocking { + feedFirst = false + answers["/v2/episodes"] = { fixture("fixtures/v2/episodes.json") } + val page = repository().getEpisodesPaginated(SHOW, 20, 0, "newest") + + assertEquals(listOf("5626175219222083", "7579913942623881"), page.episodes.map { it.id }) + assertEquals(5626175219222083L, page.episodes.first().recipeId) + + feedFirst = true + answers["/v2/podcast"] = { fixture("fixtures/v2/podcast-direct.json") } + assertEquals(ShowPageFeedResult.UPDATED, repository().readShowPageFeed(SHOW, META, Scope.NEWEST, null).result) + + assertEquals( + ShowPageFeedPort.Transported.ReadDirect(RECORDED_FEED_URL), + transportFailure(404, fixtureText("fixtures/v2/feed-not-proxied.json")), + ) + assertEquals(ShowPageFeedPort.Transported.Failed("http_503"), transportFailure(503, "{\"error\":\"feed_unavailable\"}")) } @Test @@ -224,7 +308,7 @@ class PodcastRepositoryShowPagesTest { private fun feedClient() = RssFeedClient( OkHttpClient.Builder().addInterceptor { chain -> val url = chain.request().url.toString() - val code = if (url == FEED_URL) 200 else 404 + val code = if (url == FEED_URL || url == RECORDED_FEED_URL) 200 else 404 Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(code).message("test") .body(feedXml(feedItems).toResponseBody("application/rss+xml".toMediaType())).build() }.build(), @@ -260,6 +344,17 @@ class PodcastRepositoryShowPagesTest { private companion object { const val SHOW = "920666" const val FEED_URL = "https://feeds.example/920666.xml" + const val SAVED_URL = "https://saved.example/920666.xml" + + /** The address `fixtures/v2/podcast-direct.json` names. */ + const val RECORDED_FEED_URL = "https://feeds.podcastindex.org/pc20.xml" + + /** The http-only show of `fixtures/v2/podcast-proxy.json`. */ + const val PROXIED = "1031614" + + fun fixtureText(path: String): String = requireNotNull(PodcastRepositoryShowPagesTest::class.java.classLoader?.getResource(path)) { path }.readText() + + fun fixture(path: String) = json(fixtureText(path)) val META = PodcastMeta(title = "Show") fun recipe(key: String): Long = EpisodeRecipeId.forNamespace(SHOW.toLong(), key) diff --git a/core/catalog/src/test/resources/fixtures/v2/README.md b/core/catalog/src/test/resources/fixtures/v2/README.md index c0b9b7886..c69e09abe 100644 --- a/core/catalog/src/test/resources/fixtures/v2/README.md +++ b/core/catalog/src/test/resources/fixtures/v2/README.md @@ -15,3 +15,16 @@ recorded. | `curiosity.json` | `GET /v2/curated/curiosity-v3?page=1` | | `briefing-metadata.json` | `GET /v2/briefings/metadata/us` | | `briefing-chapters.json` | the `chaptersUrl` of that briefing (`/v2/briefings/chapters/us`) | + +Show page routes, recorded the same way on 2026-10-27 (episode lists cut to two items, long text +shortened, nested lists cut to their first entry): + +| File | Request | +|---|---| +| `episodes.json` | `GET /v2/episodes?id=920666&limit=3&offset=0&sort=newest` (`source: pi`, items with `servedId` and `catalogKey`) | +| `podcast-direct.json` | `GET /v2/podcast?id=920666` (`fetchVia: direct`) | +| `podcast-proxy.json` | `GET /v2/podcast?id=1031614` (`fetchVia: proxy` with `transportUrl`) | +| `feed-not-proxied.json` | `GET /v2/feed/920666`, a show that isn't http-only: 404 `not_proxied` with `fetchUrl` | + +`POST /v2/feed-report` isn't recorded: each report queues a re-check on the live service and counts +against the phone's daily quota. Its tests use the answer shape in `V2-ROUTES.md`. diff --git a/core/catalog/src/test/resources/fixtures/v2/episodes.json b/core/catalog/src/test/resources/fixtures/v2/episodes.json new file mode 100644 index 000000000..593740114 --- /dev/null +++ b/core/catalog/src/test/resources/fixtures/v2/episodes.json @@ -0,0 +1,155 @@ +{ + "status": "true", + "fallback": true, + "source": "pi", + "feedId": 920666, + "items": [ + { + "id": 5626175219222083, + "title": "Episode 276: \"Jackleg leechers\"", + "link": "https://www.podpage.com/podcasting-20/jackleg-leechers/", + "description": "

Podcasting 2.0 October 23d 2026 Episode 276 - \"Jackleg Leechers\"Podcasting 2.0 October 16th 2026 Episode 275 - \"Off The Rails\" + /** * Stored copies the automatic cleanup may remove: not followed by either record (the feed row's * [LocalEpisodeFeedEntity.followed] and the `podcasts` row), and without an unsubscribe TTL, diff --git a/core/domain/README.md b/core/domain/README.md index 9c5668f2e..f6aaf9233 100644 --- a/core/domain/README.md +++ b/core/domain/README.md @@ -19,7 +19,7 @@ Owns thin domain ports and small result types used by ViewModels, repositories, - `ConnectivityStatusPort` and connectivity status types. - `DeviceIdentityPort` for cross-device sync attribution. - `FeedTelemetryPort` reports feed reads (`feedFetched`) and saved-address swaps (`feedUrlCorrected`) without a dependency on `:core:analytics`; `AppContainer` wires the analytics-backed one and `NONE` reports nothing. Implementations must not throw. -- `ShowPageFeedPort` is the feed-first show page's stored feed copy of a Podcast Index show, followed or not (P4 PR 16): `cached` (episode count, whether it is the whole archive, when it was read, oldest-first), `read` (`NEWEST` or `ARCHIVE` of an HTTPS address; `Stored`, `NotModified` or `Failed` with the `/v2/feed-report` status, never a throw), `store` (a document read through boxlore's transport), `markOpened` and `cleanUp` (unfollowed copies not opened in 30 days, then a size cap; never history, downloads, the queue or aliases). Pages and search of the copy go through `LocalEpisodeCatalogPort.getPage`/`search`. `:core:rss` implements it (`LocalEpisodeCatalogRepository.showPages`). +- `ShowPageFeedPort` is the feed-first show page's stored feed copy of a Podcast Index show, followed or not (P4 PR 16): `cached` (episode count, whether it is the whole archive, when it was read, oldest-first), `read` (`NEWEST` or `ARCHIVE` of an HTTPS address; `Stored`, `NotModified` or `Failed` with the `/v2/feed-report` status, never a throw), `readTransport` (an http-only feed through boxlore's transport: the caller's fetch gets the copy's ETag and answers `Transported` `Document`, `NotModified`, `ReadDirect` or `Failed`; it runs under the read's lock and download gate), `search` (bounded in the query), `markOpened` and `cleanUp` (unfollowed copies not opened in 30 days, then a size cap; never history, downloads, the queue or aliases). Pages of the copy go through `LocalEpisodeCatalogPort.getPage`. `:core:rss` implements it (`LocalEpisodeCatalogRepository.showPages`). - `LocalEpisodeCatalogPort.RefreshRequest.reason` defaults to `NORMAL`; automatic refresh and `AUTO_DOWNLOAD` share the persisted six-hour freshness gate. `MANUAL` and `NEW_RELEASE` bypass quiet/HEAD shortcuts. `canProceed` rechecks authorization before network/persistence, `runPostPersistCallback` prevents background metadata recovery from invoking ungated ingest callbacks, and `isRefreshDue` supports bounded foreground batches. `RefreshOutcome.Failure.addressGone` says the saved address looks dead rather than temporarily failing, and `RefreshOutcome.Failure.reportStatus` says what the read saw in the words `/v2/feed-report` takes (`http_404`, `timeout`, `dns`, `tls`, `parse`, `network`, `other`; null when no read happened); with `RefreshRequest.detectMove` (and a `PodcastMeta.title` to compare), `RefreshOutcome.Success.movedTo` carries a confirmed new HTTPS address. `RefreshRequest.serverEpNs` passes the episode namespace the server reported for the show; it is only compared with the stored one and never changes an ID. Publisher RSS and Room remain the source of subscribed episodes. diff --git a/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt index 911a30e3b..024ca106c 100644 --- a/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt +++ b/core/domain/src/main/java/cx/aswin/boxlore/core/domain/ports/ShowPageFeedPort.kt @@ -1,9 +1,11 @@ package cx.aswin.boxlore.core.domain.ports +import cx.aswin.boxlore.core.model.Episode + /** * The phone's stored copy of a Podcast Index show's feed for its show page, followed or not, and - * the reads that fill it. Pages and search read the copy through [LocalEpisodeCatalogPort.getPage] - * and [LocalEpisodeCatalogPort.search]; this port says what the copy holds and adds to it. + * the reads that fill it. Pages read the copy through [LocalEpisodeCatalogPort.getPage]; this port + * says what the copy holds, adds to it and searches it ([search], bounded). * * A read never makes an unfollowed show's copy the followed live list ([LocalEpisodeCatalogPort.isReady] * stays false), and it never runs the post-write work of followed shows (auto-download, new-episode @@ -34,7 +36,7 @@ interface ShowPageFeedPort { enum class Scope { NEWEST, ARCHIVE } /** - * One read of [feedUrl] (HTTPS) for show [podcastId]. [serverEpNs] is only compared with the + * One read of [feedUrl] (HTTPS; for [readTransport] the publisher's http address, if known) for show [podcastId]. [serverEpNs] is only compared with the * stored namespace (decision D12). */ data class ReadRequest( @@ -46,10 +48,22 @@ interface ShowPageFeedPort { ) /** - * A feed document read some other way (boxlore's transport address for an http-only feed). - * [complete] is false when it holds only the start of the feed. + * One answer of boxlore's transport (`/v2/feed/`), the only way to read an http-only feed. + * Transport documents are always the whole feed. */ - class Document(val bytes: ByteArray, val finalUrl: String, val etag: String?, val complete: Boolean,) + sealed interface Transported { + /** The feed document; [etag] answers the next read's `If-None-Match`. */ + class Document(val bytes: ByteArray, val etag: String?) : Transported + + /** The stored copy is current (304 to the ETag the read sent). */ + data object NotModified : Transported + + /** The show's address is HTTPS after all (`404 not_proxied`): read [url] directly. */ + data class ReadDirect(val url: String) : Transported + + /** The transport couldn't answer; [reportStatus] as in [ReadOutcome.Failed]. */ + data class Failed(val reportStatus: String) : Transported + } sealed interface ReadOutcome { /** The read was stored; [complete] when it held the whole feed. */ @@ -70,8 +84,25 @@ interface ShowPageFeedPort { /** Reads [ReadRequest.feedUrl] and stores what it holds. Never throws for a failed read. */ suspend fun read(request: ReadRequest): ReadOutcome - /** Stores [document] as a read of [request]'s show; a document that isn't a feed is [ReadOutcome.Failed]. */ - suspend fun store(request: ReadRequest, document: Document): ReadOutcome + /** + * Reads [request]'s show through boxlore's transport: [fetch] is given the stored ETag (only + * when the copy is the whole archive) and answers. It runs under the same per-show lock and + * download gate as [read]. Neither the transport address nor [ReadRequest.feedUrl] (the + * publisher's own http address) is recorded as the copy's address. A [Transported.ReadDirect] + * answer is read directly ([read]). + */ + suspend fun readTransport(request: ReadRequest, fetch: suspend (ifNoneMatch: String?) -> Transported): ReadOutcome + + /** + * In-show search over [podcastId]'s copy: titles and descriptions containing [query], newest + * first, at most [limit] episodes. + */ + suspend fun search( + podcastId: String, + query: String, + limit: Int, + meta: LocalEpisodeCatalogPort.PodcastMeta = LocalEpisodeCatalogPort.PodcastMeta(), + ): List /** Notes that the show page opened this show now, which keeps its copy from the automatic cleanup. */ suspend fun markOpened(podcastId: String, atMillis: Long) diff --git a/core/rss/README.md b/core/rss/README.md index 72e4245c7..ee5dcba70 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -15,7 +15,7 @@ Failed automatic fetches record a six-hour retry boundary while keeping catalogs - `RssPodcastRepository` implements `RssSubscriptionPort` and manages RSS podcast and episode catalog operations. Refresh and subscribe remap parsed rows through `StickyRssEpisodeRemap.prepare` before writing `latestEpisode` and `rss_episodes`, so stored `episodeId`s never remint. `getEpisodesAround` queries forward-continuation episodes via `RssEpisodeDao.getEpisodesAfter` for same-show queue playback. - `RssPodcastRepository.legacySubscriptionRepair` exposes `LegacyRssSubscriptionRepair`. `inspect` returns a `LegacyRssFeedSnapshot` without mutating Room; `upgrade` returns a `LegacyRssUpgradeOutcome` and atomically copies a verified RSS catalog under a Podcast Index id without re-keying episodes, and records a trusted alias (`rss_follow_upgrade`) for every copied ID whose key follows the identity contract, so the IDs survive eviction. The copied IDs own their episodes: a trusted owner of the same (show, key) under another ID, left by an earlier read of the show's catalog, is demoted in the same transaction and stays trusted, so a read after eviction comes back under the copied IDs that hold the listener state. When such an owner's ID holds history, a download or a queue entry under any show, the upgrade writes nothing and returns `TARGET_ALREADY_IN_USE`. Both share the per-podcast refresh lock with `refreshCatalog`. - `LocalEpisodeCatalogRepository` implements `LocalEpisodeCatalogPort`: full publisher-feed catalog under a Podcast Index id, sticky upsert by guid, and a structural per-show ready gate (complete persisted count; a non-null unsubscribe `ttlExpiresAt` is not ready so unsubscribed reads stay on Podcast Index). `getWindow` with `aroundEpisodeId` uses `continuationRows` (`getEpisodesAfter`) to fetch the anchor and subsequent forward chronological episodes. A not-ready catalog always bypasses HEAD validators and performs the repair GET. Listener-id or cached PI-tip mismatches never permanently hide a successfully persisted publisher feed. HEAD/quiet skip and mega GET (capped at 2) remain for ready catalogs. It never creates `rss:` rows; incremental upsert never shrinks existing rows. HEAD validators are not reused after the publisher `feedUrl` changes. `sweepExpired` rechecks TTL before delete so a refresh that cleared `ttlExpiresAt` is kept. `:core:catalog` `SubscriptionForegroundSync` runs that sweep on each launch / Subscriptions refresh pass. -- `LocalEpisodeCatalogRepository.showPages` implements `ShowPageFeedPort` (`ShowPageFeedCatalog`) for the feed-first show page: a stored copy of a Podcast Index show's feed, followed or not, in the same `local_episodes` rows and through the same write (sticky IDs, recipe IDs for new items, aliases, the collision guard) under the same per-show lock. A first read takes the newest 100 items with `RssFeedClient.fetchNewest`, a streaming read that stops early (`FeedPrefixReader`, RSS `` or Atom ``, cut documents closed so they parse); the archive read takes the whole feed. `FeedRowPolicy` records `archiveComplete` apart from `ready`: a show-page read never makes a copy the followed live list and never runs the post-write work (auto-download, delivery). An oldest-first feed (classified in document order) is always read whole. `ShowPageFeedRules`: the newest read sends the copy's validators; an archive read of a partial copy goes out unconditional, so a 304 that covers only the stored prefix is never taken as the archive; a copy older than an hour is read again; cleanup removes unfollowed copies not opened in 30 days, then the least recently opened over 40 shows or 20,000 episodes. A document read through boxlore's transport (`store`) never records the transport address in the row. Followed shows' refreshes now also set `archiveComplete`. P4 PR 16. +- `LocalEpisodeCatalogRepository.showPages` implements `ShowPageFeedPort` (`ShowPageFeedCatalog`) for the feed-first show page: a stored copy of a Podcast Index show's feed, followed or not, in the same `local_episodes` rows and through the same write (sticky IDs, recipe IDs for new items, aliases, the collision guard) under the same per-show lock. A first read takes the newest 100 items with `RssFeedClient.fetchNewest`, a streaming read that stops early (`FeedPrefixReader`, RSS `` or Atom ``, cut documents closed so they parse); the archive read takes the whole feed. `FeedRowPolicy` records `archiveComplete` apart from `ready`: a show-page read never makes a copy the followed live list and never runs the post-write work (auto-download, delivery). An oldest-first feed (classified in document order) is always read whole. `ShowPageFeedRules`: the newest read sends the copy's validators; an archive read of a partial copy goes out unconditional, so a 304 that covers only the stored prefix is never taken as the archive; a copy older than an hour is read again; cleanup removes unfollowed copies not opened in 30 days, then the least recently opened over 40 shows or 20,000 episodes. A document read through boxlore's transport (`readTransport`, with the copy's ETag, under the download gate) is parsed under one fixed namespace per show, and the row never records the transport or http address. A show-page write keeps the stored row's unsubscribe TTL and `ready`, read again inside its transaction, so a recently unfollowed show keeps its cheap resubscribe and TTL sweep and an unfollow during the read stays. A newest read that meets no stored item leaves a gap, so `archiveComplete` goes back to false and the next archive read goes out without validators. A new feed row takes `followed` from `podcasts.isSubscribed`. In-show search of a copy is limited in the query (`searchLimited`). `RssFeedClient.readFeedBody` is the 25 MB cap every feed read keeps (`FeedTooLargeException`), also for transport documents. Followed shows' refreshes now also set `archiveComplete`. P4 PR 16. - `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). @@ -129,7 +129,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - `RssParsePolicyTest` checks the size limit and that a large valid feed never reaches the library parser. - `FeedDocumentOrderTest` and the `feed_identity.xml` fixture (`IdentityFixtureFeed`, with the rows older builds stored for it, read by both parsers and by the streaming parser alone): a duplicate GUID whose later copy is newer keeps the first copy; GUID-less items where the parsers pick different addresses are one episode keyed on the enclosure; a feed crossing 4 MB keeps every stored ID in both directions, from rows the new code wrote and from rows older builds wrote on either side; a library-only read finds the rows a joined read stored; NBSP and U+180E padding trimmed with the recipe set (written as `\u` escapes); which item updates which stored row (ID and key per title), and which older rows are no longer refreshed, for local catalogs; every stored ID kept for true RSS rows; exact keys win over older keys. `feed_shared_enclosure.xml`: GUID-less items sharing a cover-image or trailer enclosure stay apart. `LocalEpisodeCatalogRepositoryTest` runs both fixtures through the refresh and the collision guard (rows from older builds on either side of 4 MB with the leftover duplicates unchanged; shared enclosures through repeated refreshes, a new release and a read over 4 MB). `EpisodeSupplementRepositoryTest` refreshes supplements small, large, small from older rows and from none: no ID is deleted or changes. `LegacyRssSubscriptionUpgradeLogicTest` checks the upgrade's copied catalog keeps every stored RSS ID and gives a shared key to the feed item, which owners the copied IDs displace (trusted owners of copied contract keys under other IDs), and the ownership hand-over (demoted once a copied ID owns the key, the copied ID's trusted alias promoted, an owner kept when no copied ID can own). - `EpisodeRecipeIdTest` runs every case in the shared recipe fixture against `:core:model`'s `EpisodeRecipeId` and asserts the fixture's SHA-256, so a changed copy fails here and in the scripts test. -- `FeedPrefixReaderTest` (early stop, tags split across reads, Atom, byte cap, `fetchNewest` validators and 304), `ShowPageFeedRulesTest` (the prefix 304 rule, oldest-first, cleanup age and caps) and `ShowPageFeedCatalogTest` (real Room and parser: newest 100 then the archive with the same IDs, a 304 to the stored newest never taken as the archive, oldest-first feeds read whole, transport documents, cleanup that keeps history, downloads, the queue and aliases, and follow/unfollow writing `followed`). +- `FeedPrefixReaderTest` (early stop, tags split across reads, Atom, byte cap, `fetchNewest` validators and 304), `ShowPageFeedRulesTest` (the prefix 304 rule, oldest-first, cleanup age and caps) and `ShowPageFeedCatalogTest` (real Room and parser: newest 100 then the archive with the same IDs, a 304 to the stored newest never taken as the archive, oldest-first feeds read whole, transport documents with their ETag and a `not_proxied` answer read directly, a show-page read that keeps an unsubscribe TTL so a resubscribe stays ready, a newest read past a gap, `followed` for a show followed before its first read, bounded search, cleanup that keeps history, downloads, the queue and aliases, and follow/unfollow writing `followed`). ## CI relevance diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt index 34c9da9da..19dc52eb9 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt @@ -11,6 +11,12 @@ import javax.net.ssl.SSLPeerUnverifiedException /** A publisher feed answered with an HTTP error status ([RssFeedClient.fetch]). */ class FeedHttpException(val code: Int) : IllegalArgumentException("Feed returned HTTP $code") +/** + * A feed document over the size cap ([RssFeedClient.readFeedBody]). An [IllegalArgumentException], + * as the cap's `require` threw before P4 PR 16, so callers that caught that still do. + */ +class FeedTooLargeException(maxBytes: Long) : IllegalArgumentException("Feed is larger than ${maxBytes / (1024 * 1024)} MB") + /** * Whether a failed publisher-feed read says the saved address no longer works, so it's worth asking * the server for a newer one: gone or refused (401, 403, 404, 410, 451), no such host, a certificate diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt index 9e7ca9a8e..115f74739 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt @@ -125,7 +125,9 @@ private suspend fun fetchAndPersist( * - [complete]: the read held the whole feed, so the copy is the complete archive * ([LocalEpisodeFeedEntity.archiveComplete]); a read of the newest items only leaves it as it was. * - [showPage]: a show-page read ([ShowPageFeedCatalog]). It never makes a copy the followed live - * list: `ready` stays what the stored row already allowed. + * list, and it keeps what follow and unfollow wrote: the stored row's `ready` and unsubscribe TTL + * are read again inside the write's transaction and kept, so a recently unfollowed show keeps its + * cheap resubscribe and its TTL sweep, and an unfollow that lands during the read stays. * - [documentOrder]: the feed's order classified in document order, for a row that has none yet. * - [openedAt]: when the show page opened the show (epoch ms), kept from the automatic cleanup. * - [recordedAddress]: the address the row records instead of the one read; boxlore's transport @@ -151,20 +153,40 @@ internal data class FeedRowPolicy( ?: documentOrder ?: FeedOrderLogic.classify(parsed.episodes.map { it.publishedDate }) - /** A show-page read keeps what the stored row allowed; a followed show's refresh flips once the count is complete. */ - fun ready(existing: LocalEpisodeFeedEntity?, storedCount: Int, copied: Int): Boolean = if (showPage) { - LocalCatalogReadyLogic.isReady(existing) + /** + * A show-page read keeps the stored row's `ready` ([current], read in the write's transaction); + * a followed show's refresh flips once the count is complete. + */ + fun ready(current: LocalEpisodeFeedEntity?, storedCount: Int, copied: Int): Boolean = if (showPage) { + current?.ready == true } else { LocalCatalogReadyLogic.isReadyToFlip(feedReady = storedCount >= copied) } + /** A show-page read keeps the stored unsubscribe TTL ([current]); a followed show's refresh clears it. */ + fun ttlExpiresAt(current: LocalEpisodeFeedEntity?): Long? = if (showPage) current?.ttlExpiresAt else null + + /** + * Whether a read of the newest items left a gap: the copy had rows, yet none of the items read + * was one of them, so more items than the read holds came out since the last read. The copy is + * then no longer the whole archive. + */ + fun leavesGap(existingRows: Int, planned: List): Boolean = + !complete && existingRows > 0 && planned.none { it.source == PlannedIdSource.STORED } + /** A read of the newest items leaves a pending backfill pending. */ fun needsFullBackfill(existing: LocalEpisodeFeedEntity?): Boolean = !complete && (existing?.needsFullBackfill ?: true) - /** The v39 columns [LocalEpisodeCatalogDao.upsertFeed] keeps, written on their own. */ - suspend fun writeFeedFirstColumns(dao: LocalEpisodeCatalogDao, podcastId: String) { + /** + * The v39 columns [LocalEpisodeCatalogDao.upsertFeed] keeps, written on their own: the archive + * state ([gap]: see [leavesGap]), the opening time, and `followed` for a row this write created + * ([created]), from `podcasts.isSubscribed`. + */ + suspend fun writeFeedFirstColumns(dao: LocalEpisodeCatalogDao, podcastId: String, created: Boolean, gap: Boolean) { if (complete) dao.setArchiveComplete(podcastId, true) + if (gap) dao.setArchiveComplete(podcastId, false) openedAt?.let { dao.setLastOpenedAt(podcastId, it) } + if (created) dao.seedFollowed(podcastId) } } @@ -224,8 +246,9 @@ internal suspend fun persistParsed( deps.dao.upsertEpisodes(written) recordAliases(deps, request.podcastIndexId, planned, resolved, newIds) deps.reconcileListenerState(request.podcastIndexId, written) + val current = deps.dao.getFeed(request.podcastIndexId) storedCount = maxOf(deps.dao.count(request.podcastIndexId), written.size) - ready = policy.ready(existing, storedCount, copied) + ready = policy.ready(current, storedCount, copied) deps.dao.upsertFeed( LocalEpisodeFeedEntity( podcastId = request.podcastIndexId, @@ -235,8 +258,8 @@ internal suspend fun persistParsed( fetchedAt = System.currentTimeMillis(), itemCount = storedCount, feedOrder = feedOrder, - ttlExpiresAt = null, - needsFullBackfill = policy.needsFullBackfill(existing), + ttlExpiresAt = policy.ttlExpiresAt(current), + needsFullBackfill = policy.needsFullBackfill(current ?: existing), copiedExtrasCount = copied, ready = ready, feedUrlLookupAt = existing?.feedUrlLookupAt ?: 0L, @@ -248,7 +271,12 @@ internal suspend fun persistParsed( deps.dao.setEpNsIfUnset(request.podcastIndexId, epNs) deps.identity.keepShowNamespace(request.podcastIndexId, epNs) } - policy.writeFeedFirstColumns(deps.dao, request.podcastIndexId) + policy.writeFeedFirstColumns( + deps.dao, + request.podcastIndexId, + created = current == null, + gap = policy.leavesGap(identities.size, planned), + ) } resolution?.let { logResolution(request.podcastIndexId, it, written = !incomplete) } if (incomplete) return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE) diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt index 627644a07..eec90a3f6 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt @@ -685,21 +685,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien }.getOrDefault("") } - private fun readBounded(input: java.io.InputStream): ByteArray { - val output = ByteArrayOutputStream() - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var total = 0L - while (true) { - val read = input.read(buffer) - if (read < 0) break - total += read - require(total <= MAX_FEED_BYTES) { - "Feed is larger than ${MAX_FEED_BYTES / (1024 * 1024)} MB" - } - output.write(buffer, 0, read) - } - return output.toByteArray() - } + private fun readBounded(input: java.io.InputStream): ByteArray = readFeedBody(input) private fun validateContentType(response: Response) { val contentType = @@ -802,6 +788,27 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien } companion object { + /** + * Reads a feed document from [input], at most [MAX_FEED_BYTES]: the cap every feed read + * keeps, also for documents boxlore's transport (`/v2/feed/`) serves. A larger + * document throws [FeedTooLargeException] before it fills memory. + * + * Public since P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). + */ + fun readFeedBody(input: java.io.InputStream): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > MAX_FEED_BYTES) throw FeedTooLargeException(MAX_FEED_BYTES) + output.write(buffer, 0, read) + } + return output.toByteArray() + } + private const val MAX_FEED_BYTES = 25L * 1024L * 1024L private const val MAX_DESCRIPTION_LENGTH = 20_000 private const val HTTP_NOT_MODIFIED = 304 diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt index 2445e3d6c..9bced9221 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalog.kt @@ -2,11 +2,13 @@ package cx.aswin.boxlore.core.rss import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity import cx.aswin.boxlore.core.database.LocalFeedOrder +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshRequest import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadOutcome import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.ReadRequest +import cx.aswin.boxlore.core.model.Episode import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -56,16 +58,54 @@ internal class ShowPageFeedCatalog( } } - override suspend fun store(request: ReadRequest, document: ShowPageFeedPort.Document): ReadOutcome = withContext(Dispatchers.IO) { + override suspend fun readTransport( + request: ReadRequest, + fetch: suspend (ifNoneMatch: String?) -> ShowPageFeedPort.Transported, + ): ReadOutcome = withContext(Dispatchers.IO) { if (!isFeedShow(request.podcastId)) return@withContext ReadOutcome.Failed(OTHER) - lockFor(request.podcastId).withLock { - failSafe { - val existing = deps.dao.getFeed(request.podcastId) - val result = RssFetchResult(document.finalUrl, document.etag, null, document.bytes) - // The transport address needs the API session: the row keeps the address it had, if any. - persist(request, existing, Fetched(result, document.complete), url = null, recordedAddress = existing?.feedUrl.orEmpty()) + var direct: String? = null + val outcome = + lockFor(request.podcastId).withLock { + failSafe { + val existing = deps.dao.getFeed(request.podcastId) + // Transport documents are the whole feed: the ETag goes out only for a whole copy. + val etag = ShowPageFeedRules.validators(wholeFeed = true, existing)?.first + when (val answer = deps.megaGetGate.withPermit { fetch(etag) }) { + is ShowPageFeedPort.Transported.Document -> storeTransported(request, existing, answer) + ShowPageFeedPort.Transported.NotModified -> + if (etag != null) notModified(request.podcastId, existing) else ReadOutcome.Failed(OTHER) + is ShowPageFeedPort.Transported.ReadDirect -> { + direct = answer.url + ReadOutcome.Failed(OTHER) + } + is ShowPageFeedPort.Transported.Failed -> ReadOutcome.Failed(answer.reportStatus) + } + } } - } + // Read outside the lock: [read] takes it again. + direct?.let { url -> read(request.copy(feedUrl = url)) } ?: outcome + } + + override suspend fun search(podcastId: String, query: String, limit: Int, meta: PodcastMeta,): List = withContext(Dispatchers.IO) { + val trimmed = query.trim() + if (trimmed.isEmpty() || limit <= 0) return@withContext emptyList() + deps.dao.searchLimited(podcastId, trimmed.escapeForSqlLike(), limit).map { it.toCatalogEpisode(meta) } + } + + /** + * A transport document. The phone never mints IDs from an http address ([RssIdGenerator] + * refuses them), so no older ID of the show can be under its publisher address; its namespace + * is one fixed address per show ([transportNamespace]), which never changes with the API's + * host. The row keeps the address it had, if any: the transport address needs the API session + * and is never recorded. + */ + private suspend fun storeTransported( + request: ReadRequest, + existing: LocalEpisodeFeedEntity?, + document: ShowPageFeedPort.Transported.Document, + ): ReadOutcome { + val result = RssFetchResult(transportNamespace(request.podcastId), document.etag, null, document.bytes) + return persist(request, existing, Fetched(result, complete = true), url = null, recordedAddress = existing?.feedUrl.orEmpty()) } override suspend fun markOpened(podcastId: String, atMillis: Long) = withContext(Dispatchers.IO) { @@ -164,6 +204,9 @@ internal class ShowPageFeedCatalog( private companion object { const val OTHER = "other" + /** The parse and namespace address of [podcastId]'s transport documents. */ + fun transportNamespace(podcastId: String): String = "https://transport.boxlore.invalid/feed/$podcastId" + /** Show pages store copies for Podcast Index shows; `rss:` shows have their own table. */ fun isFeedShow(podcastId: String): Boolean = podcastId.isNotBlank() && !podcastId.startsWith("rss:") 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 92ff9f76a..b82f3c867 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 @@ -821,6 +821,10 @@ class LocalEpisodeCatalogRepositoryTest { feeds[podcastId]?.let { feeds[podcastId] = it.copy(ttlExpiresAt = ttlExpiresAt, followed = followed) } } + override suspend fun seedFollowed(podcastId: String) = Unit + + override suspend fun searchLimited(podcastId: String, query: String, limit: Int,): List = emptyList() + override suspend fun listUnfollowedCopies(): List = feeds.values .filter { !it.followed && it.ttlExpiresAt == null } .map { feed -> cx.aswin.boxlore.core.database.UnfollowedCatalogCopy(feed.podcastId, feed.lastOpenedAt, feed.fetchedAt, episodes.count { it.podcastId == feed.podcastId }) } diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt index f1653a7bc..2a431598f 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowPageFeedCatalogTest.kt @@ -41,15 +41,16 @@ class ShowPageFeedCatalogTest : RoomCatalogTestBase() { private val requests = CopyOnWriteArrayList() private var document: String = feedXml(150) private var status = 200 + private var etag = ETAG private val persisted = mutableListOf() private val client = RssFeedClient( OkHttpClient.Builder().addInterceptor { chain -> requests += chain.request() - val answer = if (chain.request().header("If-None-Match") == ETAG && status == 200) 304 else status + val answer = if (chain.request().header("If-None-Match") == etag && status == 200) 304 else status Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(answer).message("test") - .header("ETag", ETAG).body(document.toResponseBody("application/rss+xml".toMediaType())).build() + .header("ETag", etag).body(document.toResponseBody("application/rss+xml".toMediaType())).build() }.build(), ) @@ -117,11 +118,105 @@ class ShowPageFeedCatalogTest : RoomCatalogTestBase() { } @Test - fun aTransportDocumentIsStoredWithoutRecordingTheTransportAddress() = runBlocking { - val transport = ShowPageFeedPort.Document(feedXml(20).encodeToByteArray(), "https://api.example/v2/feed/$SHOW", "t-etag", complete = true) + fun aTransportDocumentIsStoredWithItsEtagAndNeverRecordsAnAddress() = runBlocking { + val etags = mutableListOf() - assertEquals(ReadOutcome.Stored(20, complete = true), pages.store(request(Scope.NEWEST), transport)) - assertEquals("", database.localEpisodeCatalogDao().getFeed(SHOW)!!.feedUrl) + val stored = + pages.readTransport(request(Scope.NEWEST).copy(feedUrl = PLAIN_URL)) { etag -> + etags += etag + ShowPageFeedPort.Transported.Document(feedXml(20).encodeToByteArray(), "t-etag") + } + + assertEquals(ReadOutcome.Stored(20, complete = true), stored) + assertEquals(EpisodeRecipeId.forNamespace(SHOW.toLong(), "g1").toString(), rows(SHOW)["g1"]) + assertEquals("neither the transport nor the http address is recorded", "", database.localEpisodeCatalogDao().getFeed(SHOW)!!.feedUrl) + assertEquals(listOf(null), etags) + + // The whole copy's ETag goes with the next read, and a 304 to it keeps the copy. + val again = + pages.readTransport(request(Scope.ARCHIVE).copy(feedUrl = PLAIN_URL)) { etag -> + etags += etag + ShowPageFeedPort.Transported.NotModified + } + assertEquals(ReadOutcome.NotModified, again) + assertEquals("t-etag", etags.last()) + } + + @Test + fun aTransportThatSaysTheAddressIsHttpsReadsItDirectly() = runBlocking { + val outcome = pages.readTransport(request(Scope.NEWEST).copy(feedUrl = PLAIN_URL)) { ShowPageFeedPort.Transported.ReadDirect(feedUrl(SHOW)) } + + assertEquals(ReadOutcome.Stored(100, complete = false), outcome) + assertEquals(feedUrl(SHOW), requests.single().url.toString()) + assertEquals( + ReadOutcome.Failed("http_503"), + pages.readTransport(request(Scope.NEWEST)) { ShowPageFeedPort.Transported.Failed("http_503") }, + ) + } + + @Test + fun aShowPageReadKeepsTheUnsubscribeTtlSoAResubscribeStaysCheap() = runBlocking { + // A followed show's complete catalog, then unfollowed: TTL set, ready kept for a resubscribe. + follow(SHOW) + assertTrue(catalog.refresh(LocalEpisodeCatalogPort.RefreshRequest(SHOW, feedUrl(SHOW))) is LocalEpisodeCatalogPort.RefreshOutcome.Success) + assertTrue(catalog.isReady(SHOW)) + val ttl = System.currentTimeMillis() + 14L * DAY + catalog.setUnsubscribedTtl(SHOW, ttl) + assertFalse(catalog.isReady(SHOW)) + + pages.read(request(Scope.NEWEST)) + + val feed = database.localEpisodeCatalogDao().getFeed(SHOW)!! + assertEquals("the TTL sweep still owns the copy", ttl, feed.ttlExpiresAt) + assertTrue("ready is kept for the resubscribe", feed.ready) + assertFalse("still not the live list while unfollowed", catalog.isReady(SHOW)) + catalog.setUnsubscribedTtl(SHOW, null) + assertTrue("resubscribing uses the stored catalog at once", catalog.isReady(SHOW)) + } + + @Test + fun aNewestReadWithNoStoredItemLeavesTheArchiveIncomplete() = runBlocking { + document = feedXml(150) + pages.read(request(Scope.ARCHIVE)) + assertTrue(requireNotNull(pages.cached(SHOW)).archiveComplete) + + // More than a newest read holds came out since: none of the 100 items read is stored. + document = feedXml(150, firstIndex = -120) + etag = "\"v2\"" + assertEquals(ReadOutcome.Stored(250, complete = false), pages.read(request(Scope.NEWEST))) + + assertFalse("a gap below the new items", requireNotNull(pages.cached(SHOW)).archiveComplete) + pages.read(request(Scope.ARCHIVE)) + assertNull("the archive read goes out without validators", requests.last().header("If-None-Match")) + + // A newest read that meets a stored item leaves a whole archive whole. + document = feedXml(150, firstIndex = -125) + etag = "\"v3\"" + pages.read(request(Scope.NEWEST)) + assertTrue(requireNotNull(pages.cached(SHOW)).archiveComplete) + } + + @Test + fun aShowFollowedBeforeItsFirstReadGetsItsFollowedColumnFromTheFollow() = runBlocking { + follow(SHOW) + assertNull(database.localEpisodeCatalogDao().getFeed(SHOW)) + + pages.read(request(Scope.NEWEST)) + + assertTrue(database.localEpisodeCatalogDao().getFeed(SHOW)!!.followed) + pages.read(request(Scope.NEWEST, show = OTHER_SHOW)) + assertFalse(database.localEpisodeCatalogDao().getFeed(OTHER_SHOW)!!.followed) + } + + @Test + fun inShowSearchIsLimitedInTheQuery() = runBlocking { + pages.read(request(Scope.ARCHIVE)) + + val found = pages.search(SHOW, "Episode", limit = 7) + + assertEquals(7, found.size) + assertEquals("Episode 0", found.first().title) + assertTrue(pages.search(SHOW, " ", limit = 7).isEmpty()) } @Test @@ -166,9 +261,12 @@ class ShowPageFeedCatalogTest : RoomCatalogTestBase() { const val ETAG = "\"whole-doc\"" const val DAY = 24L * 60L * 60L * 1000L - /** [count] items dated a day apart, newest first unless [oldestFirst]. */ - fun feedXml(count: Int, oldestFirst: Boolean = false): String { - val order = if (oldestFirst) (count - 1 downTo 0) else (0 until count) + const val PLAIN_URL = "http://plain.example/920666.xml" + + /** [count] items dated a day apart from [firstIndex] (negative: newer), newest first unless [oldestFirst]. */ + fun feedXml(count: Int, oldestFirst: Boolean = false, firstIndex: Int = 0): String { + val last = firstIndex + count - 1 + val order = if (oldestFirst) (last downTo firstIndex) else (firstIndex..last) val items = order.joinToString("") { index -> val date = DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.ofEpochSecond(1_790_000_000L - index * 86_400L).atZone(ZoneOffset.UTC)) diff --git a/feature/info/README.md b/feature/info/README.md index 67dc4573d..82d2f83a0 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -29,7 +29,7 @@ Disclosure recognizes canonical RSS IDs even with stale source metadata and uses - Detail UI uses centralized Google Sans Flex weight tokens from `:core:designsystem`. - `EpisodeInfoSeekLogic` builds the progress-save payload when seeking an episode that is not the current player item. - For PI-owned shows whose local catalog is **not ready**, unsubscribed Podcast Info may still show **Missing episodes?** (opt-in extras path). The confirm dialog explains that publisher-feed access is allowed once and refreshes on future visits, while subscribing automatically keeps the latest episodes up to date. Subscribe kicks `SubscriptionForegroundSync.requestCatalogIngest` on the application scope so leaving Podcast Info does not cancel the publisher-feed persist (no PI-vs-feed extras compare). The screen only waits on `catalogIngestFinished` to remount if it is still open. Unsubscribe stamps a 14-day Room TTL; that catalog is **not** ready, so the page uses Podcast Index again (a new PI episode still appears). Resubscribe clears the TTL. Once `LocalEpisodeCatalogPort.isReady` is true, the pill is hidden and the episode list comes from Room only. Successful publisher-feed refreshes treat that feed's newest row as authoritative, including same-date/different-id and older-than-PI cross-promo cases. Pull-to-refresh: true RSS uses `refreshCatalog`; subscribed non-pure RSS shows route to a direct-feed catalog refresh via `LocalEpisodeCatalogPort.refresh` (preserving notification, auto-download, skip overrides, artwork fallback, and sort preferences); not-ready opted-in unsubscribed PI still runs the extras refresh; other unsubscribed PI shows reload from `PodcastRepository` (Room or PI). NEW badges still follow newer `publishedDate` only. -- **Feed-first show pages (P4 PR 16, behind `feed_first`).** When `PodcastRepository.showPageSource` is `FEED`, `PodcastInfoFeedFirst` runs the page: a stored copy shows at once (status `CACHED`, no line); without one the first page waits for the feed. Opening reads the newest episodes when the copy is missing or an hour old (`READING_NEWEST`: "Checking the feed for new episodes…"), and the whole archive for an oldest-first page (serial shows) or an oldest-first feed (`COMPLETING_ARCHIVE`: "Loading all episodes from the feed…"). Search, switching to oldest first and scrolling past the stored newest episodes read the archive first; a search the listener keeps typing into waits on the same read. When the feed can't be read the list becomes boxlore's list of the show with `FALLBACK` ("Can’t reach this show’s feed. Showing boxlore’s episode list instead."); offline keeps the stored copy with `OFFLINE` ("You’re offline. Showing saved episodes."). The line (`ShowFeedStatusLine`, a solid `surfaceContainerHigh` card, polite live region) sits above the episodes. After each read the page reloads as many episodes as showed (plus a page when scrolling on), so rows keep their IDs and the scroll position and selection stay while the list grows or switches source. Pull-to-refresh on a show that isn't followed reads the newest episodes at the registry's address (`Target.FEED_FIRST`); once followed, the show's own catalog refresh takes over. A page that opens tries the feed again after an earlier fallback. These pages never offer **Missing episodes?**: the chip and its dialog stay hidden, and search skips the feed extras. Decisions live in `logic/ShowFeedLogic`. With `feed_first` off, a show that isn't followed lists `/v2/episodes` and in-show search filters that list on the phone; v1 `episodes/search` is no longer used. +- **Feed-first show pages (P4 PR 16, behind `feed_first`).** When `PodcastRepository.showPageSource` is `FEED`, `PodcastInfoFeedFirst` runs the page: a stored copy shows at once (status `CACHED`, no line); without one, boxlore's list of the show shows while the feed is read, under the same IDs, so the list then grows without moving. Opening reads the newest episodes when the copy is missing or an hour old (`READING_NEWEST`: "Checking the feed for new episodes…"), and the whole archive for an oldest-first page (serial shows) or an oldest-first feed (`COMPLETING_ARCHIVE`: "Loading all episodes from the feed…"). Search, switching to oldest first and scrolling past the stored newest episodes read the archive first; a search the listener keeps typing into waits on the same read. Opening a serial show reads its whole archive even when the page opens newest first. When the feed can't be read the list becomes boxlore's list of the show with `FALLBACK` ("Can’t reach this show’s feed. Showing boxlore’s episode list instead."), and search, sorting and scrolling read no feed again until the page is opened again (`readsFeed`); pull to refresh still tries it. Offline keeps the stored copy with `OFFLINE` ("You’re offline. Showing saved episodes."), or with no copy `OFFLINE_NO_COPY` ("You’re offline. Connect to load this show’s episodes."); scrolling past the copy while offline waits for a connection instead of retrying, and the list stays open for it. The line (`ShowFeedStatusLine`, a solid `surfaceContainerHigh` card, polite live region) sits above the episodes. After each read the page reloads as many episodes as showed (plus a page when scrolling on), and on until the episode that was last is back when newer ones came in above, so rows keep their IDs and the scroll position and selection stay while the list grows or switches source. The show page reads its list through `PodcastRepository.getShowPageEpisodes`. Pull-to-refresh on a show that isn't followed reads the newest episodes at the registry's address (`Target.FEED_FIRST`); once followed, the show's own catalog refresh takes over and the page stops reading the feed (`PodcastInfoFeedFirst.stop`). A page that opens tries the feed again after an earlier fallback. These pages never offer **Missing episodes?**, also after a fallback (`isFeedFirstShowPage`): the chip and its dialog stay hidden. Search over the feed's copy skips the feed extras; after a fallback it searches the server's list with them. Decisions live in `logic/ShowFeedLogic`. With `feed_first` off, a show that isn't followed lists `/v2/episodes` and in-show search filters that list on the phone; v1 `episodes/search` is no longer used. - `loadMoreEpisodes` and `toggleSort` rethrow `CancellationException` instead of swallowing it. - Show auto-download can be enabled with notifications off or system notification permission denied. Turning the bell off preserves auto-download; the cloud-dirty preference and activation boundary are persisted by the catalog repository. Pure RSS auto-download eligibility remains unchanged. @@ -94,9 +94,9 @@ src/main/java/cx/aswin/boxlore/feature/info/ - Tablet detail content is centered in the shared Reading width (720dp); the app palette still paints the full viewport. Poster rails keep fixed card sizes and Podroll rail items use the shared RailItem cap. Phone modifiers return unchanged. - `PodcastInfoAdaptiveTransitionTest` drives one live `PodcastInfoScreen` (Robolectric/Compose JVM, mocked `PodcastInfoViewModel`) across 599↔600dp width, 479↔480dp height, 839↔840dp in landscape, portrait↔landscape and a flat↔tabletop fold, in both directions, and recreates it with `StateRestorationTester` while the window changes, which is also what a back-stack return does. It covers search query, results and scroll anchor, and the query run again after process death; sort, hide-completed and the visible refresh indicator (no reload or refresh from a window change); selection, including a full-range selection rebuilt after process death, an oldest-first selection that waits for the reloaded page and is looked up in its own order, actions held while the lookup runs, a failed lookup that keeps every ID and retries, a lookup cut short by a reload, Clear during a lookup, and IDs dropped when the page can no longer find them; each of the five dialogs and sheets on both the compact and the tablet host, keeping its content across window changes and recreation, with confirm or save, cancel, Back and outside tap each running its action once or not at all; a restored Missing episodes? dialog that waits for the offer; and the loading indicator, error, retry and empty states, also after recreation. The episode list, loading state and refresh indicator carry test tags. `PodcastEpisodeSelectionLogicTest` covers the restore lookup and its gate; `PodcastInfoLoadGuardTest` covers the repeat-load check for `rss:`-linked shows and catalog IDs that differ from the route. -- `PodcastInfoAdaptiveTransitionTest` also covers the feed status line in each state across every window gate and recreation, and a list that grows (older episodes below, newer ones above, the archive read) and switches source (stored copy → server's list → back) in the same frame as each window change, keeping the scrolled-to row, the selection and the episodes it plays. `ShowFeedLogicTest` covers read depth, the status line, archive gating, reload size, show details on stored-copy episodes and the `FEED_FIRST` pull target. +- `PodcastInfoAdaptiveTransitionTest` also covers the feed status line in each state across every window gate and recreation, and a list that grows (older episodes below, newer ones above, the archive read) and switches source (stored copy → server's list → back) in the same frame as each window change, keeping the scrolled-to row, the selection and the episodes it plays. `ShowFeedLogicTest` covers read depth (serial shows too), the status line (offline with and without a copy), archive gating, reload size and the reload anchor, keeping the list open past the copy, show details on stored-copy episodes and the `FEED_FIRST` pull target. `PodcastInfoFeedFirstTest` drives `PodcastInfoFeedFirst` against a scripted source: no feed read after a fallback until the page opens again, newer episodes above keeping the last row, offline without a copy, the copy's end waiting for a connection, concurrent searches sharing one archive read, a read that ends after the page moved on, a serial show's opening read, and following stopping the page. - **Manual acceptance (show page).** Open a show with many episodes. (1) Phone: tap Search, type a word, scroll the results, rotate twice; the field, query, results and scroll stay. (2) Long-press an episode, choose Select all, rotate; the count stays and Play acts on the same episodes. (3) Open each overflow item (Mark all as played, Mark all as unplayed, Playback for this show, Change tag / genre with a typed draft) and the Missing episodes? chip on an unfollowed show; rotate, then confirm, press Back or tap outside, and check each acts once. (4) Tablet or desktop window: repeat (1) to (3) while resizing across 600dp and 840dp wide and 480dp tall, and in split-screen. (5) Foldable: repeat while folding to tabletop and unfolding, which recreates the activity. (6) Developer options → Don't keep activities: leave and return; open search with its query, dialogs and selection come back. (7) Repeat (1), (2) and (6) on a show you follow through its feed, and on a serial show sorted oldest first. Device runs are not part of CI. -- **Manual acceptance (feed-first show page).** Turn `feed_first` on for the device (PostHog), restart the app, then open a show you don't follow. (1) Phone: the line "Checking the feed for new episodes…" shows, then disappears as the list fills; scroll to the end of the first 100 and "Loading all episodes from the feed…" shows while older episodes appear below without the list jumping. (2) Long-press an episode, then rotate and resize split-screen while the archive loads: the selection count and scroll stay. (3) Tap Search and type a word on a show opened for the first time: the archive line shows, then the results. (4) Sort oldest first on a long show: the oldest episode appears after the archive line. (5) Open the show again within an hour: the saved list shows with no line; pull to refresh reads the feed. (6) Airplane mode, reopen: the saved list shows with "You’re offline. Showing saved episodes." (7) A show whose feed is down: after a moment the line says "Can’t reach this show’s feed. Showing boxlore’s episode list instead." and episodes still play. Repeat (1)–(2) on a tablet (landscape split at 840dp) and a foldable (flat ↔ tabletop). With `feed_first` off, the same show lists its episodes from boxlore's list, and search still finds them. +- **Manual acceptance (feed-first show page).** Turn `feed_first` on for the device (PostHog), restart the app, then open a show you don't follow. (1) Phone: the line "Checking the feed for new episodes…" shows, then disappears as the list fills; scroll to the end of the first 100 and "Loading all episodes from the feed…" shows while older episodes appear below without the list jumping. (2) Long-press an episode, then rotate and resize split-screen while the archive loads: the selection count and scroll stay. (3) Tap Search and type a word on a show opened for the first time: the archive line shows, then the results. (4) Sort oldest first on a long show: the oldest episode appears after the archive line. (5) Open the show again within an hour: the saved list shows with no line; pull to refresh reads the feed. (6) Airplane mode, reopen: the saved list shows with "You’re offline. Showing saved episodes."; a show never opened before says "You’re offline. Connect to load this show’s episodes." (7) A show whose feed is down: after a moment the line says "Can’t reach this show’s feed. Showing boxlore’s episode list instead." and episodes still play. Repeat (1)–(2) on a tablet (landscape split at 840dp) and a foldable (flat ↔ tabletop). With `feed_first` off, the same show lists its episodes from boxlore's list, and search still finds them. - `InfoPhoneGoldenTest` captures the actual podcast and episode screens at both portrait phone sizes and short landscape in light/dark. Mocked ViewModel flows avoid constructing Application-backed ViewModels or making network requests. These local-only captures use `-PphoneGoldens=verify --tests '*PhoneGoldenTest'`; see [capture instructions](../../docs/screenshots/README.md). diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt index 042211c97..2e7d55d2e 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirst.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.feature.info import cx.aswin.boxlore.core.catalog.PodcastRepository import cx.aswin.boxlore.core.catalog.ShowPageFeedRead +import cx.aswin.boxlore.core.catalog.ShowPageFeedResult import cx.aswin.boxlore.core.catalog.ShowPageFeedState import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope @@ -18,13 +19,28 @@ import kotlinx.coroutines.async * switches the list (stored copy → feed → server's list) without moving the scroll position or * dropping a selection. * + * After a read falls back to the server's list, this page stops reading the feed for search, + * sorting and scrolling until it is opened again ([readsFeed]); pull to refresh still tries it. + * Following the show hands the page to the show's own catalog refresh ([stop]). + * * Added in P4 PR 16 of the own-episode-IDs plan (plan section 11, feed-first show page). * COMPAT(own-ids): show pages read the show's own feed. Permanent: never remove (MIGRATION.md §5). */ internal class PodcastInfoFeedFirst( - private val repository: PodcastRepository, + private val source: Source, private val ui: Ui, ) { + /** What the page reads through, so tests can script it ([sourceOf] in the app). */ + interface Source { + suspend fun open(podcastId: String, meta: PodcastMeta): ShowPageFeedState? + + suspend fun read(podcastId: String, meta: PodcastMeta, scope: Scope, savedFeedUrl: String?): ShowPageFeedRead + + suspend fun page(podcastId: String, limit: Int, offset: Int, sort: String): PodcastRepository.EpisodePage + + fun online(): Boolean + } + /** How the helper reads and writes the page it serves. */ interface Ui { fun state(): PodcastInfoUiState.Success? @@ -47,20 +63,43 @@ internal class PodcastInfoFeedFirst( var archiveComplete: Boolean = false private set + /** The last read of this page fell back to the server's list. */ + @Volatile + private var fellBack: Boolean = false + + /** Scrolling reached the end of the copy while offline: it waits for a connection. */ + @Volatile + private var waitingAtCopyEnd: Boolean = false + + @Volatile + private var archiveRead: Deferred? = null + fun isActive(id: String?): Boolean = id != null && id == podcastId + /** True while [id]'s page reads its feed: it is feed-first and its last read didn't fall back. */ + fun readsFeed(id: String?): Boolean = isActive(id) && !fellBack + /** Whether [podcastId]'s page reads the feed, and what it holds; null for any other source. */ suspend fun open(podcastId: String, podcast: Podcast?): ShowPageFeedState? { - val opened = repository.openFeedShowPage(podcastId, podcast?.let(ShowFeedLogic::metaOf) ?: PodcastMeta()) + val opened = source.open(podcastId, podcast?.let(ShowFeedLogic::metaOf) ?: PodcastMeta()) this.podcastId = podcastId.takeIf { opened != null } + fellBack = false + waitingAtCopyEnd = false + archiveRead = null if (opened == null) return null archiveComplete = opened.archiveComplete return opened } + /** The listener followed the show: its own catalog refresh takes over, and this page reads no more. */ + fun stop() { + podcastId = null + archiveRead = null + } + /** The status line and the read a page starts with once its first page shows. */ suspend fun begin(podcast: Podcast, sort: EpisodeSort, opened: ShowPageFeedState) { - val scope = ShowFeedLogic.openingScope(sort, opened) + val scope = ShowFeedLogic.openingScope(sort, opened, podcast.type) if (!ShowFeedLogic.readsOnOpen(opened, scope)) { setStatus(podcast.id, ShowFeedLogic.openingStatus(opened)) return @@ -75,42 +114,64 @@ internal class PodcastInfoFeedFirst( */ suspend fun read(podcast: Podcast, scope: Scope, showStatus: Boolean = true, extra: Int = 0): ShowPageFeedRead? { if (showStatus) setStatus(podcast.id, ShowFeedLogic.readingStatus(scope)) - val read = repository.readShowPageFeed(podcast.id, ShowFeedLogic.metaOf(podcast), scope, podcast.feedUrl) + val read = source.read(podcast.id, ShowFeedLogic.metaOf(podcast), scope, podcast.feedUrl) if (ui.currentPodcastId() != podcast.id) return null archiveComplete = read.archiveComplete + when (read.result) { + ShowPageFeedResult.FALLBACK -> fellBack = true + ShowPageFeedResult.UPDATED, ShowPageFeedResult.UNCHANGED -> { + fellBack = false + waitingAtCopyEnd = false + } + ShowPageFeedResult.OFFLINE -> Unit + } reload(podcast, extra) - setStatus(podcast.id, ShowFeedLogic.statusAfter(read.result)) + setStatus(podcast.id, ShowFeedLogic.statusAfter(read)) return read } /** * Reads the rest of the feed before an action that needs every episode, when the copy holds * only the newest. The read runs in [scope], so a search the listener keeps typing into waits - * on the same read instead of starting it again. + * on the same read instead of starting it again. Nothing is read once the page fell back: the + * action uses the server's list until the page opens again. [atCopyEnd]: scrolling reached the + * end of the copy, which then waits for a connection when the read finds the phone offline. */ - suspend fun completeArchive(scope: CoroutineScope, podcast: Podcast, extra: Int = 0): ShowPageFeedRead? { - if (archiveComplete) return null + suspend fun completeArchive(scope: CoroutineScope, podcast: Podcast, extra: Int = 0, atCopyEnd: Boolean = false): ShowPageFeedRead? { + if (archiveComplete || fellBack) return null val running = archiveRead?.takeIf { it.isActive } ?: scope.async { read(podcast, Scope.ARCHIVE, extra = extra) }.also { archiveRead = it } - return running.await() + val read = running.await() + if (atCopyEnd && read?.result == ShowPageFeedResult.OFFLINE) waitingAtCopyEnd = true + return read } - @Volatile - private var archiveRead: Deferred? = null + /** + * True while scrolling past the copy waits for a connection: loading more does nothing until + * the phone is online again, so the end of the list doesn't keep retrying. + */ + fun holdsAtCopyEnd(): Boolean = waitingAtCopyEnd && !source.online() - /** Pages from the start until the list holds as many episodes as it showed, plus [extra]. */ + /** + * Pages from the start until the list holds as many episodes as it showed, plus [extra], and + * down to the episode that was last ([ShowFeedLogic.keepsLoading]), so newer episodes added + * above never push the rows the listener was on out of the list. + */ suspend fun reload(podcast: Podcast, extra: Int = 0) { val state = ui.state()?.takeIf { it.podcast.id == podcast.id } ?: return val oldest = state.currentSort == EpisodeSort.OLDEST val pageSize = if (oldest) OLDEST_PAGE_SIZE else PAGE_SIZE val wanted = ShowFeedLogic.reloadCount(state.piEpisodes.size, pageSize, extra) + val anchorId = state.piEpisodes.lastOrNull()?.id val episodes = mutableListOf() var offset = 0 + var more = true var hasMore = true - while (episodes.size < wanted && hasMore) { - val page = repository.getEpisodesPaginated(podcast.id, minOf(MAX_PAGE, wanted - episodes.size), offset, if (oldest) "oldest" else "newest") + while (more && ShowFeedLogic.keepsLoading(episodes, wanted, anchorId)) { + val page = source.page(podcast.id, minOf(MAX_PAGE, wanted - episodes.size).coerceAtLeast(MIN_PAGE), offset, if (oldest) "oldest" else "newest") episodes += page.episodes offset += page.sourceCount - hasMore = page.hasMore && page.sourceCount > 0 + more = page.hasMore && page.sourceCount > 0 + hasMore = ShowFeedLogic.hasMoreAfterPage(page.hasMore, page.sourceCount, archivePending = readsFeed(podcast.id) && !archiveComplete) } val latest = ui.state()?.takeIf { it.podcast.id == podcast.id && it.currentSort == state.currentSort } ?: return ui.setOffset(offset) @@ -126,5 +187,19 @@ internal class PodcastInfoFeedFirst( const val PAGE_SIZE = 20 const val OLDEST_PAGE_SIZE = 50 private const val MAX_PAGE = 100 + private const val MIN_PAGE = 20 + + /** The app's [Source]: the repository's feed-first calls and the show page's episode list. */ + fun sourceOf(repository: PodcastRepository): Source = object : Source { + override suspend fun open(podcastId: String, meta: PodcastMeta): ShowPageFeedState? = repository.openFeedShowPage(podcastId, meta) + + override suspend fun read(podcastId: String, meta: PodcastMeta, scope: Scope, savedFeedUrl: String?): ShowPageFeedRead = + repository.readShowPageFeed(podcastId, meta, scope, savedFeedUrl) + + override suspend fun page(podcastId: String, limit: Int, offset: Int, sort: String): PodcastRepository.EpisodePage = + repository.getShowPageEpisodes(podcastId, limit, offset, sort) + + override fun online(): Boolean = repository.feedFirst.online() + } } } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt index 95cbdf28e..9cd01a2a0 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoSupplementSupport.kt @@ -52,13 +52,13 @@ internal class PodcastInfoSupplementSupport( ) : this( episodeSupplementPort, { id, limit, offset, sort -> - repository.getEpisodesPaginated(id, limit, offset, sort) + repository.getShowPageEpisodes(id, limit, offset, sort) }, { id -> repository.loadPiEpisodesForSupplement(id) }, - // A feed-first page reads the feed itself, so it never offers "Missing episodes?" (P4 PR 16). + // A feed-first page reads the feed itself (pull to refresh), so it never offers "Missing + // episodes?", also after it fell back to the server's list (P4 PR 16). { id -> - repository.localEpisodeCatalog?.isReady(id) == true || - repository.showPageSource(id) == cx.aswin.boxlore.core.catalog.ShowPageSource.FEED + repository.localEpisodeCatalog?.isReady(id) == true || repository.isFeedFirstShowPage(id) }, ) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt index c5ccbade6..ab18405f5 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoUiModels.kt @@ -44,6 +44,9 @@ enum class ShowFeedStatus { /** No connection: the list is the stored copy, as it is. */ OFFLINE, + + /** No connection and no stored copy: nothing saved to show yet. */ + OFFLINE_NO_COPY, } @Immutable 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 3b5aa0278..fd1dd7eff 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 @@ -82,7 +82,7 @@ class PodcastInfoViewModel( /** The feed-first show page (P4 PR 16): reads, the status line and reloads as the list grows. */ private val feedFirst = PodcastInfoFeedFirst( - repository, + PodcastInfoFeedFirst.sourceOf(repository), object : PodcastInfoFeedFirst.Ui { override fun state(): PodcastInfoUiState.Success? = _uiState.value as? PodcastInfoUiState.Success @@ -490,7 +490,7 @@ class PodcastInfoViewModel( } val sortParam = if (newestFirst) "newest" else "oldest" val page = - repository.getEpisodesPaginated( + repository.getShowPageEpisodes( feedId = currentPodcastId, limit = MAX_SELECTION_EPISODES, offset = 0, @@ -662,7 +662,7 @@ class PodcastInfoViewModel( if (currentPodcast?.isRss == true) { val cachedPage = - repository.getEpisodesPaginated(effectivePodcastId, limit, 0, sortParam) + repository.getShowPageEpisodes(effectivePodcastId, limit, 0, sortParam) currentOffset = cachedPage.sourceCount val successState = PodcastInfoUiState.Success( @@ -682,10 +682,11 @@ class PodcastInfoViewModel( rssRepository.refreshCatalogIfNeeded(effectivePodcastId) } - // Feed-first show pages (P4 PR 16): without a stored copy the first page waits for the feed. + // Feed-first show pages (P4 PR 16): without a stored copy, boxlore's list shows while the + // feed is read; its rows carry the same IDs, so the list then grows without moving. val feedOpened = if (currentPodcast?.isRss == true) null else feedFirst.open(effectivePodcastId, currentPodcast) val (apiPodcast, page) = - fetchPodcastAndEpisodes(effectivePodcastId, limit, sortParam, skipEpisodes = feedOpened?.cached == false) + fetchPodcastAndEpisodes(effectivePodcastId, limit, sortParam) if (apiPodcast != null) { val apiPodcastWithFallback = @@ -725,7 +726,7 @@ class PodcastInfoViewModel( state = baseSuccess, piEpisodes = pageEpisodes, hasMoreEpisodes = page.hasMore, - isLoadingMore = feedOpened?.cached == false, + isLoadingMore = false, ) feedOpened?.let { opened -> launch { beginFeedFirst(apiPodcastWithFallback, initialSort, opened) } } @@ -851,20 +852,11 @@ class PodcastInfoViewModel( } } - /** - * Show details and the first page together. [skipEpisodes]: a feed-first page with no stored - * copy yet, whose first page comes from the feed read that follows (P4 PR 16). - */ private suspend fun fetchPodcastAndEpisodes( podcastId: String, limit: Int, sortParam: String, - skipEpisodes: Boolean = false, ): Pair = kotlinx.coroutines.coroutineScope { - if (skipEpisodes) { - return@coroutineScope repository.getPodcastDetails(podcastId) to - cx.aswin.boxlore.core.catalog.PodcastRepository.EpisodePage(emptyList(), hasMore = true) - } val apiPodcast: Podcast? val page: cx.aswin.boxlore.core.catalog.PodcastRepository.EpisodePage @@ -872,7 +864,7 @@ class PodcastInfoViewModel( apiPodcast = repository.getPodcastDetails(podcastId) if (apiPodcast != null) { val realId = apiPodcast.id - val episodesDeferred = async { repository.getEpisodesPaginated(realId, limit, 0, sortParam) } + val episodesDeferred = async { repository.getShowPageEpisodes(realId, limit, 0, sortParam) } page = episodesDeferred.await() } else { page = @@ -881,7 +873,7 @@ class PodcastInfoViewModel( } } else { val podcastDeferred = async { repository.getPodcastDetails(podcastId) } - val episodesDeferred = async { repository.getEpisodesPaginated(podcastId, limit, 0, sortParam) } + val episodesDeferred = async { repository.getShowPageEpisodes(podcastId, limit, 0, sortParam) } apiPodcast = podcastDeferred.await() page = episodesDeferred.await() } @@ -889,15 +881,16 @@ class PodcastInfoViewModel( } /** - * True when [feedId]'s page reads its feed (P4 PR 16). Search then runs on the phone over every - * episode, so a copy that holds only the newest is completed first. + * True when [feedId]'s search runs over the feed's own copy (P4 PR 16): the page reads its feed, + * so a copy that holds only the newest is completed first. False once the page fell back: search + * then runs over the server's list, with cached feed extras, and reads no feed. */ private suspend fun prepareFeedFirstSearch(feedId: String, podcast: Podcast): Boolean { - if (!feedFirst.isActive(feedId)) return false + if (!feedFirst.readsFeed(feedId)) return false if (ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.SEARCH)) { feedFirst.completeArchive(viewModelScope, podcast) } - return true + return feedFirst.readsFeed(feedId) && repository.showPageSource(feedId) == cx.aswin.boxlore.core.catalog.ShowPageSource.FEED } /** Clears the loading row when a feed-first read ended without reloading the page (it moved on, or the archive was already whole). */ @@ -988,6 +981,8 @@ class PodcastInfoViewModel( if (currentState !is PodcastInfoUiState.Success) return if (currentState.isLoadingMore || !currentState.hasMoreEpisodes) return if (currentState.searchResults != null) return // Don't load more when searching + // Feed-first (P4 PR 16): past the copy while offline, wait for a connection instead of retrying. + if (feedFirst.readsFeed(currentPodcastId) && feedFirst.holdsAtCopyEnd()) return _uiState.value = currentState.copy(isLoadingMore = true) @@ -995,12 +990,12 @@ class PodcastInfoViewModel( try { val limit = if (currentState.currentSort == EpisodeSort.OLDEST) OLDEST_PAGE_SIZE else PAGE_SIZE val sortParam = if (currentState.currentSort == EpisodeSort.OLDEST) "oldest" else "newest" - val page = repository.getEpisodesPaginated(currentPodcastId, limit, currentOffset, sortParam) - if (feedFirst.isActive(currentPodcastId) && + val page = repository.getShowPageEpisodes(currentPodcastId, limit, currentOffset, sortParam) + if (feedFirst.readsFeed(currentPodcastId) && ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.LOAD_MORE, limit, page.episodes.size) ) { // Past the stored newest episodes: read the whole feed, then show one more page. - feedFirst.completeArchive(viewModelScope, currentState.podcast, extra = limit) + feedFirst.completeArchive(viewModelScope, currentState.podcast, extra = limit, atCopyEnd = true) stopLoadingMore() return@launch } @@ -1197,7 +1192,7 @@ class PodcastInfoViewModel( val sortParam = if (activeInitialState.currentSort == EpisodeSort.OLDEST) "oldest" else "newest" val podcast = repository.getPodcastDetails(targetPodcastId) ?: activeInitialState.podcast - val page = repository.getEpisodesPaginated(targetPodcastId, limit, 0, sortParam) + val page = repository.getShowPageEpisodes(targetPodcastId, limit, 0, sortParam) if (!PodcastInfoPullRefreshLogic.shouldApply(currentPodcastId, targetPodcastId)) { return } @@ -1344,7 +1339,7 @@ class PodcastInfoViewModel( try { if (newSort == EpisodeSort.OLDEST && - feedFirst.isActive(currentPodcastId) && + feedFirst.readsFeed(currentPodcastId) && ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.SORT_OLDEST) ) { // The oldest episodes are only known once the whole feed is read. @@ -1354,7 +1349,7 @@ class PodcastInfoViewModel( } val limit = if (newSort == EpisodeSort.OLDEST) OLDEST_PAGE_SIZE else PAGE_SIZE val sortParam = if (newSort == EpisodeSort.OLDEST) "oldest" else "newest" - val page = repository.getEpisodesPaginated(currentPodcastId, limit, 0, sortParam) + val page = repository.getShowPageEpisodes(currentPodcastId, limit, 0, sortParam) currentOffset = page.sourceCount val latestState = _uiState.value as? PodcastInfoUiState.Success ?: return@launch _uiState.value = @@ -1421,6 +1416,7 @@ class PodcastInfoViewModel( artist = podcast.artist, ), // The feed's own copy holds every episode the feed has: no extras to add. + // After a fallback the server's list gets them, as without the flag. isRss = podcast.isRss || readsFeed, ) @@ -1487,6 +1483,9 @@ class PodcastInfoViewModel( podcast = updatedPodcast, isSubscribed = isSubscribed, ) + // Feed-first (P4 PR 16): once followed, the show's own catalog refresh (with auto-download) + // reads the feed; this page's reads would store its validators first. + if (isSubscribed && feedFirst.isActive(latestState.podcast.id)) feedFirst.stop() cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackPodcastSubscriptionToggled( podcastId = latestState.podcast.id, podcastName = latestState.podcast.title, @@ -1616,7 +1615,7 @@ class PodcastInfoViewModel( } val oldest = pageState.currentSort == EpisodeSort.OLDEST val page = - repository.getEpisodesPaginated( + repository.getShowPageEpisodes( feedId = targetPodcastId, limit = if (oldest) OLDEST_PAGE_SIZE else PAGE_SIZE, offset = 0, diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt index f0810e88f..1ce778bd1 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/ShowFeedStatusLine.kt @@ -37,6 +37,7 @@ internal fun showFeedStatusText(status: ShowFeedStatus): Int? = when (status) { ShowFeedStatus.COMPLETING_ARCHIVE -> R.string.podcast_info_feed_completing ShowFeedStatus.FALLBACK -> R.string.podcast_info_feed_fallback ShowFeedStatus.OFFLINE -> R.string.podcast_info_feed_offline + ShowFeedStatus.OFFLINE_NO_COPY -> R.string.podcast_info_feed_offline_empty } /** @@ -71,7 +72,7 @@ internal fun ShowFeedStatusLine(status: ShowFeedStatus, modifier: Modifier = Mod CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) } else { Icon( - imageVector = if (status == ShowFeedStatus.OFFLINE) Icons.Rounded.CloudOff else Icons.Rounded.Info, + imageVector = if (status == ShowFeedStatus.OFFLINE || status == ShowFeedStatus.OFFLINE_NO_COPY) Icons.Rounded.CloudOff else Icons.Rounded.Info, contentDescription = null, modifier = Modifier.size(20.dp), ) diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt index 8c4db760d..6877e65ad 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogic.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.info.logic +import cx.aswin.boxlore.core.catalog.ShowPageFeedRead import cx.aswin.boxlore.core.catalog.ShowPageFeedResult import cx.aswin.boxlore.core.catalog.ShowPageFeedState import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta @@ -18,10 +19,12 @@ import cx.aswin.boxlore.feature.info.ShowFeedStatus */ internal object ShowFeedLogic { /** - * The read a page starts as it opens: the whole archive for an oldest-first page (serial shows - * open oldest first) and for a feed that lists its oldest items first, else the newest items. + * The read a page starts as it opens: the whole archive for an oldest-first page, a serial show + * ([podcastType] `serial`, even when the page opens newest first because the phone has no saved + * sort for it) and a feed that lists its oldest items first, else the newest items. */ - fun openingScope(sort: EpisodeSort, opened: ShowPageFeedState): Scope = if (sort == EpisodeSort.OLDEST || opened.oldestFirst) Scope.ARCHIVE else Scope.NEWEST + fun openingScope(sort: EpisodeSort, opened: ShowPageFeedState, podcastType: String? = null): Scope = + if (sort == EpisodeSort.OLDEST || opened.oldestFirst || podcastType.equals(SERIAL, ignoreCase = true)) Scope.ARCHIVE else Scope.NEWEST /** * Whether opening reads the feed: always without a stored copy or once it is an hour old, and @@ -32,10 +35,10 @@ internal object ShowFeedLogic { /** The status line while a read of [scope] runs. */ fun readingStatus(scope: Scope): ShowFeedStatus = if (scope == Scope.ARCHIVE) ShowFeedStatus.COMPLETING_ARCHIVE else ShowFeedStatus.READING_NEWEST - /** The status line once a read ended with [result]. */ - fun statusAfter(result: ShowPageFeedResult): ShowFeedStatus = when (result) { + /** The status line once [read] ended: offline says whether saved episodes show at all. */ + fun statusAfter(read: ShowPageFeedRead): ShowFeedStatus = when (read.result) { ShowPageFeedResult.UPDATED, ShowPageFeedResult.UNCHANGED -> ShowFeedStatus.NONE - ShowPageFeedResult.OFFLINE -> ShowFeedStatus.OFFLINE + ShowPageFeedResult.OFFLINE -> if (read.hasCopy) ShowFeedStatus.OFFLINE else ShowFeedStatus.OFFLINE_NO_COPY ShowPageFeedResult.FALLBACK -> ShowFeedStatus.FALLBACK } @@ -60,6 +63,24 @@ internal object ShowFeedLogic { */ fun reloadCount(showing: Int, pageSize: Int, extra: Int = 0): Int = maxOf(showing + extra, pageSize) + /** + * Whether a reload pages on: until it holds [wanted] episodes, and then until the episode that + * was last on the page ([anchorId]) is back, so episodes added above (at most [maxNewAbove], one + * read of the newest items) never push the rows the listener was on out of the list. + */ + fun keepsLoading(loaded: List, wanted: Int, anchorId: String?, maxNewAbove: Int = NEWEST_READ_ITEMS): Boolean { + if (loaded.size < wanted) return true + if (anchorId == null || loaded.size >= wanted + maxNewAbove) return false + return loaded.none { it.id == anchorId } + } + + /** + * Whether the reloaded page has more: what the last page said, except that a stored copy that + * ran out while the archive is still unread ([archivePending]) has more to come from the feed. + */ + fun hasMoreAfterPage(pageHasMore: Boolean, pageCount: Int, archivePending: Boolean): Boolean = + if (pageCount > 0) pageHasMore else pageHasMore && archivePending + /** Stored-copy episodes with the show's details filled in where the copy has none. */ fun withShow(episodes: List, podcast: Podcast): List = episodes.map { episode -> if (!episode.podcastTitle.isNullOrBlank() && !episode.podcastImageUrl.isNullOrBlank()) return@map episode @@ -81,4 +102,9 @@ internal object ShowFeedLogic { ) enum class ArchiveAction { SEARCH, SORT_OLDEST, LOAD_MORE } + + private const val SERIAL = "serial" + + /** The most episodes one read of the newest items adds above the list. */ + private const val NEWEST_READ_ITEMS = 100 } diff --git a/feature/info/src/main/res/values/strings.xml b/feature/info/src/main/res/values/strings.xml index 89d2c76a8..9efa29150 100644 --- a/feature/info/src/main/res/values/strings.xml +++ b/feature/info/src/main/res/values/strings.xml @@ -122,6 +122,7 @@ Loading all episodes from the feed… Can’t reach this show’s feed. Showing boxlore’s episode list instead. You’re offline. Showing saved episodes. + You’re offline. Connect to load this show’s episodes. Play trailer Support Select episode diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirstTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirstTest.kt new file mode 100644 index 000000000..f58aa843b --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/PodcastInfoFeedFirstTest.kt @@ -0,0 +1,208 @@ +package cx.aswin.boxlore.feature.info + +import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.catalog.ShowPageFeedRead +import cx.aswin.boxlore.core.catalog.ShowPageFeedResult +import cx.aswin.boxlore.core.catalog.ShowPageFeedState +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.PodcastMeta +import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope +import cx.aswin.boxlore.core.model.Episode +import cx.aswin.boxlore.core.testing.TestFixtures +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The feed-first show page's reads and reloads ([PodcastInfoFeedFirst]) against a scripted source: + * a fallback stops further feed reads until the page opens again, newer episodes above keep the + * rows the listener was on, offline without a copy says so, the end of the copy waits for a + * connection, concurrent searches share one archive read, and following hands the page over. + */ +class PodcastInfoFeedFirstTest { + private val source = FakeSource() + private val ui = FakeUi() + private val feedFirst = PodcastInfoFeedFirst(source, ui) + private val podcast = TestFixtures.podcast(id = SHOW, title = "Show") + + @Test + fun `after a fallback, search, the oldest sort and scrolling read no feed until the page opens again`() = runTest { + source.list = episodes(0 until 20) + feedFirst.open(SHOW, podcast) + ui.show(podcast, source.list) + source.results += ShowPageFeedResult.FALLBACK + + assertEquals(ShowPageFeedResult.FALLBACK, feedFirst.completeArchive(this, podcast)?.result) + repeat(3) { assertNull(feedFirst.completeArchive(this, podcast), "no read after the fallback") } + + assertEquals(1, source.reads.size) + assertTrue(feedFirst.isActive(SHOW), "pull to refresh still reads the feed") + assertFalse(feedFirst.readsFeed(SHOW)) + assertEquals(ShowFeedStatus.FALLBACK, ui.current?.feedStatus) + + feedFirst.open(SHOW, podcast) + assertTrue(feedFirst.readsFeed(SHOW), "opening the page tries the feed again") + } + + @Test + fun `newer episodes above keep the rows the listener was on`() = runTest { + source.list = episodes(0 until 100) + feedFirst.open(SHOW, podcast) + ui.show(podcast, source.list.take(40)) + // The read finds five newer episodes, which go above the list. + source.onRead = { source.list = episodes(-5 until 0) + source.list } + + feedFirst.read(podcast, Scope.NEWEST) + + val shown = requireNotNull(ui.current).piEpisodes.map { it.id } + assertEquals("e-5", shown.first()) + assertEquals(44, shown.indexOf("e39"), "the row that was last is still in the list, below the five new ones") + assertEquals(shown.size, ui.lastOffset) + } + + @Test + fun `offline without a stored copy never says saved episodes show`() = runTest { + feedFirst.open(SHOW, podcast) + ui.show(podcast, emptyList()) + source.results += ShowPageFeedResult.OFFLINE + source.hasCopy = false + + feedFirst.read(podcast, Scope.NEWEST) + + assertEquals(ShowFeedStatus.OFFLINE_NO_COPY, ui.current?.feedStatus) + } + + @Test + fun `the end of the copy waits for a connection, and the list stays open for it`() = runTest { + source.list = episodes(0 until 20) + source.archivePending = true + feedFirst.open(SHOW, podcast) + ui.show(podcast, source.list) + source.results += ShowPageFeedResult.OFFLINE + source.online = false + + feedFirst.completeArchive(this, podcast, extra = 20, atCopyEnd = true) + + assertTrue(feedFirst.holdsAtCopyEnd()) + assertEquals(ShowFeedStatus.OFFLINE, ui.current?.feedStatus) + assertTrue(requireNotNull(ui.current).hasMoreEpisodes, "scrolling picks up again once online") + source.online = true + assertFalse(feedFirst.holdsAtCopyEnd()) + } + + @Test + fun `searches typed while the archive reads wait on that one read`() = runTest { + source.list = episodes(0 until 20) + feedFirst.open(SHOW, podcast) + ui.show(podcast, source.list) + val gate = CompletableDeferred() + source.gate = gate + source.results += ShowPageFeedResult.UPDATED + source.archiveAfterRead = true + + val first = async { feedFirst.completeArchive(this@runTest, podcast) } + val second = async { feedFirst.completeArchive(this@runTest, podcast) } + testScheduler.advanceUntilIdle() + gate.complete(Unit) + + assertEquals(ShowPageFeedResult.UPDATED, first.await()?.result) + assertEquals(ShowPageFeedResult.UPDATED, second.await()?.result) + assertEquals(listOf(Scope.ARCHIVE), source.reads) + assertTrue(feedFirst.archiveComplete) + assertNull(feedFirst.completeArchive(this, podcast), "a whole archive is never read for search again") + } + + @Test + fun `a read that ends after the page moved to another show changes nothing`() = runTest { + source.list = episodes(0 until 20) + feedFirst.open(SHOW, podcast) + ui.show(podcast, source.list) + val before = ui.current + source.onRead = { ui.podcastId = "other" } + + assertNull(feedFirst.read(podcast, Scope.NEWEST)) + assertEquals(before?.piEpisodes, ui.current?.piEpisodes) + } + + @Test + fun `a serial show reads its archive as it opens, and following hands the page over`() = runTest { + source.list = episodes(0 until 20) + val serial = podcast.copy(type = "serial") + val opened = requireNotNull(feedFirst.open(SHOW, serial)) + ui.show(serial, source.list) + source.results += ShowPageFeedResult.UPDATED + + feedFirst.begin(serial, EpisodeSort.NEWEST, opened) + + assertEquals(listOf(Scope.ARCHIVE), source.reads) + feedFirst.stop() + assertFalse(feedFirst.isActive(SHOW)) + assertFalse(feedFirst.readsFeed(SHOW)) + } + + private class FakeSource : PodcastInfoFeedFirst.Source { + var list: List = emptyList() + var archivePending = false + var archiveAfterRead = false + var hasCopy = true + var online = true + var gate: CompletableDeferred? = null + var onRead: () -> Unit = {} + val results = ArrayDeque() + val reads = mutableListOf() + private var archiveComplete = false + + override suspend fun open(podcastId: String, meta: PodcastMeta): ShowPageFeedState = + ShowPageFeedState(cached = list.isNotEmpty(), archiveComplete = archiveComplete, recheckDue = false, oldestFirst = false) + + override suspend fun read(podcastId: String, meta: PodcastMeta, scope: Scope, savedFeedUrl: String?): ShowPageFeedRead { + reads += scope + gate?.await() + onRead() + if (archiveAfterRead && scope == Scope.ARCHIVE) archiveComplete = true + return ShowPageFeedRead(results.removeFirstOrNull() ?: ShowPageFeedResult.UPDATED, archiveComplete, hasCopy) + } + + override suspend fun page(podcastId: String, limit: Int, offset: Int, sort: String): PodcastRepository.EpisodePage { + val page = list.drop(offset).take(limit) + return PodcastRepository.EpisodePage(page, hasMore = offset + page.size < list.size || archivePending, sourceCount = page.size) + } + + override fun online(): Boolean = online + } + + private class FakeUi : PodcastInfoFeedFirst.Ui { + var current: PodcastInfoUiState.Success? = null + var podcastId = SHOW + var lastOffset = -1 + + fun show(podcast: cx.aswin.boxlore.core.model.Podcast, episodes: List) { + current = PodcastInfoUiState.Success(podcast = podcast, episodes = episodes, piEpisodes = episodes, isSubscribed = false) + } + + override fun state(): PodcastInfoUiState.Success? = current + + override fun set(state: PodcastInfoUiState.Success) { + current = state + } + + override fun currentPodcastId(): String = podcastId + + override fun setOffset(offset: Int) { + lastOffset = offset + } + + override suspend fun remount(state: PodcastInfoUiState.Success, episodes: List, hasMore: Boolean): PodcastInfoUiState.Success = + state.copy(episodes = episodes, piEpisodes = episodes, hasMoreEpisodes = hasMore) + } + + private companion object { + const val SHOW = "920666" + + fun episodes(range: IntProgression): List = range.map { TestFixtures.episode(id = "e$it", podcastId = SHOW) } + } +} diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt index f74852150..79e0cd22b 100644 --- a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/logic/ShowFeedLogicTest.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.info.logic +import cx.aswin.boxlore.core.catalog.ShowPageFeedRead import cx.aswin.boxlore.core.catalog.ShowPageFeedResult import cx.aswin.boxlore.core.catalog.ShowPageFeedState import cx.aswin.boxlore.core.domain.ports.ShowPageFeedPort.Scope @@ -19,6 +20,8 @@ import org.junit.jupiter.api.Test class ShowFeedLogicTest { private val fresh = ShowPageFeedState(cached = true, archiveComplete = false, recheckDue = false, oldestFirst = false) + private fun read(result: ShowPageFeedResult, hasCopy: Boolean = true) = ShowPageFeedRead(result, archiveComplete = false, hasCopy = hasCopy) + @Test fun `a page opens on the newest items unless it lists oldest first`() { assertEquals(Scope.NEWEST, ShowFeedLogic.openingScope(EpisodeSort.NEWEST, fresh)) @@ -26,6 +29,14 @@ class ShowFeedLogicTest { assertEquals(Scope.ARCHIVE, ShowFeedLogic.openingScope(EpisodeSort.NEWEST, fresh.copy(oldestFirst = true))) } + @Test + fun `a serial show reads its whole archive even when its page opens newest first`() { + // An unfollowed show has no saved sort, so its page opens newest first. + assertEquals(Scope.ARCHIVE, ShowFeedLogic.openingScope(EpisodeSort.NEWEST, fresh, podcastType = "serial")) + assertEquals(Scope.ARCHIVE, ShowFeedLogic.openingScope(EpisodeSort.NEWEST, fresh, podcastType = "Serial")) + assertEquals(Scope.NEWEST, ShowFeedLogic.openingScope(EpisodeSort.NEWEST, fresh, podcastType = "episodic")) + } + @Test fun `a recent copy is shown without a read, an old one or a missing archive is read`() { assertFalse(ShowFeedLogic.readsOnOpen(fresh, Scope.NEWEST)) @@ -40,10 +51,16 @@ class ShowFeedLogicTest { fun `the status line follows the read`() { assertEquals(ShowFeedStatus.READING_NEWEST, ShowFeedLogic.readingStatus(Scope.NEWEST)) assertEquals(ShowFeedStatus.COMPLETING_ARCHIVE, ShowFeedLogic.readingStatus(Scope.ARCHIVE)) - assertEquals(ShowFeedStatus.NONE, ShowFeedLogic.statusAfter(ShowPageFeedResult.UPDATED)) - assertEquals(ShowFeedStatus.NONE, ShowFeedLogic.statusAfter(ShowPageFeedResult.UNCHANGED)) - assertEquals(ShowFeedStatus.OFFLINE, ShowFeedLogic.statusAfter(ShowPageFeedResult.OFFLINE)) - assertEquals(ShowFeedStatus.FALLBACK, ShowFeedLogic.statusAfter(ShowPageFeedResult.FALLBACK)) + assertEquals(ShowFeedStatus.NONE, ShowFeedLogic.statusAfter(read(ShowPageFeedResult.UPDATED))) + assertEquals(ShowFeedStatus.NONE, ShowFeedLogic.statusAfter(read(ShowPageFeedResult.UNCHANGED))) + assertEquals(ShowFeedStatus.OFFLINE, ShowFeedLogic.statusAfter(read(ShowPageFeedResult.OFFLINE))) + assertEquals(ShowFeedStatus.FALLBACK, ShowFeedLogic.statusAfter(read(ShowPageFeedResult.FALLBACK))) + } + + @Test + fun `offline without a stored copy never says saved episodes show`() { + assertEquals(ShowFeedStatus.OFFLINE_NO_COPY, ShowFeedLogic.statusAfter(read(ShowPageFeedResult.OFFLINE, hasCopy = false))) + assertEquals(R.string.podcast_info_feed_offline_empty, showFeedStatusText(ShowFeedStatus.OFFLINE_NO_COPY)) } @Test @@ -72,6 +89,25 @@ class ShowFeedLogicTest { assertEquals(120, ShowFeedLogic.reloadCount(showing = 100, pageSize = 20, extra = 20)) } + @Test + fun `a reload pages on until the episode that was last is back, at most one newest read further`() { + val loaded = (0 until 40).map { TestFixtures.episode(id = "e$it") } + assertTrue(ShowFeedLogic.keepsLoading(loaded.take(10), wanted = 40, anchorId = "e39")) + assertFalse(ShowFeedLogic.keepsLoading(loaded, wanted = 40, anchorId = "e39")) + // Five newer episodes arrived above: the old last row is now 45th. + assertTrue(ShowFeedLogic.keepsLoading(loaded, wanted = 40, anchorId = "e44")) + assertFalse(ShowFeedLogic.keepsLoading(loaded, wanted = 40, anchorId = "gone", maxNewAbove = 0), "at most one newest read past the rows that showed") + assertFalse(ShowFeedLogic.keepsLoading(loaded, wanted = 40, anchorId = null)) + } + + @Test + fun `a copy that ran out keeps the list open while its archive is unread`() { + assertTrue(ShowFeedLogic.hasMoreAfterPage(pageHasMore = true, pageCount = 0, archivePending = true)) + assertFalse(ShowFeedLogic.hasMoreAfterPage(pageHasMore = true, pageCount = 0, archivePending = false)) + assertTrue(ShowFeedLogic.hasMoreAfterPage(pageHasMore = true, pageCount = 20, archivePending = false)) + assertFalse(ShowFeedLogic.hasMoreAfterPage(pageHasMore = false, pageCount = 20, archivePending = true)) + } + @Test fun `stored-copy episodes take the show's details only where they have none`() { val podcast = TestFixtures.podcast(id = "920666", title = "Curious world", imageUrl = "https://img.example/show.jpg") From bbe726c80cab261d8cdbbc77848cfdd14c73c535 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 23:45:42 +0530 Subject: [PATCH 5/9] fix(catalog): keep newer notification requests and older feed namespaces --- app/README.md | 2 +- .../PodcastNotificationRegistrationWorker.kt | 17 +++++-- ...dcastNotificationRegistrationWorkerTest.kt | 46 +++++++++++++++++-- core/catalog/README.md | 2 +- .../core/catalog/FeedAddressUpdater.kt | 13 ++++-- .../PendingNotificationRegistrations.kt | 36 ++++++++++----- ...cesPendingNotificationRegistrationsTest.kt | 23 +++++++++- core/database/README.md | 2 +- .../core/database/LocalEpisodeCatalogDao.kt | 7 +++ core/rss/README.md | 2 +- .../core/rss/LocalEpisodeCatalogRefresh.kt | 20 +++++--- .../cx/aswin/boxlore/core/rss/FeedMoveTest.kt | 10 ++++ .../core/rss/LegacyIdentityRoomTest.kt | 42 +++++++++++++++++ .../rss/LocalEpisodeCatalogPersistTest.kt | 16 +++++++ .../rss/LocalEpisodeCatalogRepositoryTest.kt | 2 + 15 files changed, 207 insertions(+), 33 deletions(-) diff --git a/app/README.md b/app/README.md index b0a168ab2..dfa615f78 100644 --- a/app/README.md +++ b/app/README.md @@ -68,7 +68,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - `LegacyWorkerFactory` maps legacy worker class names to current worker implementations for WorkManager continuity. - `MainActivity` hosts theme, edge-to-edge setup, app update hooks, surveys, library import state (full-screen `OpmlImportDialog` overlay so onboarding welcome never shows through), the selected floating or classic navigation presentation, and the player overlay. Its start/stop lifecycle informs the application-scoped playback repository whether UI position polling is needed; background audio, persistence, notifications, Cast, and MediaSession behavior remain service-owned. - **Feed fix off switches** (`sync/FeedFixSwitches`): PostHog kill switches for the feed and episode fixes, passed into core modules as lambdas. A fix runs unless its flag is on; no answer (PostHog not ready, flag missing or inactive, an error) keeps it on. `feed-fix-match-baseline-off` sends the feed-only extras' comparison list back from the 1,000-item `episodes/match` list to the 100-item page (`PodcastRepository.useEpisodeMatchBaseline`). `feed-fix-address-recheck-off`, `feed-fix-sync-fallback-off` and `feed-fix-moved-feed-off` turn off the subscription sync's dead-address re-check, its `/sync` fallback for failed feeds, and following moved feeds (`FeedFixToggles`). `FeedFixSwitchesTest` covers the default. The same fail-open switches pause the v2 build's jobs: `upgrade-repair-off` (the one-time repair of saved feed addresses after the update), `address-check-off` (the daily address check of followed shows), both without marking the work done, and `v2-discovery-off` (discovery back on the v1 routes). These three are read through `sync/SavedKillSwitch`, because the repair can start on a headless launch before PostHog is set up: once PostHog is ready its answer decides and is saved; while it is still starting, the last saved answer is used, and the job runs only when none was saved. -- **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the other switches have no reader yet. +- **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the daily address check and broken-feed reports read `addressCheck` (`address-check-off`, through `SubscriptionForegroundSync`, below); `feed_first` and `upgradeRepair` have no reader yet. - **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). - **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`. - **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, `feedTelemetry`, and pending registrations in SharedPreferences `boxlore_notification_reregistrations`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs. It records each outcome (`settle`): a show it gives up on stays pending and the next foreground pass queues it again; `rss:` shows keep their own journal worker. diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt b/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt index c50139277..38d7f8a0b 100644 --- a/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt +++ b/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt @@ -36,13 +36,22 @@ class PodcastNotificationRegistrationWorker(context: Context, params: WorkerPara SharedAppDependenciesHolder.require().subscriptionRepository.registerPodcastNotifications(podcastId) } - /** Records each run's outcome: only an acknowledgement (or a refusal) clears the show's pending mark. */ - internal var settle: (podcastId: String, outcome: NotificationRegistrationOutcome) -> Unit = { podcastId, outcome -> - SharedAppDependenciesHolder.require().subscriptionRepository.feedAddresses.registrationSettled(podcastId, outcome) + /** The show's pending request, read before registering so [settle] clears only that one. */ + internal var pendingMark: (podcastId: String) -> Long? = { podcastId -> + SharedAppDependenciesHolder.require().subscriptionRepository.feedAddresses.pendingMark(podcastId) + } + + /** + * Records each run's outcome: only an acknowledgement (or a refusal) clears the show's pending + * request, and only the one this run read ([pendingMark]); a newer address change stays pending. + */ + internal var settle: (podcastId: String, outcome: NotificationRegistrationOutcome, mark: Long?) -> Unit = { podcastId, outcome, mark -> + SharedAppDependenciesHolder.require().subscriptionRepository.feedAddresses.registrationSettled(podcastId, outcome, mark) } override suspend fun doWork(): Result { val podcastId = inputData.getString(KEY_PODCAST_ID)?.takeIf { it.isNotBlank() && !it.startsWith("rss:") } ?: return Result.failure() + val mark = pendingMark(podcastId) val outcome = try { register(podcastId) @@ -51,7 +60,7 @@ class PodcastNotificationRegistrationWorker(context: Context, params: WorkerPara } catch (_: Exception) { NotificationRegistrationOutcome.RETRY } - settle(podcastId, outcome) + settle(podcastId, outcome, mark) return when (outcome) { NotificationRegistrationOutcome.DONE, NotificationRegistrationOutcome.DROPPED -> Result.success() NotificationRegistrationOutcome.RETRY -> if (runAttemptCount + 1 < MAX_ATTEMPTS) Result.retry() else Result.failure() diff --git a/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt b/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt index a19bc5b4d..ac731847f 100644 --- a/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt @@ -89,6 +89,7 @@ class PodcastNotificationRegistrationWorkerTest { pendingRegistrations = pending, ) val last = worker(NotificationRegistrationOutcome.RETRY, attempt = PodcastNotificationRegistrationWorker.MAX_ATTEMPTS - 1) + last.pendingMark = updater::pendingMark last.settle = updater::registrationSettled assertEquals(ListenableWorker.Result.failure(), last.doWork()) @@ -98,7 +99,11 @@ class PodcastNotificationRegistrationWorkerTest { val queued = WorkManager.getInstance(context).getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("920666")).get() assertEquals(1, queued.count { it.state == WorkInfo.State.ENQUEUED }) - val acknowledged = worker(NotificationRegistrationOutcome.DONE).apply { settle = updater::registrationSettled } + val acknowledged = + worker(NotificationRegistrationOutcome.DONE).apply { + pendingMark = updater::pendingMark + settle = updater::registrationSettled + } assertEquals(ListenableWorker.Result.success(), acknowledged.doWork()) assertTrue(pending.all().isEmpty()) } finally { @@ -106,13 +111,44 @@ class PodcastNotificationRegistrationWorkerTest { } } + @Test + fun `a job that read an older change never clears the newer one made while it ran`() = runBlocking { + val database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + try { + val pending = MemoryPendingNotificationRegistrations().apply { add("920666") } + val updater = FeedAddressUpdater(database.podcastDao(), pendingRegistrations = pending) + val older = + worker(NotificationRegistrationOutcome.DONE).apply { + pendingMark = updater::pendingMark + register = { + pending.add("920666") // a newer address change lands while this run registers the old one + NotificationRegistrationOutcome.DONE + } + settle = updater::registrationSettled + } + + assertEquals(ListenableWorker.Result.success(), older.doWork()) + assertEquals("the newer change waits for its own job", setOf("920666"), pending.all()) + + val newer = + worker(NotificationRegistrationOutcome.DONE).apply { + pendingMark = updater::pendingMark + settle = updater::registrationSettled + } + assertEquals(ListenableWorker.Result.success(), newer.doWork()) + assertTrue(pending.all().isEmpty()) + } finally { + database.close() + } + } + @Test fun `an error counts as a retry, and work without a show fails without registering`() = runBlocking { val settled = mutableListOf() val failing = worker(NotificationRegistrationOutcome.DONE).apply { register = { error("Firebase not ready") } - settle = { _, outcome -> settled += outcome } + settle = { _, outcome, _ -> settled += outcome } } assertEquals(ListenableWorker.Result.retry(), failing.doWork()) assertEquals(listOf(NotificationRegistrationOutcome.RETRY), settled) @@ -123,7 +159,8 @@ class PodcastNotificationRegistrationWorkerTest { calls++ NotificationRegistrationOutcome.DONE } - settle = { _, _ -> calls++ } + pendingMark = { null } + settle = { _, _, _ -> calls++ } } assertEquals(ListenableWorker.Result.failure(), empty.doWork()) assertEquals(0, calls) @@ -139,6 +176,7 @@ class PodcastNotificationRegistrationWorkerTest { assertEquals("920666", id) outcome } - settle = { _, _ -> } + pendingMark = { null } + settle = { _, _, _ -> } } } diff --git a/core/catalog/README.md b/core/catalog/README.md index 13d40c3d2..71d1ffb2a 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -11,7 +11,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su Failed consent writes keep activation off, and local notification flags prevent a failed revoke from implicitly re-enabling after restart. RSS sync isolates each feed and runs alongside PI sync; the source adapter explicitly implements every catalog operation. - `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, 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`); 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). +- **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, 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). - `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 15 a day in all, below the API's 20 so the show page has room (`FeedReportLimiter`; 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. 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 662f433d9..9bf6dbcb9 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 @@ -168,9 +168,16 @@ class FeedAddressUpdater( pendingRegistrations.all().filterNot { it.startsWith("rss:") }.forEach(::requestRegistration) } - /** Records what an acknowledged registration achieved: only a retry stays pending. */ - fun registrationSettled(podcastId: String, outcome: NotificationRegistrationOutcome) { - if (outcome != NotificationRegistrationOutcome.RETRY) pendingRegistrations.remove(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) + + /** + * Records what an acknowledged registration achieved: only a retry stays pending. Only the + * request the job read before registering ([mark], see [pendingMark]) is cleared, so an address + * change made while it ran stays pending for the job that replaced it. + */ + fun registrationSettled(podcastId: String, outcome: NotificationRegistrationOutcome, mark: Long? = pendingMark(podcastId)) { + if (outcome != NotificationRegistrationOutcome.RETRY && mark != null) pendingRegistrations.remove(podcastId, mark) } private fun requestRegistration(podcastId: String) { diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt index 2c930011d..6eda3b17d 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt @@ -15,33 +15,42 @@ import android.content.SharedPreferences interface PendingNotificationRegistrations { fun all(): Set + /** [podcastId]'s pending request, or null when none is pending; each [add] gives a new mark. */ + fun mark(podcastId: String): Long? + fun add(podcastId: String) - fun remove(podcastId: String) + /** Clears [podcastId] only while its request is still [mark], so a newer address change stays pending. */ + fun remove(podcastId: String, mark: Long) } /** [PendingNotificationRegistrations] for one process only (tests, and callers built without the app's wiring). */ class MemoryPendingNotificationRegistrations : PendingNotificationRegistrations { - private val ids = LinkedHashSet() + private val marks = LinkedHashMap() + private var lastMark = 0L + + @Synchronized + override fun all(): Set = marks.keys.toSet() @Synchronized - override fun all(): Set = ids.toSet() + override fun mark(podcastId: String): Long? = marks[podcastId] @Synchronized override fun add(podcastId: String) { - ids += podcastId + marks[podcastId] = ++lastMark } @Synchronized - override fun remove(podcastId: String) { - ids -= podcastId + override fun remove(podcastId: String, mark: Long) { + if (marks[podcastId] == mark) marks -= podcastId } } /** * [PendingNotificationRegistrations] in SharedPreferences file [FILE_NAME]: one key per show ID, - * the time it was asked for (epoch ms) as its value. A restored copy only registers those shows - * again, which is harmless. Stable storage name: never rename. + * the time it was asked for (epoch ms) as its value and mark, always later than the mark it + * replaces. A restored copy only registers those shows again, which is harmless. Stable storage + * name: never rename. * * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). * COMPAT(own-ids): notifications follow a corrected address. Permanent: never remove (MIGRATION.md §5). @@ -52,14 +61,19 @@ class SharedPreferencesPendingNotificationRegistrations( ) : PendingNotificationRegistrations { override fun all(): Set = prefs.all.keys.toSet() + override fun mark(podcastId: String): Long? = (prefs.all[podcastId] as? Long) + + @Synchronized @SuppressLint("ApplySharedPref") override fun add(podcastId: String) { + val mark = maxOf(nowMillis(), (mark(podcastId) ?: 0L) + 1) // Written to disk at once: the address change that needs it follows in the same call. - prefs.edit().putLong(podcastId, nowMillis()).commit() + prefs.edit().putLong(podcastId, mark).commit() } - override fun remove(podcastId: String) { - prefs.edit().remove(podcastId).apply() + @Synchronized + override fun remove(podcastId: String, mark: Long) { + if (mark(podcastId) == mark) prefs.edit().remove(podcastId).apply() } companion object { diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt index 3c095ff5a..fbe69ec1e 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/SharedPreferencesPendingNotificationRegistrationsTest.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.core.catalog import android.content.Context import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -25,8 +26,28 @@ class SharedPreferencesPendingNotificationRegistrationsTest { val nextProcess = store() assertEquals(setOf("920666", "75075"), nextProcess.all()) - nextProcess.remove("920666") + nextProcess.remove("920666", nextProcess.mark("920666")!!) assertEquals(setOf("75075"), store().all()) assertEquals("boxlore_notification_reregistrations", SharedPreferencesPendingNotificationRegistrations.FILE_NAME) } + + @Test + fun `a newer request gets a later mark and survives a settle of the older one`() { + val now = 1_000L + val store = + SharedPreferencesPendingNotificationRegistrations( + context.getSharedPreferences(SharedPreferencesPendingNotificationRegistrations.FILE_NAME, Context.MODE_PRIVATE), + nowMillis = { now }, + ) + store.add("920666") + val older = store.mark("920666")!! + store.add("920666") // the same millisecond + val newer = store.mark("920666")!! + assertTrue(newer > older) + + store.remove("920666", older) + assertEquals(setOf("920666"), store.all()) + store.remove("920666", newer) + assertTrue(store.all().isEmpty()) + } } diff --git a/core/database/README.md b/core/database/README.md index 8b54792e6..38cbe13b2 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -13,7 +13,7 @@ Owns the main Room database, entities, DAOs, type converters, and migrations for - `BoxLoreDatabase` and its `getDatabase` factory. - Entities: `PodcastEntity`, `ListeningHistoryEntity`, `ListeningSessionEntity`, `ListeningRollupEntity`, `DownloadedEpisodeEntity`, `RssEpisodeEntity`, `EpisodeSupplementEntity`, `EpisodeSupplementItemEntity`, `LocalEpisodeFeedEntity`, `LocalEpisodeEntity`, `FolderEntity`, `PodcastFolderCrossRef`, `AutoDownloadShowEntity`, `AutoDownloadReleaseEntity`, `EpisodeAliasEntity`, `EpisodeGroupStateEntity`, `UpgradeRepairShowEntity`, `entities.QueueItem`, and `entities.QueueMetadataEntity`. String values for alias states, alias provenance and repair states live in `EpisodeAliasState`, `EpisodeAliasProvenance` and `UpgradeRepairState`; stored values are never renamed. - `EpisodeListenerStore` (from `BoxLoreDatabase.episodeListenerStore()`, so the playback service reaches it without `AppContainer`) keeps one listener state per episode across its stored IDs ([`docs/EPISODE_IDS.md`](../../docs/EPISODE_IDS.md) §5). An `EpisodeGroup` is every trusted alias of one (show, catalog key), owner first; any other ID is a group of its own and reads and writes exactly as before aliases existed. It works out the owner (the stored mark while that ID holds listener state, else the v39 upgrade's order through `EpisodeIdentityDao.chooseOwner`) and stores it in the transaction of the write that needs it (`historyId`). Reads project (`EpisodeListenerProjection`): the owner's row is the base, and progress, position and play time come from the most recently played row (the owner's, unless a pull, a restore or older-build data put later listening under another ID; an owner that holds only a like never hides another ID's progress); liked and completed are the latest decision across the rows and `episode_group_state`, a group setting winning a tie, so an older alias row can't bring back a cleared like or completion; an episode with one stored ID reads its own row unchanged. `projectRows`, `projectedRows` and `projectCompletedIds` count each episode once (the shared-alias query is watched as a Flow, so episodes with one ID cost nothing extra per emission); `projectCompletedIds` also counts an episode that only its group settings mark completed. `projectRowsByMember`, `projectedRowsByMember` and `projectedLikedIds` list an episode once per stored ID, for screens that look an episode up by the ID they show. `lastPlayedSession` is the projected last session for restores and Android Auto. Writes: `setLiked` writes an absolute like to every trusted ID and the group settings (an episode with one stored ID keeps the clock's time on its row, as before); `recordLike`, `recordCompletion` and the batched `recordCompletions` record a decision after the caller wrote the owner's row (an episode with one ID and no row records nothing); `historyIds` works out owners in batches; `removeGroupState`, `restoreGroupState` and `clearGroupStates` remove an episode's settings with its history and put them back on Undo; `downloadHolder(s)` names the ID whose completed download plays an episode; `memberIds`, `withAllAliases`, `groupTokens` and `queueIdentities` serve the queue, Smart Queue, Smart Downloads, sync and ranking; `queueRemovalIds` names the IDs a queue removal records: the removed ones and every other trusted ID of their episodes, queued here or not (a cloud sync pull keeps one queue entry per episode, so the cloud can still hold another), except the IDs of an episode that stays queued or plays; `withAllAliasesFlow`, `memberIdsFlow` and `aliasGroupsFlow` expand IDs to every trusted ID of their episodes in memory from the watched shared aliases, so download and queue indicators read an episode under the ID a screen shows. `ownerRow` gives a new history row written under another ID of the playing episode (its owner) the owner's own show (its stored row, else its catalog row, else its alias), and `keepingOwnerMetadata` keeps a stored owner row's show and episode metadata when another ID's page saves progress, so the owner row of a show switched to its feed stays under the Podcast Index show it belongs to. `showReplacementsFlow` maps each Podcast Index show a followed RSS show replaced (and the listener no longer follows) to that RSS show, for listening read by show. Like times in the group settings stay monotonic within an episode even when the clock reads earlier than a recorded decision. -- DAOs: `EpisodeIdentityDao` (alias and group-state reads and writes, `upsertGroupStates`, `getGroupStates`, `completedGroupStates`, `deleteGroupState`, `deleteAllGroupStates`, `sharedTrustedAliases` / `sharedTrustedAliasesFlow`, `chooseOwner` (the upgrade's owner order, read at runtime), `setOwner` and `holdsListenerState`; when ownership may move and which states resolve a lookup belong to `EpisodeListenerStore`), `UpgradeRepairDao` (pending shows and per-show progress), `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `PodcastDao.upsert` and `LocalEpisodeCatalogDao.upsertFeed` keep a stored row's Room v39 columns (see Version 39 below). `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `ListeningHistoryDao.getHistoryItems`, `setLikeStatusForIds` and `setCompletionForIds` and `DownloadedEpisodeDao.getCompletedDownloadIds`, `getDownloads`, `countCompletedFlow` and `countActiveFlow` read and write an episode's rows under all its IDs. `PodcastDao.observeSwitchedRssShows` watches the followed RSS shows that replaced a Podcast Index show the listener no longer follows. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `ListeningHistoryDao.getDirtyListeningHistoryBatch` reads one push batch: up to the given number of unsynced rows, most recently played first, without episode descriptions, so a large backlog is never read in full. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL found on the device and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. App code replaces a saved address only through `PodcastDao.replaceFeedAddress(FeedAddressWrite)` (P4 PR 14, used by `:core:catalog`'s `FeedAddressUpdater`): one transaction that writes `feedUrl` and `transportUrl`, keeps the old address in `previousFeedUrl`, records `feedUrlSource` and `feedUrlCheckedAt`, clears the show's ETag and Last-Modified on its row, its `local_episode_feeds` row and its `episode_supplements` row, and marks the row dirty unless the address came from the cloud. It is a compare-and-set on the address the caller read (`feedUrl IS :expected`) and never touches `rss:` rows. `PodcastDao.markFeedUrlChecked` stamps the daily address check without marking the row dirty. No schema change. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`); `findOwners` reports which (show, key) holds each episode ID so writers check before `upsertEpisodes` replaces a row. `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. +- DAOs: `EpisodeIdentityDao` (alias and group-state reads and writes, `upsertGroupStates`, `getGroupStates`, `completedGroupStates`, `deleteGroupState`, `deleteAllGroupStates`, `sharedTrustedAliases` / `sharedTrustedAliasesFlow`, `chooseOwner` (the upgrade's owner order, read at runtime), `setOwner` and `holdsListenerState`; when ownership may move and which states resolve a lookup belong to `EpisodeListenerStore`), `UpgradeRepairDao` (pending shows and per-show progress), `PodcastDao`, `ListeningHistoryDao`, `ListeningSessionDao`, `ListeningRollupDao`, `DownloadedEpisodeDao`, `RssEpisodeDao`, `EpisodeSupplementDao`, `LocalEpisodeCatalogDao`, `FolderDao`, and `dao.QueueDao`. `PodcastDao.upsert` and `LocalEpisodeCatalogDao.upsertFeed` keep a stored row's Room v39 columns (see Version 39 below). `ListeningHistoryDao.insertIfAbsent` lets service-only playback establish a row without replacing concurrent UI/history state, `enrichMetadataIfMissing` fills incomplete service seeds without touching progress or likes, `completeFromPlayback` atomically updates only playback-owned completion columns, `reopenProgress` resets completion and advances progress for intentional replays, and `updateLastPlayedAt` refreshes session recency. `ListeningHistoryDao.getHistoryItems`, `setLikeStatusForIds` and `setCompletionForIds` and `DownloadedEpisodeDao.getCompletedDownloadIds`, `getDownloads`, `countCompletedFlow` and `countActiveFlow` read and write an episode's rows under all its IDs. `PodcastDao.observeSwitchedRssShows` watches the followed RSS shows that replaced a Podcast Index show the listener no longer follows. `PodcastDao.markPodcastSyncedIfUnchanged`, `ListeningHistoryDao.markHistorySyncedIfUnchanged`, and `QueueDao.markQueueSyncedIfSequenceMatches` provide multi-column optimistic concurrency flag clearing during cloud sync. `ListeningHistoryDao.getDirtyListeningHistoryBatch` reads one push batch: up to the given number of unsynced rows, most recently played first, without episode descriptions, so a large backlog is never read in full. `PodcastDao.getDirtyCountFlow` and `ListeningHistoryDao.getDirtyCountFlow` observe pending unsynced changes to drive reactive cloud sync ignition. `PodcastDao.clearAllSubscriptionsForAccountSwitch` cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. `PodcastDao.setFeedUrl` writes a publisher HTTPS URL found on the device and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. App code replaces a saved address only through `PodcastDao.replaceFeedAddress(FeedAddressWrite)` (P4 PR 14, used by `:core:catalog`'s `FeedAddressUpdater`): one transaction that writes `feedUrl` and `transportUrl`, keeps the old address in `previousFeedUrl`, records `feedUrlSource` and `feedUrlCheckedAt`, clears the show's ETag and Last-Modified on its row, its `local_episode_feeds` row and its `episode_supplements` row, and marks the row dirty unless the address came from the cloud. It is a compare-and-set on the address the caller read (`feedUrl IS :expected`) and never touches `rss:` rows. `PodcastDao.markFeedUrlChecked` stamps the daily address check without marking the row dirty. No schema change. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. `EpisodeSupplementDao.listSupplements` is the export source for Missing episodes? opt-ins during cutover. `LocalEpisodeCatalogDao` pages the first-class PI local catalog (sticky `episodeId`, unique `(podcastId, guid)`); `findOwners` reports which (show, key) holds each episode ID so writers check before `upsertEpisodes` replaces a row. `LocalEpisodeCatalogDao.getEpisodesAfter` and `RssEpisodeDao.getEpisodesAfter` query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets `ttlExpiresAt`; a non-null TTL is not catalog-ready for live lists. `deleteCatalogIfExpired` rechecks `ttlExpiresAt` in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras **keeping episodeIds** and does not drop supplement tables. Migration 32→33 adds `customGenre` and `customGenreIcon` columns to `podcasts` for subscribed podcast tag and icon overrides. Migration 33→34 creates `folders` and `podcast_folder_cross_ref` tables with `CASCADE` on `folderId` so deleting folders never deletes shows. Migration 34→35 adds `showPodcastGrid` column to `folders`. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds `chaptersUrl` and `transcriptUrl` columns to `downloaded_episodes` for offline chapter and transcript persistence. Migration 36→37 adds `unsubscribedAt`, `isDirty`, and `syncedAt` columns to `podcasts` with index on `isDirty` for subscription tombstoning; adds `likedAt` column to `listening_history` with indices on `isDirty` and `lastPlayedAt` for decoupled likes and delta tracking; and creates `queue_metadata` table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. `LocalEpisodeCatalogDao.previousFeedUrl` reads `podcasts.previousFeedUrl`, so a catalog read still heals legacy rows minted under the address the saved one replaced (own-IDs P4 PR 14). - `EpisodeSupplementDao.replaceAll` / `upsertSupplementAndOptionalItems` keep validator rows and tip items in one Room transaction. - `QueueDao.bumpQueueVersion` keeps `queue_metadata.recentRemovedEpisodes` (a JSON list of `QueueRemovalIdentity`: ID, recipe ID, show and catalog key, written with hand-named fields) in step with the plain `recentRemovedEpisodeIds`. A removal adds one entry per removed ID, with the episode's identity when the caller passes it; restoring the same episode under any ID (same ID, or same show and key) drops its entry, and a removal of an episode that stays queued under another ID isn't recorded there. Both lists keep the newest 50. `QueueDao.richRemovalsWithin` keeps the rich list in step after a pulled queue state replaced the plain list: only entries the plain list still holds and whose episode isn't queued again. Cloud sync pushes the rich list with the queue and pulls the server's (`:core:catalog` `sync.QueueSyncResolver`). - `PodcastScoring.calculateScores` takes an optional `episodeKey`, so `:core:ranking` can count an episode stored under several IDs once; the default keeps each ID its own episode. `ScorablePodcast.listenerShowIds` (filled by `toScorable` from `Podcast.listenerShowIds` and `PodcastEntity.listenerShowIds`) lets an RSS show the listener switched to from a Podcast Index show count that show's history, unless that show is scored itself. `sortedByListening` orders shows for Most listened the same way. 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 a30009ac7..9c1cb4325 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 @@ -39,6 +39,13 @@ interface LocalEpisodeCatalogDao { @Query("SELECT * FROM local_episode_feeds WHERE podcastId = :podcastId LIMIT 1") suspend fun getFeed(podcastId: String): LocalEpisodeFeedEntity? + /** + * The address the show's saved one replaced (`podcasts.previousFeedUrl`): legacy rows minted + * under it still heal after the catalog was read at the new address (P4 PR 14). + */ + @Query("SELECT previousFeedUrl FROM podcasts WHERE podcastId = :podcastId LIMIT 1") + suspend fun previousFeedUrl(podcastId: String): String? + @Query("SELECT * FROM local_episodes WHERE episodeId = :episodeId LIMIT 1") suspend fun getEpisode(episodeId: String): LocalEpisodeEntity? diff --git a/core/rss/README.md b/core/rss/README.md index 67566949d..bb80f54fa 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -104,7 +104,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ - **Supplement IDs are kept too.** `EpisodeSupplementRepository` replaces a show's supplement rows on every refresh. Feed-only items take the ID of the stored supplement row they match (`SupplementIdentity`, the same lookup as true RSS rows), and a stored row no item matched that holds one of an item's keys (an older build's second row for the same episode) is written back unchanged. Rows of episodes that left the feed or reached the Podcast Index list are still dropped. The tip `resolveNewestTipFromFeed` writes keeps its stored ID the same way. - Feed read failures are typed: `FeedHttpException` (status code; message unchanged) and `NotAFeedException` (wrong content type or nothing parseable), both `IllegalArgumentException`s. `FeedReadFailure.isAddressGone` reads them, plus no such host and failing certificates, as a dead address (`RefreshOutcome.Failure.addressGone`); timeouts, 5xx, 429 and dropped connections are passing trouble. An address that answers with no episodes counts as dead too. `FeedReadFailure.reportStatus` names what a failed read saw for `/v2/feed-report` (`RefreshOutcome.Failure.reportStatus`): `http_`, `dns`, `timeout`, `tls`, `parse`, `network` or `other`; an address with no episodes reports `parse`. - Moved feeds: `RssFetchResult.movedTo` is the final address when every redirect was permanent (301, 308; `permanentMoveTarget`); `ParsedRssFeed.newFeedUrl` is the channel's `itunes:new-feed-url` (both parsers). With `RefreshRequest.detectMove`, `confirmedMove` reports a new HTTPS address only when it differs from the saved one and serves the same show by title (case, spacing and punctuation ignored); a declared address is read once to confirm, only while `RefreshRequest.canProceed` still allows a fetch. Temporary redirects never move a feed. -- Address changes keep legacy rows: a catalog read also tries the URL namespaces of the address the catalog was last read from and the address the read was asked for (`olderNamespaces`), so a legacy row minted under an old address (synthetic `id:` or audio-address key) still heals to its own ID after the saved address changed or the read was redirected (`LocalEpisodeCollisionGuard.resolve(olderNamespaces)`, `LocalEpisodeCatalogPersist.plan(olderNamespaces)`; `LegacyIdentityRoomTest`). +- Address changes keep legacy rows: a catalog read also tries the URL namespaces of the address the catalog was last read from, the address the read was asked for and the address the saved one replaced (`podcasts.previousFeedUrl`, `LocalEpisodeCatalogDao.previousFeedUrl`) (`olderNamespaces`), so a legacy row minted under an old address (synthetic `id:` or audio-address key) still heals to its own ID after the saved address changed or the read was redirected, also when its episode was missing from the first read at the new address; only one earlier address is kept (`LocalEpisodeCollisionGuard.resolve(olderNamespaces)`, `LocalEpisodeCatalogPersist.plan(olderNamespaces)`; `LegacyIdentityRoomTest`, and `LocalEpisodeCatalogPersistTest` for an ID only listening history holds). - Local catalog writes never replace another episode (`LocalEpisodeCollisionGuard`, [docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md) §4). The write is a REPLACE, which deletes any stored row sharing the episode ID or the unique (show, catalog key). Inside the refresh transaction, for each row: - **Its own stored row first.** When the show already stores the key, the row keeps that stored ID, so it is updated and never deleted through the unique index; an exact stored key wins over one that only matches after trimming. Stored keys are compared after the recipe trim, so a key that Room migration 31→32 left with a trailing newline, tab or no-break space keeps its ID and gets the trimmed key instead of a second row. - **Legacy heal.** A same-show row that holds the item's own minted ID (`StickyEpisodeIdentity.ownMintedId`) and whose key is the synthetic `id:`, or its audio address while the item has a GUID, is the same episode: it keeps its ID and takes the feed's key. The own mint is looked up whatever ID the row arrives with, so a new item that arrives with its recipe ID heals the legacy row instead of writing a second row under the recipe ID. diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt index 4c8321a83..1f8c5f833 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogRefresh.kt @@ -121,17 +121,25 @@ private suspend fun fetchAndPersist( /** * The URL namespaces this show's stored rows may have been minted under besides [rssNamespaceId] - * (the address this read ended at): the address the catalog was last read from, and the address - * this read was asked for. They differ after a saved address changed (a registry correction, a + * (the address this read ended at): the address the catalog was last read from, the address this + * read was asked for, and the address the saved one replaced ([previousFeedUrl], + * `podcasts.previousFeedUrl`). They differ after a saved address changed (a registry correction, a * feed move) or when the read was redirected; legacy rows minted under the old address still heal - * to their own IDs ([LocalEpisodeCollisionGuard], [LocalEpisodeCatalogPersist.plan]). + * to their own IDs ([LocalEpisodeCollisionGuard], [LocalEpisodeCatalogPersist.plan]), also an + * episode missing from the first read at the new address. Only one earlier address is kept: a + * show moved twice before such an episode came back keeps that legacy row. * * Added in P4 PR 14 of the own-episode-IDs plan (plan section 4, legacy keys resolve when the feed * is read again; section 11, followed shows). * COMPAT(own-ids): legacy rows keep their IDs across an address change. Permanent: never remove (MIGRATION.md §5). */ -internal fun olderNamespaces(rssNamespaceId: String, request: RefreshRequest, existing: LocalEpisodeFeedEntity?): List = - listOfNotNull(existing?.feedUrl, request.feedUrl) +internal fun olderNamespaces( + rssNamespaceId: String, + request: RefreshRequest, + existing: LocalEpisodeFeedEntity?, + previousFeedUrl: String? = null, +): List = + listOfNotNull(existing?.feedUrl, request.feedUrl, previousFeedUrl) .map(String::trim) .filter(String::isNotEmpty) .mapNotNull { url -> runCatching { RssIdGenerator.podcastId(url) }.getOrNull() } // An address that can't be one mints nothing. @@ -148,7 +156,7 @@ private suspend fun persistParsed( ): RefreshOutcome { val identities = deps.dao.listIdentities(request.podcastIndexId) val newIds = newEpisodeIds(deps, request, existing) - val olderNamespaces = olderNamespaces(rssNamespaceId, request, existing) + val olderNamespaces = olderNamespaces(rssNamespaceId, request, existing, deps.dao.previousFeedUrl(request.podcastIndexId)) val planned = LocalEpisodeCatalogPersist.plan( podcastIndexId = request.podcastIndexId, diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedMoveTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedMoveTest.kt index db8ef14fb..81309c599 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedMoveTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedMoveTest.kt @@ -59,6 +59,16 @@ class FeedMoveTest { assertNull(confirmedMove(broken, request("Moving Show"), fetched, parsed("Moving Show", newFeedUrl = new)), "new address fails") } + @Test + fun `a declared new feed address is never read when the caller's gate refuses another fetch`() = runTest { + val fetched = RssFetchResult(finalUrl = old, etag = null, lastModified = null, body = ByteArray(0)) + val client = FakeClient(mapOf(new to "Moving Show")) + val gated = request("Moving Show").copy(canProceed = { false }) + + assertNull(confirmedMove(client, gated, fetched, parsed("Moving Show", newFeedUrl = new))) + assertTrue(client.fetched.isEmpty()) + } + @Test fun `titles match ignoring case, spacing and punctuation`() { assertTrue(sameShow("The Daily", "the daily")) diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt index 28c0450f6..1cd81a0ac 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LegacyIdentityRoomTest.kt @@ -4,6 +4,7 @@ import androidx.test.core.app.ApplicationProvider 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.FeedAddressWrite import cx.aswin.boxlore.core.database.LocalEpisodeFeedEntity import cx.aswin.boxlore.core.database.LocalFeedOrder import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort @@ -116,6 +117,47 @@ class LegacyIdentityRoomTest : RoomCatalogTestBase() { assertNotNull(database.listeningHistoryDao().getHistoryItem(minted)) } + @Test + fun aLegacyRowMissingFromTheFirstReadAtTheNewAddressStillHealsWhenItsEpisodeComesBack() = runBlocking { + // The saved address moved; the first read there lacks the episode, and records the new + // address as the one the catalog was read from. The address it replaced is still tried. + follow(SHOW) + val old = feedUrl(SHOW) + val moved = "https://moved.example/$SHOW.xml" + val minted = StickyEpisodeIdentity.ownMintedId(namespace(old), "g2") + database.localEpisodeCatalogDao().upsertEpisodes(listOf(row(minted, SHOW, "id:$minted"))) + database.localEpisodeCatalogDao().upsertFeed( + LocalEpisodeFeedEntity( + podcastId = SHOW, + feedUrl = old, + feedEtag = null, + feedLastModified = null, + fetchedAt = 1L, + itemCount = 1, + feedOrder = LocalFeedOrder.NEWEST_FIRST, + ttlExpiresAt = null, + needsFullBackfill = false, + copiedExtrasCount = 0, + ready = true, + feedUrlLookupAt = 0L, + ), + ) + history(minted, SHOW) + val podcasts = database.podcastDao() + assertTrue(podcasts.replaceFeedAddress(FeedAddressWrite(SHOW, null, old, null, null, 0L, markDirty = false))) + assertTrue(podcasts.replaceFeedAddress(FeedAddressWrite(SHOW, old, moved, null, "registry", 1L, markDirty = false))) + + feed.items = listOf(Item("g1")) + assertTrue(refresh(SHOW, url = moved) is RefreshOutcome.Success) + feed.items = listOf(Item("g1"), Item("g2")) + assertTrue(refresh(SHOW, url = moved) is RefreshOutcome.Success) + + val stored = rows(SHOW) + assertEquals("the returning episode heals its own row: never a second copy", minted, stored["g2"]) + assertEquals(setOf("g1", "g2"), stored.keys) + assertNotNull(database.listeningHistoryDao().getHistoryItem(minted)) + } + @Test fun theNamespacesOfTheAddressesReadBeforeAreTriedAfterThisReadsOwn() { val request = LocalEpisodeCatalogPort.RefreshRequest(podcastIndexId = SHOW, feedUrl = " https://requested.example/a.xml ") diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersistTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersistTest.kt index 275d5bc9e..430b88436 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersistTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/LocalEpisodeCatalogPersistTest.kt @@ -161,6 +161,20 @@ class LocalEpisodeCatalogPersistTest { assertEquals(PlannedIdSource.OWN_MINT, row.source) } + @Test + fun aHistoryOnlyIdMintedUnderTheShowsOlderAddressIsKeptAfterTheAddressChanged() { + // No stored row holds it: only listening history does, minted when the show had another address. + val older = RssIdGenerator.podcastId("https://old.example/feed.xml") + val item = rssEpisode(guid = "g1") + val olderMint = StickyEpisodeIdentity.ownMintedId(older, "g1") + val ids = withListeners(heldIds = setOf(olderMint)) + + assertEquals(PlannedIdSource.RECIPE, plan(listOf(item), newIds = ids).single().source, "without the older address nothing proves it") + val row = plan(listOf(item), newIds = ids, olderNamespaces = listOf(older)).single() + assertEquals(olderMint, row.entity.episodeId) + assertEquals(PlannedIdSource.OWN_MINT, row.source) + } + @Test fun aPodcastIndexIdOnlyHistoryHoldsIsKeptWhenItsAudioMatchesOneItem() { val audio = "https://cdn.example/played.mp3" @@ -284,6 +298,7 @@ class LocalEpisodeCatalogPersistTest { existing: List = emptyList(), newIds: NewEpisodeIds = show100, parsedItems: Boolean = false, + olderNamespaces: List = emptyList(), ): List = LocalEpisodeCatalogPersist.plan( podcastIndexId = "100", rssNamespaceId = namespace, @@ -292,6 +307,7 @@ class LocalEpisodeCatalogPersistTest { newIds = newIds, channelImageUrl = null, showImageUrl = null, + olderNamespaces = olderNamespaces, ) private fun withListeners(heldIds: Set, references: List = emptyList()) = 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 428a55701..7f0f4e04a 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 @@ -738,6 +738,8 @@ class LocalEpisodeCatalogRepositoryTest { override suspend fun getFeed(podcastId: String): LocalEpisodeFeedEntity? = feeds[podcastId] + override suspend fun previousFeedUrl(podcastId: String): String? = null + override suspend fun getEpisode(episodeId: String): LocalEpisodeEntity? = findEpisode(episodeId) private fun findEpisode(id: String) = episodes.find { it.episodeId == id } From 814bd369112a14488e552e91f03bb7f9697bd27a Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 23:50:58 +0530 Subject: [PATCH 6/9] fix(catalog): read pending registration marks without a cast --- .../boxlore/core/catalog/PendingNotificationRegistrations.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt index 6eda3b17d..371183fb7 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt @@ -61,7 +61,7 @@ class SharedPreferencesPendingNotificationRegistrations( ) : PendingNotificationRegistrations { override fun all(): Set = prefs.all.keys.toSet() - override fun mark(podcastId: String): Long? = (prefs.all[podcastId] as? Long) + override fun mark(podcastId: String): Long? = if (prefs.contains(podcastId)) prefs.getLong(podcastId, 0L) else null @Synchronized @SuppressLint("ApplySharedPref") From 9404ccadf51e505b7b75c666d370ed1e9819ef91 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 23:51:43 +0530 Subject: [PATCH 7/9] refactor(info): split show page paging checks --- .../feature/info/PodcastInfoViewModel.kt | 85 +++++++++++-------- 1 file changed, 49 insertions(+), 36 deletions(-) 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 fd1dd7eff..862856c45 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 @@ -978,11 +978,7 @@ class PodcastInfoViewModel( fun loadMoreEpisodes() { val currentState = _uiState.value - if (currentState !is PodcastInfoUiState.Success) return - if (currentState.isLoadingMore || !currentState.hasMoreEpisodes) return - if (currentState.searchResults != null) return // Don't load more when searching - // Feed-first (P4 PR 16): past the copy while offline, wait for a connection instead of retrying. - if (feedFirst.readsFeed(currentPodcastId) && feedFirst.holdsAtCopyEnd()) return + if (currentState !is PodcastInfoUiState.Success || !canLoadMore(currentState)) return _uiState.value = currentState.copy(isLoadingMore = true) @@ -991,9 +987,7 @@ class PodcastInfoViewModel( val limit = if (currentState.currentSort == EpisodeSort.OLDEST) OLDEST_PAGE_SIZE else PAGE_SIZE val sortParam = if (currentState.currentSort == EpisodeSort.OLDEST) "oldest" else "newest" val page = repository.getShowPageEpisodes(currentPodcastId, limit, currentOffset, sortParam) - if (feedFirst.readsFeed(currentPodcastId) && - ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.LOAD_MORE, limit, page.episodes.size) - ) { + if (needsArchiveToLoadMore(limit, page.episodes.size)) { // Past the stored newest episodes: read the whole feed, then show one more page. feedFirst.completeArchive(viewModelScope, currentState.podcast, extra = limit, atCopyEnd = true) stopLoadingMore() @@ -1018,6 +1012,21 @@ class PodcastInfoViewModel( } } + /** + * Whether another page may load: not already loading, more to come, not searching, and (for a + * feed-first page, P4 PR 16) not past the stored copy while offline, which waits for a connection. + */ + private fun canLoadMore(state: PodcastInfoUiState.Success): Boolean = + !state.isLoadingMore && + state.hasMoreEpisodes && + state.searchResults == null && + !(feedFirst.readsFeed(currentPodcastId) && feedFirst.holdsAtCopyEnd()) + + /** A feed-first page past its stored newest episodes reads the whole feed before the next page (P4 PR 16). */ + private fun needsArchiveToLoadMore(limit: Int, loaded: Int): Boolean = + feedFirst.readsFeed(currentPodcastId) && + ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.LOAD_MORE, limit, loaded) + fun refreshRssFeed() { val currentState = _uiState.value as? PodcastInfoUiState.Success ?: return if (currentState.isRssRefreshing) return @@ -1336,36 +1345,40 @@ class PodcastInfoViewModel( val sortString = if (newSort == EpisodeSort.OLDEST) "oldest" else "newest" subscriptionRepository.updatePreferredSort(currentPodcastId, sortString) } + loadFirstSortedPage(currentState, newSort) + } + } - try { - if (newSort == EpisodeSort.OLDEST && - feedFirst.readsFeed(currentPodcastId) && - ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.SORT_OLDEST) - ) { - // The oldest episodes are only known once the whole feed is read. - feedFirst.completeArchive(viewModelScope, currentState.podcast) - stopLoadingMore() - return@launch - } - val limit = if (newSort == EpisodeSort.OLDEST) OLDEST_PAGE_SIZE else PAGE_SIZE - val sortParam = if (newSort == EpisodeSort.OLDEST) "oldest" else "newest" - val page = repository.getShowPageEpisodes(currentPodcastId, limit, 0, sortParam) - currentOffset = page.sourceCount - val latestState = _uiState.value as? PodcastInfoUiState.Success ?: return@launch - _uiState.value = - supplementSupport.remountWithSupplements( - state = latestState, - piEpisodes = page.episodes, - hasMoreEpisodes = page.hasMore, - isLoadingMore = false, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - e.printStackTrace() - val latestState = _uiState.value as? PodcastInfoUiState.Success ?: return@launch - _uiState.value = latestState.copy(isLoadingMore = false) + /** The first page in [sort] after the sort changed; a feed-first page's oldest wait for the whole feed (P4 PR 16). */ + private suspend fun loadFirstSortedPage(currentState: PodcastInfoUiState.Success, sort: EpisodeSort) { + try { + if (sort == EpisodeSort.OLDEST && + feedFirst.readsFeed(currentPodcastId) && + ShowFeedLogic.needsArchive(feedFirst.archiveComplete, ShowFeedLogic.ArchiveAction.SORT_OLDEST) + ) { + // The oldest episodes are only known once the whole feed is read. + feedFirst.completeArchive(viewModelScope, currentState.podcast) + stopLoadingMore() + return } + val limit = if (sort == EpisodeSort.OLDEST) OLDEST_PAGE_SIZE else PAGE_SIZE + val sortParam = if (sort == EpisodeSort.OLDEST) "oldest" else "newest" + val page = repository.getShowPageEpisodes(currentPodcastId, limit, 0, sortParam) + currentOffset = page.sourceCount + val latestState = _uiState.value as? PodcastInfoUiState.Success ?: return + _uiState.value = + supplementSupport.remountWithSupplements( + state = latestState, + piEpisodes = page.episodes, + hasMoreEpisodes = page.hasMore, + isLoadingMore = false, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + val latestState = _uiState.value as? PodcastInfoUiState.Success ?: return + _uiState.value = latestState.copy(isLoadingMore = false) } } From 6bffd950704686de4a3ad4beab019cd2e70af5aa Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 23:57:17 +0530 Subject: [PATCH 8/9] fix(catalog): write no address when its notification request can't be saved --- core/catalog/README.md | 4 ++-- .../core/catalog/FeedAddressUpdater.kt | 9 +++++++- .../PendingNotificationRegistrations.kt | 10 +++++---- .../core/catalog/FeedAddressUpdaterTest.kt | 21 +++++++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/core/catalog/README.md b/core/catalog/README.md index 71d1ffb2a..860eaff8f 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -11,7 +11,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su Failed consent writes keep activation off, and local notification flags prevent a failed revoke from implicitly re-enabling after restart. RSS sync isolates each feed and runs alongside PI sync; the source adapter explicitly implements every catalog operation. - `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, 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). +- **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, 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). - `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 15 a day in all, below the API's 20 so the show page has room (`FeedReportLimiter`; 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. @@ -165,7 +165,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit ## Testing notes -- 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; 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, 15 a day in all, 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). +- 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, 15 a day in all, 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`. - `PodcastRepositoryApiV2Test` (Robolectric, MockWebServer): the production `api` and `apiV2` reach the same base URL and open the HTTP cache once; test overrides open none. 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 9bf6dbcb9..e1a0df95e 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 @@ -106,6 +106,12 @@ enum class FeedAddressUpdate { * kept and the follow is marked for the next push, so the account gets it back. */ KEPT_CORRECTION, + + /** + * The show's notifications need registering again, but that request couldn't be saved, so the + * address wasn't written either: a later check, move or pull tries again. + */ + NOT_SAVED, } /** @@ -141,7 +147,8 @@ class FeedAddressUpdater( val row = podcastDao.getPodcast(change.podcastId) ?: return FeedAddressUpdate.NO_SHOW notWritten(row, change, https)?.let { return it } val notificationsOn = row.isSubscribed && row.notificationsEnabled - if (notificationsOn) pendingRegistrations.add(row.podcastId) + // The request is saved before the address, so a written address always has its registration. + if (notificationsOn && !pendingRegistrations.add(row.podcastId)) return FeedAddressUpdate.NOT_SAVED val written = podcastDao.replaceFeedAddress( FeedAddressWrite( diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt index 371183fb7..4f6f78d23 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PendingNotificationRegistrations.kt @@ -18,7 +18,8 @@ interface PendingNotificationRegistrations { /** [podcastId]'s pending request, or null when none is pending; each [add] gives a new mark. */ fun mark(podcastId: String): Long? - fun add(podcastId: String) + /** Records a new request for [podcastId]; false when it couldn't be saved, so the caller writes nothing. */ + fun add(podcastId: String): Boolean /** Clears [podcastId] only while its request is still [mark], so a newer address change stays pending. */ fun remove(podcastId: String, mark: Long) @@ -36,8 +37,9 @@ class MemoryPendingNotificationRegistrations : PendingNotificationRegistrations override fun mark(podcastId: String): Long? = marks[podcastId] @Synchronized - override fun add(podcastId: String) { + override fun add(podcastId: String): Boolean { marks[podcastId] = ++lastMark + return true } @Synchronized @@ -65,10 +67,10 @@ class SharedPreferencesPendingNotificationRegistrations( @Synchronized @SuppressLint("ApplySharedPref") - override fun add(podcastId: String) { + override fun add(podcastId: String): Boolean { val mark = maxOf(nowMillis(), (mark(podcastId) ?: 0L) + 1) // Written to disk at once: the address change that needs it follows in the same call. - prefs.edit().putLong(podcastId, mark).commit() + return prefs.edit().putLong(podcastId, mark).commit() } @Synchronized diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt index 4171b10fb..392f3fe92 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/FeedAddressUpdaterTest.kt @@ -159,6 +159,27 @@ class FeedAddressUpdaterTest { assertTrue(pending.all().isEmpty()) } + @Test + fun `an address is never written when its notification request couldn't be saved`() = runTest { + val unsaved = + object : PendingNotificationRegistrations { + override fun all(): Set = emptySet() + + override fun mark(podcastId: String): Long? = null + + override fun add(podcastId: String): Boolean = false + + override fun remove(podcastId: String, mark: Long) = Unit + } + dao.upsertRow(show("920666", OLD, notifications = true)) + + val result = FeedAddressUpdater(dao, requestNotificationRegistration = { registrations += it }, pendingRegistrations = unsaved).update(correction("920666")) + + assertEquals(FeedAddressUpdate.NOT_SAVED, result) + assertEquals(OLD, dao.getPodcast("920666")!!.feedUrl) + assertTrue(registrations.isEmpty()) + } + @Test fun `a registration lost with the process is asked for again on the next launch`() = runTest { val pending = MemoryPendingNotificationRegistrations() From 444bb157ffa26668105a14b57a0f88d19a1da255 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 28 Oct 2026 00:10:29 +0530 Subject: [PATCH 9/9] fix(catalog): retry address changes dropped with their notification request --- core/catalog/README.md | 2 +- .../core/catalog/FeedAddressDailyCheck.kt | 11 +++-- .../core/catalog/FeedAddressUpdater.kt | 9 ++++ .../core/catalog/SubscriptionRepository.kt | 3 +- .../backup/LibraryBackupDirectFeedRestore.kt | 8 +++- .../catalog/sync/SubscriptionSyncResolver.kt | 3 ++ .../core/catalog/FeedAddressDailyCheckTest.kt | 26 ++++++++++++ .../PodcastNotificationRegistrarTest.kt | 22 ++++++++++ .../LibraryBackupDirectFeedRestoreTest.kt | 41 +++++++++++++++++-- 9 files changed, 110 insertions(+), 15 deletions(-) diff --git a/core/catalog/README.md b/core/catalog/README.md index 860eaff8f..d6c80564a 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -11,7 +11,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su Failed consent writes keep activation off, and local notification flags prevent a failed revoke from implicitly re-enabling after restart. RSS sync isolates each feed and runs alongside PI sync; the source adapter explicitly implements every catalog operation. - `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, 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). +- **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). - `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 15 a day in all, below the API's 20 so the show page has room (`FeedReportLimiter`; 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. 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 1dba5a5da..66fe44a81 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 @@ -89,12 +89,11 @@ class FeedAddressDailyCheck( private suspend fun applyOne(row: PodcastEntity, result: V2ShowAddress, now: Long): Boolean { noteNamespace(row, result) val target = confirmedMove(row, result) - val swapped = - target != null && - readable(row.podcastId, target) && - replaceAddress(change(row, result, target, now)) == FeedAddressUpdate.UPDATED - // A swap stamps the show with its address; anything else is stamped here, after the swap had its chance. - if (!swapped) markChecked(row.podcastId, now) + val update = if (target != null && readable(row.podcastId, target)) replaceAddress(change(row, result, target, now)) else null + val swapped = update == FeedAddressUpdate.UPDATED + // A swap stamps the show with its address; a swap that couldn't be saved stays due for the + // next pass; anything else is stamped here, after the swap had its chance. + if (!swapped && update != FeedAddressUpdate.NOT_SAVED) markChecked(row.podcastId, now) return swapped } 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 e1a0df95e..8240187d4 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 @@ -114,6 +114,15 @@ enum class FeedAddressUpdate { NOT_SAVED, } +/** + * A saved address change that was dropped because its notification request couldn't be saved + * ([FeedAddressUpdate.NOT_SAVED]). A caller that can't retry on its own throws it, so its run + * fails and is tried again: a cloud pull keeps its pull time, a restore reports the show. + * + * Added in P4 PR 14 of the own-episode-IDs plan (plan section 11, followed shows: one durable address update). + */ +class FeedAddressNotSavedException(podcastId: String) : java.io.IOException("The address change of $podcastId wasn't saved: its notification request couldn't be saved") + /** * The one writer of a show's saved feed address. In one transaction ([PodcastDao.replaceFeedAddress]) * it writes the publisher and transport addresses and keeps the old one for undo, resets every 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 0e6a9b8ec..8cab021e9 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 @@ -670,9 +670,8 @@ class SubscriptionRepository( * notification RTDB can poll the same feed after library restore. Goes through the one address * writer ([feedAddresses]); [source] records where the address came from. */ - suspend fun ensureHttpsFeedUrl(podcastId: String, feedUrl: String, source: String = FeedUrlSources.PODCAST_LOOKUP,) { + suspend fun ensureHttpsFeedUrl(podcastId: String, feedUrl: String, source: String = FeedUrlSources.PODCAST_LOOKUP,): FeedAddressUpdate = feedAddresses.update(FeedAddressChange(podcastId = podcastId, feedUrl = feedUrl, source = source)) - } /** * Replaces a show's saved feed address through the one address writer ([FeedAddressUpdater]). diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestore.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestore.kt index d06d0530a..692b01d75 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestore.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestore.kt @@ -1,5 +1,7 @@ package cx.aswin.boxlore.core.catalog.backup +import cx.aswin.boxlore.core.catalog.FeedAddressNotSavedException +import cx.aswin.boxlore.core.catalog.FeedAddressUpdate import cx.aswin.boxlore.core.domain.ports.EpisodeSupplementOutcome import cx.aswin.boxlore.core.model.Episode import kotlinx.coroutines.CancellationException @@ -12,7 +14,8 @@ import kotlinx.coroutines.sync.withPermit /** Callbacks for [LibraryBackupDirectFeedRestore.restoreAndRefresh]. */ internal data class DirectFeedRestoreActions( val restoreStub: suspend (podcastId: String, feedUrl: String) -> Unit, - val ensureFeedUrl: suspend (podcastId: String, feedUrl: String) -> Unit, + /** Saves the backup's address through the one address writer; [FeedAddressUpdate.NOT_SAVED] fails the show. */ + val ensureFeedUrl: suspend (podcastId: String, feedUrl: String) -> FeedAddressUpdate, val invalidateCache: (podcastId: String) -> Unit, val refreshFeed: suspend (podcastId: String, feedUrl: String) -> EpisodeSupplementOutcome, val saveTip: suspend (podcastId: String, episode: Episode) -> Unit, @@ -60,7 +63,8 @@ internal object LibraryBackupDirectFeedRestore { if (id.isEmpty() || url.isNullOrBlank()) return try { actions.restoreStub(id, url) - actions.ensureFeedUrl(id, url) + // An address dropped with its notification request is reported, never treated as restored. + if (actions.ensureFeedUrl(id, url) == FeedAddressUpdate.NOT_SAVED) throw FeedAddressNotSavedException(id) actions.syncTrackedUrl(id) actions.invalidateCache(id) when (val outcome = actions.refreshFeed(id, url)) { diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt index 138031b34..d83585fcc 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/SubscriptionSyncResolver.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.core.catalog.sync import android.util.Log import cx.aswin.boxlore.core.catalog.FeedAddressChange +import cx.aswin.boxlore.core.catalog.FeedAddressNotSavedException import cx.aswin.boxlore.core.catalog.FeedAddressUpdate import cx.aswin.boxlore.core.catalog.FeedAddressUpdater import cx.aswin.boxlore.core.catalog.FeedUrlSources @@ -292,6 +293,8 @@ class SubscriptionSyncResolver( ) // KEPT_CORRECTION: the pull would undo a confirmed correction; it stays and is pushed back. val result = writer.update(change) + // The pull fails before its time is saved, so the next sync pulls this address again. + if (result == FeedAddressUpdate.NOT_SAVED) throw FeedAddressNotSavedException(local.podcastId) if (result != FeedAddressUpdate.UPDATED) Log.d(TAG, "Pulled address for ${local.podcastId} not saved: $result") } 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 b1b42e225..48ef1a80e 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 @@ -8,6 +8,7 @@ import cx.aswin.boxlore.core.network.model.V2ShowFlags import cx.aswin.boxlore.core.network.model.V2ShowsFeedsResponse import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -143,6 +144,31 @@ class FeedAddressDailyCheckTest { assertEquals(listOf(NEW), changes.map { it.feedUrl }) } + @Test + fun `a swap whose notification request couldn't be saved stays due for the next pass`() = runTest { + add(show("1")) + val notSaved = + FeedAddressDailyCheck( + followedShows = { rows.values.toList() }, + fetchAddresses = { ids -> answer(ids) }, + readsAt = { _, _ -> true }, + replaceAddress = { change -> + changes += change + FeedAddressUpdate.NOT_SAVED + }, + markChecked = { id, at -> rows[id] = rows.getValue(id).copy(feedUrlCheckedAt = at) }, + enabled = { true }, + nowMillis = { now }, + ) + + notSaved.run() + assertNotEquals(now, rows.getValue("1").feedUrlCheckedAt, "not stamped: the swap is still owed") + notSaved.run() + + assertEquals(2, changes.size, "tried again on the next pass") + assertEquals(OLD, rows.getValue("1").feedUrl) + } + @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/PodcastNotificationRegistrarTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt index f2798bf5a..ecf648640 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/PodcastNotificationRegistrarTest.kt @@ -142,6 +142,28 @@ class PodcastNotificationRegistrarTest { assertTrue("notifications stay on the corrected address", queued.isEmpty()) } + @Test + fun `a pulled address whose notification request couldn't be saved fails the pull, so the next sync pulls it again`() = runTest { + dao.upsertRow(show("920666", notifications = true).copy(feedUrl = OLD, isDirty = false, syncedAt = 1L, subscribedAt = 1L)) + val unsaved = + object : PendingNotificationRegistrations { + override fun all(): Set = emptySet() + + override fun mark(podcastId: String): Long? = null + + override fun add(podcastId: String): Boolean = false + + override fun remove(podcastId: String, mark: Long) = Unit + } + val resolver = SubscriptionSyncResolver(podcastDao = dao, feedAddresses = FeedAddressUpdater(dao, pendingRegistrations = unsaved)) + val pulled = UserSubscriptionSyncDto(podcastId = "920666", isSubscribed = true, subscribedAt = 1L, notificationsEnabled = true, feedUrl = NEW, updatedAt = 9L) + + val failure = runCatching { resolver.resolveSubscription(pulled, syncedAt = 10L) }.exceptionOrNull() + + assertTrue(failure is FeedAddressNotSavedException) + assertEquals(OLD, dao.getPodcast("920666")!!.feedUrl) + } + @Test fun `a merge never writes its older copy's address over one saved meanwhile`() = runTest { dao.upsertRow(show("920666", notifications = false).copy(feedUrl = OLD, isDirty = false, syncedAt = 1L, subscribedAt = 1L)) diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestoreTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestoreTest.kt index 3b10d586a..1a41bfef5 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestoreTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/backup/LibraryBackupDirectFeedRestoreTest.kt @@ -1,5 +1,7 @@ package cx.aswin.boxlore.core.catalog.backup +import cx.aswin.boxlore.core.catalog.FeedAddressNotSavedException +import cx.aswin.boxlore.core.catalog.FeedAddressUpdate import cx.aswin.boxlore.core.domain.ports.EpisodeSupplementOutcome import cx.aswin.boxlore.core.testing.TestFixtures import kotlinx.coroutines.test.runTest @@ -21,7 +23,10 @@ class LibraryBackupDirectFeedRestoreTest { actions = DirectFeedRestoreActions( restoreStub = { id, _ -> order.add("stub:$id") }, - ensureFeedUrl = { id, _ -> order.add("ensure:$id") }, + ensureFeedUrl = { id, _ -> + order.add("ensure:$id") + FeedAddressUpdate.UPDATED + }, invalidateCache = { order.add("invalidate:$it") }, refreshFeed = { id, _ -> order.add("refresh:$id") @@ -63,7 +68,7 @@ class LibraryBackupDirectFeedRestoreTest { actions = DirectFeedRestoreActions( restoreStub = { id, _ -> stubs.add(id) }, - ensureFeedUrl = { _, _ -> }, + ensureFeedUrl = { _, _ -> FeedAddressUpdate.UPDATED }, invalidateCache = {}, refreshFeed = { id, _ -> if (id == "100") { @@ -91,7 +96,10 @@ class LibraryBackupDirectFeedRestoreTest { actions = DirectFeedRestoreActions( restoreStub = { _, _ -> called = true }, - ensureFeedUrl = { _, _ -> called = true }, + ensureFeedUrl = { _, _ -> + called = true + FeedAddressUpdate.UPDATED + }, invalidateCache = { called = true }, refreshFeed = { _, _ -> called = true @@ -115,7 +123,7 @@ class LibraryBackupDirectFeedRestoreTest { ), actions = DirectFeedRestoreActions( restoreStub = { _, _ -> }, - ensureFeedUrl = { _, _ -> }, + ensureFeedUrl = { _, _ -> FeedAddressUpdate.UPDATED }, invalidateCache = {}, refreshFeed = { _, _ -> EpisodeSupplementOutcome.NoDisconnect }, saveTip = { _, _ -> }, @@ -127,4 +135,29 @@ class LibraryBackupDirectFeedRestoreTest { assertEquals(listOf("201", "202"), started.sorted()) assertEquals(listOf("201", "202"), completed.sorted()) } + + @Test + fun `a show whose address couldn't be saved is reported, never refreshed as restored`() = runTest { + val errors = mutableListOf() + var refreshed = false + LibraryBackupDirectFeedRestore.restoreAndRefresh( + targets = listOf(DirectFeedOptInBackup("100", "https://feeds.example/a.xml")), + actions = + DirectFeedRestoreActions( + restoreStub = { _, _ -> }, + ensureFeedUrl = { _, _ -> FeedAddressUpdate.NOT_SAVED }, + invalidateCache = {}, + refreshFeed = { _, _ -> + refreshed = true + EpisodeSupplementOutcome.NoDisconnect + }, + saveTip = { _, _ -> }, + syncTrackedUrl = {}, + onError = { _, error -> errors += error }, + ), + ) + + assertTrue(errors.single() is FeedAddressNotSavedException) + assertFalse(refreshed) + } }