Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
abb0002
feat(catalog): update feed addresses in one place and check them daily
ashwkun Oct 27, 2026
8997c48
feat(app): repair saved feed addresses after the update
ashwkun Oct 27, 2026
1fc69f8
feat(catalog): update feed addresses in one place and check them daily
ashwkun Oct 27, 2026
f75ac82
feat(app): repair saved feed addresses after the update
ashwkun Oct 27, 2026
30d18bf
Merge branch 'feat/feed-address-updates' into feat/upgrade-address-re…
ashwkun Oct 27, 2026
bbe726c
fix(catalog): keep newer notification requests and older feed namespaces
ashwkun Oct 27, 2026
5f7b009
Merge branch 'feat/feed-address-updates' into feat/upgrade-address-re…
ashwkun Oct 27, 2026
814bd36
fix(catalog): read pending registration marks without a cast
ashwkun Oct 27, 2026
cee1817
Merge branch 'feat/feed-address-updates' into feat/upgrade-address-re…
ashwkun Oct 27, 2026
6bffd95
fix(catalog): write no address when its notification request can't be…
ashwkun Oct 27, 2026
3248312
Merge branch 'feat/feed-address-updates' into feat/upgrade-address-re…
ashwkun Oct 27, 2026
444bb15
fix(catalog): retry address changes dropped with their notification r…
ashwkun Oct 27, 2026
eb43d7d
Merge branch 'feat/feed-address-updates' into feat/upgrade-address-re…
ashwkun Oct 27, 2026
b83e085
Merge remote-tracking branch 'origin/master' into feat/upgrade-addres…
ashwkun Oct 27, 2026
686d255
refactor(sync): map queue items in their own function
ashwkun Oct 27, 2026
0e63a98
Merge remote-tracking branch 'origin/master' into feat/upgrade-addres…
ashwkun Oct 27, 2026
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 ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ These values are part of the shipping product. Renames or recreations break upgr
| Deep links | `boxlore://` and `boxcast://`, plus both HTTPS share-path prefixes. Patterns are only added: episode links may also carry `show=<show ID>`, which fills the route's `podcastId` |
| Episode share registration | Unique work names `episode-share-registration-<hash of show and catalog key>` (`EpisodeShareRegistrationWorker`); never renamed |
| Notification re-registration | Unique work names `podcast-notification-registration-<show ID>` (`PodcastNotificationRegistrationWorker`, after a saved feed address changes); never renamed |
| Feed address repair after the update | Unique work name `feed-address-upgrade-repair` (`UpgradeRepairWorker`); step 4 and sync catch-up record `noBackupFilesDir/upgrade_repair_notifications`; per-show progress in Room `upgrade_repair_shows`; never renamed |
| BuildConfig | Prefer `BOXLORE_*`; Gradle still dual-reads `BOXCAST_*` fallbacks |
| Episode / media IDs | `rss:` prefixes, negative RSS IDs, mediaId prefixes, and `customCacheKey` schemes stay as implemented. PI **episode supplements** also use negative episode IDs (generated under an `rss:` feed namespace) but keep `Episode.podcastId` as the Podcast Index show id — they are not `rss:` library rows. Episode identity (show ID, catalog key), recipe IDs, collisions and aliases follow [`docs/EPISODE_IDS.md`](docs/EPISODE_IDS.md) |
| Playback instance | One UI-scoped `PlaybackRepository`; routes and workers must not construct a second one |
Expand Down
10 changes: 6 additions & 4 deletions app/README.md

Large diffs are not rendered by default.

31 changes: 31 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.AddressCheckAnswer
import cx.aswin.boxlore.core.catalog.DiscoveryEpisodeIds
import cx.aswin.boxlore.core.catalog.DiscoveryRouting
import cx.aswin.boxlore.core.catalog.EpisodeAliasReads
Expand All @@ -13,6 +14,7 @@ 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.FileUpgradeNotificationMarker
import cx.aswin.boxlore.core.catalog.FolderRepository
import cx.aswin.boxlore.core.catalog.InstallReferrerManager
import cx.aswin.boxlore.core.catalog.LegacyRssRepair
Expand All @@ -31,6 +33,8 @@ import cx.aswin.boxlore.core.catalog.SharedPreferencesFeedReportStamps
import cx.aswin.boxlore.core.catalog.SharedPreferencesPendingNotificationRegistrations
import cx.aswin.boxlore.core.catalog.SubscriptionForegroundSync
import cx.aswin.boxlore.core.catalog.SubscriptionRepository
import cx.aswin.boxlore.core.catalog.UpgradeAddressRepair
import cx.aswin.boxlore.core.catalog.UpgradeRepairPorts
import cx.aswin.boxlore.core.catalog.ports.SmartDownloadSyncPort
import cx.aswin.boxlore.core.catalog.privacy.ConsentManager
import cx.aswin.boxlore.core.catalog.sync.HistorySyncResolver
Expand Down Expand Up @@ -447,6 +451,33 @@ class AppContainer(
)
}

