Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +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.
- 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.
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ 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.sync.CloudSyncTriggerCoordinator
import cx.aswin.boxlore.sync.FeedFixSwitches
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -186,6 +187,7 @@ class AppContainer(
rssRepository = rssPodcastRepository,
episodeSupplementRepository = episodeSupplementRepository,
localEpisodeCatalog = subscribedEpisodeCatalog,
useEpisodeMatchBaseline = { FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) },
)
}

Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package cx.aswin.boxlore.sync

import com.posthog.PostHog
import cx.aswin.boxlore.core.analytics.PostHogStartup

/**
* PostHog off switches for the feed and episode fixes, so one can be turned off without an app
* update. Each fix runs unless its flag is on: with no answer (PostHog not ready, the flag missing
* or inactive, an error) the fix stays on. To turn one off, enable its flag for everyone.
*/
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"

/** 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)

private fun postHogFlagOn(flag: String): Boolean = PostHogStartup.isReady && PostHog.isFeatureEnabled(flag, false)
}
21 changes: 21 additions & 0 deletions app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cx.aswin.boxlore.sync

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class FeedFixSwitchesTest {
@Test
fun aFixRunsUnlessItsOffFlagIsOn() {
val asked = mutableListOf<String>()
assertTrue(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { asked += it; false })
assertFalse(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { true })
assertEquals(listOf("feed-fix-match-baseline-off"), asked)
}

@Test
fun noAnswerFromPostHogKeepsTheFixOn() {
assertTrue(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { error("PostHog not started") })
}
}
4 changes: 2 additions & 2 deletions core/catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Failed consent writes keep activation off, and local notification flags prevent
- 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.

- `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` remains for **first** catalog persist rematch only. `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).
- `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`).
- 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.
Expand Down Expand Up @@ -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`, `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 / `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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ class PodcastRepository(
* [NetworkModule.createBoxLoreApi] builds the cached OkHttp client from [context].
*/
boxLoreApi: BoxLoreApi? = null,
/**
* Off switch for the 1,000-item match baseline ([loadPiEpisodesForBaseline]). The app passes
* a PostHog kill switch; false means the old 100-item page path.
*/
private val useEpisodeMatchBaseline: () -> Boolean = { true },
) : cx.aswin.boxlore.core.domain.ports.PodcastCatalogPort {
val api: BoxLoreApi = boxLoreApi ?: NetworkModule.createBoxLoreApi(baseUrl, context)
internal val contentCatalogPreferences =
Expand Down Expand Up @@ -278,12 +283,22 @@ class PodcastRepository(
}

/**
* Strict PI page for feed-extra matching. Throws on HTTP/network failure so
* Strict PI baseline for feed-episode matching (sync, restore, Info): the episodes whose IDs
* history, progress and downloads already use. Throws on HTTP/network failure so
* [EpisodeSupplementPort.refreshFromFeed] does not persist extras against an
* empty baseline (which would treat every feed item as feed-only).
*
* Reads up to [limit] (at most 1,000) newest episodes from `/episodes/match`, match fields only.
* The full-page path is capped at 100 for memory and returns the oldest 100, so a show with more
* episodes left its newer ones unmatched: they got negative IDs, detached from history and
* downloads, and showed twice on Info. Falls back to that page when the route fails or the
* switch is off.
*/
suspend fun loadPiEpisodesForBaseline(feedId: String, limit: Int,): List<Episode> = withContext(Dispatchers.IO) {
val resolvedId = resolvePodcastIndexFeedId(feedId)
if (useEpisodeMatchBaseline()) {
loadMatchBaseline(resolvedId, limit)?.let { return@withContext it }
}
val safeLimit = clampPageLimit(limit)
val response = api.getEpisodesPaginated(publicKey, resolvedId, safeLimit, 0, "oldest").execute()
check(response.isSuccessful && response.body() != null) {
Expand All @@ -292,6 +307,28 @@ class PodcastRepository(
response.body()!!.items.mapNotNull { mapToEpisode(it) }
}

/**
* `/episodes/match` as baseline episodes, or null to use the page path: an HTTP or network
* error, or an empty list (the page path confirms a show with no episodes, so a misbehaving
* route can never leave a feed matched against nothing).
*/
private fun loadMatchBaseline(resolvedId: String, limit: Int): List<Episode>? = try {
val response = api.getEpisodeMatchList(publicKey, resolvedId, limit.coerceIn(1, MAX_MATCH_BASELINE)).execute()
val body = response.body()
if (response.isSuccessful && body != null) {
body.items.mapNotNull { it.toBaselineEpisode(resolvedId) }.takeIf { it.isNotEmpty() }
} else {
null
}
} catch (e: java.io.IOException) {
android.util.Log.w("PodcastRepository", "match baseline failed for $resolvedId; using the page path", e)
null
} catch (e: IllegalArgumentException) {
// An unreadable body (the JSON converter's SerializationException is one).
android.util.Log.w("PodcastRepository", "match baseline unreadable for $resolvedId; using the page path", e)
null
}

private suspend fun getRssEpisodesPaginated(feedId: String, limit: Int, offset: Int, sort: String,): EpisodePage = try {
val episodes = rssRepository.getEpisodes(feedId, limit, offset, sort)
val total = rssRepository.episodeCount(feedId)
Expand Down
Loading
Loading