diff --git a/app/README.md b/app/README.md index 111188ccf..bdb4dadc9 100644 --- a/app/README.md +++ b/app/README.md @@ -66,7 +66,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - `DownloadServiceLauncherHolder` is installed with `MediaDownloadService::class.java` so `:core:downloads` can launch the foreground download service without depending on `:core:playback`. - `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 baselines back to the 100-item page. `FeedFixSwitchesTest` covers the default. +- **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 baselines back to the 100-item page; `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. - 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 d65dba3a9..a1c78f185 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore import android.content.Context import cx.aswin.boxlore.connectivity.AndroidConnectivityObserver +import cx.aswin.boxlore.core.catalog.FeedFixToggles import cx.aswin.boxlore.core.catalog.FolderRepository import cx.aswin.boxlore.core.catalog.InstallReferrerManager import cx.aswin.boxlore.core.catalog.LegacyRssRepair @@ -280,6 +281,12 @@ class AppContainer( episodeSupplementPort = episodeSupplementRepository, localEpisodeCatalog = subscribedEpisodeCatalog, scope = syncScope, + feedFixes = + FeedFixToggles( + addressRecheck = { FeedFixSwitches.isOn(FeedFixSwitches.ADDRESS_RECHECK_OFF) }, + syncFallback = { FeedFixSwitches.isOn(FeedFixSwitches.SYNC_FALLBACK_OFF) }, + movedFeed = { FeedFixSwitches.isOn(FeedFixSwitches.MOVED_FEED_OFF) }, + ), ) } diff --git a/app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt b/app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt index 61bbfef8a..9864c5134 100644 --- a/app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt +++ b/app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt @@ -12,6 +12,15 @@ internal object FeedFixSwitches { /** Baselines read up to 1,000 episodes from `/episodes/match` instead of the 100-item page. */ const val MATCH_BASELINE_OFF = "feed-fix-match-baseline-off" + /** A dead saved feed address is re-checked against the server's confirmed one (once a day per show). */ + const val ADDRESS_RECHECK_OFF = "feed-fix-address-recheck-off" + + /** Shows whose own feed failed get their latest episode from `/sync`. */ + const val SYNC_FALLBACK_OFF = "feed-fix-sync-fallback-off" + + /** A feed that moved for good (permanent redirects or `itunes:new-feed-url`, same title) gets its new address saved. */ + const val MOVED_FEED_OFF = "feed-fix-moved-feed-off" + /** True unless [offFlag] is on. [isFlagOn] is the PostHog read, replaced in tests. */ fun isOn(offFlag: String, isFlagOn: (String) -> Boolean = ::postHogFlagOn,): Boolean = !runCatching { isFlagOn(offFlag) }.getOrDefault(false) diff --git a/core/catalog/README.md b/core/catalog/README.md index 78cab8a17..9ca6a4176 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -15,12 +15,12 @@ Failed consent writes keep activation off, and local notification flags prevent - RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. - `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` is the strict PI baseline for feed-episode matching (first catalog persist, library restore, Info, push hydration): up to 1,000 newest episodes as match fields only from `GET episodes/match` (`MAX_MATCH_BASELINE`), so a show with more than 100 episodes keeps its newer episodes on the IDs history, progress and downloads use. It falls back to the old oldest-100 page when the route fails, returns no episodes, or the `useEpisodeMatchBaseline` off switch (a PostHog kill switch passed by the app) is off. Full pages keep the 100 cap. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). -- `syncSubscriptions` (`POST /sync`) is PI-only for shows that are **not** catalog-ready and **not** still on extras opt-in. The PI ids go out in chunks of 20 (`PI_SYNC_CHUNK_SIZE`), at most two calls at a time (`PI_SYNC_PARALLELISM`), because the API serves only the first 40 ids of a call: whole-library callers (Smart Downloads, backup restore) used to lose every show past the 40th. A failed chunk drops only its own shows' tips. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). +- `syncSubscriptions` (`POST /sync`) is PI-only. `SubscriptionForegroundSync` sends it the shows that are **not** catalog-ready and have no saved HTTPS feed address, plus the shows whose own feed failed this pass. The PI ids go out in chunks of 20 (`PI_SYNC_CHUNK_SIZE`), at most two calls at a time (`PI_SYNC_PARALLELISM`), because the API serves only the first 40 ids of a call: whole-library callers (Smart Downloads, backup restore) used to lose every show past the 40th. A failed chunk drops only its own shows' tips. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). - Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead` / Meili) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: one CF embed → Qdrant podcasts + episodes; additive `feeds` + legacy `items`). - `logic.mergeShowSearchResults` dedupes Meili vs hybrid by id / itunes / feed URL. Missing/generic typeahead genres are filled from matching hybrid results when their known genre agrees; title-only matches and conflicting genres are not enrichment evidence. Existing catalog identity, other metadata, order and grouping stay unchanged. - `mapRegionForBriefing` maps content regions to briefing markets (`us` / `in` / `gb` / `global`; legacy `uk` → `gb`) via `ContentRegions.briefingMarket`. - `SubscriptionRepository`, `ChapterRepository`, and `TranscriptRepository` expose catalog-adjacent data operations. `ChapterRepository` supports remote JSON, local file paths, and show note description parsing (`hasChaptersInDescription`, `parseChaptersFromDescription`). `TranscriptRepository` supports remote SRT/VTT and local file paths. Offline persistence and file retrieval for downloaded episodes are modularized in `ChapterOfflineStorage` (`saveOfflineChapters`, `getOfflineChapters`, `deleteOfflineChapters`, `hasOfflineChapters`) and `TranscriptOfflineStorage` (`saveOfflineTranscript`, `getOfflineTranscript`, `deleteOfflineTranscript`, `hasOfflineTranscript`). Normal `updateLatestEpisode` calls never replace a newer Room tip with an older item (`LatestEpisodeTipLogic`); same published date does **not** replace or badge. A successful full publisher-feed ingest passes `publisherFeedAuthoritative`, allowing the real feed tip to replace a newer PI cross-promo or same-date different identity. NEW is still set only when `publishedDate` is newer. Subscribe recovers a missing HTTPS `feedUrl` via `GET /podcast` (daily retry also runs in launch sync). For catalogue notification subscriptions, `SubscriptionRepository` writes RTDB `tracked_podcasts/{podcastIndexId}` with `title`, `imageUrl`, and an HTTPS `feedUrl` when a Room tip exists. Live RTDB rules allow those three keys only. The checker selects RSS whenever that URL is present, independently of Missing episodes?; it seeds `lastRssKey` quietly unless the newest RSS item matches a PI episode different from the saved `lastEpisodeId`. Turning catalogue notifications **off** removes the `feedUrl` child. Pure RSS uses the journaled per-device URL scopes described above. Unsubscribe sets a 14-day TTL on the local catalog (no CASCADE wipe), records an `unsubscribedAt` tombstone timestamp, and marks `isDirty = 1` for delta sync; resubscribing resets `unsubscribedAt = 0` and sets `isDirty = 1`. Show settings updates (notifications, custom genre/icon, auto-download, skip intro/outro, playback speed) mark the show `isDirty = 1` for cloud sync. `updateCustomGenre` applies user-defined genre overrides and custom icon keys for subscribed shows; unsubscription clears any custom tag/icon overrides and resets to original catalog genre. -- `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. 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`): 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. 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`). @@ -131,7 +131,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit - `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`. - `PodcastRepositoryRequestBoundsTest` (MockWebServer that, like the API, serves only 40 ids per `/sync` call): a 60-show library syncs every show in three chunks of at most 20 with at most two in flight, a failed chunk drops only its own shows, and legacy, v2 and bootstrap recommendation requests send at most 12 interests. -- Existing coverage includes `PodcastRepositoryCatalogTest` (including the match baseline: 1,000-item request, skipped rows, fallback to the page path, off switch), `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 / `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. +- Existing coverage includes `PodcastRepositoryCatalogTest` (including the match baseline: 1,000-item request, skipped rows, fallback to the page path, off switch), `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. - `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`. - `LibraryBackupExportTest` pins the JSON export sections behind the Settings summary (no `directFeedOptIns`) and the lowercase OPML title. 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 new file mode 100644 index 000000000..ef79d6030 --- /dev/null +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/DirectFeedRecovery.kt @@ -0,0 +1,105 @@ +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 kotlinx.coroutines.CancellationException + +/** + * Shows whose own feed failed in a [SubscriptionForegroundSync] pass (P4a). + * + * 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. + */ +internal object DirectFeedRecovery { + private const val TAG = "DirectFeedRecovery" + + /** + * @param readAgain reads one show's feed at this address and saves its tip + * @param syncFromServer asks `/sync` for these shows and saves their tips + */ + suspend fun recover( + failed: Map, + directFeed: DirectFeedSyncSeams, + readAgain: suspend (id: String, feedUrl: String) -> DirectFeedResolveResult?, + syncFromServer: suspend (List) -> Unit, + ) { + if (failed.isEmpty()) return + 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) } + if (candidate != null && retried != null && !retried.failed) { + directFeed.saveAddress(id, candidate) + } else { + stillFailing += id + } + } + if (stillFailing.isEmpty() || !directFeed.syncFallbackEnabled()) return + Log.d(TAG, "Asking /sync for ${stillFailing.size} shows whose own feed failed") + syncFromServer(stillFailing) + } + + private suspend fun recheck(directFeed: DirectFeedSyncSeams, id: String): String? = try { + directFeed.recheckAddress(id) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Feed address re-check failed for $id", e) + null + } + + /** The production [DirectFeedSyncSeams.recheckAddress]: [recheckGoneFeedAddress] behind its switch. */ + fun addressRecheck( + feedFixes: FeedFixToggles, + subscriptionRepository: SubscriptionRepository, + podcastRepository: PodcastRepository, + localEpisodeCatalog: LocalEpisodeCatalogPort?, + ): suspend (String) -> String? = { id -> + if (!feedFixes.addressRecheck()) { + null + } else { + recheckGoneFeedAddress( + id = id, + now = System.currentTimeMillis(), + savedFeedUrl = subscriptionRepository.getPodcastEntity(id)?.feedUrl, + lastLookupAt = { localEpisodeCatalog?.lastFeedUrlLookupAt(id) ?: 0L }, + markLookup = { at -> localEpisodeCatalog?.markFeedUrlLookup(id, at) }, + lookupFeedUrl = { podcastRepository.getPodcastDetails(id)?.feedUrl }, + ) + } + } + + /** + * 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. + * @return a different HTTPS address to try, or null + */ + suspend fun recheckGoneFeedAddress( + id: String, + now: Long, + savedFeedUrl: String?, + lastLookupAt: suspend () -> Long, + markLookup: suspend (Long) -> Unit, + lookupFeedUrl: suspend () -> String?, + ): String? { + 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 + val confirmed = lookupFeedUrl() ?: return null + markLookup(now) + val https = TrackedPodcastRtdbLogic.httpsFeedUrl(confirmed) ?: return null + return https.takeIf { it != current } + } +} 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 10da60391..662c75a37 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 @@ -250,6 +250,7 @@ class SubscriptionForegroundSync( initialDelayMs: Long = DEFAULT_INITIAL_DELAY_MS, feedNetworkDelayMs: Long = DEFAULT_FEED_NETWORK_DELAY_MS, chunkSize: Int = DEFAULT_CHUNK_SIZE, + feedFixes: FeedFixToggles = FeedFixToggles(), ): SubscriptionForegroundSync { val preferred = AtomicReference(null) val refreshed = @@ -279,6 +280,7 @@ class SubscriptionForegroundSync( refreshed = refreshed, feedNetworkDelayMs = feedNetworkDelayMs, chunkSize = chunkSize, + feedFixes = feedFixes, ), ) } @@ -293,6 +295,7 @@ class SubscriptionForegroundSync( refreshed: MutableSharedFlow, feedNetworkDelayMs: Long, chunkSize: Int, + feedFixes: FeedFixToggles, ): suspend () -> Unit { val refreshBatch = DirectFeedRefreshBatch() return { @@ -354,6 +357,8 @@ class SubscriptionForegroundSync( localEpisodeCatalog = localEpisodeCatalog, episodeSupplementPort = episodeSupplementPort, podcastRepository = podcastRepository, + followMoves = feedFixes.movedFeed(), + adoptMovedFeed = { url -> subscriptionRepository.ensureHttpsFeedUrl(id, url) }, ) }, saveDirectFeedLatest = { id, episode -> @@ -374,6 +379,9 @@ class SubscriptionForegroundSync( } }, onFeedRefreshed = { refreshed.tryEmit(it) }, + recheckAddress = DirectFeedRecovery.addressRecheck(feedFixes, subscriptionRepository, podcastRepository, localEpisodeCatalog), + saveAddress = { id, url -> subscriptionRepository.ensureHttpsFeedUrl(id, url) }, + syncFallbackEnabled = feedFixes.syncFallback, ), ) syncTrackedFeedUrlsForHttpsNotifications( @@ -383,25 +391,36 @@ class SubscriptionForegroundSync( } } + @Suppress("LongParameterList") private suspend fun resolveLocalCatalogTip( podcastId: String, meta: DirectFeedTipMeta, localEpisodeCatalog: LocalEpisodeCatalogPort?, episodeSupplementPort: EpisodeSupplementPort, podcastRepository: PodcastRepository, + followMoves: Boolean, + adoptMovedFeed: suspend (String) -> Unit, ): DirectFeedResolveResult { val catalog = localEpisodeCatalog if (catalog != null) { - return resolveCatalogRefresh(podcastId, meta, catalog, podcastRepository) + return resolveCatalogRefresh(podcastId, meta, catalog, podcastRepository, followMoves, adoptMovedFeed) } return resolveLegacySupplementTip(podcastId, meta, episodeSupplementPort, podcastRepository) } + /** + * With [followMoves], a feed that moved for good ([LocalEpisodeCatalogPort.RefreshOutcome.Success.movedTo]) + * has its new address saved through [adoptMovedFeed] (P4a); never for `rss:` shows, whose + * address is their identity and notification consent. + */ + @Suppress("LongParameterList") internal suspend fun resolveCatalogRefresh( podcastId: String, meta: DirectFeedTipMeta, catalog: LocalEpisodeCatalogPort, podcastRepository: PodcastRepository, + followMoves: Boolean = false, + adoptMovedFeed: suspend (String) -> Unit = {}, ): DirectFeedResolveResult { val podcastMeta = catalogMeta(meta) if (catalog.isPublisherFeedUnchanged(podcastId, meta.feedUrl.orEmpty())) { @@ -429,16 +448,22 @@ class SubscriptionForegroundSync( } else { null }, + detectMove = followMoves && !podcastId.startsWith("rss:"), ), ) ) { - is LocalEpisodeCatalogPort.RefreshOutcome.Success -> + 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) + } DirectFeedResolveResult(tip = outcome.newest, persisted = true) + } is LocalEpisodeCatalogPort.RefreshOutcome.Unchanged -> DirectFeedResolveResult(tip = outcome.newest, persisted = false) is LocalEpisodeCatalogPort.RefreshOutcome.Failure -> { - Log.w(TAG, "Local catalog refresh failed for $podcastId: ${outcome.message}") - DirectFeedResolveResult(tip = null, persisted = false) + Log.w(TAG, "Local catalog refresh failed for $podcastId: ${outcome.message} (gone=${outcome.addressGone})") + DirectFeedResolveResult(tip = null, persisted = false, failed = true, addressGone = outcome.addressGone) } } } @@ -480,7 +505,7 @@ class SubscriptionForegroundSync( is EpisodeSupplementOutcome.Success -> DirectFeedResolveResult(tip = outcome.newestFeedEpisode, persisted = true) is EpisodeSupplementOutcome.Failure -> - DirectFeedResolveResult(tip = null, persisted = false) + DirectFeedResolveResult(tip = null, persisted = false, failed = true) EpisodeSupplementOutcome.NoDisconnect -> DirectFeedResolveResult(tip = null, persisted = false) } @@ -564,7 +589,14 @@ class SubscriptionForegroundSync( delay(directFeed.feedNetworkDelayMs) } - syncDirectFeedNetwork(feedTipIds, loadPodcastMeta, saveFeed, directFeed) + DirectFeedRecovery.recover( + failed = syncDirectFeedNetwork(feedTipIds, loadPodcastMeta, saveFeed, directFeed), + directFeed = directFeed, + readAgain = { id, url -> + syncOneDirectFeedTip(id, { loadPodcastMeta(it)?.copy(feedUrl = url) }, directFeed.resolveFeedTip, saveFeed, directFeed.onFeedRefreshed) + }, + syncFromServer = { ids -> syncPiChunks(ids, chunkSize, syncChunk, saveLatest) }, + ) Log.d(TAG, "Finished background sync for all ${currentSubs.size} subs") } @@ -587,31 +619,35 @@ class SubscriptionForegroundSync( } } + /** Reads each due show's own feed; returns the shows whose read failed, with their results. */ private suspend fun syncDirectFeedNetwork( feedTipIds: List, loadPodcastMeta: suspend (String) -> DirectFeedTipMeta?, saveFeed: suspend (String, Episode) -> Unit, directFeed: DirectFeedSyncSeams, - ) { - if (feedTipIds.isEmpty()) return + ): Map { + if (feedTipIds.isEmpty()) return emptyMap() val ordered = directFeed.selectRefreshBatch(DirectFeedSyncOrder.prioritize(feedTipIds, directFeed.preferredPodcastId())) val gate = Semaphore(directFeed.feedConcurrency.coerceAtLeast(1)) - coroutineScope { + return coroutineScope { ordered .map { id -> async { gate.withPermit { - syncOneDirectFeedTip( - podcastId = id, - loadPodcastMeta = loadPodcastMeta, - resolveFeedTip = directFeed.resolveFeedTip, - saveLatest = saveFeed, - onFeedRefreshed = directFeed.onFeedRefreshed, - ) + id to + syncOneDirectFeedTip( + podcastId = id, + loadPodcastMeta = loadPodcastMeta, + resolveFeedTip = directFeed.resolveFeedTip, + saveLatest = saveFeed, + onFeedRefreshed = directFeed.onFeedRefreshed, + ) } } }.awaitAll() + .mapNotNull { (id, result) -> result?.takeIf { it.failed }?.let { id to it } } + .toMap() } } @@ -724,18 +760,19 @@ class SubscriptionForegroundSync( } } + /** Reads one show's own feed and saves its tip; null when there was nothing to read. */ private suspend fun syncOneDirectFeedTip( podcastId: String, loadPodcastMeta: suspend (String) -> DirectFeedTipMeta?, resolveFeedTip: suspend (String, DirectFeedTipMeta) -> DirectFeedResolveResult, saveLatest: suspend (String, Episode) -> Unit, onFeedRefreshed: (String) -> Unit, - ) { + ): DirectFeedResolveResult? { try { val meta = loadPodcastMeta(podcastId) if (meta == null) { Log.w(TAG, "No Room row for opted-in $podcastId; skipping feed tip") - return + return null } val storedUrl = meta.feedUrl?.trim().orEmpty() if (storedUrl.isEmpty()) { @@ -753,17 +790,19 @@ class SubscriptionForegroundSync( val known = meta.knownTip if (known != null && known.id == tip.id && known.publishedDate == tip.publishedDate) { Log.d(TAG, "Network tip unchanged for $podcastId") - return + return resolved } saveLatest(podcastId, tip) Log.d(TAG, "Saved direct-feed tip for $podcastId") } else { Log.w(TAG, "Direct-feed tip empty for $podcastId") } + return resolved } catch (e: CancellationException) { throw e } catch (e: Exception) { Log.e(TAG, "Direct-feed tip sync failed for $podcastId", e) + return DirectFeedResolveResult(tip = null, persisted = false, failed = true) } } @@ -797,8 +836,29 @@ internal data class DirectFeedTipMeta( val knownTip: Episode?, ) -/** Result of an opted-in publisher-feed resolve (tip plus whether extras were persisted). */ -internal data class DirectFeedResolveResult(val tip: Episode?, val persisted: Boolean,) +/** + * 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]). + */ +internal data class DirectFeedResolveResult( + val tip: Episode?, + val persisted: Boolean, + val failed: Boolean = false, + val addressGone: Boolean = false, +) + +/** + * Off switches for the feed fixes in this sync, from the app's PostHog kill switches (each fix + * stays on without an answer). [addressRecheck]: re-check a dead saved address; [syncFallback]: + * ask `/sync` for shows whose own feed failed; [movedFeed]: follow a feed that moved for good. + */ +data class FeedFixToggles( + val addressRecheck: () -> Boolean = { true }, + val syncFallback: () -> Boolean = { true }, + /** Adopt a feed's confirmed new address (permanent redirects, `itunes:new-feed-url`). */ + val movedFeed: () -> Boolean = { true }, +) /** Direct-feed callbacks grouped so [SubscriptionForegroundSync.syncSubscribedLatestEpisodes] stays under the param limit. */ internal data class DirectFeedSyncSeams( @@ -813,4 +873,10 @@ internal data class DirectFeedSyncSeams( val preferredPodcastId: () -> String? = { null }, val onFeedRefreshed: (String) -> Unit = {}, 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 = { _, _ -> }, + /** Whether shows whose own feed failed this pass get their latest episode from `/sync`. */ + val syncFallbackEnabled: () -> Boolean = { true }, ) 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 91cbea708..1e4ef9c74 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,5 +1,6 @@ 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 @@ -503,6 +504,136 @@ class SubscriptionForegroundSyncTest { assertTrue(cancelled) } + @Test + fun `a failed own feed falls back to sync, and a dead address is re-checked and read again`() = runTest { + // Before, a show with a saved HTTPS address never went to /sync and its address was never re-checked. + val urls = + mutableMapOf( + "moved" to "https://old.example/moved.xml", + "badnew" to "https://old.example/badnew.xml", + "flaky" to "https://feeds.example/flaky.xml", + "dead" to "https://feeds.example/dead.xml", + "rss:own" to "https://feeds.example/own.xml", + ) + val piSynced = mutableListOf() + val saved = mutableMapOf() + val rechecked = mutableListOf() + val reads = mutableMapOf() + val savedAddresses = mutableMapOf() + SubscriptionForegroundSync.syncSubscribedLatestEpisodes( + loadIds = { urls.keys.toSet() }, + loadPodcastMeta = { id -> DirectFeedTipMeta(urls[id], id, null, null, null, null) }, + syncChunk = { chunk -> + piSynced += chunk + chunk.associateWith { episode(id = "pi_$it", podcastId = it) } + }, + saveLatest = { id, ep -> saved[id] = ep.id }, + directFeed = + DirectFeedSyncSeams( + loadOptedInIds = { urls.keys.toSet() }, + resolveFeedTip = { id, meta -> + reads[id] = (reads[id] ?: 0) + 1 + when { + id == "moved" && meta.feedUrl == "https://new.example/moved.xml" -> + DirectFeedResolveResult(tip = episode(id = "-7", podcastId = id), persisted = true) + id == "flaky" -> DirectFeedResolveResult(tip = null, persisted = false, failed = true) + else -> DirectFeedResolveResult(tip = null, persisted = false, failed = true, addressGone = true) + } + }, + recheckAddress = { id -> + rechecked += id + when (id) { + "moved" -> "https://new.example/moved.xml" + "badnew" -> "https://new.example/badnew.xml" + else -> null + } + }, + saveAddress = { id, url -> savedAddresses[id] = url }, + ), + ) + assertEquals(setOf("moved", "badnew", "dead"), rechecked.toSet(), "only dead addresses, never rss: shows") + assertEquals(2, reads["moved"], "read again at the new address") + assertEquals("-7", saved["moved"]) + assertEquals(mapOf("moved" to "https://new.example/moved.xml"), savedAddresses, "an offered address that also fails is never saved") + assertEquals(2, reads["badnew"]) + assertEquals(setOf("flaky", "badnew", "dead"), piSynced.toSet()) + assertEquals("pi_flaky", saved["flaky"]) + assertEquals("pi_dead", saved["dead"]) + assertFalse(saved.containsKey("rss:own")) + } + + @Test + fun `with the sync fallback switched off a failed own feed stays off sync`() = runTest { + val piSynced = mutableListOf() + SubscriptionForegroundSync.syncSubscribedLatestEpisodes( + loadIds = { setOf("down") }, + loadPodcastMeta = { id -> DirectFeedTipMeta("https://feeds.example/$id.xml", id, null, null, null, null) }, + syncChunk = { chunk -> + piSynced += chunk + emptyMap() + }, + saveLatest = { _, _ -> }, + directFeed = + DirectFeedSyncSeams( + loadOptedInIds = { setOf("down") }, + resolveFeedTip = { _, _ -> DirectFeedResolveResult(tip = null, persisted = false, failed = true) }, + syncFallbackEnabled = { false }, + ), + ) + 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) + val repository = org.mockito.Mockito.mock(PodcastRepository::class.java) + suspend fun resolve(id: String, followMoves: Boolean): Pair, Boolean?> { + val catalog = + cx.aswin.boxlore.core.testing.fakes.FakeLocalEpisodeCatalogPort(readyIds = setOf(id)).apply { + publisherFeedUnchanged = false + refreshOutcome = + cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Success( + newest = null, itemCount = 1, ready = true, movedTo = "https://new.example/feed.xml", + ) + } + val adopted = mutableListOf() + SubscriptionForegroundSync.resolveCatalogRefresh(id, meta, catalog, repository, followMoves) { adopted += it } + return adopted to catalog.lastRefreshRequest?.detectMove + } + + assertEquals(listOf("https://new.example/feed.xml") to true, resolve("123", followMoves = true)) + assertEquals(emptyList() to false, resolve("123", followMoves = false)) + assertEquals(emptyList() to false, resolve("rss:abc", followMoves = true)) + } + private fun episode(id: String, podcastId: String,) = cx.aswin.boxlore.core.model.Episode( id = id, title = "T", diff --git a/core/domain/README.md b/core/domain/README.md index e020640b2..bd39fcfb1 100644 --- a/core/domain/README.md +++ b/core/domain/README.md @@ -19,7 +19,7 @@ Owns thin domain ports and small result types used by ViewModels, repositories, - `ConnectivityStatusPort` and connectivity status types. - `DeviceIdentityPort` for cross-device sync attribution. -- `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. 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; with `RefreshRequest.detectMove` (and a `PodcastMeta.title` to compare), `RefreshOutcome.Success.movedTo` carries a confirmed new HTTPS address. 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 d5586cd72..ab38d3fcd 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 @@ -30,14 +30,26 @@ interface LocalEpisodeCatalogPort { val canProceed: suspend () -> Boolean = { true }, /** Background callers own gated release admission rather than the ordinary ingest callback. */ val runPostPersistCallback: Boolean = true, + /** + * Check whether the feed moved for good ([RefreshOutcome.Success.movedTo]): every redirect + * permanent, or a declared `itunes:new-feed-url` that serves the same show. Needs + * [PodcastMeta.title] to compare with. + */ + val detectMove: Boolean = false, ) sealed interface RefreshOutcome { - data class Success(val newest: Episode?, val itemCount: Int, val ready: Boolean,) : RefreshOutcome + /** [movedTo]: the feed's confirmed new HTTPS address, when [RefreshRequest.detectMove] found one. */ + data class Success(val newest: Episode?, val itemCount: Int, val ready: Boolean, val movedTo: String? = null,) : RefreshOutcome data class Unchanged(val newest: Episode?,) : RefreshOutcome - data class Failure(val message: String,) : RefreshOutcome + /** + * [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. + */ + data class Failure(val message: String, val addressGone: Boolean = false,) : RefreshOutcome } /** True when this show must be served from Room only (no PI ∪ extras merge). */ diff --git a/core/rss/README.md b/core/rss/README.md index 72b1f81d3..9f178f76b 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -79,13 +79,15 @@ src/main/java/cx/aswin/boxlore/core/rss/ - RSS episode IDs are strings encoding deterministic negative `Long` values to avoid collision with positive Podcast Index IDs. - RSS episode and podcast rows are persisted in the main database owned by `:core:database`. - Exact legacy upgrades retain the old RSS row and episodes as an unsubscribed compatibility alias; the active PI row owns an equivalent ready local catalog using the same negative episode ids. +- 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. +- 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). Two shows that read the same feed (one feed listed twice in Podcast Index) mint the same IDs from one feed namespace; inside the refresh transaction, a row whose ID another (show, catalog key) holds, or that an earlier row of the batch took, gets a reserved negative ID (`StickyEpisodeIdentity.reservedEpisodeId`, stable across refreshes), while the same (show, key) keeps updating its row. If a row finds no free ID after 8 tries, the refresh writes nothing and fails, so a partial catalog is never marked complete. The legacy RSS upgrade keeps its IDs for listener state, so it stays on RSS instead when another show holds one. - Package root is `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, and episode ID clashes (`LocalEpisodeCollisionGuardTest`: cross-show, same-show other key, within one batch, stable reserved IDs, exhaustion; `LocalEpisodeCatalogRepositoryTest`: two shows refreshing one feed keep their own rows). +- 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; `LocalEpisodeCatalogRepositoryTest`: two shows refreshing one feed keep their own rows). - MockWebServer is available for feed-fetch tests. ```bash 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 new file mode 100644 index 000000000..e0f868c14 --- /dev/null +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/FeedReadFailure.kt @@ -0,0 +1,39 @@ +package cx.aswin.boxlore.core.rss + +import java.net.UnknownHostException +import java.security.cert.CertificateException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +/** A publisher feed answered with an HTTP error status ([RssFeedClient.fetch]). */ +class FeedHttpException(val code: Int) : IllegalArgumentException("Feed returned HTTP $code") + +/** + * Whether a failed publisher-feed read says the saved address no longer works, so it's worth asking + * the server for a newer one: gone or refused (401, 403, 404, 410, 451), no such host, a certificate + * that fails, or a body that isn't a feed. Timeouts, server errors, 429 and dropped connections are + * passing trouble and keep the address. + */ +internal object FeedReadFailure { + private val GONE_STATUSES = setOf(401, 403, 404, 410, 451) + + fun isAddressGone(error: Throwable): Boolean { + var cause: Throwable? = error + var depth = 0 + while (cause != null && depth < MAX_CAUSE_DEPTH) { + when (cause) { + is FeedHttpException -> return cause.code in GONE_STATUSES + is UnknownHostException, is SSLPeerUnverifiedException, is SSLHandshakeException, is CertificateException -> return true + is NotAFeedException -> return true + } + cause = cause.cause + depth++ + } + return false + } + + private const val MAX_CAUSE_DEPTH = 6 +} + +/** The address answered, but not with an RSS or Atom feed (wrong content type, nothing parseable). */ +class NotAFeedException(message: String, cause: Throwable? = null) : IllegalArgumentException(message, cause) 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 fa3ff9353..cd07a955c 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 @@ -113,8 +113,8 @@ private suspend fun fetchAndPersist( } } catch (error: CancellationException) { throw error -} catch (_: Exception) { - RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE) +} catch (error: Exception) { + RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE, addressGone = FeedReadFailure.isAddressGone(error)) } private suspend fun persistParsed( @@ -138,7 +138,8 @@ private suspend fun persistParsed( showImageUrl = request.meta.imageUrl, ) if (rows.isEmpty()) { - return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE) + // The address answered, but with nothing we can read as episodes. + return RefreshOutcome.Failure(LocalEpisodeCatalogRepository.FEED_LOAD_FAILED_MESSAGE, addressGone = true) } val copied = existing?.copiedExtrasCount ?: 0 val feedOrder = @@ -185,5 +186,47 @@ private suspend fun persistParsed( newest = newestRow?.toCatalogEpisode(request.meta), itemCount = storedCount, ready = ready, + movedTo = if (request.detectMove) confirmedMove(deps.feedClient, request, fetched, parsed) else null, ) } + +/** + * The feed's new permanent HTTPS address, or null (P4a). Before, a moved feed stayed on its old + * address: OkHttp followed the redirect, but the saved address never changed, so every pass + * downloaded the whole feed again and the address other devices and notifications use stayed old. + * 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. + */ +internal suspend fun confirmedMove(feedClient: RssFeedClient, request: RefreshRequest, fetched: RssFetchResult, parsed: ParsedRssFeed,): String? { + val showTitle = request.meta.title?.takeIf(String::isNotBlank) ?: return null + val saved = request.feedUrl.trim() + fetched.movedTo + ?.takeIf { isNewHttpsAddress(it, saved) && sameShow(parsed.title, showTitle) } + ?.let { return it } + val declared = + parsed.newFeedUrl + ?.trim() + ?.takeIf { isNewHttpsAddress(it, saved) && isNewHttpsAddress(it, fetched.finalUrl) } + ?: return null + return try { + val next = feedClient.fetch(declared) + val nextParsed = feedClient.parse(next.finalUrl, next.body, RssIdGenerator.podcastId(next.finalUrl)) + next.finalUrl.takeIf { isNewHttpsAddress(it, saved) && sameShow(nextParsed.title, showTitle) } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + null + } +} + +private fun isNewHttpsAddress(candidate: String, current: String): Boolean = + candidate.startsWith("https://", ignoreCase = true) && !candidate.trim().equals(current.trim(), ignoreCase = true) + +/** Same show by title, ignoring case, spacing and punctuation. */ +internal fun sameShow(feedTitle: String, showTitle: String): Boolean { + val normalize = { value: String -> value.lowercase(java.util.Locale.ROOT).filter(Char::isLetterOrDigit) } + val a = normalize(feedTitle) + return a.isNotEmpty() && a == normalize(showTitle) +} diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt index 2bb484e62..8e05238a5 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssFeedClient.kt @@ -33,9 +33,18 @@ data class ParsedRssFeed( val podcastGuid: String?, val declaredUpdatedAt: Long?, val episodes: List, + /** The channel's ``: where the publisher says the feed moved. */ + val newFeedUrl: String? = null, ) -data class RssFetchResult(val finalUrl: String, val etag: String?, val lastModified: String?, val body: ByteArray,) +/** [movedTo]: the final address when every redirect on the way was permanent (301, 308). */ +data class RssFetchResult( + val finalUrl: String, + val etag: String?, + val lastModified: String?, + val body: ByteArray, + val movedTo: String? = null, +) sealed interface RssFreshnessResult { data class Unchanged(val etag: String?, val lastModified: String?,) : RssFreshnessResult @@ -61,9 +70,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien .build() return execute(request) { response -> - require(response.isSuccessful) { - "Feed returned HTTP ${response.code}" - } + if (!response.isSuccessful) throw FeedHttpException(response.code) validateContentType(response) val body = response.body val declaredLength = body.contentLength() @@ -75,6 +82,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien etag = response.header("ETag"), lastModified = response.header(LAST_MODIFIED_HEADER), body = readBounded(body.byteStream()), + movedTo = permanentMoveTarget(response), ) } } @@ -83,7 +91,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien open suspend fun fetchConditional(url: String, etag: String?, lastModified: String?): RssFetchResult? = execute(conditionalGetRequest(url, etag, lastModified)) { response -> if (response.code == HTTP_NOT_MODIFIED) return@execute null - require(response.isSuccessful) { "Feed returned HTTP ${response.code}" } + if (!response.isSuccessful) throw FeedHttpException(response.code) validateContentType(response) val body = response.body val declaredLength = body.contentLength() @@ -93,6 +101,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien etag = response.header("ETag"), lastModified = response.header(LAST_MODIFIED_HEADER), body = readBounded(body.byteStream()), + movedTo = permanentMoveTarget(response), ) } @@ -193,7 +202,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien null } return mergeParsedFeeds(libraryFeed, customFeed) - ?: throw IllegalArgumentException( + ?: throw NotAFeedException( customError?.message ?: libraryError?.message ?: "Unable to parse this RSS feed", @@ -293,6 +302,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien ?: "episodic", podcastGuid = null, declaredUpdatedAt = parseDate(channel.lastBuildDate.orEmpty()), + newFeedUrl = itunes?.newsFeedUrl?.trim()?.takeIf(String::isNotBlank), episodes = episodes .distinctBy(RssEpisodeEntity::episodeId) @@ -356,6 +366,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien podcastType = feed.podcastType, podcastGuid = feed.podcastGuid, declaredUpdatedAt = feed.declaredUpdatedAt, + newFeedUrl = feed.newFeedUrl, episodes = feed.episodes .distinctBy(RssEpisodeEntity::episodeId) @@ -433,6 +444,9 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien qualifiedName == "podcast:guid" -> { feed.podcastGuid = readSimpleText(parser).takeIf(String::isNotBlank) } + qualifiedName == "itunes:new-feed-url" -> { + feed.newFeedUrl = readSimpleText(parser).trim().takeIf(String::isNotBlank) + } name == "type" -> { val type = readSimpleText(parser).lowercase(Locale.ROOT) if (type == "serial" || type == "episodic") feed.podcastType = type @@ -499,6 +513,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien declaredUpdatedAt = customFeed.declaredUpdatedAt ?: libraryFeed.declaredUpdatedAt, + newFeedUrl = customFeed.newFeedUrl ?: libraryFeed.newFeedUrl, episodes = mergedEpisodes .distinctBy(RssEpisodeEntity::episodeId) @@ -680,15 +695,13 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien ?.trim() ?.lowercase(Locale.ROOT) ?: return - require( + val feedLike = contentType.contains("xml") || contentType.contains("rss") || contentType.contains("atom") || contentType == "text/plain" || - contentType == "application/octet-stream", - ) { - "URL did not return an RSS or Atom feed" - } + contentType == "application/octet-stream" + if (!feedLike) throw NotAFeedException("URL did not return an RSS or Atom feed") } private fun validatorsMatch( @@ -713,6 +726,7 @@ open class RssFeedClient(private val httpClient: OkHttpClient = defaultHttpClien var podcastType: String = "episodic" var podcastGuid: String? = null var declaredUpdatedAt: Long? = null + var newFeedUrl: String? = null var insideChannelImage: Boolean = false var insideFeedAuthor: Boolean = false val episodes = mutableListOf() @@ -877,3 +891,19 @@ internal fun parseDate(value: String): Long? { { Instant.parse(value).epochSecond }, ).firstNotNullOfOrNull { parser -> runCatching(parser).getOrNull() } } + +/** + * The final address of [response] when it came through redirects that were all permanent (301, + * 308); null without redirects or with any temporary one (302, 303, 307), which never moves a feed. + */ +internal fun permanentMoveTarget(response: Response): String? { + var hop = response.priorResponse ?: return null + while (true) { + if (hop.code != HTTP_MOVED_PERMANENTLY && hop.code != HTTP_PERMANENT_REDIRECT) return null + hop = hop.priorResponse ?: break + } + return response.request.url.toString() +} + +private const val HTTP_MOVED_PERMANENTLY = 301 +private const val HTTP_PERMANENT_REDIRECT = 308 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 new file mode 100644 index 000000000..db8ef14fb --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedMoveTest.kt @@ -0,0 +1,132 @@ +package cx.aswin.boxlore.core.rss + +import cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort +import kotlinx.coroutines.test.runTest +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +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 + +/** Moved feeds (P4a): only permanent moves to HTTPS that still serve the same show are adopted. */ +class FeedMoveTest { + private val old = "https://old.example.com/feed.xml" + private val new = "https://new.example.com/feed.xml" + + @Test + fun `the library parser reads itunes new-feed-url`() = runTest { + val bytes = requireNotNull(javaClass.getResourceAsStream("/feed_moved.xml")).readBytes() + assertEquals(new, RssFeedClient().parse(old, bytes).newFeedUrl) + } + + @Test + fun `only an all-permanent redirect chain is a move`() { + assertEquals(new, permanentMoveTarget(response(new, prior = listOf(301)))) + assertEquals(new, permanentMoveTarget(response(new, prior = listOf(308, 301)))) + assertNull(permanentMoveTarget(response(new, prior = listOf(302)))) + assertNull(permanentMoveTarget(response(new, prior = listOf(301, 307))), "one temporary hop") + assertNull(permanentMoveTarget(response(old, prior = emptyList())), "no redirect") + } + + @Test + fun `a permanent redirect is adopted only for the same show`() = runTest { + val client = FakeClient(emptyMap()) + val fetched = RssFetchResult(finalUrl = new, etag = null, lastModified = null, body = ByteArray(0), movedTo = new) + assertEquals(new, confirmedMove(client, request("Moving Show"), fetched, parsed("Moving show!"))) + assertNull(confirmedMove(client, request("Moving Show"), fetched, parsed("Another Show")), "different title") + assertNull(confirmedMove(client, request(null), fetched, parsed("Moving Show")), "no title to compare") + assertNull(confirmedMove(client, request("Moving Show"), fetched.copy(movedTo = "http://new.example.com/feed.xml"), parsed("Moving Show")), "not HTTPS") + } + + @Test + fun `a declared new feed address is read once and adopted when it serves the same show`() = runTest { + val fetched = RssFetchResult(finalUrl = old, etag = null, lastModified = null, body = ByteArray(0)) + val same = FakeClient(mapOf(new to "Moving Show")) + assertEquals(new, confirmedMove(same, request("Moving Show"), fetched, parsed("Moving Show", newFeedUrl = new))) + assertEquals(listOf(new), same.fetched) + + val other = FakeClient(mapOf(new to "Something Else")) + assertNull(confirmedMove(other, request("Moving Show"), fetched, parsed("Moving Show", newFeedUrl = new))) + + val self = FakeClient(emptyMap()) + assertNull(confirmedMove(self, request("Moving Show"), fetched, parsed("Moving Show", newFeedUrl = old)), "declares itself") + assertTrue(self.fetched.isEmpty()) + + val broken = FakeClient(emptyMap()) + assertNull(confirmedMove(broken, request("Moving Show"), fetched, parsed("Moving Show", newFeedUrl = new)), "new address fails") + } + + @Test + fun `titles match ignoring case, spacing and punctuation`() { + assertTrue(sameShow("The Daily", "the daily")) + assertTrue(sameShow("Teen Taal – Aaj Tak", "Teen Taal - Aaj Tak")) + assertFalse(sameShow("", "")) + assertFalse(sameShow("The Daily", "The Weekly")) + } + + private fun request(title: String?) = LocalEpisodeCatalogPort.RefreshRequest( + podcastIndexId = "123", + feedUrl = old, + meta = LocalEpisodeCatalogPort.PodcastMeta(title = title), + detectMove = true, + ) + + private fun parsed(title: String, newFeedUrl: String? = null) = ParsedRssFeed( + title = title, + author = "", + description = null, + imageUrl = null, + genre = null, + podcastType = "episodic", + podcastGuid = null, + declaredUpdatedAt = null, + episodes = emptyList(), + newFeedUrl = newFeedUrl, + ) + + private fun response(finalUrl: String, prior: List): Response { + var previous: Response? = null + for (code in prior.reversed()) { + previous = Response.Builder() + .request(Request.Builder().url(old).build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("redirect") + .apply { if (previous != null) priorResponse(previous) } + .build() + } + return Response.Builder() + .request(Request.Builder().url(finalUrl).build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .apply { if (previous != null) priorResponse(previous) } + .build() + } + + /** Serves a parsed title per URL; any other URL fails like a dead address. */ + private class FakeClient(private val titles: Map) : RssFeedClient() { + val fetched = mutableListOf() + + override suspend fun fetch(url: String): RssFetchResult { + fetched += url + if (url !in titles) throw FeedHttpException(404) + return RssFetchResult(finalUrl = url, etag = null, lastModified = null, body = titles.getValue(url).toByteArray()) + } + + override suspend fun parse(feedUrl: String, bytes: ByteArray, podcastId: String,) = ParsedRssFeed( + title = String(bytes), + author = "", + description = null, + imageUrl = null, + genre = null, + podcastType = "episodic", + podcastGuid = null, + declaredUpdatedAt = null, + episodes = emptyList(), + ) + } +} 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 new file mode 100644 index 000000000..24d3134d1 --- /dev/null +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/FeedReadFailureTest.kt @@ -0,0 +1,38 @@ +package cx.aswin.boxlore.core.rss + +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLPeerUnverifiedException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class FeedReadFailureTest { + @Test + fun `gone or refused statuses, missing hosts, failing certificates and non-feeds mean the address is gone`() { + for (code in listOf(401, 403, 404, 410, 451)) assertTrue(FeedReadFailure.isAddressGone(FeedHttpException(code)), "HTTP $code") + assertTrue(FeedReadFailure.isAddressGone(UnknownHostException("feeds.example"))) + assertTrue(FeedReadFailure.isAddressGone(SSLPeerUnverifiedException("hostname not verified"))) + assertTrue(FeedReadFailure.isAddressGone(NotAFeedException("URL did not return an RSS or Atom feed"))) + assertTrue(FeedReadFailure.isAddressGone(IllegalStateException("wrapped", FeedHttpException(404))), "found through the cause") + } + + @Test + fun `passing trouble keeps the address`() { + for (code in listOf(429, 500, 502, 503)) assertFalse(FeedReadFailure.isAddressGone(FeedHttpException(code)), "HTTP $code") + assertFalse(FeedReadFailure.isAddressGone(SocketTimeoutException("slow"))) + assertFalse(FeedReadFailure.isAddressGone(ConnectException("refused"))) + assertFalse(FeedReadFailure.isAddressGone(IOException("connection reset"))) + assertFalse(FeedReadFailure.isAddressGone(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) + assertTrue(FeedHttpException(404) is IllegalArgumentException) + assertTrue(NotAFeedException("x") is IllegalArgumentException) + } +} 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 cceb077ef..1a3bf6ec1 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 @@ -178,6 +178,48 @@ class LocalEpisodeCatalogRepositoryTest { ), ) assertTrue(outcome is RefreshOutcome.Failure) + assertTrue((outcome as RefreshOutcome.Failure).addressGone, "an address with no episodes is worth re-checking") + } + + @Test + fun refreshReportsAPermanentMoveOnlyWhenAskedAndTheShowMatches() = runTest { + val moved = + object : RssFeedClient() { + private val shared = SharedFeedClient() + + override suspend fun fetch(url: String) = shared.fetch(url).copy(finalUrl = "https://new.example/shared.xml", movedTo = "https://new.example/shared.xml") + + override suspend fun parse(feedUrl: String, bytes: ByteArray, podcastId: String,) = shared.parse(feedUrl, bytes, podcastId) + } + suspend fun movedTo(detectMove: Boolean, title: String) = + ( + catalogRepo(feedClient = moved).refresh( + LocalEpisodeCatalogPort.RefreshRequest( + podcastIndexId = "100", + feedUrl = "https://feeds.example/shared.xml", + meta = LocalEpisodeCatalogPort.PodcastMeta(title = title), + reason = LocalEpisodeCatalogPort.RefreshReason.MANUAL, + detectMove = detectMove, + ), + ) as RefreshOutcome.Success + ).movedTo + assertEquals("https://new.example/shared.xml", movedTo(detectMove = true, title = "Shared")) + assertNull(movedTo(detectMove = false, title = "Shared")) + assertNull(movedTo(detectMove = true, title = "Another show")) + } + + @Test + fun refreshFailureSaysWhetherTheSavedAddressLooksGone() = runTest { + val outcomes = + listOf(FeedHttpException(410), FeedHttpException(503), java.net.SocketTimeoutException("slow")).map { error -> + catalogRepo( + feedClient = + object : RssFeedClient() { + override suspend fun fetch(url: String): RssFetchResult = throw error + }, + ).refresh(LocalEpisodeCatalogPort.RefreshRequest(podcastIndexId = "100", feedUrl = "https://feeds.example/show.xml")) + } + assertEquals(listOf(true, false, false), outcomes.map { (it as RefreshOutcome.Failure).addressGone }) } @Test diff --git a/core/rss/src/test/resources/feed_moved.xml b/core/rss/src/test/resources/feed_moved.xml new file mode 100644 index 000000000..9371a64a2 --- /dev/null +++ b/core/rss/src/test/resources/feed_moved.xml @@ -0,0 +1,13 @@ + + + + Moving Show + https://new.example.com/feed.xml + + Last episode at the old address + moving-1 + Wed, 01 Jan 2020 00:00:00 GMT + + + + diff --git a/core/testing/README.md b/core/testing/README.md index 79465ad7c..d49f9bf17 100644 --- a/core/testing/README.md +++ b/core/testing/README.md @@ -11,7 +11,7 @@ Owns shared JVM test fixtures, dispatcher helpers, and architecture guards used - `PhoneGoldenRule` and `PhoneGoldenConfiguration` provide the local, six-configuration phone capture matrix. Roborazzi writes actual renders; verification compares dimensions and every ARGB pixel. Recording refuses to overwrite an existing phone baseline. - The same fixtures support `-PtabletCaptures=before|after`: 600×960, 840×1280, 1280×800, 1600×1000 and 673×841 flat/tabletop, in light/dark. `setContent` supplies the shared window model for tablet after captures; before captures select the preserved legacy presentation. Phone captures retain their original composition and animation start frame. These review images are separate from immutable phone baselines. Standalone feature captures can opt into the same fold-safe viewport selection used by hidden-navigation shell routes; the full player retains its separate art/control regions. The phone branch still passes the original content directly to Compose. - A single private constant owns the existing `boxlore.tabletCaptures` JVM property key across matrix selection, content setup, and output routing; its values and capture behavior remain unchanged. -- `fakes.FakeLocalEpisodeCatalogPort` pages/sorts/searches in memory and returns null from `findByCatalogKey` when guid and enclosure are both blank. +- `fakes.FakeLocalEpisodeCatalogPort` pages/sorts/searches in memory and returns null from `findByCatalogKey` when guid and enclosure are both blank. `refreshOutcome`, `publisherFeedUnchanged` and `lastRefreshRequest` let a test drive and inspect `refresh`. - `fakes.FakeDeviceIdentityPort` provides a deterministic or mutable device ID fake for sync tests. - Test dependencies such as JUnit Jupiter, coroutines-test, Turbine, and MockWebServer are exposed for modules that declare `testImplementation(projects.core.testing)`. - Architecture guards under `src/test` validate project structure and dependency rules. diff --git a/core/testing/src/main/java/cx/aswin/boxlore/core/testing/fakes/FakePorts.kt b/core/testing/src/main/java/cx/aswin/boxlore/core/testing/fakes/FakePorts.kt index fbe4b0ac3..1ed9b451b 100644 --- a/core/testing/src/main/java/cx/aswin/boxlore/core/testing/fakes/FakePorts.kt +++ b/core/testing/src/main/java/cx/aswin/boxlore/core/testing/fakes/FakePorts.kt @@ -46,6 +46,11 @@ class FakeLocalCatalogPort( class FakeLocalEpisodeCatalogPort(var readyIds: Set = emptySet(), var episodes: MutableMap = mutableMapOf(),) : LocalEpisodeCatalogPort { var refreshCalls: Int = 0 var refreshError: Throwable? = null + + /** What [refresh] returns; null keeps the default `Unchanged` with the newest stored episode. */ + var refreshOutcome: LocalEpisodeCatalogPort.RefreshOutcome? = null + var lastRefreshRequest: LocalEpisodeCatalogPort.RefreshRequest? = null + var publisherFeedUnchanged: Boolean = true var sweepCalls: Int = 0 var sweepError: Throwable? = null var lastSweepNowMs: Long? = null @@ -119,11 +124,12 @@ class FakeLocalEpisodeCatalogPort(var readyIds: Set = emptySet(), var ep override suspend fun refresh(request: LocalEpisodeCatalogPort.RefreshRequest): LocalEpisodeCatalogPort.RefreshOutcome { refreshCalls += 1 + lastRefreshRequest = request refreshError?.let { throw it } - return LocalEpisodeCatalogPort.RefreshOutcome.Unchanged(newest(request.podcastIndexId)) + return refreshOutcome ?: LocalEpisodeCatalogPort.RefreshOutcome.Unchanged(newest(request.podcastIndexId)) } - override suspend fun isPublisherFeedUnchanged(podcastId: String, feedUrl: String,): Boolean = true + override suspend fun isPublisherFeedUnchanged(podcastId: String, feedUrl: String,): Boolean = publisherFeedUnchanged override suspend fun markFeedUrlLookup(podcastId: String, atMillis: Long,) = Unit