/**
* The one-time repair of saved feed addresses after the update, run by
* [cx.aswin.boxlore.sync.UpgradeRepairWorker] behind `upgrade-repair-off`. Step 4's record lives
* in `noBackupFilesDir/upgrade_repair_notifications`, so a restored phone registers again.
* Once every show was read again, the sync's identity catch-up runs once more.
* Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair).
* COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5).
*/
override val upgradeAddressRepair: UpgradeAddressRepair by lazy {
UpgradeAddressRepair(
repairDao = database.upgradeRepairDao(),
podcastDao = database.podcastDao(),
ports =
UpgradeRepairPorts(
fetchAddresses = AddressCheckAnswer.fetchWith(podcastRepository.apiV2),
replaceAddress = subscriptionRepository::updateFeedAddress,
reread = UpgradeAddressRepair.rereadWith(subscribedEpisodeCatalog),
requestNotificationRegistration = { id -> PodcastNotificationRegistrationWorker.request(appContext, id) },
onAddressStepsFinished = { userSyncCoordinator.requestIdentityCatchUp() },
isOnline = connectivityStatus::isOnline,
telemetry = feedTelemetry,
),
marker = FileUpgradeNotificationMarker(java.io.File(appContext.noBackupFilesDir, FileUpgradeNotificationMarker.FILE_NAME)),
enabled = rolloutSwitches.upgradeRepair,
)
}

/**
* 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).
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,24 @@ class BoxLoreApplication :
container.smartDownloadManager.reconcileScheduleWithPreferences()
}
}
applicationScope.launch {
startupWorkGate.runWhenReady { scheduleUpgradeRepair() }
}
}

/**
* Enqueues the one-time repair of saved feed addresses on every launch while it has work left,
* headless launches included; it reads stored state, so a restore starts it again.
* Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair).
* COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5).
*/
private suspend fun scheduleUpgradeRepair() {
runCatching {
cx.aswin.boxlore.sync.UpgradeRepairWorker.scheduleIfNeeded(this, container.upgradeAddressRepair)
}.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
android.util.Log.w("BoxLoreApplication", "Feed address repair scheduling failed", error)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import cx.aswin.boxlore.core.catalog.NotificationRegistrationOutcome
import cx.aswin.boxlore.core.catalog.SharedAppDependenciesHolder
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -87,8 +89,21 @@ class PodcastNotificationRegistrationWorker(context: Context, params: WorkerPara

/** Asks for [podcastId]'s notifications to be registered again; `rss:` shows have their own journal. */
fun enqueue(context: Context, podcastId: String) {
if (podcastId.isBlank() || podcastId.startsWith("rss:")) return
WorkManager.getInstance(context).enqueueUniqueWork(workName(podcastId), ExistingWorkPolicy.REPLACE, workRequest(podcastId))
enqueueWork(context, podcastId)
}

/**
* [enqueue], returning once WorkManager has saved the request, so a caller that records it
* (the upgrade repair's step 4) never records one lost to process death. Throws when the
* request couldn't be saved. Added in P4 PR 15 of the own-episode-IDs plan (plan section 11).
*/
suspend fun request(context: Context, podcastId: String) {
enqueueWork(context, podcastId)?.await()
}

private fun enqueueWork(context: Context, podcastId: String): Operation? {
if (podcastId.isBlank() || podcastId.startsWith("rss:")) return null
return WorkManager.getInstance(context).enqueueUniqueWork(workName(podcastId), ExistingWorkPolicy.REPLACE, workRequest(podcastId))
}
}
}
86 changes: 86 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/sync/UpgradeRepairWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cx.aswin.boxlore.sync

import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import cx.aswin.boxlore.core.catalog.SharedAppDependenciesHolder
import cx.aswin.boxlore.core.catalog.UpgradeAddressRepair
import cx.aswin.boxlore.core.catalog.UpgradeRepairRun
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CancellationException

