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 8997c48394854263bb3ec10a4d0e313d81882346 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 21:26:06 +0530 Subject: [PATCH 2/9] feat(app): repair saved feed addresses after the update --- ARCHITECTURE.md | 1 + app/README.md | 10 +- .../java/cx/aswin/boxlore/AppContainer.kt | 29 ++ .../cx/aswin/boxlore/BoxLoreApplication.kt | 18 + .../aswin/boxlore/sync/UpgradeRepairWorker.kt | 86 ++++ .../cx/aswin/boxlore/AppContainerSmokeTest.kt | 17 + .../boxlore/sync/UpgradeRepairWorkerTest.kt | 146 ++++++ core/catalog/README.md | 5 +- .../core/catalog/SharedAppDependencies.kt | 7 + .../core/catalog/UpgradeAddressRepair.kt | 384 ++++++++++++++++ .../core/catalog/UpgradeNotificationMarker.kt | 107 +++++ .../core/catalog/UpgradeAddressRepairTest.kt | 435 ++++++++++++++++++ 12 files changed, 1240 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/cx/aswin/boxlore/sync/UpgradeRepairWorker.kt create mode 100644 app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 011f861e0..e11190ae4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,6 +29,7 @@ These values are part of the shipping product. Renames or recreations break upgr | 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 | +| Feed address repair after the update | Unique work name `feed-address-upgrade-repair` (`UpgradeRepairWorker`); step 4 record `noBackupFilesDir/upgrade_repair_notifications`; per-show progress in Room `upgrade_repair_shows`; never renamed | | BuildConfig | Prefer `BOXLORE_*`; Gradle still dual-reads `BOXCAST_*` fallbacks | | Episode / media IDs | `rss:` prefixes, negative RSS IDs, mediaId prefixes, and `customCacheKey` schemes stay as implemented. PI **episode supplements** also use negative episode IDs (generated under an `rss:` feed namespace) but keep `Episode.podcastId` as the Podcast Index show id — they are not `rss:` library rows. Episode identity (show ID, catalog key), recipe IDs, collisions and aliases follow [`docs/EPISODE_IDS.md`](docs/EPISODE_IDS.md) | | Playback instance | One UI-scoped `PlaybackRepository`; routes and workers must not construct a second one | diff --git a/app/README.md b/app/README.md index 4275d5d92..0e68bc835 100644 --- a/app/README.md +++ b/app/README.md @@ -68,10 +68,11 @@ 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 `addressCheck`, and the feed address repair `upgradeRepair`; `feedFirst` has 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. +- **Feed address repair after the update** (P4 PR 15, `sync/UpgradeRepairWorker`): a thin WorkManager shell around `:core:catalog` `UpgradeAddressRepair` (`AppContainer.upgradeAddressRepair`, reached through `SharedAppDependenciesHolder`). Every launch, headless ones included, `BoxLoreApplication` calls `UpgradeRepairWorker.scheduleIfNeeded`, which enqueues unique work `feed-address-upgrade-repair` (KEEP, network-gated, exponential backoff from 30 s) only while the stored state says work is left: followed shows with repair steps left, or step 4 not yet recorded in `noBackupFilesDir/upgrade_repair_notifications`. A fresh install, or one with only `rss:` follows, settles without network and enqueues nothing. A restore brings Room but not WorkManager's queue or the step 4 record, so the next launch enqueues the repair again and every followed show's notifications are registered again. A run maps `DONE` and `PAUSED` (`upgrade-repair-off`) to success and `RETRY` to a retry; a launch's chain stops after 10 runs and the next launch enqueues it again. `AppContainer` wires `/v2/shows/feeds`, the one address writer, the catalog re-read, `PodcastNotificationRegistrationWorker`, `connectivityStatus` and `feedTelemetry`. - 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. @@ -163,7 +164,7 @@ Routes include onboarding, home, learn, briefing, settings, debug, explore, libr Unacknowledged tablet launches first show an opaque alpha notice with the Home feedback icon and OK. The Activity latches this initial-window decision through recreation and resize. It needs both the tablet window gate and a display whose shortest side is at least 600dp, so phones never show the notice or read its acknowledgment, even when a landscape window at a small display size passes the tablet gate. The app root, Welcome/onboarding owners and sign-in portal are not composed until OK, which persists a separate local acknowledgment. Cached appearance settings theme the notice, and its body scrolls in short fold-safe regions. NPS, queued SDK surveys and update presentation wait behind this gate; post-OK prompts resume after app frames establish playback state. The visible notice releases ordinary deferred startup work after its own first frames without creating Welcome. -`StartupWorkGate` is process-scoped and releases optional cloud coordinator initialization, periodic sync scheduling, Smart Downloads scheduling/catch-up, foreground subscription sync and legacy RSS repair after the initial screen commits. Optional initialization uses a suspending mutex so ready does not release a burst of initializers. Home signals after loaded local content and two frames; other initial routes release after their first two frames. A headless worker/widget launch releases at the first idle main queue; `MainActivity` identifies a UI launch before creating content. If the first Activity stops before content is ready, background startup also releases; configuration recreation waits for the replacement UI. Recreated roots reuse the same gate. Repeated signals and cancelled initializers are safe (`StartupWorkGateTest`). Adaptive ranking telemetry also waits for readiness. Appearance flows use restored fast-cache values while background hydration fills missing DataStore keys; existing choices are never overwritten. Play update checks begin after the first two Compose frames. Playback/session restoration and preference identity remain owned by the existing composition root. +`StartupWorkGate` is process-scoped and releases optional cloud coordinator initialization, periodic sync scheduling, Smart Downloads scheduling/catch-up, the feed address repair after the update (`UpgradeRepairWorker.scheduleIfNeeded`), foreground subscription sync and legacy RSS repair after the initial screen commits. Optional initialization uses a suspending mutex so ready does not release a burst of initializers. Home signals after loaded local content and two frames; other initial routes release after their first two frames. A headless worker/widget launch releases at the first idle main queue; `MainActivity` identifies a UI launch before creating content. If the first Activity stops before content is ready, background startup also releases; configuration recreation waits for the replacement UI. Recreated roots reuse the same gate. Repeated signals and cancelled initializers are safe (`StartupWorkGateTest`). Adaptive ranking telemetry also waits for readiness. Appearance flows use restored fast-cache values while background hydration fills missing DataStore keys; existing choices are never overwritten. Play update checks begin after the first two Compose frames. Playback/session restoration and preference identity remain owned by the existing composition root. - Release runtime dependency snapshots include Jsoup through `:core:catalog`, which owns shared show-notes resource and chapter parsing. No direct parser-library dependency is added here. `BoxLoreApplication.onCreate` never builds `PlaybackRepository` for Cast state. `AppContainer.synchronizeCastSession` keeps the latest state in `DeferredCastSessionState` and applies it when playback is first created, so widget, worker and notification starts no longer construct playback (prefs migration, MediaController bind) on the main thread. PostHog setup runs on a background thread through `:core:analytics` `PostHogStartup`, and environment super-properties are registered before queued calls replay. Navigation string arguments encode the literal text `null` as the `_` placeholder, because Navigation reads it as a null argument. @@ -189,7 +190,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`) and `podcast-notification-registration-` (`fcm/PodcastNotificationRegistrationWorker`) are permanent. +- The unique work names `episode-share-registration-` (`EpisodeShareRegistrationWorker`), `podcast-notification-registration-` (`fcm/PodcastNotificationRegistrationWorker`) and `feed-address-upgrade-repair` (`sync/UpgradeRepairWorker`) are permanent. +- The feed address repair's step 4 record lives in `noBackupFilesDir/upgrade_repair_notifications` (`FileUpgradeNotificationMarker`), so a restore never brings it and the restored phone registers notifications again; stable name. - SharedPreferences file `boxlore_feed_reports` (`SharedPreferencesFeedReportStamps`, opened by `AppContainer`) holds broken-feed report times; stable name. - 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. @@ -201,7 +203,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. `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. +- `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. `UpgradeRepairWorkerTest` covers the repair's permanent unique name, constraints and backoff, KEEP across launches, no work for a fresh install or `rss:`-only follows, result mapping with the per-launch cap, errors counted as retries, and a run of the real repair to the end; `AppContainerSmokeTest` checks the container hands workers one repair and settles a fresh install. `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 898148c55..112d65b42 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -7,10 +7,12 @@ 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.FeedAddressDailyCheck 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.FileUpgradeNotificationMarker import cx.aswin.boxlore.core.catalog.FolderRepository import cx.aswin.boxlore.core.catalog.InstallReferrerManager import cx.aswin.boxlore.core.catalog.LegacyRssRepair @@ -28,6 +30,8 @@ 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.UpgradeAddressRepair +import cx.aswin.boxlore.core.catalog.UpgradeRepairPorts import cx.aswin.boxlore.core.catalog.ports.SmartDownloadSyncPort import cx.aswin.boxlore.core.catalog.privacy.ConsentManager import cx.aswin.boxlore.core.catalog.sync.HistorySyncResolver @@ -433,6 +437,31 @@ class AppContainer( ) } + /** + * The one-time repair of saved feed addresses after the update, run by + * [cx.aswin.boxlore.sync.UpgradeRepairWorker] behind `upgrade-repair-off`. Step 4's record lives + * in `noBackupFilesDir/upgrade_repair_notifications`, so a restored phone registers again. + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ + override val upgradeAddressRepair: UpgradeAddressRepair by lazy { + UpgradeAddressRepair( + repairDao = database.upgradeRepairDao(), + podcastDao = database.podcastDao(), + ports = + UpgradeRepairPorts( + fetchAddresses = { ids -> FeedAddressDailyCheck.fetchWith(podcastRepository.apiV2, ids) }, + replaceAddress = subscriptionRepository::updateFeedAddress, + reread = UpgradeAddressRepair.rereadWith(subscribedEpisodeCatalog), + requestNotificationRegistration = { id -> PodcastNotificationRegistrationWorker.enqueue(appContext, id) }, + isOnline = connectivityStatus::isOnline, + telemetry = feedTelemetry, + ), + marker = FileUpgradeNotificationMarker(java.io.File(appContext.noBackupFilesDir, FileUpgradeNotificationMarker.FILE_NAME)), + enabled = rolloutSwitches.upgradeRepair, + ) + } + override val folderRepository: FolderRepository by lazy { RoomFolderRepository( folderDao = database.folderDao(), diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index 829d8686f..05731152d 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -294,6 +294,24 @@ class BoxLoreApplication : container.smartDownloadManager.reconcileScheduleWithPreferences() } } + applicationScope.launch { + startupWorkGate.runWhenReady { scheduleUpgradeRepair() } + } + } + + /** + * Enqueues the one-time repair of saved feed addresses on every launch while it has work left, + * headless launches included; it reads stored state, so a restore starts it again. + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ + private suspend fun scheduleUpgradeRepair() { + runCatching { + cx.aswin.boxlore.sync.UpgradeRepairWorker.scheduleIfNeeded(this, container.upgradeAddressRepair) + }.onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + android.util.Log.w("BoxLoreApplication", "Feed address repair scheduling failed", error) + } } /** diff --git a/app/src/main/java/cx/aswin/boxlore/sync/UpgradeRepairWorker.kt b/app/src/main/java/cx/aswin/boxlore/sync/UpgradeRepairWorker.kt new file mode 100644 index 000000000..143c71643 --- /dev/null +++ b/app/src/main/java/cx/aswin/boxlore/sync/UpgradeRepairWorker.kt @@ -0,0 +1,86 @@ +package cx.aswin.boxlore.sync + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +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.SharedAppDependenciesHolder +import cx.aswin.boxlore.core.catalog.UpgradeAddressRepair +import cx.aswin.boxlore.core.catalog.UpgradeRepairRun +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException + +/** + * Runs the one-time repair of saved feed addresses after the update ([UpgradeAddressRepair] in + * `:core:catalog`, reached through [SharedAppDependenciesHolder]). The logic and its stored + * progress live there; this shell only maps a run to a WorkManager result. + * + * Enqueued on every launch while the repair has work left ([scheduleIfNeeded], from + * `BoxLoreApplication`), so a restore, which never brings WorkManager's queue, starts it again. + * Unique work [WORK_NAME] (a permanent identity), KEEP, network-gated, exponential backoff from + * 30 s. A launch's chain ends after [MAX_RUNS] runs; the next launch enqueues it again while + * work is left. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ +class UpgradeRepairWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + internal var repair: suspend () -> UpgradeRepairRun = { + SharedAppDependenciesHolder.require().upgradeAddressRepair?.run() ?: UpgradeRepairRun.DONE + } + + override suspend fun doWork(): Result { + val run = + try { + repair() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w(TAG, "Feed address repair failed", error) + UpgradeRepairRun.RETRY + } + return when (run) { + UpgradeRepairRun.DONE, UpgradeRepairRun.PAUSED -> Result.success() + UpgradeRepairRun.RETRY -> if (runAttemptCount + 1 < MAX_RUNS) Result.retry() else Result.success() + } + } + + companion object { + private const val TAG = "UpgradeRepairWorker" + + /** Permanent unique-work name. Never rename. */ + const val WORK_NAME = "feed-address-upgrade-repair" + + /** Runs per launch, the first included. */ + const val MAX_RUNS = 10 + + private const val BACKOFF_SECONDS = 30L + + fun workRequest(): OneTimeWorkRequest = OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + + /** Enqueues the repair unless it is already waiting or running. */ + fun enqueue(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, workRequest()) + } + + /** + * Enqueues the repair when it has work left. A fresh install, or one with only `rss:` + * follows, settles here without network and enqueues nothing. + */ + suspend fun scheduleIfNeeded(context: Context, repair: UpgradeAddressRepair): Boolean { + if (!repair.needsWork()) return false + enqueue(context) + return true + } + } +} diff --git a/app/src/test/java/cx/aswin/boxlore/AppContainerSmokeTest.kt b/app/src/test/java/cx/aswin/boxlore/AppContainerSmokeTest.kt index 97dc46e94..d1764133a 100644 --- a/app/src/test/java/cx/aswin/boxlore/AppContainerSmokeTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/AppContainerSmokeTest.kt @@ -138,4 +138,21 @@ class AppContainerSmokeTest { assertTrue(next.addressCheck()) assertTrue(next.v2Discovery()) } + + @Test + fun upgradeRepairIsOneInstanceReachableByWorkersAndSettlesAFreshInstall() = kotlinx.coroutines.runBlocking { + val container = + AppContainer( + context = context, + apiBaseUrl = "https://example.test", + publicKey = "test-key", + sharedUserPreferences = UserPreferencesRepository(context), + ) + val repair = container.upgradeAddressRepair + assertSame(repair, container.upgradeAddressRepair) + assertSame(repair, (container as cx.aswin.boxlore.core.catalog.SharedAppDependencies).upgradeAddressRepair) + + assertFalse("nothing followed: settled without network", repair.needsWork()) + assertTrue(java.io.File(context.noBackupFilesDir, cx.aswin.boxlore.core.catalog.FileUpgradeNotificationMarker.FILE_NAME).exists()) + } } diff --git a/app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt b/app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt new file mode 100644 index 000000000..6d0ef8614 --- /dev/null +++ b/app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt @@ -0,0 +1,146 @@ +package cx.aswin.boxlore.sync + +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 +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.MemoryUpgradeNotificationMarker +import cx.aswin.boxlore.core.catalog.UpgradeAddressRepair +import cx.aswin.boxlore.core.catalog.UpgradeRepairPorts +import cx.aswin.boxlore.core.catalog.UpgradeRepairRun +import cx.aswin.boxlore.core.database.BoxLoreDatabase +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.database.UpgradeRepairShowEntity +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort +import cx.aswin.boxlore.core.network.model.V2ShowAddress +import cx.aswin.boxlore.core.network.model.V2ShowsFeedsResponse +import kotlinx.coroutines.runBlocking +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 shell of the one-time feed address repair (own-episode-IDs P4 PR 15, plan section 11): one + * permanent unique work item, KEEP, network-gated with backoff, enqueued only while work is left, + * and a run mapped to a WorkManager result with a per-launch cap. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class UpgradeRepairWorkerTest { + private val context get() = ApplicationProvider.getApplicationContext() + private lateinit var database: BoxLoreDatabase + + @Before + fun setUp() { + WorkManagerTestInitHelper.initializeTestWorkManager(context, Configuration.Builder().setExecutor(SynchronousExecutor()).build()) + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `the work has one permanent name, waits for a network and backs off exponentially`() { + assertEquals("feed-address-upgrade-repair", UpgradeRepairWorker.WORK_NAME) + val work = UpgradeRepairWorker.workRequest().workSpec + assertEquals(NetworkType.CONNECTED, work.constraints.requiredNetworkType) + assertEquals(BackoffPolicy.EXPONENTIAL, work.backoffPolicy) + assertEquals(30_000L, work.backoffDelayDuration) + } + + @Test + fun `every launch with work left keeps the one waiting item`() = runBlocking { + database.podcastDao().upsertRow(show("920666")) + database.upgradeRepairDao().upsert(UpgradeRepairShowEntity("920666")) + val repair = repair() + + assertTrue(UpgradeRepairWorker.scheduleIfNeeded(context, repair)) + val first = waiting().single().id + assertTrue(UpgradeRepairWorker.scheduleIfNeeded(context, repair)) + + assertEquals("KEEP leaves the waiting run in place", listOf(first), waiting().map { it.id }) + } + + @Test + fun `a fresh install or rss-only follows enqueue nothing`() = runBlocking { + assertFalse(UpgradeRepairWorker.scheduleIfNeeded(context, repair())) + database.podcastDao().upsertRow(show("rss:abc").copy(sourceType = PodcastEntity.SOURCE_RSS)) + assertFalse(UpgradeRepairWorker.scheduleIfNeeded(context, repair())) + + assertTrue(waiting().isEmpty()) + } + + @Test + fun `a run maps to a result, and retries stop at the per-launch cap`() = runBlocking { + assertEquals(ListenableWorker.Result.success(), worker(UpgradeRepairRun.DONE).doWork()) + assertEquals("paused work is enqueued again by the next launch", ListenableWorker.Result.success(), worker(UpgradeRepairRun.PAUSED).doWork()) + assertEquals(ListenableWorker.Result.retry(), worker(UpgradeRepairRun.RETRY).doWork()) + assertEquals(ListenableWorker.Result.retry(), worker(UpgradeRepairRun.RETRY, attempt = UpgradeRepairWorker.MAX_RUNS - 2).doWork()) + assertEquals(ListenableWorker.Result.success(), worker(UpgradeRepairRun.RETRY, attempt = UpgradeRepairWorker.MAX_RUNS - 1).doWork()) + } + + @Test + fun `an error counts as a retry`() = runBlocking { + val failing = TestListenableWorkerBuilder(context).build().apply { repair = { error("database closed") } } + assertEquals(ListenableWorker.Result.retry(), failing.doWork()) + } + + @Test + fun `the worker runs the real repair to the end`() = runBlocking { + database.podcastDao().upsertRow(show("920666")) + database.upgradeRepairDao().upsert(UpgradeRepairShowEntity("920666")) + val repair = repair() + val worker = TestListenableWorkerBuilder(context).build().apply { this.repair = { repair.run() } } + + assertEquals(ListenableWorker.Result.success(), worker.doWork()) + assertEquals(0, database.upgradeRepairDao().pendingCount()) + assertFalse(repair.needsWork()) + } + + private fun worker(run: UpgradeRepairRun, attempt: Int = 0) = + TestListenableWorkerBuilder(context).setRunAttemptCount(attempt).build().apply { repair = { run } } + + private fun waiting() = + WorkManager.getInstance(context).getWorkInfosForUniqueWork(UpgradeRepairWorker.WORK_NAME).get().filter { it.state == WorkInfo.State.ENQUEUED } + + private fun repair() = UpgradeAddressRepair( + repairDao = database.upgradeRepairDao(), + podcastDao = database.podcastDao(), + ports = + UpgradeRepairPorts( + fetchAddresses = { ids -> V2ShowsFeedsResponse(registryAvailable = true, results = ids.map { V2ShowAddress(id = it) }) }, + replaceAddress = { error("no swap expected") }, + reread = { _, _ -> LocalEpisodeCatalogPort.RefreshOutcome.Unchanged(null) }, + requestNotificationRegistration = {}, + ), + marker = MemoryUpgradeNotificationMarker(), + enabled = { true }, + ) + + private fun show(id: String) = PodcastEntity( + podcastId = id, + title = "Show", + author = "Author", + imageUrl = "", + description = null, + isSubscribed = true, + feedUrl = "https://example.com/feed.xml", + ) +} diff --git a/core/catalog/README.md b/core/catalog/README.md index 6eb021325..81cee7a20 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -15,6 +15,7 @@ Failed consent writes keep activation off, and local notification flags prevent - `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. +- **Feed address repair after the update.** `UpgradeAddressRepair` (`SharedAppDependencies.upgradeAddressRepair`, run by the app's `UpgradeRepairWorker`) repairs the saved address of every Podcast Index show followed when the phone upgraded; the v39 upgrade seeds one pending `upgrade_repair_shows` row each, a fresh install none. Per show: (1) `POST /v2/shows/feeds` in chunks of at most 500 (`FeedAddressDailyCheck.fetchWith`); a chunk that fails, or that the registry couldn't answer, stays at step 0 for the next run while the other chunks go on; the answer stamps the daily check and records `checkedFetchUrl` only for a confirmed move (`FeedAddressDailyCheck.confirmedMove`), plus the transport address, source and the server's `epNs`; (2) the swap through the one address writer (`feed_url_corrected`, step `upgrade_repair`; skipped for follows changed on the phone and not pushed yet, which the daily check handles once pushed); (3) a catalog re-read of the saved address (`rereadWith`; the server's namespace is passed only for comparison, so issued IDs never change, and moves found are followed by the catalog); (4) notification re-registration for every followed show with notifications on whose own steps are over, recorded in `UpgradeNotificationMarker` (`FileUpgradeNotificationMarker`, `noBackupFilesDir`), so a restored phone registers again even when Room says steps 1–3 are done. Each step is written as it finishes, so process death resumes at the next one; the row is read again before each step, and a show no longer followed ends `done`. A failed re-read while online counts against a cap of 5 tries (`gave_up`); failures while offline never count and stop the run. Shows followed after the upgrade get no row. `needsWork()` settles a fresh install, or one with only `rss:` follows, without network. Behind `RolloutSwitches.upgradeRepair`: off returns `PAUSED` without marking anything done. One `feed_fetch_result` background summary (`source=feed`) per run that read feeds. - 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. @@ -33,7 +34,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`): 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`. +- `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` by the upgrade repair (`UpgradeAddressRepair`). 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`). @@ -155,6 +156,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. +- `FileUpgradeNotificationMarker` keeps the feed address repair's step 4 record (show IDs registered again and a completion flag, a properties file written atomically) in the file the app opens under `noBackupFilesDir` (`upgrade_repair_notifications`), so Android restore never brings it. The repair's per-show progress lives in Room `upgrade_repair_shows` with the stored `UpgradeRepairState` values. Stable storage names and values: never rename. - `SharedPreferencesFeedReportStamps` keeps each show's last broken-feed report time in SharedPreferences file `boxlore_feed_reports` (key: show ID, value: epoch ms; entries older than a day are dropped). `FeedUrlSources` values (`feed_move`, `podcast_lookup`, `cloud_sync`, `feed_report`, `backup_restore`, `registry`) are stored in `podcasts.feedUrlSource`. Stable storage names and values: never rename. - `RecommendationsLastGood` stores the last good `/v2` picks in SharedPreferences file `boxlore_discovery_last_good`, keys `v2_recommendations|personal|` and `v2_recommendations|home|` (episode JSON as the answer named them) plus a `|fallback` key each. Stable storage names: never rename. - Main Room database identity is owned by `:core:database` and exposed through catalog APIs. @@ -173,6 +175,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - Episode links and shares: `EpisodeLinkResolverTest` (resolution order, old IDs to the phone's own row, evicted aliases, recipe IDs with and without a show, API answers translated to local rows, an old Podcast Index link answered with its recipe ID opening the recipe-ID row, a v1 answer matched by enclosure, missing vs unreachable and the HTTP status mapping, `needsFeed` hit and miss, stored namespace, contract keys only, a duplicated feed item), `EpisodeLinkRoomTest` (Room aliases: trusted only, recipe collisions, namespace; recorded `/v2/episode` `needsFeed` answer read through `/v2/feed/` and parsed; a recorded `/v2/episode` Podcast Index answer resolving to a stored recipe-ID row by key and by alias; an enclosure-keyed row and the v1 fallback when v2 is unavailable; `getEpisode` never reads the feed; 404 vs 429 vs 503; push match by recipe ID), `EpisodeSharePreparerTest` (alias targets, RSS shows linked to a Podcast Index show, enclosure keys, phone-only IDs shared by their key's recipe ID, the offline and slow paths going out at once with the retry scheduled, dropped requests, `rss:` and keyless episodes, the recorded register body and every answer class). `InstallReferrerManagerTest` covers a share link's `show` in the install referrer. - Existing coverage includes `PodcastRepositoryCatalogTest` (including the extras' 1,000-item `episodes/match` list with 150 episodes, its page fallback when the route fails or returns nothing, its off switch, and recipe-range lookups on `/v2/episode` with and without a show, served IDs kept as asked, `needsFeed`/404/errors never falling back to Podcast Index, and Podcast Index IDs on `/v2/episode` with v1 only when the v2 route can't answer), `PodcastRepositoryEpisodeSupplementTest` (offset-0 merge, later-page PI-only, `mergeSupplements = false` PI baseline, search union, opted-in `/sync` skip), `LegacyRssRepairLogicTest` (URL/GUID-only acceptance, conflicts, transient failures), `LatestEpisodeTipLogicTest`, `TrackedPodcastRtdbLogicTest` (HTTPS `feedUrl` on notification RTDB rows), `ShowSearchMergeTest`, `DirectFeedSyncOrderTest`, `LibraryBackupDirectFeedLogicTest` / `LibraryBackupDirectFeedRestoreTest` (JSON v6 opt-in export targeting, Gson-null skip, restore-before-`/sync` plan, RTDB patch before feed refresh), `LibraryBackupManualOrderPinsTest` (Manual sort + id lists round-trip; older JSON without lists), `LibraryBackupImportLogicTest` (restore/OPML rethrow cancellation), `OpmlImportLogicTest` (catalog URL/title/feed match before `rss:` fallback, URL scheme/slash/`www` candidates, HTTPS rewrite for feed peek), `SubscriptionForegroundSyncLogicTest` (cooldown / subscriptions-first fetch / catalog ingest id gates), `SubscriptionRepositoryTest` (subscription tombstoning with `unsubscribedAt`, resubscription tombstone clearing, and dirty tracking on settings updates), `InstallReferrerManager` channel derivation / attribution callback seams, `SubscriptionForegroundSync` delay / `requestRefresh` / periodic / failed-feed recovery (dead-address re-check and retry, `/sync` fallback and its switch, once-a-day re-check that counts only answered lookups, moved-feed adoption) / `SubscriptionForegroundSyncIngestTest` (`requestCatalogIngest` cooldown bypass, RSS skip, finished emit, unsubscribe TTL, expiry sweep isolation) / direct-feed concurrency and failure isolation (including missing-feed URL recovery), content orchestration tests, content signal enrichment, grouped sections, recent section intent storage, cross-promotion detection, transcript behavior, and dependency-holder behavior. - `PodcastRepositoryV2DiscoveryTest` (Robolectric, MockWebServer, answers recorded from the live `/v2` routes under `fixtures/v2`, see its README): recommendation seeds and exclusions as recipe IDs (Podcast Index, phone-minted and RSS-linked history; an unplaced Podcast Index ID excluded as itself), at most four languages, picks back under local owners with their recipe IDs and without episodes the history holds; through Room, a played Podcast Index episode with no alias matched by its audio address and not offered again; the last good picks after a failure, after a restart and for an answer without picks, never a v1 request, nothing for another country, unreadable stored picks ignored; Home picks keeping their fallback flag, country-default picks for a listener with no seed or interest, interest-only picks personal, and `failed` with nothing to keep; every discovery route on `/v2` without the app key and translated (trending, vibes, semantic, because-you-like, similar with its recipe-ID seed or `"0"` and without the seed itself, curiosity, briefing metadata, bootstrap with related episodes); show search and typeahead on v1; the trending stream and its last good list; an empty page staying empty; the switch sending everything to v1, personal picks on contract 1; v1 and v2 answers never sharing a cache entry. `RoomDiscoveryEpisodeIdsTest` (Robolectric Room) covers both directions through the alias table (trusted only, owner by listening, collisions and other shows left alone), an owner stored under the show's own feed, and the audio-address match (unique rows only, never a row an alias places). `ChapterRepositoryDiscoveryTest` reads the recorded briefing chapters through `ChapterRepository` from the signed `/v2` link, the cache and a downloaded briefing's file. `BriefingLinksTest` covers the chapters and transcript links, the installed switch, briefing related episodes and a failing alias table. `PodcastRecommendationSeedsTest` covers seed IDs and the `/v2` history scope; `PodcastRepositoryRequestBoundsTest` the contract version, the v1 contract-1 path and the `/v2` request bodies. +- Feed address repair: `UpgradeAddressRepairTest` (Robolectric Room with the real address writer): a fresh install and `rss:`-only follows settle without network; check, confirmed swap, re-read and re-registration per show with one summary event; 501 shows in calls of 500 and 1; a partial batch kept at step 0 with no try counted; offline checks and reads counting no try; process death mid-step resuming without asking or swapping again; a restore with Room done but no step 4 record registering every followed show; unfollow between steps; a follow added mid-run left out; a dead show giving up at the cap; a different server `epNs` recorded and never applied; a follow changed on the phone keeping its address; the switch pausing without marking done; the step 4 file surviving a new process. - `RolloutSwitchesTest` pins the defaults without the app's wiring (`feedFirst` off, every job on) and that each switch reads its own lambda. - `RssNotificationReconcilerTest` runs `FirebaseRssNotificationRemote` against a fake that applies the live `tracked_podcasts` write rule: cleanup keeps the node and drops only `feedUrl`, a never-published key still leaves its topic, and any other failure keeps the topic and the journal entry. - RSS ID and matcher tests live in `:core:rss`; smart-queue tests live in `:core:playback`. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SharedAppDependencies.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SharedAppDependencies.kt index fc69acf45..37dfd3a9d 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SharedAppDependencies.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/SharedAppDependencies.kt @@ -35,6 +35,13 @@ interface SharedAppDependencies { val folderRepository: FolderRepository val deviceIdentityPort: cx.aswin.boxlore.core.domain.ports.DeviceIdentityPort? get() = null val userSyncCoordinator: cx.aswin.boxlore.core.catalog.sync.UserSyncCoordinator? get() = null + + /** + * The one-time repair of saved feed addresses after the update, run by the app's + * `UpgradeRepairWorker`; null when the composition root doesn't provide it (tests). + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). + */ + val upgradeAddressRepair: UpgradeAddressRepair? get() = null } /** diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt new file mode 100644 index 000000000..d448ff4a9 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt @@ -0,0 +1,384 @@ +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 cx.aswin.boxlore.core.database.UpgradeRepairDao +import cx.aswin.boxlore.core.database.UpgradeRepairShowEntity +import cx.aswin.boxlore.core.database.UpgradeRepairState +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +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 cx.aswin.boxlore.core.model.FeedFetchReport +import cx.aswin.boxlore.core.model.FeedFetchSource +import cx.aswin.boxlore.core.model.FeedUrlCorrectionReason +import cx.aswin.boxlore.core.model.FeedUrlCorrectionStep +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.V2ShowFlags +import cx.aswin.boxlore.core.network.model.V2ShowsFeedsRequest +import cx.aswin.boxlore.core.network.model.V2ShowsFeedsResponse +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** How one [UpgradeAddressRepair.run] ended. */ +enum class UpgradeRepairRun { + /** Every step finished, or there was nothing to do. */ + DONE, + + /** Steps are left: offline, the server didn't answer, or a show failed below its cap. */ + RETRY, + + /** `upgrade-repair-off` is on: the repair stopped without marking anything done. */ + PAUSED, +} + +/** + * What [UpgradeAddressRepair] reaches outside Room. + * + * - [fetchAddresses]: one `POST /v2/shows/feeds` for at most [V2ShowsFeedsRequest.MAX_IDS] show + * IDs; null for any answer but 200 ([FeedAddressDailyCheck.fetchWith]). + * - [replaceAddress]: the one address writer ([FeedAddressUpdater.update]). + * - [reread]: reads a followed show's feed again into the local catalog, passing the namespace the + * server reported ([rereadWith]); moves it finds are followed by the catalog itself. + * - [requestNotificationRegistration]: asks for a show's notifications to be registered again, + * acknowledged and retried by the app's capped job; throws [IllegalStateException] when it + * couldn't be asked. + * - [isOnline]: failures while offline never count against a show's cap. + * - [telemetry]: one `feed_fetch_result` summary per run that read feeds. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). + */ +data class UpgradeRepairPorts( + val fetchAddresses: suspend (List) -> V2ShowsFeedsResponse?, + val replaceAddress: suspend (FeedAddressChange) -> FeedAddressUpdate, + val reread: suspend (show: PodcastEntity, serverEpNs: Long?) -> RefreshOutcome, + val requestNotificationRegistration: (podcastId: String) -> Unit, + val isOnline: () -> Boolean = { true }, + val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, +) + +/** + * The one-time repair of saved feed addresses after the update, for every Podcast Index show + * followed when the phone upgraded (the v39 upgrade seeds one pending `upgrade_repair_shows` row + * each; a fresh install has none). Per show: + * + * 1. Ask the server for the show's current address: `POST /v2/shows/feeds`, every waiting show + * in chunks of at most [V2ShowsFeedsRequest.MAX_IDS]. A chunk that fails, or that the address + * registry couldn't answer, stays at step 0 for the next run; the other chunks go on. The answer + * is recorded: `checkedFetchUrl` holds the address to swap to, set only for a confirmed move + * ([FeedAddressDailyCheck.confirmedMove]: the registry flags the show for repair and its own + * HTTPS address differs from the saved one); `fetchUrl: null` keeps the saved address. A + * namespace (`epNs`) that differs from the one the phone keeps is recorded in `checkedEpNs` and + * logged, and never applied to issued IDs (decision D12). + * 2. Swap the saved address through the one address writer, reported as `feed_url_corrected` + * (step `upgrade_repair`) and registered again by the writer. A follow changed on this phone and + * not pushed yet keeps its address (decision 2); the daily check swaps it once it is pushed. + * 3. Read the feed again. A failure while online counts against the show's cap of [MAX_ATTEMPTS]; + * the show then ends [UpgradeRepairState.GAVE_UP], and the daily check keeps watching it. + * 4. Register notifications again for every followed show with notifications on, once its own + * steps are over, recorded in [UpgradeNotificationMarker] (`noBackupFilesDir`), so a restored + * phone registers again even when Room says steps 1–3 are done. + * + * Each finished step is written at once, so process death resumes at the next step. Before each + * step the show's row is read again: a show no longer followed ends [UpgradeRepairState.DONE] + * without further steps. Shows followed after the upgrade have no repair row and are left to the + * daily check. With [enabled] off (`upgrade-repair-off`) the repair stops without marking anything + * done. [needsWork] settles a fresh install, or one with only `rss:` follows, without network. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair; decisions 2 and D12). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ +class UpgradeAddressRepair( + private val repairDao: UpgradeRepairDao, + private val podcastDao: PodcastDao, + private val ports: UpgradeRepairPorts, + private val marker: UpgradeNotificationMarker, + private val enabled: () -> Boolean, + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + private val runLock = Mutex() + private val notifications = UpgradeNotificationStep(repairDao, podcastDao, marker, ports.requestNotificationRegistration) + + /** What one run did, and why it stopped early. */ + private class Pass { + var offline = false + var paused = false + var read = 0 + var notModified = 0 + var failed = 0 + + val stopped: Boolean get() = offline || paused + + /** One `feed_fetch_result` summary for the feeds this run read, if any. */ + fun report(telemetry: FeedTelemetryPort) { + if (read + notModified + failed == 0) return + telemetry.feedFetched(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, read, notModified, failed)) + } + } + + /** + * Whether a run has anything to do: shows with steps left, or step 4 not finished with followed + * shows to register. With neither, step 4 is recorded as finished here, without network. + */ + suspend fun needsWork(): Boolean { + if (repairDao.pendingCount() > 0) return true + if (marker.isComplete) return false + if (notifications.targets().isNotEmpty()) return true + marker.markComplete() + return false + } + + /** Runs every step that is due; see the class description. */ + suspend fun run(): UpgradeRepairRun = runLock.withLock { + if (!enabled()) return@withLock UpgradeRepairRun.PAUSED + val pass = Pass() + checkAddresses(pass) + repairShows(pass) + if (!pass.stopped) notifications.register() + pass.report(ports.telemetry) + when { + pass.paused -> UpgradeRepairRun.PAUSED + repairDao.pendingCount() == 0 && marker.isComplete -> UpgradeRepairRun.DONE + else -> UpgradeRepairRun.RETRY + } + } + + /** Step 1 for every followed show still at step 0. */ + private suspend fun checkAddresses(pass: Pass) { + val askable = LinkedHashMap() + for (entry in repairDao.pendingShows().filter { it.completedStep == 0 }) { + val row = followedRow(entry) ?: continue + val showId = V2EpisodeIds.parseShowId(row.podcastId) + if (showId == null) recordAnswer(entry, row, null) else askable[showId] = entry + } + for (chunk in askable.keys.chunked(V2ShowsFeedsRequest.MAX_IDS)) { + if (stopsHere(pass)) return + val answer = fetch(chunk)?.takeIf(V2ShowsFeedsResponse::registryAvailable) + if (answer == null) pass.offline = !ports.isOnline() else recordChunk(chunk, askable, answer) + } + } + + private suspend fun recordChunk(chunk: List, askable: Map, answer: V2ShowsFeedsResponse) { + val byId = answer.results.associateBy(V2ShowAddress::id) + for (showId in chunk) { + val entry = askable.getValue(showId) + podcastDao.getPodcast(entry.podcastId)?.let { row -> recordAnswer(entry, row, byId[showId]) } + } + } + + private suspend fun fetch(ids: List): V2ShowsFeedsResponse? = try { + ports.fetchAddresses(ids) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Address check of ${ids.size} shows failed", e) + null + } + + /** Records step 1 for one show; the registry's answer also counts as today's daily check. */ + private suspend fun recordAnswer(entry: UpgradeRepairShowEntity, row: PodcastEntity, result: V2ShowAddress?) { + val now = nowMillis() + val served = result?.validEpNs + val kept = row.epNs ?: EpisodeRecipeId.showIdOrNull(row.podcastId) + if (served != null && kept != null && served != kept) { + Log.w(TAG, "Show ${row.podcastId} has namespace $kept here, $served on the server; keeping $kept") + } + if (result != null && !FeedAddressDailyCheck.changedOnPhone(row)) podcastDao.markFeedUrlChecked(row.podcastId, now) + repairDao.upsert( + entry.copy( + completedStep = 1, + checkedFetchUrl = result?.let { FeedAddressDailyCheck.confirmedMove(row, it) }, + checkedTransportUrl = result?.transportUrl, + checkedFeedSource = result?.feedSource, + checkedEpNs = served, + lastAttemptAt = now, + lastError = null, + ), + ) + } + + /** Steps 2 and 3 for every show past step 1, one show at a time. */ + private suspend fun repairShows(pass: Pass) { + for (entry in repairDao.pendingShows().filter { it.completedStep != 0 }) { + if (stopsHere(pass)) return + try { + val current = if (entry.completedStep == 1) swapAddress(entry) else entry + if (current?.completedStep == 2) readAgain(current, pass) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Repair of ${entry.podcastId} failed", e) + recordFailure(entry.podcastId, e.javaClass.simpleName, pass) + } + } + } + + /** Step 2; null when the show is no longer followed. */ + private suspend fun swapAddress(entry: UpgradeRepairShowEntity): UpgradeRepairShowEntity? { + val row = followedRow(entry) ?: return null + val target = swapTarget(row, entry)?.takeUnless { FeedAddressDailyCheck.changedOnPhone(row) } + if (target != null) { + val change = + FeedAddressChange( + podcastId = row.podcastId, + feedUrl = target, + source = entry.checkedFeedSource ?: FeedUrlSources.REGISTRY, + checkedAt = nowMillis(), + onlyReplacing = row.feedUrl, + reason = FeedUrlCorrectionReason.REGISTRY, + step = FeedUrlCorrectionStep.UPGRADE_REPAIR, + ) + Log.i(TAG, "Address swap for ${row.podcastId}: ${ports.replaceAddress(change)}") + } + return entry.copy(completedStep = 2, lastAttemptAt = nowMillis(), lastError = null).also { repairDao.upsert(it) } + } + + /** Step 3. */ + private suspend fun readAgain(entry: UpgradeRepairShowEntity, pass: Pass) { + val row = followedRow(entry) ?: return + if (row.feedUrl.isNullOrBlank()) { + finish(entry) + return + } + when (val outcome = ports.reread(row, entry.checkedEpNs)) { + is RefreshOutcome.Success -> { + pass.read++ + finish(entry) + } + is RefreshOutcome.Unchanged -> { + pass.notModified++ + finish(entry) + } + is RefreshOutcome.Failure -> { + pass.failed++ + recordFailure(entry.podcastId, outcome.reportStatus ?: READ_FAILED, pass) + } + } + } + + private suspend fun finish(entry: UpgradeRepairShowEntity) { + repairDao.upsert(entry.copy(completedStep = 3, state = UpgradeRepairState.DONE, lastAttemptAt = nowMillis(), lastError = null)) + } + + /** Counts a failed try against the show's cap, unless the phone is offline (then the run stops). */ + private suspend fun recordFailure(podcastId: String, reason: String, pass: Pass) { + if (!ports.isOnline()) { + pass.offline = true + return + } + val current = repairDao.getShow(podcastId) ?: return + val attempts = current.attempts + 1 + val state = if (attempts >= MAX_ATTEMPTS) UpgradeRepairState.GAVE_UP else current.state + if (state == UpgradeRepairState.GAVE_UP) Log.w(TAG, "Giving up the repair of $podcastId after $attempts tries ($reason)") + repairDao.upsert(current.copy(attempts = attempts, state = state, lastAttemptAt = nowMillis(), lastError = reason.take(MAX_ERROR_LENGTH))) + } + + /** The show's row while it is still a followed Podcast Index show; otherwise its repair ends done. */ + private suspend fun followedRow(entry: UpgradeRepairShowEntity): PodcastEntity? { + val row = podcastDao.getPodcast(entry.podcastId) + if (row != null && row.isSubscribed && !row.isRss) return row + repairDao.upsert(entry.copy(state = UpgradeRepairState.DONE, lastAttemptAt = nowMillis())) + return null + } + + private fun stopsHere(pass: Pass): Boolean { + if (!pass.stopped && !enabled()) pass.paused = true + return pass.stopped + } + + companion object { + private const val TAG = "UpgradeAddressRepair" + private const val READ_FAILED = "read_failed" + private const val MAX_ERROR_LENGTH = 80 + + /** Failed tries of one show (while online) before its repair gives up. */ + const val MAX_ATTEMPTS = 5 + + /** + * The address step 2 swaps to: step 1's confirmed move, checked again against the row as it + * is now (the saved address may have changed since). Step 1 kept it only when the registry + * flagged the show for repair with its own direct address, so the same rule applies. + */ + internal fun swapTarget(row: PodcastEntity, entry: UpgradeRepairShowEntity): String? { + val fetchUrl = entry.checkedFetchUrl ?: return null + val confirmed = + V2ShowAddress( + id = V2EpisodeIds.parseShowId(row.podcastId) ?: return null, + fetchUrl = fetchUrl, + fetchVia = V2FetchVia.DIRECT, + feedSource = entry.checkedFeedSource, + flags = V2ShowFlags(addressRepair = true), + ) + return FeedAddressDailyCheck.confirmedMove(row, confirmed) + } + + /** The production step 3: a catalog refresh of the saved address, with the server's namespace for comparison only. */ + fun rereadWith(catalog: LocalEpisodeCatalogPort): suspend (PodcastEntity, Long?) -> RefreshOutcome = { row, serverEpNs -> + catalog.refresh( + LocalEpisodeCatalogPort.RefreshRequest( + podcastIndexId = row.podcastId, + feedUrl = row.feedUrl.orEmpty(), + meta = LocalEpisodeCatalogPort.PodcastMeta(title = row.title, imageUrl = row.imageUrl, genre = row.genre, artist = row.author), + serverEpNs = serverEpNs, + ), + ) + } + } +} + +/** + * Step 4 of [UpgradeAddressRepair]: asks for every followed Podcast Index show with notifications + * on, whose own repair steps are over, to be registered again, records each request in [marker], + * and records the step done once no show has steps left and every request was made. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair; "Android backup"). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ +internal class UpgradeNotificationStep( + private val repairDao: UpgradeRepairDao, + private val podcastDao: PodcastDao, + private val marker: UpgradeNotificationMarker, + private val requestRegistration: (podcastId: String) -> Unit, +) { + suspend fun register() { + if (marker.isComplete) return + val requested = mutableListOf() + var allRequested = true + for (podcastId in targets()) { + if (request(podcastId)) requested += podcastId else allRequested = false + if (requested.size >= MARKER_BATCH) { + marker.addRegistered(requested) + requested.clear() + } + } + marker.addRegistered(requested) + if (allRequested && repairDao.pendingCount() == 0) marker.markComplete() + } + + /** Followed Podcast Index shows with notifications on, not yet registered here, whose repair steps are over. */ + suspend fun targets(): List { + val registered = marker.registered() + val waiting = repairDao.pendingShows().mapTo(HashSet()) { it.podcastId } + return podcastDao + .getSubscribedPodcastsList() + .filter { !it.isRss && it.notificationsEnabled && it.podcastId !in registered && it.podcastId !in waiting } + .map { it.podcastId } + } + + private fun request(podcastId: String): Boolean = try { + requestRegistration(podcastId) + true + } catch (e: IllegalStateException) { + Log.e(TAG, "Couldn't ask to register notifications again for $podcastId", e) + false + } + + private companion object { + const val TAG = "UpgradeAddressRepair" + const val MARKER_BATCH = 50 + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt new file mode 100644 index 000000000..8613bf217 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt @@ -0,0 +1,107 @@ +package cx.aswin.boxlore.core.catalog + +import android.util.AtomicFile +import java.io.File +import java.io.FileNotFoundException +import java.util.Properties + +/** + * Step 4 of the one-time feed address repair ([UpgradeAddressRepair]): which followed shows had + * their new-episode notifications registered again on this install, and whether the step finished. + * + * Steps 1–3 are kept in Room (`upgrade_repair_shows`), which an Android restore brings back; this + * marker lives in `noBackupFilesDir`, which a restore never brings, so a restored phone (a new + * messaging token, no topic subscriptions) registers every followed show again even when Room says + * the repair is done. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair; "Android backup"). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ +interface UpgradeNotificationMarker { + /** True once every followed show was registered again after the repair. */ + val isComplete: Boolean + + /** Shows whose registration was already requested on this install. */ + fun registered(): Set + + /** Records [podcastIds] as requested; false when the record couldn't be saved. */ + fun addRegistered(podcastIds: Collection): Boolean + + /** Records that step 4 finished; false when the record couldn't be saved. */ + fun markComplete(): Boolean +} + +/** In-memory [UpgradeNotificationMarker] for tests and previews. */ +class MemoryUpgradeNotificationMarker : UpgradeNotificationMarker { + private val values = linkedSetOf() + + @Volatile + override var isComplete: Boolean = false + private set + + @Synchronized override fun registered(): Set = values.toSet() + + @Synchronized override fun addRegistered(podcastIds: Collection): Boolean { + values += podcastIds + return true + } + + @Synchronized override fun markComplete(): Boolean { + isComplete = true + return true + } +} + +/** + * [UpgradeNotificationMarker] in one file under `noBackupFilesDir` (the app opens + * [FILE_NAME]); written atomically, so process death mid-write keeps the previous record. + * A file that can't be read counts as no record: the shows are registered again, which is harmless. + */ +class FileUpgradeNotificationMarker internal constructor(private val storage: AtomicFile) : UpgradeNotificationMarker { + constructor(file: File) : this(AtomicFile(file)) + + override val isComplete: Boolean + @Synchronized get() = read().getProperty(COMPLETE_KEY) == "true" + + @Synchronized override fun registered(): Set = read().stringPropertyNames().filter { it != COMPLETE_KEY }.toSet() + + @Synchronized override fun addRegistered(podcastIds: Collection): Boolean = + podcastIds.isEmpty() || change { values -> podcastIds.forEach { values.setProperty(it, "1") } } + + @Synchronized override fun markComplete(): Boolean = change { it.setProperty(COMPLETE_KEY, "true") } + + private fun read(): Properties = Properties().apply { + try { + storage.openRead().use { load(it) } + } catch (_: FileNotFoundException) { + // No record yet. + } catch (_: java.io.IOException) { + clear() + } catch (_: IllegalArgumentException) { + clear() + } + } + + private fun change(edit: (Properties) -> Unit): Boolean = try { + val values = read() + edit(values) + val output = storage.startWrite() + try { + values.store(output, "Feed address repair notifications v1") + storage.finishWrite(output) + } catch (error: java.io.IOException) { + storage.failWrite(output) + throw error + } + true + } catch (_: java.io.IOException) { + false + } + + companion object { + /** File name under `noBackupFilesDir`. Stable storage name: never rename. */ + const val FILE_NAME = "upgrade_repair_notifications" + + private const val COMPLETE_KEY = "__complete" + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt new file mode 100644 index 000000000..b699ec796 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt @@ -0,0 +1,435 @@ +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.PodcastDao +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.database.UpgradeRepairDao +import cx.aswin.boxlore.core.database.UpgradeRepairShowEntity +import cx.aswin.boxlore.core.database.UpgradeRepairState +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome +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.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 java.io.File +import java.io.IOException +import kotlinx.coroutines.CancellationException +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.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The one-time repair of saved feed addresses after the update (own-episode-IDs P4 PR 15, plan + * section 11): steps 1–3 per show in Room, step 4 in a marker a restore never brings, resumable + * after process death, offline-safe, capped per show, and silent on fresh installs. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class UpgradeAddressRepairTest { + @get:Rule + val folder = TemporaryFolder() + + private lateinit var database: BoxLoreDatabase + private lateinit var podcasts: PodcastDao + private lateinit var repairs: UpgradeRepairDao + private lateinit var updater: FeedAddressUpdater + private val calls = mutableListOf>() + private val answers = mutableMapOf() + private val rereads = mutableListOf>() + private val registrations = mutableListOf() + private val corrections = mutableListOf() + private val reports = mutableListOf() + private var marker: UpgradeNotificationMarker = MemoryUpgradeNotificationMarker() + private var online = true + private var enabled = true + private var fetch: suspend (List) -> V2ShowsFeedsResponse? = { ids -> answer(ids) } + private var reread: suspend (PodcastEntity, Long?) -> RefreshOutcome = { _, _ -> RefreshOutcome.Success(null, 1, true) } + + private val telemetry = + object : FeedTelemetryPort { + override fun feedFetched(report: FeedFetchReport) { + reports += report + } + + override fun feedUrlCorrected(correction: FeedUrlCorrection) { + corrections += correction + } + } + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + podcasts = database.podcastDao() + repairs = database.upgradeRepairDao() + updater = FeedAddressUpdater(podcasts, requestNotificationRegistration = { registrations += it }, telemetry = telemetry) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `a fresh install and rss-only follows settle without network`() = runTest { + val repair = repair() + assertFalse(repair.needsWork()) + assertTrue(marker.isComplete) + + marker = MemoryUpgradeNotificationMarker() + podcasts.upsertRow(show("rss:abc", "https://example.com/rss.xml", notifications = true).copy(sourceType = PodcastEntity.SOURCE_RSS)) + val rssOnly = repair() + assertFalse(rssOnly.needsWork()) + assertEquals(UpgradeRepairRun.DONE, rssOnly.run()) + + assertTrue(calls.isEmpty()) + assertTrue(rereads.isEmpty()) + assertTrue(registrations.isEmpty()) + } + + @Test + fun `each show is checked, a confirmed move swapped, read again and registered again`() = runTest { + seed(show("920666", OLD, notifications = true)) + seed(show("920667", OTHER)) + answers[920666] = moved(920666) + + val repair = repair() + assertTrue(repair.needsWork()) + assertEquals(UpgradeRepairRun.DONE, repair.run()) + + val swapped = podcasts.getPodcast("920666")!! + assertEquals(NEW, swapped.feedUrl) + assertEquals(OLD, swapped.previousFeedUrl) + assertEquals("registry:apple", swapped.feedUrlSource) + assertEquals(NOW, swapped.feedUrlCheckedAt) + assertEquals("a show the server doesn't know keeps its address", OTHER, podcasts.getPodcast("920667")!!.feedUrl) + assertEquals(NOW, podcasts.getPodcast("920667")!!.feedUrlCheckedAt) + assertEquals(listOf(FeedUrlCorrection("920666", FeedUrlCorrectionReason.REGISTRY, FeedUrlCorrectionStep.UPGRADE_REPAIR)), corrections) + assertEquals(listOf("920666" to 920666L, "920667" to null), rereads) + for (id in listOf("920666", "920667")) { + val row = repairs.getShow(id)!! + assertEquals(3, row.completedStep) + assertEquals(UpgradeRepairState.DONE, row.state) + } + assertEquals("the writer and step 4 both ask; shows with notifications off never", listOf("920666", "920666"), registrations) + assertTrue(marker.isComplete) + assertEquals(listOf(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, okCount = 2, notModifiedCount = 0, failedCount = 0)), reports) + assertFalse(repair.needsWork()) + } + + @Test + fun `more than 500 followed shows are asked in chunks of at most 500`() = runTest { + repeat(501) { seed(show((1_000 + it).toString(), OLD)) } + + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + assertEquals(listOf(500, 1), calls.map { it.size }) + assertEquals(0, repairs.pendingCount()) + } + + @Test + fun `a partial batch keeps the failed chunk at step 0 without counting a try`() = runTest { + repeat(501) { seed(show((1_000 + it).toString(), OLD)) } + fetch = { ids -> if (calls.isEmpty()) answer(ids) else null.also { calls += ids } } + + assertEquals(UpgradeRepairRun.RETRY, repair().run()) + + assertEquals(1, repairs.pendingCount()) + val waiting = repairs.getShow("1500")!! + assertEquals(0, waiting.completedStep) + assertEquals(0, waiting.attempts) + assertEquals(3, repairs.getShow("1499")!!.completedStep) + assertFalse("step 4 waits for every show", marker.isComplete) + + calls.clear() + fetch = { ids -> answer(ids) } + assertEquals(UpgradeRepairRun.DONE, repair().run()) + assertEquals(listOf(listOf(1500L)), calls) + } + + @Test + fun `offline the address check advances nothing and counts no try`() = runTest { + seed(show("920666", OLD, notifications = true)) + online = false + fetch = { ids -> + calls += ids + throw IOException("offline") + } + + assertEquals(UpgradeRepairRun.RETRY, repair().run()) + + val row = repairs.getShow("920666")!! + assertEquals(0, row.completedStep) + assertEquals(0, row.attempts) + assertTrue(rereads.isEmpty()) + assertTrue(registrations.isEmpty()) + assertFalse(marker.isComplete) + } + + @Test + fun `a read that fails while offline counts no try and stops the run`() = runTest { + seed(show("920666", OLD), completedStep = 2) + seed(show("920667", OTHER), completedStep = 2) + online = false + reread = { _, _ -> RefreshOutcome.Failure("offline", reportStatus = "network") } + + assertEquals(UpgradeRepairRun.RETRY, repair().run()) + + assertEquals(listOf("920666"), rereads.map { it.first }) + for (id in listOf("920666", "920667")) { + assertEquals(0, repairs.getShow(id)!!.attempts) + assertEquals(UpgradeRepairState.PENDING, repairs.getShow(id)!!.state) + } + } + + @Test + fun `process death mid-step resumes at the next step without asking or swapping again`() = runTest { + seed(show("920666", OLD, notifications = true)) + answers[920666] = moved(920666) + reread = { _, _ -> throw CancellationException("process killed") } + + try { + repair().run() + fail("the run was stopped") + } catch (_: CancellationException) { + // Expected: the process died during step 3. + } + assertEquals(2, repairs.getShow("920666")!!.completedStep) + assertEquals(NEW, podcasts.getPodcast("920666")!!.feedUrl) + + calls.clear() + rereads.clear() + reread = { _, _ -> RefreshOutcome.Unchanged(null) } + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + assertTrue("step 1 is not asked again", calls.isEmpty()) + assertEquals("the address is swapped once", 1, corrections.size) + assertEquals(listOf("920666" to 920666L), rereads) + assertEquals(UpgradeRepairState.DONE, repairs.getShow("920666")!!.state) + } + + @Test + fun `a restore with Room done but no step 4 record registers every followed show again`() = runTest { + podcasts.upsertRow(show("920666", OLD, notifications = true)) + podcasts.upsertRow(show("920667", OTHER, notifications = true)) + podcasts.upsertRow(show("920668", OTHER)) + podcasts.upsertRow(show("rss:abc", OTHER, notifications = true).copy(sourceType = PodcastEntity.SOURCE_RSS)) + repairs.upsert(UpgradeRepairShowEntity("920666", completedStep = 3, state = UpgradeRepairState.DONE)) + repairs.upsert(UpgradeRepairShowEntity("920667", completedStep = 1, state = UpgradeRepairState.GAVE_UP)) + + val repair = repair() + assertTrue(repair.needsWork()) + assertEquals(UpgradeRepairRun.DONE, repair.run()) + + assertEquals(listOf("920666", "920667"), registrations.sorted()) + assertTrue(calls.isEmpty()) + assertTrue(rereads.isEmpty()) + assertTrue(marker.isComplete) + assertFalse(repair.needsWork()) + } + + @Test + fun `a show unfollowed between steps ends done without a swap or a read`() = runTest { + seed(show("920666", OLD, notifications = true)) + answers[920666] = moved(920666) + fetch = { ids -> + answer(ids).also { podcasts.upsertRow(podcasts.getPodcast("920666")!!.copy(isSubscribed = false)) } + } + + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + val row = repairs.getShow("920666")!! + assertEquals(UpgradeRepairState.DONE, row.state) + assertEquals(1, row.completedStep) + assertEquals(OLD, podcasts.getPodcast("920666")!!.feedUrl) + assertTrue(rereads.isEmpty()) + assertTrue(registrations.isEmpty()) + } + + @Test + fun `a follow added mid-run is left to the daily check and doesn't hold the repair`() = runTest { + seed(show("920666", OLD)) + answers[920666] = moved(920666) + answers[920700] = moved(920700) + fetch = { ids -> answer(ids).also { podcasts.upsertRow(show("920700", OLD, notifications = true)) } } + + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + assertEquals(listOf(listOf(920666L)), calls) + assertNull(repairs.getShow("920700")) + assertEquals(OLD, podcasts.getPodcast("920700")!!.feedUrl) + assertEquals(listOf("920666"), rereads.map { it.first }) + assertTrue(marker.isComplete) + } + + @Test + fun `a dead show gives up at the cap and the repair then finishes`() = runTest { + seed(show("920666", OLD, notifications = true)) + reread = { _, _ -> RefreshOutcome.Failure("gone", addressGone = true, reportStatus = "http_404") } + val repair = repair() + + repeat(UpgradeAddressRepair.MAX_ATTEMPTS - 1) { assertEquals(UpgradeRepairRun.RETRY, repair.run()) } + assertEquals(UpgradeRepairState.PENDING, repairs.getShow("920666")!!.state) + assertTrue("step 4 waits for the show", registrations.isEmpty()) + + assertEquals(UpgradeRepairRun.DONE, repair.run()) + val row = repairs.getShow("920666")!! + assertEquals(UpgradeRepairState.GAVE_UP, row.state) + assertEquals(UpgradeAddressRepair.MAX_ATTEMPTS, row.attempts) + assertEquals("http_404", row.lastError) + assertEquals(UpgradeAddressRepair.MAX_ATTEMPTS, rereads.size) + assertEquals("notifications are registered again even for a show that gave up", listOf("920666"), registrations) + assertEquals(UpgradeRepairRun.DONE, repair.run()) + assertEquals(UpgradeAddressRepair.MAX_ATTEMPTS, rereads.size) + } + + @Test + fun `a different server namespace is recorded and never applied to the phone's IDs`() = runTest { + seed(show("920666", OLD).copy(epNs = 920666L)) + answers[920666] = V2ShowAddress(id = 920666, fetchUrl = OLD, fetchVia = "direct", feedSource = "registry:apple", epNs = 4242) + + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + assertEquals(4242L, repairs.getShow("920666")!!.checkedEpNs) + assertEquals(920666L, podcasts.getPodcast("920666")!!.epNs) + assertEquals("the read only compares it", listOf("920666" to 4242L), rereads) + assertTrue(corrections.isEmpty()) + } + + @Test + fun `a follow changed on the phone keeps its address`() = runTest { + seed(show("920666", OLD).copy(isDirty = true, syncedAt = 5L)) + answers[920666] = moved(920666) + + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + val row = podcasts.getPodcast("920666")!! + assertEquals(OLD, row.feedUrl) + assertEquals("the daily check asks again once it is pushed", 0L, row.feedUrlCheckedAt) + assertTrue(corrections.isEmpty()) + assertEquals(UpgradeRepairState.DONE, repairs.getShow("920666")!!.state) + } + + @Test + fun `with the switch off nothing runs and nothing is marked done`() = runTest { + seed(show("920666", OLD, notifications = true)) + enabled = false + + val repair = repair() + assertEquals(UpgradeRepairRun.PAUSED, repair.run()) + + assertTrue(calls.isEmpty()) + assertEquals(1, repairs.pendingCount()) + assertFalse(marker.isComplete) + assertTrue(repair.needsWork()) + + enabled = true + assertEquals(UpgradeRepairRun.DONE, repair.run()) + } + + @Test + fun `the step 4 record survives a new process in its file and starts empty without one`() { + val file = File(folder.root, FileUpgradeNotificationMarker.FILE_NAME) + val first = FileUpgradeNotificationMarker(file) + assertFalse(first.isComplete) + assertTrue(first.registered().isEmpty()) + + assertTrue(first.addRegistered(listOf("920666", "920667"))) + assertTrue(first.markComplete()) + + val next = FileUpgradeNotificationMarker(file) + assertEquals(setOf("920666", "920667"), next.registered()) + assertTrue(next.isComplete) + + val restored = FileUpgradeNotificationMarker(File(folder.root, "restored")) + assertFalse(restored.isComplete) + } + + @Test + fun `the swap target is checked again against the row as it is now`() { + val entry = UpgradeRepairShowEntity("920666", completedStep = 1, checkedFetchUrl = NEW, checkedFeedSource = "registry:apple") + assertEquals(NEW, UpgradeAddressRepair.swapTarget(show("920666", OLD), entry)) + assertNull("already there", UpgradeAddressRepair.swapTarget(show("920666", NEW), entry)) + assertNull( + "a confirmed feed move left that address", + UpgradeAddressRepair.swapTarget(show("920666", OLD).copy(feedUrlSource = FeedUrlSources.FEED_MOVE, previousFeedUrl = NEW), entry), + ) + assertNull("no confirmed move", UpgradeAddressRepair.swapTarget(show("920666", OLD), entry.copy(checkedFetchUrl = null))) + } + + private fun repair() = UpgradeAddressRepair( + repairDao = repairs, + podcastDao = podcasts, + ports = + UpgradeRepairPorts( + fetchAddresses = { ids -> fetch(ids) }, + replaceAddress = updater::update, + reread = { row, epNs -> + rereads += row.podcastId to epNs + reread(row, epNs) + }, + requestNotificationRegistration = { registrations += it }, + isOnline = { online }, + telemetry = telemetry, + ), + marker = marker, + enabled = { enabled }, + nowMillis = { NOW }, + ) + + private fun answer(ids: List): V2ShowsFeedsResponse { + calls += ids + return V2ShowsFeedsResponse(registryAvailable = true, results = ids.map { answers[it] ?: V2ShowAddress(id = it) }) + } + + private suspend fun seed(row: PodcastEntity, completedStep: Int = 0) { + podcasts.upsertRow(row) + repairs.upsert(UpgradeRepairShowEntity(row.podcastId, completedStep = completedStep)) + } + + private fun moved(id: Long) = V2ShowAddress( + id = id, + fetchUrl = NEW, + fetchVia = "direct", + feedSource = "registry:apple", + epNs = id, + flags = V2ShowFlags(addressRepair = true), + ) + + 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 companion object { + const val OLD = "https://old.example.com/feed.xml" + const val NEW = "https://new.example.com/feed.xml" + const val OTHER = "https://other.example.com/feed.xml" + const val NOW = 1_800_000_000_000L + } +} 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 f75ac826afaee32e0651dc413a3189b2c92e1c74 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 22:10:47 +0530 Subject: [PATCH 4/9] feat(app): repair saved feed addresses after the update --- ARCHITECTURE.md | 2 +- app/README.md | 4 +- .../java/cx/aswin/boxlore/AppContainer.kt | 8 +- .../PodcastNotificationRegistrationWorker.kt | 19 +- ...dcastNotificationRegistrationWorkerTest.kt | 11 + .../boxlore/sync/UpgradeRepairWorkerTest.kt | 3 +- core/analytics/README.md | 2 +- .../core/analytics/FeedAnalyticsTracks.kt | 20 +- .../core/analytics/FeedAnalyticsTracksTest.kt | 9 + core/catalog/README.md | 7 +- .../core/catalog/UpgradeAddressCheck.kt | 215 +++++++++++++++ .../core/catalog/UpgradeAddressRepair.kt | 252 +++++++---------- .../core/catalog/UpgradeNotificationMarker.kt | 29 +- .../core/catalog/UpgradeNotificationStep.kt | 63 +++++ .../core/catalog/sync/UserSyncCoordinator.kt | 18 ++ .../catalog/UpgradeAddressRepairApiTest.kt | 173 ++++++++++++ .../core/catalog/UpgradeAddressRepairTest.kt | 254 ++++++++++++++++-- .../catalog/sync/CloudSyncIdentityTest.kt | 34 +++ .../src/test/resources/fixtures/v2/README.md | 8 + .../v2/shows-feeds-registry-down.json | 15 ++ .../resources/fixtures/v2/shows-feeds.json | 45 ++++ core/database/README.md | 2 +- .../boxlore/core/database/UpgradeRepairDao.kt | 7 + core/model/README.md | 2 +- .../aswin/boxlore/core/model/FeedTelemetry.kt | 7 +- docs/analytics/event_glossary.csv | 2 +- 26 files changed, 1010 insertions(+), 201 deletions(-) create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressCheck.kt create mode 100644 core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationStep.kt create mode 100644 core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairApiTest.kt create mode 100644 core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-down.json create mode 100644 core/catalog/src/test/resources/fixtures/v2/shows-feeds.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e11190ae4..bc3a0deb5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,7 +29,7 @@ These values are part of the shipping product. Renames or recreations break upgr | 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 | -| Feed address repair after the update | Unique work name `feed-address-upgrade-repair` (`UpgradeRepairWorker`); step 4 record `noBackupFilesDir/upgrade_repair_notifications`; per-show progress in Room `upgrade_repair_shows`; never renamed | +| Feed address repair after the update | Unique work name `feed-address-upgrade-repair` (`UpgradeRepairWorker`); step 4 and sync catch-up record `noBackupFilesDir/upgrade_repair_notifications`; per-show progress in Room `upgrade_repair_shows`; never renamed | | BuildConfig | Prefer `BOXLORE_*`; Gradle still dual-reads `BOXCAST_*` fallbacks | | Episode / media IDs | `rss:` prefixes, negative RSS IDs, mediaId prefixes, and `customCacheKey` schemes stay as implemented. PI **episode supplements** also use negative episode IDs (generated under an `rss:` feed namespace) but keep `Episode.podcastId` as the Podcast Index show id — they are not `rss:` library rows. Episode identity (show ID, catalog key), recipe IDs, collisions and aliases follow [`docs/EPISODE_IDS.md`](docs/EPISODE_IDS.md) | | Playback instance | One UI-scoped `PlaybackRepository`; routes and workers must not construct a second one | diff --git a/app/README.md b/app/README.md index 0e68bc835..921ee8e4c 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 address repair after the update** (P4 PR 15, `sync/UpgradeRepairWorker`): a thin WorkManager shell around `:core:catalog` `UpgradeAddressRepair` (`AppContainer.upgradeAddressRepair`, reached through `SharedAppDependenciesHolder`). Every launch, headless ones included, `BoxLoreApplication` calls `UpgradeRepairWorker.scheduleIfNeeded`, which enqueues unique work `feed-address-upgrade-repair` (KEEP, network-gated, exponential backoff from 30 s) only while the stored state says work is left: followed shows with repair steps left, or step 4 not yet recorded in `noBackupFilesDir/upgrade_repair_notifications`. A fresh install, or one with only `rss:` follows, settles without network and enqueues nothing. A restore brings Room but not WorkManager's queue or the step 4 record, so the next launch enqueues the repair again and every followed show's notifications are registered again. A run maps `DONE` and `PAUSED` (`upgrade-repair-off`) to success and `RETRY` to a retry; a launch's chain stops after 10 runs and the next launch enqueues it again. `AppContainer` wires `/v2/shows/feeds`, the one address writer, the catalog re-read, `PodcastNotificationRegistrationWorker`, `connectivityStatus` and `feedTelemetry`. +- **Feed address repair after the update** (P4 PR 15, `sync/UpgradeRepairWorker`): a thin WorkManager shell around `:core:catalog` `UpgradeAddressRepair` (`AppContainer.upgradeAddressRepair`, reached through `SharedAppDependenciesHolder`). Every launch, headless ones included, `BoxLoreApplication` calls `UpgradeRepairWorker.scheduleIfNeeded`, which enqueues unique work `feed-address-upgrade-repair` (KEEP, network-gated, exponential backoff from 30 s) only while the stored state says work is left: followed shows with repair steps left, or step 4 not yet recorded in `noBackupFilesDir/upgrade_repair_notifications`. A fresh install, or one with only `rss:` follows, settles without network and enqueues nothing. A restore brings Room but not WorkManager's queue or the step 4 record, so the next launch enqueues the repair again and every followed show's notifications are registered again. A run maps `DONE` and `PAUSED` (`upgrade-repair-off`) to success and `RETRY` to a retry; a launch's chain stops after 10 runs and the next launch enqueues it again. `AppContainer` wires `/v2/shows/feeds` (`AddressCheckAnswer.fetchWith`), the one address writer, the catalog re-read, `PodcastNotificationRegistrationWorker.request` (returns once WorkManager has saved the request, so step 4 never records one lost to process death), `userSyncCoordinator.requestIdentityCatchUp` (once every followed show was read again, the next sync runs the identity catch-up again), `connectivityStatus` and `feedTelemetry`. - 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. @@ -203,7 +203,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. `UpgradeRepairWorkerTest` covers the repair's permanent unique name, constraints and backoff, KEEP across launches, no work for a fresh install or `rss:`-only follows, result mapping with the per-launch cap, errors counted as retries, and a run of the real repair to the end; `AppContainerSmokeTest` checks the container hands workers one repair and settles a fresh install. `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. +- `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. `UpgradeRepairWorkerTest` covers the repair's permanent unique name, constraints and backoff, KEEP across launches, no work for a fresh install or `rss:`-only follows, result mapping with the per-launch cap, errors counted as retries, and a run of the real repair to the end; `AppContainerSmokeTest` checks the container hands workers one repair and settles a fresh install. `PodcastNotificationRegistrationWorkerTest` covers its unique name per show (none for `rss:` shows, a newer request replacing the waiting one, a request returning once the work is saved), 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 112d65b42..5864b2a99 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -2,12 +2,12 @@ package cx.aswin.boxlore import android.content.Context import cx.aswin.boxlore.connectivity.AndroidConnectivityObserver +import cx.aswin.boxlore.core.catalog.AddressCheckAnswer 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.FeedAddressDailyCheck import cx.aswin.boxlore.core.catalog.FeedAddressUpkeep import cx.aswin.boxlore.core.catalog.FeedAddressWiring import cx.aswin.boxlore.core.catalog.FeedFixToggles @@ -441,6 +441,7 @@ class AppContainer( * The one-time repair of saved feed addresses after the update, run by * [cx.aswin.boxlore.sync.UpgradeRepairWorker] behind `upgrade-repair-off`. Step 4's record lives * in `noBackupFilesDir/upgrade_repair_notifications`, so a restored phone registers again. + * Once every show was read again, the sync's identity catch-up runs once more. * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). */ @@ -450,10 +451,11 @@ class AppContainer( podcastDao = database.podcastDao(), ports = UpgradeRepairPorts( - fetchAddresses = { ids -> FeedAddressDailyCheck.fetchWith(podcastRepository.apiV2, ids) }, + fetchAddresses = AddressCheckAnswer.fetchWith(podcastRepository.apiV2), replaceAddress = subscriptionRepository::updateFeedAddress, reread = UpgradeAddressRepair.rereadWith(subscribedEpisodeCatalog), - requestNotificationRegistration = { id -> PodcastNotificationRegistrationWorker.enqueue(appContext, id) }, + requestNotificationRegistration = { id -> PodcastNotificationRegistrationWorker.request(appContext, id) }, + onAddressStepsFinished = { userSyncCoordinator.requestIdentityCatchUp() }, isOnline = connectivityStatus::isOnline, telemetry = feedTelemetry, ), 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..8e785fbc5 100644 --- a/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt +++ b/app/src/main/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorker.kt @@ -9,8 +9,10 @@ import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation import androidx.work.WorkManager import androidx.work.WorkerParameters +import androidx.work.await import cx.aswin.boxlore.core.catalog.NotificationRegistrationOutcome import cx.aswin.boxlore.core.catalog.SharedAppDependenciesHolder import java.util.concurrent.TimeUnit @@ -70,8 +72,21 @@ class PodcastNotificationRegistrationWorker(context: Context, params: WorkerPara /** 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)) + enqueueWork(context, podcastId) + } + + /** + * [enqueue], returning once WorkManager has saved the request, so a caller that records it + * (the upgrade repair's step 4) never records one lost to process death. Throws when the + * request couldn't be saved. Added in P4 PR 15 of the own-episode-IDs plan (plan section 11). + */ + suspend fun request(context: Context, podcastId: String) { + enqueueWork(context, podcastId)?.await() + } + + private fun enqueueWork(context: Context, podcastId: String): Operation? { + if (podcastId.isBlank() || podcastId.startsWith("rss:")) return null + 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 index f8f30d429..8b52420e0 100644 --- a/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/fcm/PodcastNotificationRegistrationWorkerTest.kt @@ -49,6 +49,17 @@ class PodcastNotificationRegistrationWorkerTest { assertTrue(WorkManager.getInstance(context).getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("rss:abc")).get().isEmpty()) } + @Test + fun `a request returns once the work is saved, and never saves one for rss shows`() = runBlocking { + PodcastNotificationRegistrationWorker.request(context, "920666") + PodcastNotificationRegistrationWorker.request(context, "rss:abc") + + val manager = WorkManager.getInstance(context) + val work = manager.getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("920666")).get() + assertEquals(1, work.count { it.state == WorkInfo.State.ENQUEUED }) + assertTrue(manager.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 diff --git a/app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt b/app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt index 6d0ef8614..5cc869aba 100644 --- a/app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/sync/UpgradeRepairWorkerTest.kt @@ -13,6 +13,7 @@ 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.AddressCheckAnswer import cx.aswin.boxlore.core.catalog.MemoryUpgradeNotificationMarker import cx.aswin.boxlore.core.catalog.UpgradeAddressRepair import cx.aswin.boxlore.core.catalog.UpgradeRepairPorts @@ -125,7 +126,7 @@ class UpgradeRepairWorkerTest { podcastDao = database.podcastDao(), ports = UpgradeRepairPorts( - fetchAddresses = { ids -> V2ShowsFeedsResponse(registryAvailable = true, results = ids.map { V2ShowAddress(id = it) }) }, + fetchAddresses = { ids -> AddressCheckAnswer.Answered(V2ShowsFeedsResponse(registryAvailable = true, results = ids.map { V2ShowAddress(id = it) })) }, replaceAddress = { error("no swap expected") }, reread = { _, _ -> LocalEpisodeCatalogPort.RefreshOutcome.Unchanged(null) }, requestNotificationRegistration = {}, diff --git a/core/analytics/README.md b/core/analytics/README.md index c53fe390f..c091938f6 100644 --- a/core/analytics/README.md +++ b/core/analytics/README.md @@ -13,7 +13,7 @@ Owns analytics event capture and non-fatal error reporting for Boxlore. The modu - `trackApiSessionStatus(ApiSessionStatus)` emits `api_session_status` for API sessions (`ApiSessionAnalyticsTracks`); null properties are left out. One event per `/auth/session` exchange (`kind=exchange`: `trigger`, `success`, `duration_ms`, `attestation`, `limited`, `app_check_attempted`, `failure_reason`, `server_reason`), per first renewal of a token or renewal problem (`kind=renew`, `renew_outcome`) and per Turnstile step-up (`kind=step_up`, `step_up_outcome`). Properties are categories only, never tokens, ids or keys. `trackAppCheckStatus` takes an optional `reason` (a short failure category such as `play_integrity_-9`); App Check now runs only inside a session exchange, so `app_check_status` arrives once per attempt rather than once per launch. -- `trackFeedFetchResult(FeedFetchReport)` emits `feed_fetch_result`: once per show-page read (`context=show_page`, `show_id`, `source`, `result`, `completeness` on successful reads, `http_status` only on `http_error` and `gone`) and once per background pass and source as a summary (`context=background_pass`, `result` `ok`/`mixed`/`failed`, `shows_count`, `ok_count`, `not_modified_count`, `failed_count`; a pass that read nothing sends nothing). `trackFeedUrlCorrected(FeedUrlCorrection)` emits `feed_url_corrected` once per saved-address swap (`show_id`, `reason`, `step`). Neither carries an address: a show ID that looks like one is left out, and `feed_url_corrected` is then dropped (`FeedAnalyticsTracks`, `FeedAnalyticsTracksTest`). Catalog code reaches them through `:core:domain`'s `FeedTelemetryPort`, which `:app` wires; nothing emits them yet. +- `trackFeedFetchResult(FeedFetchReport)` emits `feed_fetch_result`: once per show-page read (`context=show_page`, `show_id`, `source`, `result`, `completeness` on successful reads, `http_status` only on `http_error` and `gone`) and once per background pass and source as a summary (`context=background_pass`, `result` `ok`/`mixed`/`failed`, `shows_count`, `ok_count`, `not_modified_count`, `failed_count`, and `namespace_mismatch_count` only when the server reported another episode namespace for shows read in the pass; a pass that read nothing sends nothing). `trackFeedUrlCorrected(FeedUrlCorrection)` emits `feed_url_corrected` once per saved-address swap (`show_id`, `reason`, `step`). Neither carries an address: a show ID that looks like one is left out, and `feed_url_corrected` is then dropped (`FeedAnalyticsTracks`, `FeedAnalyticsTracksTest`). Catalog code reaches them through `:core:domain`'s `FeedTelemetryPort`, which `:app` wires. - `AnalyticsGlossary.EPISODE_ID_EVENTS` lists the 25 events that carry `episode_id`; each also declares an optional `recipe_id` (`RECIPE_ID_PROPERTY`), and `home_surface_impression` declares `recipe_ids` (`RECIPE_IDS_PROPERTY`). The façades do not send them yet. `GlossaryRecipeIdTest` keeps the set, the CSV and the Markdown glossary in step. - `Analytics` is the interface used by production code and tests. - `AnalyticsHelper` is the PostHog-backed production singleton. diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracks.kt index 032e35c1d..e899c0a52 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracks.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracks.kt @@ -55,15 +55,17 @@ internal object FeedAnalyticsTracks { } } - private fun backgroundPassProperties(report: FeedFetchReport.BackgroundPass): Map = mapOf( - "context" to CONTEXT_BACKGROUND_PASS, - "source" to report.source.wireName, - "result" to report.passResult.wireName, - "shows_count" to report.showsCount, - "ok_count" to report.okCount.coerceAtLeast(0), - "not_modified_count" to report.notModifiedCount.coerceAtLeast(0), - "failed_count" to report.failedCount.coerceAtLeast(0), - ) + private fun backgroundPassProperties(report: FeedFetchReport.BackgroundPass): Map = buildMap { + put("context", CONTEXT_BACKGROUND_PASS) + put("source", report.source.wireName) + put("result", report.passResult.wireName) + put("shows_count", report.showsCount) + put("ok_count", report.okCount.coerceAtLeast(0)) + put("not_modified_count", report.notModifiedCount.coerceAtLeast(0)) + put("failed_count", report.failedCount.coerceAtLeast(0)) + // Sent only when a show's namespace differs (decision D12), so ordinary passes stay as they were. + if (report.namespaceMismatchCount > 0) put("namespace_mismatch_count", report.namespaceMismatchCount) + } /** Show IDs are Podcast Index numbers or hashed `rss:` IDs; anything with a scheme or host is dropped. */ private fun safeShowId(showId: String): String? = showId.trim().takeIf { it.isNotEmpty() && "/" !in it && "." !in it } diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracksTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracksTest.kt index 76b552f3a..0a7ec8cb1 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracksTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/FeedAnalyticsTracksTest.kt @@ -134,6 +134,15 @@ class FeedAnalyticsTracksTest { ) } + @Test + fun `a background pass counts shows whose namespace differs only when there are some`() { + AnalyticsHelper.trackFeedFetchResult(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, 2, 0, 0, namespaceMismatchCount = 1)) + AnalyticsHelper.trackFeedFetchResult(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, 2, 0, 0)) + + assertEquals(1, recorder[0].second["namespace_mismatch_count"]) + assertFalse("namespace_mismatch_count" in recorder[1].second) + } + @Test fun `a background pass result is ok without failures and failed when every read failed`() { AnalyticsHelper.trackFeedFetchResult(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, 3, 4, 0)) diff --git a/core/catalog/README.md b/core/catalog/README.md index 81cee7a20..b4cb1d9c9 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -15,7 +15,7 @@ Failed consent writes keep activation off, and local notification flags prevent - `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. -- **Feed address repair after the update.** `UpgradeAddressRepair` (`SharedAppDependencies.upgradeAddressRepair`, run by the app's `UpgradeRepairWorker`) repairs the saved address of every Podcast Index show followed when the phone upgraded; the v39 upgrade seeds one pending `upgrade_repair_shows` row each, a fresh install none. Per show: (1) `POST /v2/shows/feeds` in chunks of at most 500 (`FeedAddressDailyCheck.fetchWith`); a chunk that fails, or that the registry couldn't answer, stays at step 0 for the next run while the other chunks go on; the answer stamps the daily check and records `checkedFetchUrl` only for a confirmed move (`FeedAddressDailyCheck.confirmedMove`), plus the transport address, source and the server's `epNs`; (2) the swap through the one address writer (`feed_url_corrected`, step `upgrade_repair`; skipped for follows changed on the phone and not pushed yet, which the daily check handles once pushed); (3) a catalog re-read of the saved address (`rereadWith`; the server's namespace is passed only for comparison, so issued IDs never change, and moves found are followed by the catalog); (4) notification re-registration for every followed show with notifications on whose own steps are over, recorded in `UpgradeNotificationMarker` (`FileUpgradeNotificationMarker`, `noBackupFilesDir`), so a restored phone registers again even when Room says steps 1–3 are done. Each step is written as it finishes, so process death resumes at the next one; the row is read again before each step, and a show no longer followed ends `done`. A failed re-read while online counts against a cap of 5 tries (`gave_up`); failures while offline never count and stop the run. Shows followed after the upgrade get no row. `needsWork()` settles a fresh install, or one with only `rss:` follows, without network. Behind `RolloutSwitches.upgradeRepair`: off returns `PAUSED` without marking anything done. One `feed_fetch_result` background summary (`source=feed`) per run that read feeds. +- **Feed address repair after the update.** `UpgradeAddressRepair` (`SharedAppDependencies.upgradeAddressRepair`, run by the app's `UpgradeRepairWorker`) repairs the saved address of every Podcast Index show followed when the phone upgraded; the v39 upgrade seeds one pending `upgrade_repair_shows` row each, a fresh install none. Per show: (1) `POST /v2/shows/feeds` in chunks of at most 500 (`UpgradeAddressCheckStep`, production request `AddressCheckAnswer.fetchWith`); the answer stamps the daily check and records `checkedFetchUrl` only for a confirmed move (`FeedAddressDailyCheck.confirmedMove`), plus the transport address, source and the server's `epNs`. A chunk the server couldn't answer (503, 429, another 5xx, no connection, or `registryAvailable: false`) stays at step 0 without counting a try while the other chunks go on; one it refused (another 4xx) or whose answer couldn't be read counts a try for each show. A show refused 5 times, or unanswered for 7 days since its first failed check (`MAX_UNANSWERED_MS`), goes on without an answer: no swap, and the daily check looks after its address. (2) The swap through the one address writer (`feed_url_corrected`, step `upgrade_repair`; skipped for follows changed on the phone and not pushed yet, which the daily check handles once pushed). An answer more than a day old is asked again at the start of a run (a repair paused or stopped for a while never applies an old answer), and one the daily check has since replaced with a newer answer is not applied. (3) A catalog re-read of the saved address (`rereadWith`; the server's namespace is passed only for comparison, so issued IDs never change, and moves found are followed by the catalog). It never starts the foreground auto-download pass (`runPostPersistCallback = false`): the regular background pass applies the listener's background download rules. (4) Notification re-registration for every followed show with notifications on whose own steps are over, recorded in `UpgradeNotificationMarker` (`FileUpgradeNotificationMarker`, `noBackupFilesDir`) only once the app reports the request saved, so a restored phone registers again even when Room says steps 1–3 are done. Once no show has steps 1–3 left, `UpgradeRepairPorts.onAddressStepsFinished` runs once per install (recorded in the same file; never on a fresh install, while shows wait, or while paused), which the app wires to `UserSyncCoordinator.requestIdentityCatchUp`, so history and queue rows whose episodes the reads settled reach the cloud with their identity. Each step is written as it finishes, so process death resumes at the next one; the row is read again before each step, and a show no longer followed ends `done`. A failed re-read while online counts against a cap of 5 tries (`gave_up`); failures while offline never count and stop the run. Shows followed after the upgrade get no row. `needsWork()` settles a fresh install, or one with only `rss:` follows, without network. Behind `RolloutSwitches.upgradeRepair`: off returns `PAUSED` without marking anything done. One `feed_fetch_result` background summary (`source=feed`) per run that read feeds, with `namespaceMismatchCount` for reads of shows whose server namespace differs from the phone's (decision D12). - 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. @@ -53,6 +53,7 @@ Failed consent writes keep activation off, and local notification flags prevent - **Pull.** Pulls send `richRemovals: true`. Before the resolvers merge anything, every pulled history and queue row whose ID has no alias gets one under its show: trusted when it names, by catalog key (else by a recipe ID exactly one key of the show has), an episode this phone already holds under a trusted ID, so it joins that episode's projected state; unresolved otherwise, so it is never a new owner and the show's next feed read can still attribute it. An unresolved alias of the same show is settled the same way. Rows of `rss:` shows, Brief IDs, IDs a catalog row holds and IDs this phone's history stores under another show get none. A pulled row of the playing episode under an ID without a row here is stored as the cloud has it (playback writes only the owner row, which is the newest again from its next save). The idle mini-player is handed the pulled episode's owner ID, and `IdleSessionHandoff` in `:core:playback` leaves it alone when it already holds that episode under another ID. A pull from the start by a device that synced before (`executePull`'s `deliveredUntil`, the last sync time) doesn't restore a history row removed here: history removal is local, and a cloud row stored before the last sync that this device no longer has was removed by the listener. - **Queue merge.** `QueueSyncResolver` works per episode: a pulled item of an episode this phone queues keeps the phone's item and ID, each episode is queued once, and the playing episode keeps the ID it plays under. This phone's unsynced removals also drop the pulled item of the same episode under another ID, and the server's plain and rich removals (united; a rich removal counts only while the plain list still holds its ID) also drop this phone's unsynced item of the same episode. A removal whose episode the same side still queues under another ID is stale and matches only its own ID; a pulled item counts by the identity it carries too, so the cloud's re-queue under an ID this phone can't identify yet still makes the cloud's removal stale. The stored rich list stays in step with the plain one: without unsynced changes here it is the server's own list (not merged with this phone's older one), and no stored rich removal names an episode the merged queue holds, by this phone's aliases or by a pulled item's identity, because the server removes every queue row of a rich removal's episode. The merge keeps one entry per episode, so the cloud can still hold an episode under an ID this phone folded into its own entry; a removal on this phone therefore records every ID of the episode (`QueueRepository` through `EpisodeListenerStore.queueRemovalIds`), which also reaches a copy an older build pushed without identity. `QueueItemHydration` builds the row of a pulled item this phone doesn't queue: the catalog and history tiers read the episode under any of its trusted IDs, and the network tier asks `/v2/episode` (through `PodcastRepository.getEpisode` with the show) for the recipe ID the server sent before the stored ID. - **One-time catch-up.** Sync metadata version 3 (`UserSyncCoordinator.IDENTITY_SYNC_METADATA_VERSION`): the first sync pulls from the start, then pushes back the cloud copies that lack an identity this phone knows (`identityCatchUp` in `SyncIdentityCatchUp.kt`): each pulled history row exactly as the cloud sent it, with its own `updatedAt`, plus only the missing recipe ID or catalog key, and the pulled queue the same way (without rich removals, so the server keeps its list) when one of its items gains an identity. The server keeps every other value, other devices' delta pulls receive nothing, and no local row is marked for the push. A copy that names another key or recipe ID, or a row this phone can't identify, is left as it is. The pushes go batch after batch in the same sync (at most `MAX_CATCH_UP_PUSHES`, 100 batches of 100 rows), and version 3 is recorded only after all of them went through; a failed or cut-short run starts again with a pull from the start on the next sync, and copies already caught up are skipped. + - `requestIdentityCatchUp()` sets the version back below 3 on a phone that already caught up (under the sync lock, so a running sync can't record the catch-up done over it); the feed address repair calls it once every followed show was read again, so episodes those reads settled get their identity in the cloud. - The account-switch purge also clears the queue's rich removals, and the switch sets the sync metadata version below 3 and runs the catch-up for the new account right after its pull from the start. Cloud sync doesn't carry the link of an RSS show to the Podcast Index show it replaced yet, so a phone restored from the cloud links no switched show. - `FolderRepository` and `RoomFolderRepository` manage library subscription folders: creating iconless or icon-assigned folders with optional `showPodcastGrid` 1×1 cover style, updating display sizes, restoring folders from JSON backups (`restoreFolder`), auto-organizing subscribed shows into genre folders (with selectable display sizes and cover styles, while preserving custom and existing folders), auto-syncing subscribed shows matching `linkedGenre` (with canonical genre token matching and Tech/Technology synonym normalization), moving shows between folders on genre customization, adding/removing podcasts from folders, and cascading folder deletions without unsubscribing shows. - `:core:rss`, `:core:domain`, `:core:database`, and `:core:prefs` are re-exported where existing public signatures require those types. @@ -156,7 +157,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. -- `FileUpgradeNotificationMarker` keeps the feed address repair's step 4 record (show IDs registered again and a completion flag, a properties file written atomically) in the file the app opens under `noBackupFilesDir` (`upgrade_repair_notifications`), so Android restore never brings it. The repair's per-show progress lives in Room `upgrade_repair_shows` with the stored `UpgradeRepairState` values. Stable storage names and values: never rename. +- `FileUpgradeNotificationMarker` keeps the feed address repair's step 4 record (show IDs registered again, a completion flag and whether the sync catch-up was asked for; a properties file written atomically) in the file the app opens under `noBackupFilesDir` (`upgrade_repair_notifications`), so Android restore never brings it. The repair's per-show progress lives in Room `upgrade_repair_shows` with the stored `UpgradeRepairState` values. Stable storage names and values: never rename. - `SharedPreferencesFeedReportStamps` keeps each show's last broken-feed report time in SharedPreferences file `boxlore_feed_reports` (key: show ID, value: epoch ms; entries older than a day are dropped). `FeedUrlSources` values (`feed_move`, `podcast_lookup`, `cloud_sync`, `feed_report`, `backup_restore`, `registry`) are stored in `podcasts.feedUrlSource`. Stable storage names and values: never rename. - `RecommendationsLastGood` stores the last good `/v2` picks in SharedPreferences file `boxlore_discovery_last_good`, keys `v2_recommendations|personal|` and `v2_recommendations|home|` (episode JSON as the answer named them) plus a `|fallback` key each. Stable storage names: never rename. - Main Room database identity is owned by `:core:database` and exposed through catalog APIs. @@ -175,7 +176,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - Episode links and shares: `EpisodeLinkResolverTest` (resolution order, old IDs to the phone's own row, evicted aliases, recipe IDs with and without a show, API answers translated to local rows, an old Podcast Index link answered with its recipe ID opening the recipe-ID row, a v1 answer matched by enclosure, missing vs unreachable and the HTTP status mapping, `needsFeed` hit and miss, stored namespace, contract keys only, a duplicated feed item), `EpisodeLinkRoomTest` (Room aliases: trusted only, recipe collisions, namespace; recorded `/v2/episode` `needsFeed` answer read through `/v2/feed/` and parsed; a recorded `/v2/episode` Podcast Index answer resolving to a stored recipe-ID row by key and by alias; an enclosure-keyed row and the v1 fallback when v2 is unavailable; `getEpisode` never reads the feed; 404 vs 429 vs 503; push match by recipe ID), `EpisodeSharePreparerTest` (alias targets, RSS shows linked to a Podcast Index show, enclosure keys, phone-only IDs shared by their key's recipe ID, the offline and slow paths going out at once with the retry scheduled, dropped requests, `rss:` and keyless episodes, the recorded register body and every answer class). `InstallReferrerManagerTest` covers a share link's `show` in the install referrer. - Existing coverage includes `PodcastRepositoryCatalogTest` (including the extras' 1,000-item `episodes/match` list with 150 episodes, its page fallback when the route fails or returns nothing, its off switch, and recipe-range lookups on `/v2/episode` with and without a show, served IDs kept as asked, `needsFeed`/404/errors never falling back to Podcast Index, and Podcast Index IDs on `/v2/episode` with v1 only when the v2 route can't answer), `PodcastRepositoryEpisodeSupplementTest` (offset-0 merge, later-page PI-only, `mergeSupplements = false` PI baseline, search union, opted-in `/sync` skip), `LegacyRssRepairLogicTest` (URL/GUID-only acceptance, conflicts, transient failures), `LatestEpisodeTipLogicTest`, `TrackedPodcastRtdbLogicTest` (HTTPS `feedUrl` on notification RTDB rows), `ShowSearchMergeTest`, `DirectFeedSyncOrderTest`, `LibraryBackupDirectFeedLogicTest` / `LibraryBackupDirectFeedRestoreTest` (JSON v6 opt-in export targeting, Gson-null skip, restore-before-`/sync` plan, RTDB patch before feed refresh), `LibraryBackupManualOrderPinsTest` (Manual sort + id lists round-trip; older JSON without lists), `LibraryBackupImportLogicTest` (restore/OPML rethrow cancellation), `OpmlImportLogicTest` (catalog URL/title/feed match before `rss:` fallback, URL scheme/slash/`www` candidates, HTTPS rewrite for feed peek), `SubscriptionForegroundSyncLogicTest` (cooldown / subscriptions-first fetch / catalog ingest id gates), `SubscriptionRepositoryTest` (subscription tombstoning with `unsubscribedAt`, resubscription tombstone clearing, and dirty tracking on settings updates), `InstallReferrerManager` channel derivation / attribution callback seams, `SubscriptionForegroundSync` delay / `requestRefresh` / periodic / failed-feed recovery (dead-address re-check and retry, `/sync` fallback and its switch, once-a-day re-check that counts only answered lookups, moved-feed adoption) / `SubscriptionForegroundSyncIngestTest` (`requestCatalogIngest` cooldown bypass, RSS skip, finished emit, unsubscribe TTL, expiry sweep isolation) / direct-feed concurrency and failure isolation (including missing-feed URL recovery), content orchestration tests, content signal enrichment, grouped sections, recent section intent storage, cross-promotion detection, transcript behavior, and dependency-holder behavior. - `PodcastRepositoryV2DiscoveryTest` (Robolectric, MockWebServer, answers recorded from the live `/v2` routes under `fixtures/v2`, see its README): recommendation seeds and exclusions as recipe IDs (Podcast Index, phone-minted and RSS-linked history; an unplaced Podcast Index ID excluded as itself), at most four languages, picks back under local owners with their recipe IDs and without episodes the history holds; through Room, a played Podcast Index episode with no alias matched by its audio address and not offered again; the last good picks after a failure, after a restart and for an answer without picks, never a v1 request, nothing for another country, unreadable stored picks ignored; Home picks keeping their fallback flag, country-default picks for a listener with no seed or interest, interest-only picks personal, and `failed` with nothing to keep; every discovery route on `/v2` without the app key and translated (trending, vibes, semantic, because-you-like, similar with its recipe-ID seed or `"0"` and without the seed itself, curiosity, briefing metadata, bootstrap with related episodes); show search and typeahead on v1; the trending stream and its last good list; an empty page staying empty; the switch sending everything to v1, personal picks on contract 1; v1 and v2 answers never sharing a cache entry. `RoomDiscoveryEpisodeIdsTest` (Robolectric Room) covers both directions through the alias table (trusted only, owner by listening, collisions and other shows left alone), an owner stored under the show's own feed, and the audio-address match (unique rows only, never a row an alias places). `ChapterRepositoryDiscoveryTest` reads the recorded briefing chapters through `ChapterRepository` from the signed `/v2` link, the cache and a downloaded briefing's file. `BriefingLinksTest` covers the chapters and transcript links, the installed switch, briefing related episodes and a failing alias table. `PodcastRecommendationSeedsTest` covers seed IDs and the `/v2` history scope; `PodcastRepositoryRequestBoundsTest` the contract version, the v1 contract-1 path and the `/v2` request bodies. -- Feed address repair: `UpgradeAddressRepairTest` (Robolectric Room with the real address writer): a fresh install and `rss:`-only follows settle without network; check, confirmed swap, re-read and re-registration per show with one summary event; 501 shows in calls of 500 and 1; a partial batch kept at step 0 with no try counted; offline checks and reads counting no try; process death mid-step resuming without asking or swapping again; a restore with Room done but no step 4 record registering every followed show; unfollow between steps; a follow added mid-run left out; a dead show giving up at the cap; a different server `epNs` recorded and never applied; a follow changed on the phone keeping its address; the switch pausing without marking done; the step 4 file surviving a new process. +- Feed address repair: `UpgradeAddressRepairTest` (Robolectric Room with the real address writer): a fresh install and `rss:`-only follows settle without network and never ask for the sync catch-up; check, confirmed swap, re-read and re-registration per show with one summary event; 501 shows in calls of 500 and 1; a partial batch kept at step 0 with no try counted; offline checks and reads counting no try; process death mid-step resuming without asking or swapping again; a restore with Room done but no step 4 record registering every followed show; unfollow between steps; a follow added mid-run left out; a dead show giving up at the cap; a different server `epNs` recorded, counted on the summary and only compared by the read; the production read keeping the saved address, passing the namespace and starting no auto-download pass; a follow changed on the phone keeping its address; the switch pausing without marking done; an answer left waiting by a pause asked again two days later and never applied; a failed swap keeping its answer's time; a refused check reaching the cap and going on without an answer; an unanswered check (503, registry down) counting no try for 7 days, then going on; the sync catch-up asked for once after the last show and not while shows wait or the run is paused, and retried until it could be asked; a registration request that couldn't be saved not recorded and asked again; the step 4 file surviving a new process. `UpgradeAddressRepairApiTest` runs step 1 through the real `/v2` client against MockWebServer with `/v2/shows/feeds` answers in the route's shape (`fixtures/v2`): a confirmed direct move swapped, an http-only show kept with its transport address, an unknown show kept, another `epNs` counted; a 503 and `registryAvailable: false` counting no try; a 400 and an unreadable answer each counting one. `CloudSyncIdentityTest` covers the catch-up asked for again after the repair settled an episode, and a request leaving a phone that hasn't caught up as it is. - `RolloutSwitchesTest` pins the defaults without the app's wiring (`feedFirst` off, every job on) and that each switch reads its own lambda. - `RssNotificationReconcilerTest` runs `FirebaseRssNotificationRemote` against a fake that applies the live `tracked_podcasts` write rule: cleanup keeps the node and drops only `feedUrl`, a never-published key still leaves its topic, and any other failure keeps the topic and the journal entry. - RSS ID and matcher tests live in `:core:rss`; smart-queue tests live in `:core:playback`. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressCheck.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressCheck.kt new file mode 100644 index 000000000..0d3bccfd7 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressCheck.kt @@ -0,0 +1,215 @@ +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 cx.aswin.boxlore.core.database.UpgradeRepairDao +import cx.aswin.boxlore.core.database.UpgradeRepairShowEntity +import cx.aswin.boxlore.core.network.BoxLoreApiV2 +import cx.aswin.boxlore.core.network.model.V2EpisodeIds +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 java.io.IOException +import kotlinx.coroutines.CancellationException +import retrofit2.awaitResponse + +/** + * What one `POST /v2/shows/feeds` request of the upgrade repair's step 1 got. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ +sealed interface AddressCheckAnswer { + /** A 200 answer. One with `registryAvailable: false` counts as [Unavailable]. */ + data class Answered(val response: V2ShowsFeedsResponse) : AddressCheckAnswer + + /** + * The server couldn't answer now (503 `lookup_unavailable`, 429, another 5xx) or the request + * never got through. Never counts as a try; a show unanswered for + * [UpgradeAddressRepair.MAX_UNANSWERED_MS] goes on without an answer. + */ + data object Unavailable : AddressCheckAnswer + + /** + * The server refused the request (another 4xx) or its answer couldn't be read: the same + * request would fail again, so it counts as a try for each show asked. + */ + data object Rejected : AddressCheckAnswer + + companion object { + private const val TOO_MANY_REQUESTS = 429 + private const val SERVER_ERRORS = 500 + + /** The production step 1 request: [api]'s `/v2/shows/feeds`, its status sorted as above. */ + fun fetchWith(api: BoxLoreApiV2): suspend (List) -> AddressCheckAnswer = { ids -> + try { + val response = api.getShowFeeds(V2ShowsFeedsRequest(ids)).awaitResponse() + val body = response.body() + when { + response.isSuccessful && body != null -> Answered(body) + response.isSuccessful -> Rejected + response.code() == TOO_MANY_REQUESTS || response.code() >= SERVER_ERRORS -> Unavailable + else -> Rejected + } + } catch (e: CancellationException) { + throw e + } catch (_: IOException) { + Unavailable + } catch (e: Exception) { + Log.w("UpgradeAddressRepair", "Address check answer couldn't be read", e) + Rejected + } + } + } +} + +/** + * What one [UpgradeAddressRepair.run] did, and why it stopped early. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). + */ +internal class UpgradeRepairPass { + var offline = false + var paused = false + var read = 0 + var notModified = 0 + var failed = 0 + + /** Reads of shows whose namespace on the server differs from the one the phone keeps (D12). */ + var namespaceMismatches = 0 + + val stopped: Boolean get() = offline || paused +} + +/** + * Step 1 of [UpgradeAddressRepair]: asks the server for the current address of every followed + * show still at step 0, in chunks of at most [V2ShowsFeedsRequest.MAX_IDS], and records each + * answer (see the repair's class description). + * + * A chunk the server couldn't answer ([AddressCheckAnswer.Unavailable], or the registry down) + * counts no try; one it refused ([AddressCheckAnswer.Rejected]) counts a try for each show. A + * show unanswered for [UpgradeAddressRepair.MAX_UNANSWERED_MS] since its first failed check, or + * refused [UpgradeAddressRepair.MAX_ATTEMPTS] times, goes on to steps 2–4 with no answer: no swap, + * and the daily check looks after its address. While a show is at step 0, `lastAttemptAt` holds + * its first failed check. Offline, nothing is recorded and the run stops. + * + * An answer older than [FeedAddressDailyCheck.CHECK_INTERVAL_MS] that still holds a move to swap + * to is dropped at the start of a run ([expireStaleAnswers]), so a repair paused or stopped for a + * while asks again instead of applying an old answer. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair; decision D12). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ +internal class UpgradeAddressCheckStep( + private val repairDao: UpgradeRepairDao, + private val podcastDao: PodcastDao, + private val ports: UpgradeRepairPorts, + private val nowMillis: () -> Long, + private val followedRow: suspend (UpgradeRepairShowEntity) -> PodcastEntity?, + private val stopsHere: (UpgradeRepairPass) -> Boolean, +) { + /** Sends shows at step 1 whose move to swap to is too old back to step 0. */ + suspend fun expireStaleAnswers() { + val now = nowMillis() + for (entry in repairDao.pendingShows()) { + if (entry.completedStep != 1 || entry.checkedFetchUrl == null || !answerExpired(entry.lastAttemptAt, now)) continue + Log.i(TAG, "Address check of ${entry.podcastId} is out of date; asking again") + repairDao.upsert(entry.withoutAnswer().copy(completedStep = 0, lastAttemptAt = 0L, lastError = null)) + } + } + + /** Step 1 for every followed show still at step 0. */ + suspend fun check(pass: UpgradeRepairPass) { + val askable = LinkedHashMap() + for (entry in repairDao.pendingShows().filter { it.completedStep == 0 }) { + val row = followedRow(entry) ?: continue + val showId = V2EpisodeIds.parseShowId(row.podcastId) + if (showId == null) recordAnswer(entry, row, null) else askable[showId] = entry + } + for (chunk in askable.keys.chunked(V2ShowsFeedsRequest.MAX_IDS)) { + if (stopsHere(pass)) return + val answer = fetch(chunk) + if (answer is AddressCheckAnswer.Answered && answer.response.registryAvailable) { + recordChunk(chunk, askable, answer.response) + } else { + unanswered(chunk.map(askable::getValue), counts = answer is AddressCheckAnswer.Rejected, pass) + } + } + } + + private suspend fun fetch(ids: List): AddressCheckAnswer = try { + ports.fetchAddresses(ids) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + Log.w(TAG, "Address check of ${ids.size} shows failed", e) + AddressCheckAnswer.Unavailable + } catch (e: Exception) { + Log.w(TAG, "Address check of ${ids.size} shows failed", e) + AddressCheckAnswer.Rejected + } + + private suspend fun recordChunk(chunk: List, askable: Map, answer: V2ShowsFeedsResponse) { + val byId = answer.results.associateBy(V2ShowAddress::id) + for (showId in chunk) { + val entry = askable.getValue(showId) + podcastDao.getPodcast(entry.podcastId)?.let { row -> recordAnswer(entry, row, byId[showId]) } + } + } + + /** Records step 1 for one show; the registry's answer also counts as today's daily check. */ + private suspend fun recordAnswer(entry: UpgradeRepairShowEntity, row: PodcastEntity, result: V2ShowAddress?) { + val now = nowMillis() + val served = result?.validEpNs + if (UpgradeAddressRepair.namespaceDiffers(row, served)) { + Log.w(TAG, "Show ${row.podcastId} has another namespace on the server ($served); keeping the phone's") + } + if (result != null && !FeedAddressDailyCheck.changedOnPhone(row)) podcastDao.markFeedUrlChecked(row.podcastId, now) + repairDao.upsert( + entry.copy( + completedStep = 1, + attempts = 0, + checkedFetchUrl = result?.let { FeedAddressDailyCheck.confirmedMove(row, it) }, + checkedTransportUrl = result?.transportUrl, + checkedFeedSource = result?.feedSource, + checkedEpNs = served, + lastAttemptAt = now, + lastError = null, + ), + ) + } + + /** A chunk without an answer: counted against its shows ([counts]) unless offline, which stops the run. */ + private suspend fun unanswered(entries: List, counts: Boolean, pass: UpgradeRepairPass) { + if (!ports.isOnline()) { + pass.offline = true + return + } + val now = nowMillis() + for (entry in entries) { + val current = repairDao.getShow(entry.podcastId)?.takeIf { it.completedStep == 0 } ?: continue + val since = current.lastAttemptAt.takeIf { it in 1..now } ?: now + val attempts = if (counts) current.attempts + 1 else current.attempts + if (attempts >= UpgradeAddressRepair.MAX_ATTEMPTS || now - since >= UpgradeAddressRepair.MAX_UNANSWERED_MS) { + Log.w(TAG, "No address answer for ${current.podcastId}; going on without one") + val reason = if (counts) REFUSED else UNANSWERED + repairDao.upsert(current.withoutAnswer().copy(completedStep = 1, attempts = 0, lastAttemptAt = now, lastError = reason)) + } else { + repairDao.upsert(current.copy(attempts = attempts, lastAttemptAt = since, lastError = if (counts) REFUSED else UNAVAILABLE)) + } + } + } + + private fun UpgradeRepairShowEntity.withoutAnswer() = copy(checkedFetchUrl = null, checkedTransportUrl = null, checkedFeedSource = null, checkedEpNs = null) + + companion object { + private const val TAG = "UpgradeAddressRepair" + private const val UNAVAILABLE = "address_unavailable" + private const val UNANSWERED = "address_unanswered" + private const val REFUSED = "address_refused" + + /** Whether a step 1 answer recorded at [answeredAt] is too old to swap to at [now]. */ + fun answerExpired(answeredAt: Long, now: Long): Boolean = answeredAt <= 0L || answeredAt > now || now - answeredAt >= FeedAddressDailyCheck.CHECK_INTERVAL_MS + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt index d448ff4a9..a8e65aec4 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepair.kt @@ -19,7 +19,6 @@ import cx.aswin.boxlore.core.network.model.V2FetchVia import cx.aswin.boxlore.core.network.model.V2ShowAddress import cx.aswin.boxlore.core.network.model.V2ShowFlags import cx.aswin.boxlore.core.network.model.V2ShowsFeedsRequest -import cx.aswin.boxlore.core.network.model.V2ShowsFeedsResponse import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -40,23 +39,26 @@ enum class UpgradeRepairRun { * What [UpgradeAddressRepair] reaches outside Room. * * - [fetchAddresses]: one `POST /v2/shows/feeds` for at most [V2ShowsFeedsRequest.MAX_IDS] show - * IDs; null for any answer but 200 ([FeedAddressDailyCheck.fetchWith]). + * IDs ([AddressCheckAnswer.fetchWith]). * - [replaceAddress]: the one address writer ([FeedAddressUpdater.update]). * - [reread]: reads a followed show's feed again into the local catalog, passing the namespace the * server reported ([rereadWith]); moves it finds are followed by the catalog itself. - * - [requestNotificationRegistration]: asks for a show's notifications to be registered again, - * acknowledged and retried by the app's capped job; throws [IllegalStateException] when it - * couldn't be asked. + * - [requestNotificationRegistration]: asks for a show's notifications to be registered again by + * the app's capped, acknowledged job, and returns once the request is saved; throws when it + * couldn't be saved. + * - [onAddressStepsFinished]: called once, after every show's steps 1–3 are over, so the cloud + * sync's identity catch-up runs again for the episodes the reads settled. * - [isOnline]: failures while offline never count against a show's cap. * - [telemetry]: one `feed_fetch_result` summary per run that read feeds. * * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair). */ data class UpgradeRepairPorts( - val fetchAddresses: suspend (List) -> V2ShowsFeedsResponse?, + val fetchAddresses: suspend (List) -> AddressCheckAnswer, val replaceAddress: suspend (FeedAddressChange) -> FeedAddressUpdate, val reread: suspend (show: PodcastEntity, serverEpNs: Long?) -> RefreshOutcome, - val requestNotificationRegistration: (podcastId: String) -> Unit, + val requestNotificationRegistration: suspend (podcastId: String) -> Unit, + val onAddressStepsFinished: suspend () -> Unit = {}, val isOnline: () -> Boolean = { true }, val telemetry: FeedTelemetryPort = FeedTelemetryPort.NONE, ) @@ -66,23 +68,33 @@ data class UpgradeRepairPorts( * followed when the phone upgraded (the v39 upgrade seeds one pending `upgrade_repair_shows` row * each; a fresh install has none). Per show: * - * 1. Ask the server for the show's current address: `POST /v2/shows/feeds`, every waiting show - * in chunks of at most [V2ShowsFeedsRequest.MAX_IDS]. A chunk that fails, or that the address - * registry couldn't answer, stays at step 0 for the next run; the other chunks go on. The answer - * is recorded: `checkedFetchUrl` holds the address to swap to, set only for a confirmed move - * ([FeedAddressDailyCheck.confirmedMove]: the registry flags the show for repair and its own - * HTTPS address differs from the saved one); `fetchUrl: null` keeps the saved address. A - * namespace (`epNs`) that differs from the one the phone keeps is recorded in `checkedEpNs` and - * logged, and never applied to issued IDs (decision D12). + * 1. Ask the server for the show's current address ([UpgradeAddressCheckStep]): `POST + * /v2/shows/feeds`, every waiting show in chunks of at most [V2ShowsFeedsRequest.MAX_IDS]. The + * answer is recorded: `checkedFetchUrl` holds the address to swap to, set only for a confirmed + * move ([FeedAddressDailyCheck.confirmedMove]: the registry flags the show for repair and its + * own HTTPS address differs from the saved one); `fetchUrl: null` keeps the saved address. A + * chunk the server couldn't answer stays at step 0 without counting a try, for at most + * [MAX_UNANSWERED_MS]; one it refused counts a try. Either way the show then goes on without + * an answer. A namespace (`epNs`) the server reports differently from the phone's is recorded + * in `checkedEpNs`, counted on `feed_fetch_result` when the show is read, and never applied to + * issued IDs (decision D12). * 2. Swap the saved address through the one address writer, reported as `feed_url_corrected` * (step `upgrade_repair`) and registered again by the writer. A follow changed on this phone and - * not pushed yet keeps its address (decision 2); the daily check swaps it once it is pushed. - * 3. Read the feed again. A failure while online counts against the show's cap of [MAX_ATTEMPTS]; - * the show then ends [UpgradeRepairState.GAVE_UP], and the daily check keeps watching it. + * not pushed yet keeps its address (decision 2); the daily check swaps it once it is pushed. An + * answer older than a day is asked again first, and one the daily check has since replaced + * with a newer answer is not applied. + * 3. Read the feed again, without the auto-download pass a foreground read starts (the background + * pass applies the listener's rules). A failure while online counts against the show's cap of + * [MAX_ATTEMPTS]; the show then ends [UpgradeRepairState.GAVE_UP], and the daily check keeps + * watching it. * 4. Register notifications again for every followed show with notifications on, once its own * steps are over, recorded in [UpgradeNotificationMarker] (`noBackupFilesDir`), so a restored * phone registers again even when Room says steps 1–3 are done. * + * Once no show has steps 1–3 left, [UpgradeRepairPorts.onAddressStepsFinished] runs once per + * install (recorded in the same marker), so the sync's one-time identity catch-up runs again for + * the episodes the reads settled. A fresh install never calls it. + * * Each finished step is written at once, so process death resumes at the next step. Before each * step the show's row is read again: a show no longer followed ends [UpgradeRepairState.DONE] * without further steps. Shows followed after the upgrade have no repair row and are left to the @@ -101,31 +113,16 @@ class UpgradeAddressRepair( private val nowMillis: () -> Long = System::currentTimeMillis, ) { private val runLock = Mutex() + private val addresses = UpgradeAddressCheckStep(repairDao, podcastDao, ports, nowMillis, ::followedRow, ::stopsHere) private val notifications = UpgradeNotificationStep(repairDao, podcastDao, marker, ports.requestNotificationRegistration) - /** What one run did, and why it stopped early. */ - private class Pass { - var offline = false - var paused = false - var read = 0 - var notModified = 0 - var failed = 0 - - val stopped: Boolean get() = offline || paused - - /** One `feed_fetch_result` summary for the feeds this run read, if any. */ - fun report(telemetry: FeedTelemetryPort) { - if (read + notModified + failed == 0) return - telemetry.feedFetched(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, read, notModified, failed)) - } - } - /** - * Whether a run has anything to do: shows with steps left, or step 4 not finished with followed - * shows to register. With neither, step 4 is recorded as finished here, without network. + * Whether a run has anything to do: shows with steps left, the sync catch-up not asked for yet, + * or step 4 not finished with followed shows to register. With none, step 4 is recorded as + * finished here, without network. */ suspend fun needsWork(): Boolean { - if (repairDao.pendingCount() > 0) return true + if (repairDao.pendingCount() > 0 || catchUpDue()) return true if (marker.isComplete) return false if (notifications.targets().isNotEmpty()) return true marker.markComplete() @@ -135,74 +132,47 @@ class UpgradeAddressRepair( /** Runs every step that is due; see the class description. */ suspend fun run(): UpgradeRepairRun = runLock.withLock { if (!enabled()) return@withLock UpgradeRepairRun.PAUSED - val pass = Pass() - checkAddresses(pass) + val pass = UpgradeRepairPass() + addresses.expireStaleAnswers() + addresses.check(pass) repairShows(pass) - if (!pass.stopped) notifications.register() - pass.report(ports.telemetry) + if (!pass.stopped) { + askForSyncCatchUp() + notifications.register() + } + report(pass) when { pass.paused -> UpgradeRepairRun.PAUSED - repairDao.pendingCount() == 0 && marker.isComplete -> UpgradeRepairRun.DONE + repairDao.pendingCount() == 0 && marker.isComplete && !catchUpDue() -> UpgradeRepairRun.DONE else -> UpgradeRepairRun.RETRY } } - /** Step 1 for every followed show still at step 0. */ - private suspend fun checkAddresses(pass: Pass) { - val askable = LinkedHashMap() - for (entry in repairDao.pendingShows().filter { it.completedStep == 0 }) { - val row = followedRow(entry) ?: continue - val showId = V2EpisodeIds.parseShowId(row.podcastId) - if (showId == null) recordAnswer(entry, row, null) else askable[showId] = entry - } - for (chunk in askable.keys.chunked(V2ShowsFeedsRequest.MAX_IDS)) { - if (stopsHere(pass)) return - val answer = fetch(chunk)?.takeIf(V2ShowsFeedsResponse::registryAvailable) - if (answer == null) pass.offline = !ports.isOnline() else recordChunk(chunk, askable, answer) - } - } + /** Steps 1–3 are over for an install that had shows to repair, and the sync catch-up wasn't asked for yet. */ + private suspend fun catchUpDue(): Boolean = !marker.syncCatchUpRequested && repairDao.pendingCount() == 0 && repairDao.hasShows() - private suspend fun recordChunk(chunk: List, askable: Map, answer: V2ShowsFeedsResponse) { - val byId = answer.results.associateBy(V2ShowAddress::id) - for (showId in chunk) { - val entry = askable.getValue(showId) - podcastDao.getPodcast(entry.podcastId)?.let { row -> recordAnswer(entry, row, byId[showId]) } + private suspend fun askForSyncCatchUp() { + if (!catchUpDue()) return + try { + ports.onAddressStepsFinished() + marker.markSyncCatchUpRequested() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Couldn't ask for the sync catch-up", e) } } - private suspend fun fetch(ids: List): V2ShowsFeedsResponse? = try { - ports.fetchAddresses(ids) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Address check of ${ids.size} shows failed", e) - null - } - - /** Records step 1 for one show; the registry's answer also counts as today's daily check. */ - private suspend fun recordAnswer(entry: UpgradeRepairShowEntity, row: PodcastEntity, result: V2ShowAddress?) { - val now = nowMillis() - val served = result?.validEpNs - val kept = row.epNs ?: EpisodeRecipeId.showIdOrNull(row.podcastId) - if (served != null && kept != null && served != kept) { - Log.w(TAG, "Show ${row.podcastId} has namespace $kept here, $served on the server; keeping $kept") - } - if (result != null && !FeedAddressDailyCheck.changedOnPhone(row)) podcastDao.markFeedUrlChecked(row.podcastId, now) - repairDao.upsert( - entry.copy( - completedStep = 1, - checkedFetchUrl = result?.let { FeedAddressDailyCheck.confirmedMove(row, it) }, - checkedTransportUrl = result?.transportUrl, - checkedFeedSource = result?.feedSource, - checkedEpNs = served, - lastAttemptAt = now, - lastError = null, - ), + /** One `feed_fetch_result` summary for the feeds this run read, if any. */ + private fun report(pass: UpgradeRepairPass) { + if (pass.read + pass.notModified + pass.failed == 0) return + ports.telemetry.feedFetched( + FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, pass.read, pass.notModified, pass.failed, namespaceMismatchCount = pass.namespaceMismatches), ) } /** Steps 2 and 3 for every show past step 1, one show at a time. */ - private suspend fun repairShows(pass: Pass) { + private suspend fun repairShows(pass: UpgradeRepairPass) { for (entry in repairDao.pendingShows().filter { it.completedStep != 0 }) { if (stopsHere(pass)) return try { @@ -220,7 +190,7 @@ class UpgradeAddressRepair( /** Step 2; null when the show is no longer followed. */ private suspend fun swapAddress(entry: UpgradeRepairShowEntity): UpgradeRepairShowEntity? { val row = followedRow(entry) ?: return null - val target = swapTarget(row, entry)?.takeUnless { FeedAddressDailyCheck.changedOnPhone(row) } + val target = swapTarget(row, entry, nowMillis())?.takeUnless { FeedAddressDailyCheck.changedOnPhone(row) } if (target != null) { val change = FeedAddressChange( @@ -234,17 +204,19 @@ class UpgradeAddressRepair( ) Log.i(TAG, "Address swap for ${row.podcastId}: ${ports.replaceAddress(change)}") } - return entry.copy(completedStep = 2, lastAttemptAt = nowMillis(), lastError = null).also { repairDao.upsert(it) } + return entry.copy(completedStep = 2, attempts = 0, lastAttemptAt = nowMillis(), lastError = null).also { repairDao.upsert(it) } } /** Step 3. */ - private suspend fun readAgain(entry: UpgradeRepairShowEntity, pass: Pass) { + private suspend fun readAgain(entry: UpgradeRepairShowEntity, pass: UpgradeRepairPass) { val row = followedRow(entry) ?: return if (row.feedUrl.isNullOrBlank()) { finish(entry) return } - when (val outcome = ports.reread(row, entry.checkedEpNs)) { + val outcome = ports.reread(row, entry.checkedEpNs) + if (namespaceDiffers(row, entry.checkedEpNs)) pass.namespaceMismatches++ + when (outcome) { is RefreshOutcome.Success -> { pass.read++ finish(entry) @@ -264,8 +236,11 @@ class UpgradeAddressRepair( repairDao.upsert(entry.copy(completedStep = 3, state = UpgradeRepairState.DONE, lastAttemptAt = nowMillis(), lastError = null)) } - /** Counts a failed try against the show's cap, unless the phone is offline (then the run stops). */ - private suspend fun recordFailure(podcastId: String, reason: String, pass: Pass) { + /** + * Counts a failed try against the show's cap, unless the phone is offline (then the run + * stops). A show at step 1 keeps the time of its answer, so an old answer still expires. + */ + private suspend fun recordFailure(podcastId: String, reason: String, pass: UpgradeRepairPass) { if (!ports.isOnline()) { pass.offline = true return @@ -274,7 +249,8 @@ class UpgradeAddressRepair( val attempts = current.attempts + 1 val state = if (attempts >= MAX_ATTEMPTS) UpgradeRepairState.GAVE_UP else current.state if (state == UpgradeRepairState.GAVE_UP) Log.w(TAG, "Giving up the repair of $podcastId after $attempts tries ($reason)") - repairDao.upsert(current.copy(attempts = attempts, state = state, lastAttemptAt = nowMillis(), lastError = reason.take(MAX_ERROR_LENGTH))) + val triedAt = if (current.completedStep <= 1) current.lastAttemptAt else nowMillis() + repairDao.upsert(current.copy(attempts = attempts, state = state, lastAttemptAt = triedAt, lastError = reason.take(MAX_ERROR_LENGTH))) } /** The show's row while it is still a followed Podcast Index show; otherwise its repair ends done. */ @@ -285,7 +261,7 @@ class UpgradeAddressRepair( return null } - private fun stopsHere(pass: Pass): Boolean { + private fun stopsHere(pass: UpgradeRepairPass): Boolean { if (!pass.stopped && !enabled()) pass.paused = true return pass.stopped } @@ -295,16 +271,21 @@ class UpgradeAddressRepair( private const val READ_FAILED = "read_failed" private const val MAX_ERROR_LENGTH = 80 - /** Failed tries of one show (while online) before its repair gives up. */ + /** Failed tries of one show (while online) before its repair gives up, or goes on without an address answer. */ const val MAX_ATTEMPTS = 5 + /** How long a show waits for an address answer the server couldn't give before it goes on without one. */ + const val MAX_UNANSWERED_MS = 7L * FeedAddressDailyCheck.CHECK_INTERVAL_MS + /** * The address step 2 swaps to: step 1's confirmed move, checked again against the row as it * is now (the saved address may have changed since). Step 1 kept it only when the registry - * flagged the show for repair with its own direct address, so the same rule applies. + * flagged the show for repair with its own direct address, so the same rule applies. None + * when the answer is older than a day at [now], or the daily check has a newer one. */ - internal fun swapTarget(row: PodcastEntity, entry: UpgradeRepairShowEntity): String? { + internal fun swapTarget(row: PodcastEntity, entry: UpgradeRepairShowEntity, now: Long): String? { val fetchUrl = entry.checkedFetchUrl ?: return null + if (UpgradeAddressCheckStep.answerExpired(entry.lastAttemptAt, now) || row.feedUrlCheckedAt > entry.lastAttemptAt) return null val confirmed = V2ShowAddress( id = V2EpisodeIds.parseShowId(row.podcastId) ?: return null, @@ -316,69 +297,28 @@ class UpgradeAddressRepair( return FeedAddressDailyCheck.confirmedMove(row, confirmed) } - /** The production step 3: a catalog refresh of the saved address, with the server's namespace for comparison only. */ + /** True when [served], the namespace the server reported, differs from the one the phone keeps for [row]. */ + internal fun namespaceDiffers(row: PodcastEntity, served: Long?): Boolean { + val kept = row.epNs ?: EpisodeRecipeId.showIdOrNull(row.podcastId) + return served != null && kept != null && served != kept + } + + /** + * The production step 3: a catalog refresh of the saved address, with the server's namespace + * for comparison only. It never runs the auto-download pass a foreground read starts: the + * repair runs in the background, where the regular pass applies the listener's background + * download rules. + */ fun rereadWith(catalog: LocalEpisodeCatalogPort): suspend (PodcastEntity, Long?) -> RefreshOutcome = { row, serverEpNs -> catalog.refresh( LocalEpisodeCatalogPort.RefreshRequest( podcastIndexId = row.podcastId, feedUrl = row.feedUrl.orEmpty(), meta = LocalEpisodeCatalogPort.PodcastMeta(title = row.title, imageUrl = row.imageUrl, genre = row.genre, artist = row.author), + runPostPersistCallback = false, serverEpNs = serverEpNs, ), ) } } } - -/** - * Step 4 of [UpgradeAddressRepair]: asks for every followed Podcast Index show with notifications - * on, whose own repair steps are over, to be registered again, records each request in [marker], - * and records the step done once no show has steps left and every request was made. - * - * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair; "Android backup"). - * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). - */ -internal class UpgradeNotificationStep( - private val repairDao: UpgradeRepairDao, - private val podcastDao: PodcastDao, - private val marker: UpgradeNotificationMarker, - private val requestRegistration: (podcastId: String) -> Unit, -) { - suspend fun register() { - if (marker.isComplete) return - val requested = mutableListOf() - var allRequested = true - for (podcastId in targets()) { - if (request(podcastId)) requested += podcastId else allRequested = false - if (requested.size >= MARKER_BATCH) { - marker.addRegistered(requested) - requested.clear() - } - } - marker.addRegistered(requested) - if (allRequested && repairDao.pendingCount() == 0) marker.markComplete() - } - - /** Followed Podcast Index shows with notifications on, not yet registered here, whose repair steps are over. */ - suspend fun targets(): List { - val registered = marker.registered() - val waiting = repairDao.pendingShows().mapTo(HashSet()) { it.podcastId } - return podcastDao - .getSubscribedPodcastsList() - .filter { !it.isRss && it.notificationsEnabled && it.podcastId !in registered && it.podcastId !in waiting } - .map { it.podcastId } - } - - private fun request(podcastId: String): Boolean = try { - requestRegistration(podcastId) - true - } catch (e: IllegalStateException) { - Log.e(TAG, "Couldn't ask to register notifications again for $podcastId", e) - false - } - - private companion object { - const val TAG = "UpgradeAddressRepair" - const val MARKER_BATCH = 50 - } -} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt index 8613bf217..8793e9a55 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationMarker.kt @@ -8,11 +8,13 @@ import java.util.Properties /** * Step 4 of the one-time feed address repair ([UpgradeAddressRepair]): which followed shows had * their new-episode notifications registered again on this install, and whether the step finished. + * It also records that the sync's identity catch-up was asked for once steps 1–3 were over. * * Steps 1–3 are kept in Room (`upgrade_repair_shows`), which an Android restore brings back; this * marker lives in `noBackupFilesDir`, which a restore never brings, so a restored phone (a new * messaging token, no topic subscriptions) registers every followed show again even when Room says - * the repair is done. + * the repair is done. The catch-up is then asked for again too, which only makes the next sync + * pull from the start once more. * * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair; "Android backup"). * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). @@ -29,6 +31,12 @@ interface UpgradeNotificationMarker { /** Records that step 4 finished; false when the record couldn't be saved. */ fun markComplete(): Boolean + + /** True once the sync's identity catch-up was asked for after steps 1–3. */ + val syncCatchUpRequested: Boolean + + /** Records that the sync's identity catch-up was asked for; false when the record couldn't be saved. */ + fun markSyncCatchUpRequested(): Boolean } /** In-memory [UpgradeNotificationMarker] for tests and previews. */ @@ -50,6 +58,15 @@ class MemoryUpgradeNotificationMarker : UpgradeNotificationMarker { isComplete = true return true } + + @Volatile + override var syncCatchUpRequested: Boolean = false + private set + + @Synchronized override fun markSyncCatchUpRequested(): Boolean { + syncCatchUpRequested = true + return true + } } /** @@ -63,13 +80,18 @@ class FileUpgradeNotificationMarker internal constructor(private val storage: At override val isComplete: Boolean @Synchronized get() = read().getProperty(COMPLETE_KEY) == "true" - @Synchronized override fun registered(): Set = read().stringPropertyNames().filter { it != COMPLETE_KEY }.toSet() + override val syncCatchUpRequested: Boolean + @Synchronized get() = read().getProperty(SYNC_CATCH_UP_KEY) == "true" + + @Synchronized override fun registered(): Set = read().stringPropertyNames().filterNot { it.startsWith(RECORD_KEY_PREFIX) }.toSet() @Synchronized override fun addRegistered(podcastIds: Collection): Boolean = podcastIds.isEmpty() || change { values -> podcastIds.forEach { values.setProperty(it, "1") } } @Synchronized override fun markComplete(): Boolean = change { it.setProperty(COMPLETE_KEY, "true") } + @Synchronized override fun markSyncCatchUpRequested(): Boolean = change { it.setProperty(SYNC_CATCH_UP_KEY, "true") } + private fun read(): Properties = Properties().apply { try { storage.openRead().use { load(it) } @@ -102,6 +124,9 @@ class FileUpgradeNotificationMarker internal constructor(private val storage: At /** File name under `noBackupFilesDir`. Stable storage name: never rename. */ const val FILE_NAME = "upgrade_repair_notifications" + /** Keys of the file's own records start with this; show IDs never do. */ + private const val RECORD_KEY_PREFIX = "__" private const val COMPLETE_KEY = "__complete" + private const val SYNC_CATCH_UP_KEY = "__sync_catch_up" } } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationStep.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationStep.kt new file mode 100644 index 000000000..70fba5976 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/UpgradeNotificationStep.kt @@ -0,0 +1,63 @@ +package cx.aswin.boxlore.core.catalog + +import android.util.Log +import cx.aswin.boxlore.core.database.PodcastDao +import cx.aswin.boxlore.core.database.UpgradeRepairDao +import kotlinx.coroutines.CancellationException + +/** + * Step 4 of [UpgradeAddressRepair]: asks for every followed Podcast Index show with notifications + * on, whose own repair steps are over, to be registered again, records each request in [marker] + * once the request is saved ([requestRegistration] returns), and records the step done once no + * show has steps left and every request was saved. A request that couldn't be saved is not + * recorded, so the next run asks again. + * + * Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair; "Android backup"). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ +internal class UpgradeNotificationStep( + private val repairDao: UpgradeRepairDao, + private val podcastDao: PodcastDao, + private val marker: UpgradeNotificationMarker, + private val requestRegistration: suspend (podcastId: String) -> Unit, +) { + suspend fun register() { + if (marker.isComplete) return + val requested = mutableListOf() + var allRequested = true + for (podcastId in targets()) { + if (request(podcastId)) requested += podcastId else allRequested = false + if (requested.size >= MARKER_BATCH) { + marker.addRegistered(requested) + requested.clear() + } + } + marker.addRegistered(requested) + if (allRequested && repairDao.pendingCount() == 0) marker.markComplete() + } + + /** Followed Podcast Index shows with notifications on, not yet registered here, whose repair steps are over. */ + suspend fun targets(): List { + val registered = marker.registered() + val waiting = repairDao.pendingShows().mapTo(HashSet()) { it.podcastId } + return podcastDao + .getSubscribedPodcastsList() + .filter { !it.isRss && it.notificationsEnabled && it.podcastId !in registered && it.podcastId !in waiting } + .map { it.podcastId } + } + + private suspend fun request(podcastId: String): Boolean = try { + requestRegistration(podcastId) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Couldn't ask to register notifications again for $podcastId", e) + false + } + + private companion object { + const val TAG = "UpgradeAddressRepair" + const val MARKER_BATCH = 50 + } +} diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt index 6158ca489..6a23ecef2 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt @@ -116,6 +116,24 @@ open class UserSyncCoordinator( } } + /** + * Runs the one-time identity catch-up again on the next sync (a pull from the start, then the + * pushes of cloud copies that gain an identity) when it already ran: sets the sync metadata + * version back below [IDENTITY_SYNC_METADATA_VERSION]. A phone that never synced, or one still + * waiting for the catch-up, is left as it is. Waits for a running sync, so that sync can't + * record the catch-up done over this request. + * + * Called once the one-time feed address repair has read every followed show again, so history + * and queue rows whose episodes those reads settled reach the cloud with their identity. Added + * in P4 PR 15 of the own-episode-IDs plan (plan sections 8 and 11). + * COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5). + */ + suspend fun requestIdentityCatchUp() = syncMutex.withLock { + if (requireBoxcastPrefs.getSyncMetadataVersion() >= IDENTITY_SYNC_METADATA_VERSION) { + requireBoxcastPrefs.setSyncMetadataVersion(IDENTITY_SYNC_METADATA_VERSION - 1) + } + } + private suspend fun handleAccountSwitch(userId: String, token: String?): SyncResult { purgeLocalDataForAccountSwitch() requireBoxcastPrefs.setLastSyncTimestamp(0L) diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairApiTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairApiTest.kt new file mode 100644 index 000000000..4f21f5095 --- /dev/null +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairApiTest.kt @@ -0,0 +1,173 @@ +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.PodcastDao +import cx.aswin.boxlore.core.database.PodcastEntity +import cx.aswin.boxlore.core.database.UpgradeRepairDao +import cx.aswin.boxlore.core.database.UpgradeRepairShowEntity +import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome +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.NetworkModule +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.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 + +/** + * Step 1 of the one-time address repair (own-episode-IDs P4 PR 15, plan section 11) through the + * real `/v2` client against MockWebServer serving `/v2/shows/feeds` answers in the route's shape + * (`fixtures/v2`, see its README): how each kind of answer is recorded, and which failures count + * as a try. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class UpgradeAddressRepairApiTest { + private lateinit var server: MockWebServer + private lateinit var database: BoxLoreDatabase + private lateinit var podcasts: PodcastDao + private lateinit var repairs: UpgradeRepairDao + private val responses = ArrayDeque() + private val rereads = mutableListOf>() + private val reports = mutableListOf() + + private val telemetry = + object : FeedTelemetryPort { + override fun feedFetched(report: FeedFetchReport) { + reports += report + } + + override fun feedUrlCorrected(correction: FeedUrlCorrection) = Unit + } + + @Before + fun setUp() { + server = MockWebServer() + server.dispatcher = + object : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse = responses.removeFirstOrNull() ?: MockResponse().setResponseCode(404) + } + server.start() + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, BoxLoreDatabase::class.java).allowMainThreadQueries().build() + podcasts = database.podcastDao() + repairs = database.upgradeRepairDao() + } + + @After + fun tearDown() { + database.close() + server.shutdown() + } + + @Test + fun `a recorded answer swaps the confirmed move only and records the rest`() = runTest { + seed("920666", OLD) + seed("920667", "http://plain.example.com/feed.xml") + seed("920668", OTHER) + seed("920669", OTHER) + responses += fixture("fixtures/v2/shows-feeds.json") + + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + val sent = server.takeRequest() + assertEquals("POST", sent.method) + assertEquals("/v2/shows/feeds", sent.path) + assertEquals("{\"ids\":[920666,920667,920668,920669]}", sent.body.readUtf8()) + + assertEquals("a confirmed direct move is swapped", NEW, podcasts.getPodcast("920666")!!.feedUrl) + assertEquals("an http-only show keeps its address", "http://plain.example.com/feed.xml", podcasts.getPodcast("920667")!!.feedUrl) + assertEquals("https://api.example.test/v2/feed/920667", repairs.getShow("920667")!!.checkedTransportUrl) + assertNull(repairs.getShow("920667")!!.checkedFetchUrl) + assertEquals("a show the lookup doesn't know keeps its address", OTHER, podcasts.getPodcast("920668")!!.feedUrl) + assertEquals("an unflagged address is no move", OTHER, podcasts.getPodcast("920669")!!.feedUrl) + assertEquals(4242L, repairs.getShow("920669")!!.checkedEpNs) + assertNull("the server's namespace is never stored for the show", podcasts.getPodcast("920669")!!.epNs) + assertEquals(listOf("920666" to 920666L, "920667" to 920667L, "920668" to 920668L, "920669" to 4242L), rereads) + assertEquals(listOf(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, 4, 0, 0, namespaceMismatchCount = 1)), reports) + } + + @Test + fun `a 503 and a registry that is down keep the chunk waiting without counting a try`() = runTest { + seed("920666", OLD) + responses += json("""{"error":"lookup_unavailable"}""", code = 503) + responses += fixture("fixtures/v2/shows-feeds-registry-down.json") + val repair = repair() + + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + + val row = repairs.getShow("920666")!! + assertEquals(0, row.completedStep) + assertEquals(0, row.attempts) + assertEquals("address_unavailable", row.lastError) + assertEquals(OLD, podcasts.getPodcast("920666")!!.feedUrl) + assertTrue(rereads.isEmpty()) + assertEquals(2, server.requestCount) + } + + @Test + fun `a refused request and an unreadable answer each count a try`() = runTest { + seed("920666", OLD) + responses += json("""{"error":"invalid_ids","max":500}""", code = 400) + responses += json("") + val repair = repair() + + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + assertEquals(1, repairs.getShow("920666")!!.attempts) + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + assertEquals(2, repairs.getShow("920666")!!.attempts) + assertEquals("address_refused", repairs.getShow("920666")!!.lastError) + } + + private fun repair() = UpgradeAddressRepair( + repairDao = repairs, + podcastDao = podcasts, + ports = + UpgradeRepairPorts( + fetchAddresses = AddressCheckAnswer.fetchWith(NetworkModule.createBoxLoreApiV2(server.url("/").toString(), OkHttpClient())), + replaceAddress = FeedAddressUpdater(podcasts, requestNotificationRegistration = {})::update, + reread = { row, epNs -> + rereads += row.podcastId to epNs + RefreshOutcome.Success(null, 1, true) + }, + requestNotificationRegistration = {}, + telemetry = telemetry, + ), + marker = MemoryUpgradeNotificationMarker(), + enabled = { true }, + nowMillis = { NOW }, + ) + + private suspend fun seed(id: String, feedUrl: String) { + podcasts.upsertRow( + PodcastEntity(podcastId = id, title = "Show", author = "Author", imageUrl = "", description = null, isSubscribed = true, feedUrl = feedUrl), + ) + repairs.upsert(UpgradeRepairShowEntity(id)) + } + + private fun fixture(path: String): MockResponse = json(requireNotNull(javaClass.classLoader?.getResource(path)) { path }.readText()) + + private fun json(body: String, code: Int = 200): MockResponse = MockResponse().setResponseCode(code).setHeader("Content-Type", "application/json").setBody(body) + + private companion object { + const val OLD = "https://old.example.com/feed.xml" + const val NEW = "https://new.example.com/feed.xml" + const val OTHER = "https://other.example.com/feed.xml" + const val NOW = 1_800_000_000_000L + } +} diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt index b699ec796..d861549bd 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/UpgradeAddressRepairTest.kt @@ -10,6 +10,7 @@ import cx.aswin.boxlore.core.database.UpgradeRepairDao import cx.aswin.boxlore.core.database.UpgradeRepairShowEntity import cx.aswin.boxlore.core.database.UpgradeRepairState import cx.aswin.boxlore.core.domain.ports.FeedTelemetryPort +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome import cx.aswin.boxlore.core.model.FeedFetchReport import cx.aswin.boxlore.core.model.FeedFetchSource @@ -19,6 +20,7 @@ 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 cx.aswin.boxlore.core.testing.fakes.FakeLocalEpisodeCatalogPort import java.io.File import java.io.IOException import kotlinx.coroutines.CancellationException @@ -61,7 +63,10 @@ class UpgradeAddressRepairTest { private var marker: UpgradeNotificationMarker = MemoryUpgradeNotificationMarker() private var online = true private var enabled = true - private var fetch: suspend (List) -> V2ShowsFeedsResponse? = { ids -> answer(ids) } + private var now = NOW + private var catchUps = 0 + private var fetch: suspend (List) -> AddressCheckAnswer = { ids -> answer(ids) } + private var register: suspend (String) -> Unit = { registrations += it } private var reread: suspend (PodcastEntity, Long?) -> RefreshOutcome = { _, _ -> RefreshOutcome.Success(null, 1, true) } private val telemetry = @@ -104,6 +109,7 @@ class UpgradeAddressRepairTest { assertTrue(calls.isEmpty()) assertTrue(rereads.isEmpty()) assertTrue(registrations.isEmpty()) + assertEquals("nothing was repaired, so the sync is left alone", 0, catchUps) } @Test @@ -132,6 +138,7 @@ class UpgradeAddressRepairTest { } assertEquals("the writer and step 4 both ask; shows with notifications off never", listOf("920666", "920666"), registrations) assertTrue(marker.isComplete) + assertEquals(1, catchUps) assertEquals(listOf(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, okCount = 2, notModifiedCount = 0, failedCount = 0)), reports) assertFalse(repair.needsWork()) } @@ -149,7 +156,7 @@ class UpgradeAddressRepairTest { @Test fun `a partial batch keeps the failed chunk at step 0 without counting a try`() = runTest { repeat(501) { seed(show((1_000 + it).toString(), OLD)) } - fetch = { ids -> if (calls.isEmpty()) answer(ids) else null.also { calls += ids } } + fetch = { ids -> if (calls.isEmpty()) answer(ids) else AddressCheckAnswer.Unavailable.also { calls += ids } } assertEquals(UpgradeRepairRun.RETRY, repair().run()) @@ -241,6 +248,7 @@ class UpgradeAddressRepairTest { assertEquals(UpgradeRepairRun.DONE, repair.run()) assertEquals(listOf("920666", "920667"), registrations.sorted()) + assertEquals("the restored phone's next sync catches up again", 1, catchUps) assertTrue(calls.isEmpty()) assertTrue(rereads.isEmpty()) assertTrue(marker.isComplete) @@ -303,16 +311,41 @@ class UpgradeAddressRepairTest { } @Test - fun `a different server namespace is recorded and never applied to the phone's IDs`() = runTest { + fun `a different server namespace is recorded, reported and handed to the read only to compare`() = runTest { seed(show("920666", OLD).copy(epNs = 920666L)) + seed(show("920667", OTHER)) answers[920666] = V2ShowAddress(id = 920666, fetchUrl = OLD, fetchVia = "direct", feedSource = "registry:apple", epNs = 4242) + answers[920667] = V2ShowAddress(id = 920667, epNs = 920667) assertEquals(UpgradeRepairRun.DONE, repair().run()) assertEquals(4242L, repairs.getShow("920666")!!.checkedEpNs) - assertEquals(920666L, podcasts.getPodcast("920666")!!.epNs) - assertEquals("the read only compares it", listOf("920666" to 4242L), rereads) + assertEquals("the phone's own namespace is kept", 920666L, podcasts.getPodcast("920666")!!.epNs) + assertEquals("the read gets it only to compare", listOf("920666" to 4242L, "920667" to 920667L), rereads) assertTrue(corrections.isEmpty()) + assertEquals(listOf(FeedFetchReport.BackgroundPass(FeedFetchSource.FEED, 2, 0, 0, namespaceMismatchCount = 1)), reports) + } + + @Test + fun `the production read keeps the saved address, passes the namespace and starts no auto-download`() = runTest { + val requests = mutableListOf() + val catalog = + object : LocalEpisodeCatalogPort by FakeLocalEpisodeCatalogPort() { + override suspend fun refresh(request: LocalEpisodeCatalogPort.RefreshRequest): RefreshOutcome { + requests += request + return RefreshOutcome.Unchanged(null) + } + } + + val outcome = UpgradeAddressRepair.rereadWith(catalog)(show("920666", OLD).copy(epNs = 920666L), 4242L) + + assertEquals(RefreshOutcome.Unchanged(null), outcome) + val request = requests.single() + assertEquals("920666", request.podcastIndexId) + assertEquals(OLD, request.feedUrl) + assertEquals(4242L, request.serverEpNs) + assertEquals(LocalEpisodeCatalogPort.RefreshReason.NORMAL, request.reason) + assertFalse("the background repair never starts the foreground auto-download pass", request.runPostPersistCallback) } @Test @@ -355,10 +388,13 @@ class UpgradeAddressRepairTest { assertTrue(first.addRegistered(listOf("920666", "920667"))) assertTrue(first.markComplete()) + assertFalse(first.syncCatchUpRequested) + assertTrue(first.markSyncCatchUpRequested()) val next = FileUpgradeNotificationMarker(file) - assertEquals(setOf("920666", "920667"), next.registered()) + assertEquals("its own records are no shows", setOf("920666", "920667"), next.registered()) assertTrue(next.isComplete) + assertTrue(next.syncCatchUpRequested) val restored = FileUpgradeNotificationMarker(File(folder.root, "restored")) assertFalse(restored.isComplete) @@ -366,39 +402,223 @@ class UpgradeAddressRepairTest { @Test fun `the swap target is checked again against the row as it is now`() { - val entry = UpgradeRepairShowEntity("920666", completedStep = 1, checkedFetchUrl = NEW, checkedFeedSource = "registry:apple") - assertEquals(NEW, UpgradeAddressRepair.swapTarget(show("920666", OLD), entry)) - assertNull("already there", UpgradeAddressRepair.swapTarget(show("920666", NEW), entry)) + val entry = UpgradeRepairShowEntity("920666", completedStep = 1, checkedFetchUrl = NEW, checkedFeedSource = "registry:apple", lastAttemptAt = NOW) + assertEquals(NEW, UpgradeAddressRepair.swapTarget(show("920666", OLD), entry, NOW)) + assertNull("already there", UpgradeAddressRepair.swapTarget(show("920666", NEW), entry, NOW)) assertNull( "a confirmed feed move left that address", - UpgradeAddressRepair.swapTarget(show("920666", OLD).copy(feedUrlSource = FeedUrlSources.FEED_MOVE, previousFeedUrl = NEW), entry), + UpgradeAddressRepair.swapTarget(show("920666", OLD).copy(feedUrlSource = FeedUrlSources.FEED_MOVE, previousFeedUrl = NEW), entry, NOW), ) - assertNull("no confirmed move", UpgradeAddressRepair.swapTarget(show("920666", OLD), entry.copy(checkedFetchUrl = null))) + assertNull("no confirmed move", UpgradeAddressRepair.swapTarget(show("920666", OLD), entry.copy(checkedFetchUrl = null), NOW)) + assertNull("a day old", UpgradeAddressRepair.swapTarget(show("920666", OLD), entry, NOW + FeedAddressDailyCheck.CHECK_INTERVAL_MS)) + assertNull("the daily check has a newer answer", UpgradeAddressRepair.swapTarget(show("920666", OLD).copy(feedUrlCheckedAt = NOW + 1), entry, NOW + 1)) + } + + @Test + fun `an answer left waiting by a pause is asked again later and never applied`() = runTest { + seed(show("920666", OLD, notifications = true)) + answers[920666] = moved(920666) + fetch = { ids -> answer(ids).also { enabled = false } } + + assertEquals("switched off after step 1", UpgradeRepairRun.PAUSED, repair().run()) + assertEquals(1, repairs.getShow("920666")!!.completedStep) + assertEquals(NEW, repairs.getShow("920666")!!.checkedFetchUrl) + + now = NOW + 2 * FeedAddressDailyCheck.CHECK_INTERVAL_MS + enabled = true + answers.remove(920666) + calls.clear() + fetch = { ids -> answer(ids) } + assertEquals(UpgradeRepairRun.DONE, repair().run()) + + assertEquals("asked again", listOf(listOf(920666L)), calls) + assertEquals("the old answer is never applied", OLD, podcasts.getPodcast("920666")!!.feedUrl) + assertTrue(corrections.isEmpty()) + assertEquals(UpgradeRepairState.DONE, repairs.getShow("920666")!!.state) + } + + @Test + fun `a failed swap keeps the time of its answer, so the answer still expires`() = runTest { + seed(show("920666", OLD)) + answers[920666] = moved(920666) + var swaps = 0 + val failingSwap: suspend (FeedAddressChange) -> FeedAddressUpdate = { + swaps++ + error("database busy") + } + + assertEquals(UpgradeRepairRun.RETRY, repair(replace = failingSwap).run()) + val waiting = repairs.getShow("920666")!! + assertEquals(1, waiting.completedStep) + assertEquals(1, waiting.attempts) + assertEquals(NOW, waiting.lastAttemptAt) + + now = NOW + FeedAddressDailyCheck.CHECK_INTERVAL_MS + answers.remove(920666) + assertEquals(UpgradeRepairRun.DONE, repair().run()) + assertEquals(OLD, podcasts.getPodcast("920666")!!.feedUrl) + assertEquals(1, swaps) + } + + @Test + fun `a refused address check counts a try and the show goes on without an answer at the cap`() = runTest { + seed(show("920666", OLD, notifications = true)) + fetch = { ids -> AddressCheckAnswer.Rejected.also { calls += ids } } + val repair = repair() + + repeat(UpgradeAddressRepair.MAX_ATTEMPTS - 1) { assertEquals(UpgradeRepairRun.RETRY, repair.run()) } + val waiting = repairs.getShow("920666")!! + assertEquals(0, waiting.completedStep) + assertEquals(UpgradeAddressRepair.MAX_ATTEMPTS - 1, waiting.attempts) + assertTrue(rereads.isEmpty()) + + assertEquals(UpgradeRepairRun.DONE, repair.run()) + val row = repairs.getShow("920666")!! + assertEquals(UpgradeRepairState.DONE, row.state) + assertNull("no answer, so no swap", row.checkedFetchUrl) + assertEquals(OLD, podcasts.getPodcast("920666")!!.feedUrl) + assertEquals("the read and step 4 still run", listOf("920666" to null), rereads) + assertEquals(listOf("920666"), registrations) + assertEquals(UpgradeAddressRepair.MAX_ATTEMPTS, calls.size) + } + + @Test + fun `an address check the server can't answer counts no try and goes on without an answer after a week`() = runTest { + seed(show("920666", OLD)) + seed(show("920667", OTHER)) + var registryDown = false + fetch = { ids -> + calls += ids + if (registryDown) { + AddressCheckAnswer.Answered(V2ShowsFeedsResponse(registryAvailable = false, results = ids.map { V2ShowAddress(id = it, fetchUrl = OLD) })) + } else { + AddressCheckAnswer.Unavailable + } + } + val repair = repair() + + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + now = NOW + 6 * FeedAddressDailyCheck.CHECK_INTERVAL_MS + registryDown = true + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + for (id in listOf("920666", "920667")) { + val row = repairs.getShow(id)!! + assertEquals(0, row.completedStep) + assertEquals("never counted as a try", 0, row.attempts) + assertEquals("the first failed check", NOW, row.lastAttemptAt) + } + assertTrue(rereads.isEmpty()) + + now = NOW + UpgradeAddressRepair.MAX_UNANSWERED_MS + assertEquals(UpgradeRepairRun.DONE, repair.run()) + assertEquals(listOf("920666" to null, "920667" to null), rereads) + assertEquals(OTHER, podcasts.getPodcast("920667")!!.feedUrl) + assertTrue(corrections.isEmpty()) + assertEquals(0, repairs.pendingCount()) + } + + @Test + fun `the sync catch-up is asked for once, after the last show, never while shows wait or the run is paused`() = runTest { + repeat(501) { seed(show((1_000 + it).toString(), OLD)) } + fetch = { ids -> if (calls.isEmpty()) answer(ids) else AddressCheckAnswer.Unavailable.also { calls += ids } } + val repair = repair() + + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + assertEquals("one show still waits", 0, catchUps) + + enabled = false + assertEquals(UpgradeRepairRun.PAUSED, repair.run()) + assertEquals(0, catchUps) + + enabled = true + fetch = { ids -> answer(ids) } + assertEquals(UpgradeRepairRun.DONE, repair.run()) + assertEquals(1, catchUps) + assertTrue(marker.syncCatchUpRequested) + + assertEquals(UpgradeRepairRun.DONE, repair.run()) + assertFalse(repair.needsWork()) + assertEquals("never again on this install", 1, catchUps) + } + + @Test + fun `a sync catch-up that couldn't be asked for keeps the repair going until it is`() = runTest { + seed(show("920666", OLD)) + var failing = true + val repair = + UpgradeAddressRepair( + repairDao = repairs, + podcastDao = podcasts, + ports = + UpgradeRepairPorts( + fetchAddresses = { ids -> answer(ids) }, + replaceAddress = updater::update, + reread = { _, _ -> RefreshOutcome.Unchanged(null) }, + requestNotificationRegistration = { registrations += it }, + onAddressStepsFinished = { + check(!failing) { "prefs unavailable" } + catchUps++ + }, + ), + marker = marker, + enabled = { true }, + nowMillis = { now }, + ) + + assertEquals(UpgradeRepairRun.RETRY, repair.run()) + assertFalse(marker.syncCatchUpRequested) + assertTrue(repair.needsWork()) + + failing = false + assertEquals(UpgradeRepairRun.DONE, repair.run()) + assertEquals(1, catchUps) + assertFalse(repair.needsWork()) + } + + @Test + fun `a registration request that couldn't be saved is not recorded and is asked again`() = runTest { + seed(show("920666", OLD, notifications = true)) + var saved = false + register = { id -> + check(saved) { "the request wasn't saved" } + registrations += id + } + + assertEquals(UpgradeRepairRun.RETRY, repair().run()) + assertFalse("920666" in marker.registered()) + assertFalse(marker.isComplete) + + saved = true + assertEquals(UpgradeRepairRun.DONE, repair().run()) + assertEquals(listOf("920666"), registrations) + assertTrue("920666" in marker.registered()) + assertTrue(marker.isComplete) } - private fun repair() = UpgradeAddressRepair( + private fun repair(replace: suspend (FeedAddressChange) -> FeedAddressUpdate = updater::update) = UpgradeAddressRepair( repairDao = repairs, podcastDao = podcasts, ports = UpgradeRepairPorts( fetchAddresses = { ids -> fetch(ids) }, - replaceAddress = updater::update, + replaceAddress = replace, reread = { row, epNs -> rereads += row.podcastId to epNs reread(row, epNs) }, - requestNotificationRegistration = { registrations += it }, + requestNotificationRegistration = { register(it) }, + onAddressStepsFinished = { catchUps++ }, isOnline = { online }, telemetry = telemetry, ), marker = marker, enabled = { enabled }, - nowMillis = { NOW }, + nowMillis = { now }, ) - private fun answer(ids: List): V2ShowsFeedsResponse { + private fun answer(ids: List): AddressCheckAnswer { calls += ids - return V2ShowsFeedsResponse(registryAvailable = true, results = ids.map { answers[it] ?: V2ShowAddress(id = it) }) + return AddressCheckAnswer.Answered(V2ShowsFeedsResponse(registryAvailable = true, results = ids.map { answers[it] ?: V2ShowAddress(id = it) })) } private suspend fun seed(row: PodcastEntity, completedStep: Int = 0) { diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/CloudSyncIdentityTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/CloudSyncIdentityTest.kt index bc8f121bf..4ae42f3ca 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/CloudSyncIdentityTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/CloudSyncIdentityTest.kt @@ -295,6 +295,40 @@ class CloudSyncIdentityTest { assertEquals(1, server.pushes.size) } + @Test + fun aCatchUpAskedForAfterTheAddressRepairSendsTheIdentityOfEpisodesItsReadsSettled() = runTest(dispatcher) { + // Own-episode-IDs P4 PR 15: the repair's reads settle aliases after the first catch-up ran. + upgradedDevice(history(PI_ID, dirty = false, syncedAt = 100L)) + server.push(SyncPushRequest(history = listOf(oldBuildRow(PI_ID)))) + server.pushes.clear() + assertTrue(coordinator.syncNow() is SyncResult.Success) + assertEquals(UserSyncCoordinator.IDENTITY_SYNC_METADATA_VERSION, prefs.getSyncMetadataVersion()) + assertNull("nothing known yet", server.history.getValue(PI_ID).recipeId) + + // The repair's read of the feed settles the episode: its alias becomes trusted with a key. + database.episodeIdentityDao().upsertAlias(alias(PI_ID, owner = true, provenance = EpisodeAliasProvenance.FEED_CONFIRMED_KEY)) + assertTrue(coordinator.syncNow() is SyncResult.Success) + assertNull("a delta sync doesn't send it", server.history.getValue(PI_ID).recipeId) + + coordinator.requestIdentityCatchUp() + assertEquals(UserSyncCoordinator.IDENTITY_SYNC_METADATA_VERSION - 1, prefs.getSyncMetadataVersion()) + assertTrue(coordinator.syncNow() is SyncResult.Success) + + assertEquals(0L, server.pulls.last().since) + assertEquals(RECIPE to KEY, server.history.getValue(PI_ID).let { it.recipeId to it.catalogKey }) + assertEquals(UserSyncCoordinator.IDENTITY_SYNC_METADATA_VERSION, prefs.getSyncMetadataVersion()) + } + + @Test + fun aCatchUpRequestLeavesAPhoneThatHasntCaughtUpYetAsItIs() = runTest(dispatcher) { + coordinator.requestIdentityCatchUp() + assertEquals("never synced", 0, prefs.getSyncMetadataVersion()) + + prefs.setSyncMetadataVersion(2) + coordinator.requestIdentityCatchUp() + assertEquals(2, prefs.getSyncMetadataVersion()) + } + @Test fun theCatchUpKeepsEachCloudRowsTimeSoOtherDevicesPullNothingNew() = runTest(dispatcher) { aliases(alias(PI_ID, owner = true)) diff --git a/core/catalog/src/test/resources/fixtures/v2/README.md b/core/catalog/src/test/resources/fixtures/v2/README.md index c0b9b7886..14638ff93 100644 --- a/core/catalog/src/test/resources/fixtures/v2/README.md +++ b/core/catalog/src/test/resources/fixtures/v2/README.md @@ -15,3 +15,11 @@ 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`) | + +The `/v2/shows/feeds` answers are not live recordings: they were written on 2026-10-27 from the +route's documented contract and its own tests, field for field, with example addresses. + +| File | Request | +|---|---| +| `shows-feeds.json` | `POST /v2/shows/feeds` with four IDs: a confirmed move (`registry:` source, `flags.addressRepair`), an http-only show (`fetchVia: proxy` with its `transportUrl`), a show the lookup doesn't know (`fetchUrl: null`) and one with another `epNs` | +| `shows-feeds-registry-down.json` | the same route while the address registry is unavailable (`registryAvailable: false`, dump addresses only) | diff --git a/core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-down.json b/core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-down.json new file mode 100644 index 000000000..54a0695d8 --- /dev/null +++ b/core/catalog/src/test/resources/fixtures/v2/shows-feeds-registry-down.json @@ -0,0 +1,15 @@ +{ + "registryAvailable": false, + "results": [ + { + "id": 920666, + "fetchUrl": "https://old.example.com/feed.xml", + "fetchVia": "direct", + "transportUrl": null, + "feedSource": "pidump", + "epNs": 920666, + "flags": { "addressRepair": false, "episodeStale": false }, + "verifiedAt": null + } + ] +} 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..96834d16a --- /dev/null +++ b/core/catalog/src/test/resources/fixtures/v2/shows-feeds.json @@ -0,0 +1,45 @@ +{ + "registryAvailable": true, + "results": [ + { + "id": 920666, + "fetchUrl": "https://new.example.com/feed.xml", + "fetchVia": "direct", + "transportUrl": null, + "feedSource": "registry:apple", + "epNs": 920666, + "flags": { "addressRepair": true, "episodeStale": false }, + "verifiedAt": 1792700000 + }, + { + "id": 920667, + "fetchUrl": "http://plain.example.com/feed.xml", + "fetchVia": "proxy", + "transportUrl": "https://api.example.test/v2/feed/920667", + "feedSource": "registry:apple", + "epNs": 920667, + "flags": { "addressRepair": true, "episodeStale": false }, + "verifiedAt": 1792700000 + }, + { + "id": 920668, + "fetchUrl": null, + "fetchVia": null, + "transportUrl": null, + "feedSource": null, + "epNs": 920668, + "flags": { "addressRepair": false, "episodeStale": false }, + "verifiedAt": null + }, + { + "id": 920669, + "fetchUrl": "https://other.example.com/feed.xml", + "fetchVia": "direct", + "transportUrl": null, + "feedSource": "pidump", + "epNs": 4242, + "flags": { "addressRepair": false, "episodeStale": false }, + "verifiedAt": null + } + ] +} diff --git a/core/database/README.md b/core/database/README.md index 3bb6f9bd4..d47159c88 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, per-show progress, and `hasShows` for whether the upgrade seeded any), `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. diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/UpgradeRepairDao.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/UpgradeRepairDao.kt index 3fe6d82e1..64fcd2409 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/UpgradeRepairDao.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/UpgradeRepairDao.kt @@ -20,6 +20,13 @@ interface UpgradeRepairDao { @Query("SELECT COUNT(*) FROM upgrade_repair_shows WHERE state = 'pending'") suspend fun pendingCount(): Int + /** + * Whether the upgrade seeded any show to repair; false on a fresh install. Added in P4 PR 15 of + * the own-episode-IDs plan (plan section 11, upgrade repair). + */ + @Query("SELECT EXISTS(SELECT 1 FROM upgrade_repair_shows)") + suspend fun hasShows(): Boolean + /** One show's repair progress, or null when the show was never part of the repair. */ @Query("SELECT * FROM upgrade_repair_shows WHERE podcastId = :podcastId LIMIT 1") suspend fun getShow(podcastId: String): UpgradeRepairShowEntity? diff --git a/core/model/README.md b/core/model/README.md index 488dc0a53..02ad1bb1a 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -24,7 +24,7 @@ Owns shared domain models, enums, and pure value helpers used across network, da - `ListeningHistoryRemoval` holds what removing an episode from history took away, for Undo: the chosen row, its sessions and daily totals, and for an episode stored under several IDs every removed row exactly as stored (`ListeningHistoryRowSnapshot`, with like and completion times and how it was completed) and its shared settings (`ListeningGroupStateSnapshot`), so Undo puts the episode back unchanged. - `PodcastGenres` and `RankingAggregateTelemetry`. - `Episode.recipeId` (boxlore's own episode ID) and `Episode.catalogKey` (trimmed GUID, else enclosure URL) are optional and null until a source fills them; `id` stays the local ID. Unset values are not written to JSON, and JSON without them still decodes (`EpisodeIdentityFieldsTest`). -- `FeedTelemetry.kt`: values for the feed analytics events. `FeedFetchReport.ShowPage` (one show-page read) and `FeedFetchReport.BackgroundPass` (one summary per pass and source, with `showsCount` and `passResult`), `FeedFetchSource`, `FeedFetchOutcome`, `FeedFetchCompleteness`, `FeedPassResult`, and `FeedUrlCorrection` with its `FeedUrlCorrectionReason` and `FeedUrlCorrectionStep`. Each enum's `wireName` is its glossary value. None of them holds an address (`FeedTelemetryTest`). +- `FeedTelemetry.kt`: values for the feed analytics events. `FeedFetchReport.ShowPage` (one show-page read) and `FeedFetchReport.BackgroundPass` (one summary per pass and source, with `showsCount`, `passResult` and `namespaceMismatchCount`), `FeedFetchSource`, `FeedFetchOutcome`, `FeedFetchCompleteness`, `FeedPassResult`, and `FeedUrlCorrection` with its `FeedUrlCorrectionReason` and `FeedUrlCorrectionStep`. Each enum's `wireName` is its glossary value. None of them holds an address (`FeedTelemetryTest`). - Cross-promotion model types: `CrossPromotionResult` / `ResolvedCrossPromotion`, `CrossPromotionConfidence` and `CrossPromotionIndicator`, whose `DISTINCT_ARTWORK` marks an episode whose own cover differs from its show's (evidence `:core:catalog`'s detector uses for trailers of another show). Indicators live only in UI state. - `ShowNotes`, `EpisodeLink`, and `EpisodeLinkKind` carry locally parsed plain text, publisher HTML, chapters, resource purpose, destination, and publisher labels without UI or parser-library dependencies. - `Podcast.isLatestEpisodeNew`: shared NEW badge. Room `rssHasNewEpisodes` is true for true-RSS freshness **and** for Podcast Index direct-feed tip promotions (`updateLatestEpisode(..., markAsNew = true)`). Opening the show clears the flag. Otherwise the 48h window / last-seen id rules apply. diff --git a/core/model/src/main/java/cx/aswin/boxlore/core/model/FeedTelemetry.kt b/core/model/src/main/java/cx/aswin/boxlore/core/model/FeedTelemetry.kt index e9f922a52..2e586550c 100644 --- a/core/model/src/main/java/cx/aswin/boxlore/core/model/FeedTelemetry.kt +++ b/core/model/src/main/java/cx/aswin/boxlore/core/model/FeedTelemetry.kt @@ -87,12 +87,17 @@ sealed interface FeedFetchReport { val httpStatus: Int? = null, ) : FeedFetchReport - /** A background pass over many shows (daily check, refresh pass, upgrade repair). */ + /** + * A background pass over many shows (daily check, refresh pass, upgrade repair). + * [namespaceMismatchCount]: reads in the pass of shows whose episode namespace on the server + * differs from the one the phone keeps (decision D12; added in P4 PR 15). + */ data class BackgroundPass( override val source: FeedFetchSource, val okCount: Int, val notModifiedCount: Int, val failedCount: Int, + val namespaceMismatchCount: Int = 0, ) : FeedFetchReport { /** Shows read in the pass; negative counts are treated as zero. */ val showsCount: Int diff --git a/docs/analytics/event_glossary.csv b/docs/analytics/event_glossary.csv index f68470653..9b78ccc8f 100644 --- a/docs/analytics/event_glossary.csv +++ b/docs/analytics/event_glossary.csv @@ -74,7 +74,7 @@ rss_refresh_failed,RSS refresh failed,RSS feed refresh failed,C,RSS reliability, progress_sync_anomaly,Progress sync anomaly,Progress sync inconsistency detected,C,Sync trust,anomaly_type:string:Y;episode_id:string:N;recipe_id:string:N,,playback,,none,Shipped PR9 late_night_safeguard_decision,Late-night safeguard decision,Late-night listening safeguard fired,C,Late-night UX,decision:string:Y,late_night_safeguard_decision,playback;delight,,none, proxy_fallback_triggered,Image proxy fallback,Image load fell back to proxy,C,CDN/proxy health,reason:string:N,proxy_fallback_triggered,infra,,none,Emitted by designsystem OptimizedImage through the AnalyticsHelper façade -feed_fetch_result,Feed fetch result,A show page read its episodes or a background pass read followed feeds,C,Feed reliability by source; partial vs complete reads; failure mix,context:enum:Y;source:enum:Y;result:enum:Y;show_id:string:N;completeness:enum:N;http_status:int:N;shows_count:int:N;ok_count:int:N;not_modified_count:int:N;failed_count:int:N,,catalog;rss,,none,context: show_page|background_pass; source: feed|transport|v2_episodes|cache; result on show_page: ok|not_modified|network_error|timeout|http_error|gone|parse_error; result on background_pass: ok|mixed|failed; completeness: partial|complete (successful show-page reads); http_status on http_error|gone only; show_id on show_page only; counts on background_pass only (one event per pass and source); never a URL +feed_fetch_result,Feed fetch result,A show page read its episodes or a background pass read followed feeds,C,Feed reliability by source; partial vs complete reads; failure mix,context:enum:Y;source:enum:Y;result:enum:Y;show_id:string:N;completeness:enum:N;http_status:int:N;shows_count:int:N;ok_count:int:N;not_modified_count:int:N;failed_count:int:N;namespace_mismatch_count:int:N,,catalog;rss,,none,context: show_page|background_pass; source: feed|transport|v2_episodes|cache; result on show_page: ok|not_modified|network_error|timeout|http_error|gone|parse_error; result on background_pass: ok|mixed|failed; completeness: partial|complete (successful show-page reads); http_status on http_error|gone only; show_id on show_page only; counts on background_pass only (one event per pass and source); namespace_mismatch_count only when the server reports another episode namespace for shows read in the pass; never a URL feed_url_corrected,Feed address corrected,A followed show's saved feed address was replaced,C,How often saved addresses go stale and which path repairs them,show_id:string:Y;reason:enum:Y;step:enum:Y,,catalog;rss,,none,reason: dead|moved|registry; step: upgrade_repair|daily_check|feed_refresh|feed_report; one event per swap; never a URL support_page_viewed,Support page viewed,Support boxlore development screen opened,B,Support discovery; conversion; support-world experiment exposure,support_world:enum:N;assignment_source:enum:N;entry_point:string:N;local_hour:int:N;weekday:int:N,,settings;support,support_view,none,Dual-emits canonical settings_interaction (action=support_page_viewed) support_tier_toggled,Support tier toggled,Support tier artifact swiped or lore inspected,B,Tier preference; lore engagement,tier_title:string:Y;codename:string:Y;amount:string:Y;is_lore_inspect:bool:N;support_world:enum:N;assignment_source:enum:N;tier_index:int:N,,settings;support,support_tier_select,none,Dual-emits canonical settings_interaction (action=support_tier_toggled) 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 6bffd950704686de4a3ad4beab019cd2e70af5aa Mon Sep 17 00:00:00 2001 From: Aswin C Date: Tue, 27 Oct 2026 23:57:17 +0530 Subject: [PATCH 7/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 8/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) + } } From 686d25533907a6125b837230badbe9bbc8c15b10 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 28 Oct 2026 00:30:08 +0530 Subject: [PATCH 9/9] refactor(sync): map queue items in their own function --- .../core/catalog/sync/UserSyncCoordinator.kt | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt index 6a23ecef2..eaa6ffc10 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt @@ -463,28 +463,7 @@ open class UserSyncCoordinator( if (!queueDirty || queueMeta == null) return null val identities = episodeIdentity?.outgoing(queueItems.map { SyncEpisodeIdentity.OutgoingRow(it.episodeId, it.podcastId) }).orEmpty() return QueueSyncDto( - items = queueItems.map { q -> - val identity = identities[SyncEpisodeIdentity.OutgoingRow(q.episodeId, q.podcastId)] - QueueItemSyncDto( - episodeId = q.episodeId, - podcastId = q.podcastId, - title = q.title.takeIf { it.isNotBlank() }, - podcastTitle = q.podcastTitle.takeIf { it.isNotBlank() }, - imageUrl = persistableArtworkUrl(q.imageUrl), - podcastImageUrl = persistableArtworkUrl(q.podcastImageUrl), - audioUrl = q.audioUrl.takeIf { it.isNotBlank() }, - duration = q.duration, - pubDate = q.pubDate, - description = q.description?.takeIf { it.isNotBlank() }, - position = q.position, - addedAt = q.addedAt, - contextType = q.contextType, - contextSourceId = q.contextSourceId, - updatedAt = queueMeta.queueUpdatedAt, - recipeId = identity?.recipeId, - catalogKey = identity?.catalogKey, - ) - }, + items = queueItems.map { q -> queueItemDto(q, queueMeta, identities[SyncEpisodeIdentity.OutgoingRow(q.episodeId, q.podcastId)]) }, queueUpdatedAt = queueMeta.queueUpdatedAt, queueSequence = queueMeta.queueSequence, lastModifiedDeviceId = queueMeta.lastModifiedDeviceId, @@ -493,6 +472,31 @@ open class UserSyncCoordinator( ) } + private fun queueItemDto( + q: QueueItem, + queueMeta: QueueMetadataEntity, + identity: SyncEpisodeIdentity.WireIdentity?, + ): QueueItemSyncDto = + QueueItemSyncDto( + episodeId = q.episodeId, + podcastId = q.podcastId, + title = q.title.takeIf { it.isNotBlank() }, + podcastTitle = q.podcastTitle.takeIf { it.isNotBlank() }, + imageUrl = persistableArtworkUrl(q.imageUrl), + podcastImageUrl = persistableArtworkUrl(q.podcastImageUrl), + audioUrl = q.audioUrl.takeIf { it.isNotBlank() }, + duration = q.duration, + pubDate = q.pubDate, + description = q.description?.takeIf { it.isNotBlank() }, + position = q.position, + addedAt = q.addedAt, + contextType = q.contextType, + contextSourceId = q.contextSourceId, + updatedAt = queueMeta.queueUpdatedAt, + recipeId = identity?.recipeId, + catalogKey = identity?.catalogKey, + ) + /** * The queue's rich removals as pushed: the stored list, always sent (an empty list too), because * the server replaces its list with the one sent. Recipe IDs that name another episode of the