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
3 changes: 2 additions & 1 deletion app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,11 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc
- `LegacyWorkerFactory` maps legacy worker class names to current worker implementations for WorkManager continuity.
- `MainActivity` hosts theme, edge-to-edge setup, app update hooks, surveys, library import state (full-screen `OpmlImportDialog` overlay so onboarding welcome never shows through), the selected floating or classic navigation presentation, and the player overlay. Its start/stop lifecycle informs the application-scoped playback repository whether UI position polling is needed; background audio, persistence, notifications, Cast, and MediaSession behavior remain service-owned.
- **Feed fix off switches** (`sync/FeedFixSwitches`): PostHog kill switches for the feed and episode fixes, passed into core modules as lambdas. A fix runs unless its flag is on; no answer (PostHog not ready, flag missing or inactive, an error) keeps it on. `feed-fix-match-baseline-off` sends the feed-only extras' comparison list back from the 1,000-item `episodes/match` list to the 100-item page (`PodcastRepository.useEpisodeMatchBaseline`). `feed-fix-address-recheck-off`, `feed-fix-sync-fallback-off` and `feed-fix-moved-feed-off` turn off the subscription sync's dead-address re-check, its `/sync` fallback for failed feeds, and following moved feeds (`FeedFixToggles`). `FeedFixSwitchesTest` covers the default. The same fail-open switches pause the v2 build's jobs: `upgrade-repair-off` (the one-time repair of saved feed addresses after the update), `address-check-off` (the daily address check of followed shows), both without marking the work done, and `v2-discovery-off` (discovery back on the v1 routes). These three are read through `sync/SavedKillSwitch`, because the repair can start on a headless launch before PostHog is set up: once PostHog is ready its answer decides and is saved; while it is still starting, the last saved answer is used, and the job runs only when none was saved.
- **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the daily address check and broken-feed reports read `addressCheck` (`address-check-off`, through `SubscriptionForegroundSync`, below); `feed_first` and `upgradeRepair` have no reader yet.
- **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the daily address check and broken-feed reports read `addressCheck` (`address-check-off`, through `SubscriptionForegroundSync`, below); show pages read `feed_first` (`PodcastRepository.isFeedFirstShow`, below); `upgradeRepair` has no reader yet.
- **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)).
- **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`.
- **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, `feedTelemetry`, and pending registrations in SharedPreferences `boxlore_notification_reregistrations`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-<show ID>`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs. It records each outcome (`settle`): a show it gives up on stays pending and the next foreground pass queues it again; `rss:` shows keep their own journal worker.
- **Feed-first show pages** (P4 PR 16): `AppContainer.podcastRepository` gets `FeedFirstWiring`: the stored copies (`localEpisodeCatalogRepository.showPages`), `connectivityStatus` for the offline rung, one broken-feed report limiter (`feedReportLimiter` over the `boxlore_feed_reports` stamps, the same instance `FeedAddressUpkeep.reportLimiter` gives the daily address check, so both claim inside one daily limit), `feedTelemetry` for `feed_fetch_result`, and `isFollowed` (`podcasts.isSubscribed`), so the show page leaves followed shows' reports to their own refresh. The page reads the feed only while `feed_first` is on (`RolloutSwitches.feedFirst`, fixed per process).
- NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API).
- JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme.
- JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents.
Expand Down
33 changes: 29 additions & 4 deletions app/src/main/java/cx/aswin/boxlore/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import cx.aswin.boxlore.core.catalog.EpisodeSharePreparer
import cx.aswin.boxlore.core.catalog.EpisodeShareRegistrar
import cx.aswin.boxlore.core.catalog.FeedAddressUpkeep
import cx.aswin.boxlore.core.catalog.FeedAddressWiring
import cx.aswin.boxlore.core.catalog.FeedFirstWiring
import cx.aswin.boxlore.core.catalog.FeedFixToggles
import cx.aswin.boxlore.core.catalog.FeedMoveFollower
import cx.aswin.boxlore.core.catalog.FeedReportLimiter
import cx.aswin.boxlore.core.catalog.FolderRepository
import cx.aswin.boxlore.core.catalog.InstallReferrerManager
import cx.aswin.boxlore.core.catalog.LegacyRssRepair
Expand Down Expand Up @@ -227,6 +229,15 @@ class AppContainer(
rolloutSwitches = rolloutSwitches,
episodeAliases = episodeAliasReads,
discoveryEpisodeIds = discoveryEpisodeIds,
// Feed-first show pages (P4 PR 16): stored copies, the shared report limiter and feed_fetch_result.
feedFirstWiring =
FeedFirstWiring(
copies = localEpisodeCatalogRepository.showPages,
isOnline = { connectivityStatus.isOnline() },
reportLimiter = feedReportLimiter,
telemetry = feedTelemetry,
isFollowed = { id -> database.podcastDao().getPodcast(id)?.isSubscribed == true },
),
)
}

Expand Down Expand Up @@ -429,15 +440,29 @@ class AppContainer(
// Daily address check and broken-feed reports (P4 PR 14), behind address-check-off.
addressUpkeep =
FeedAddressUpkeep(
reportStamps =
SharedPreferencesFeedReportStamps(
appContext.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE),
),
reportStamps = feedReportStamps,
isOnline = connectivityStatus::isOnline,
reportLimiter = feedReportLimiter,
),
)
}

/**
* Broken-feed report times, shared by the daily address check and the show page so both stay
* inside one daily limit (P4 PR 14 and PR 16).
*/
private val feedReportStamps: SharedPreferencesFeedReportStamps by lazy {
SharedPreferencesFeedReportStamps(
appContext.getSharedPreferences(SharedPreferencesFeedReportStamps.FILE_NAME, Context.MODE_PRIVATE),
)
}

/**
* The app's one broken-feed report limiter over [feedReportStamps]: the daily address check and
* the show page claim through the same instance, so they never pass the caps together (P4 PR 16).
*/
private val feedReportLimiter: FeedReportLimiter by lazy { FeedReportLimiter(feedReportStamps) }

override val folderRepository: FolderRepository by lazy {
RoomFolderRepository(
folderDao = database.folderDao(),
Expand Down
Loading
Loading