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
2 changes: 1 addition & 1 deletion app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) },
),
)
}

Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/sync/FeedFixSwitches.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions core/catalog/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<String, DirectFeedResolveResult>,
directFeed: DirectFeedSyncSeams,
readAgain: suspend (id: String, feedUrl: String) -> DirectFeedResolveResult?,
syncFromServer: suspend (List<String>) -> Unit,
) {
if (failed.isEmpty()) return
val stillFailing = mutableListOf<String>()
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 }
}
}
Loading
Loading