/**
* Runs the one-time repair of saved feed addresses after the update ([UpgradeAddressRepair] in
* `:core:catalog`, reached through [SharedAppDependenciesHolder]). The logic and its stored
* progress live there; this shell only maps a run to a WorkManager result.
*
* Enqueued on every launch while the repair has work left ([scheduleIfNeeded], from
* `BoxLoreApplication`), so a restore, which never brings WorkManager's queue, starts it again.
* Unique work [WORK_NAME] (a permanent identity), KEEP, network-gated, exponential backoff from
* 30 s. A launch's chain ends after [MAX_RUNS] runs; the next launch enqueues it again while
* work is left.
*
* Added in P4 PR 15 of the own-episode-IDs plan (plan section 11, upgrade repair).
* COMPAT(own-ids): someone can upgrade from an old build at any time. Permanent: never remove (MIGRATION.md §5).
*/
class UpgradeRepairWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
internal var repair: suspend () -> UpgradeRepairRun = {
SharedAppDependenciesHolder.require().upgradeAddressRepair?.run() ?: UpgradeRepairRun.DONE
}

override suspend fun doWork(): Result {
val run =
try {
repair()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Log.w(TAG, "Feed address repair failed", error)
UpgradeRepairRun.RETRY
}
return when (run) {
UpgradeRepairRun.DONE, UpgradeRepairRun.PAUSED -> Result.success()
UpgradeRepairRun.RETRY -> if (runAttemptCount + 1 < MAX_RUNS) Result.retry() else Result.success()
}
}

companion object {
private const val TAG = "UpgradeRepairWorker"

/** Permanent unique-work name. Never rename. */
const val WORK_NAME = "feed-address-upgrade-repair"

/** Runs per launch, the first included. */
const val MAX_RUNS = 10

private const val BACKOFF_SECONDS = 30L

fun workRequest(): OneTimeWorkRequest = OneTimeWorkRequestBuilder<UpgradeRepairWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
.build()

/** Enqueues the repair unless it is already waiting or running. */
fun enqueue(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, workRequest())
}

/**
* Enqueues the repair when it has work left. A fresh install, or one with only `rss:`
* follows, settles here without network and enqueues nothing.
*/
suspend fun scheduleIfNeeded(context: Context, repair: UpgradeAddressRepair): Boolean {
if (!repair.needsWork()) return false
enqueue(context)
return true
}
}
}
17 changes: 17 additions & 0 deletions app/src/test/java/cx/aswin/boxlore/AppContainerSmokeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,21 @@ class AppContainerSmokeTest {
assertTrue(next.addressCheck())
assertTrue(next.v2Discovery())
}

@Test
fun upgradeRepairIsOneInstanceReachableByWorkersAndSettlesAFreshInstall() = kotlinx.coroutines.runBlocking {
val container =
AppContainer(
context = context,
apiBaseUrl = "https://example.test",
publicKey = "test-key",
sharedUserPreferences = UserPreferencesRepository(context),
)
val repair = container.upgradeAddressRepair
assertSame(repair, container.upgradeAddressRepair)
assertSame(repair, (container as cx.aswin.boxlore.core.catalog.SharedAppDependencies).upgradeAddressRepair)

assertFalse("nothing followed: settled without network", repair.needsWork())
assertTrue(java.io.File(context.noBackupFilesDir, cx.aswin.boxlore.core.catalog.FileUpgradeNotificationMarker.FILE_NAME).exists())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ class PodcastNotificationRegistrationWorkerTest {
assertTrue(WorkManager.getInstance(context).getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("rss:abc")).get().isEmpty())
}

@Test
fun `a request returns once the work is saved, and never saves one for rss shows`() = runBlocking {
PodcastNotificationRegistrationWorker.request(context, "920666")
PodcastNotificationRegistrationWorker.request(context, "rss:abc")

val manager = WorkManager.getInstance(context)
val work = manager.getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("920666")).get()
assertEquals(1, work.count { it.state == WorkInfo.State.ENQUEUED })
assertTrue(manager.getWorkInfosForUniqueWork(PodcastNotificationRegistrationWorker.workName("rss:abc")).get().isEmpty())
}

@Test
fun `the work waits for a network and backs off exponentially`() {
val work = PodcastNotificationRegistrationWorker.workRequest("920666").workSpec
Expand Down
Loading
Loading