fix(#147, #411): review round 7 - stable copy ownership on provider renames and hand-offs, ledger writes tied to their folder, back up after settings change

This commit is contained in:
makiolaj committed 2026-11-13 22:20:58 +01:00
1 parent bb075d052e
commit 8754f046f2
8 files changed
+60 -18

No files matched your search

@@ -174,7 +174,7 @@ class BackupWorker(
val stamp = Clock.System.now()
val liveKeys = all.map { it.id.toString() }.toSet()
suspend fun record(key: String, files: List<String>) =
prefs.updateAutoBackupLedger { withLedgerEntry(it, key, files, liveKeys) }
prefs.updateAutoBackupLedger(folder.toString()) { withLedgerEntry(it, key, files, liveKeys) }
var written = 0
val landedLatest = mutableSetOf<String>()
for (plan in plans) {
@@ -203,23 +203,28 @@ class BackupWorker(
}
// Best-effort: the stable copy never holds up the dated backup,
// its pruning or the run's status. An old copy goes only once the
// new one has landed.
// new one has landed, so a series is never left without one.
try {
val latest = plan.latestName ?: return@withContext
val latestKey = latestLedgerKey(target.key)
// A provider that renamed it leaves a copy to clean up next run.
val landed = exporter.writeToFolder(folder, listing, latest, bytes)
record(latestKey, plan.staleLatest + landed)
// A provider that renamed it leaves a copy to clean up next
// run; the intended name stays ours only if it already was.
val ownsLatest = landed == latest || latest in listing.files ||
latest in ledger[latestKey].orEmpty()
val current = listOfNotNull(landed, latest.takeIf { ownsLatest })
record(latestKey, plan.staleLatest + plan.handOff + current)
if (landed == latest) landedLatest += target.key
val staleLeft = exporter.deleteFromFolder(listing, plan.staleLatest)
record(latestKey, plan.staleLatest.filter { it in staleLeft } + landed)
record(latestKey, plan.staleLatest.filter { it in staleLeft } + plan.handOff + current)
} catch (e: Exception) {
Log.w(TAG, "Stable copy of one series failed", e)
}
}
}
// A layout switch retires the other layout's copies once every new one is there.
if (landedLatest.size == plans.size) {
val stableCopies = plans.count { it.latestName != null }
if (stableCopies > 0 && landedLatest.size == stableCopies) {
replacedLatestCopies(targets, prefs.autoBackupLedger.first(), existing).forEach { (key, names) ->
withContext(NonCancellable) {
val left = exporter.deleteFromFolder(listing, names)
@@ -195,7 +195,6 @@ class SettingsPrefs @Inject constructor(
val autoBackupSelection: Flow<BackupSelection> = prefs.snapshot { it.autoBackupSelection }
val autoBackupCombined: Flow<Boolean> = prefs.snapshot { it.autoBackupCombined }
val autoBackupKeep: Flow<Int> = prefs.flow(SettingsKeys.autoBackupKeep)
val autoBackupLegacyInstall: Flow<Boolean> = prefs.snapshot { it.autoBackupLegacyInstall }
val autoBackupLedger: Flow<BackupLedger> = prefs.flow(SettingsKeys.autoBackupLedger)
val specialDatesEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.specialDatesEnabled)
val specialDatesTypes: Flow<Set<SpecialDateType>> = prefs.snapshot { it.specialDatesTypes }
@@ -370,9 +369,17 @@ class SettingsPrefs @Inject constructor(
suspend fun setAutoBackupKeep(keep: Int) = prefs.set(SettingsKeys.autoBackupKeep, keep)
/** Atomic, so a run's ledger writes never undo one made meanwhile (pinning the layout). */
suspend fun updateAutoBackupLedger(transform: (BackupLedger) -> BackupLedger) {
prefs.update(SettingsKeys.autoBackupLedger, transform)
/**
* Atomic, so a run's ledger writes never undo one made meanwhile (pinning
* the layout). Dropped when the folder is no longer [folder]: a run still
* finishing must not carry the old folder's names into the new one's ledger.
*/
suspend fun updateAutoBackupLedger(folder: String, transform: (BackupLedger) -> BackupLedger) {
prefs.edit {
if (it[SettingsKeys.autoBackupFolderUri] == folder) {
it[SettingsKeys.autoBackupLedger] = transform(it[SettingsKeys.autoBackupLedger])
}
}
}
/** Record a pre-#411 install's old file as its combined series' stable copy (see [pinBackupLayout]). */
@@ -76,6 +76,11 @@ data class BackupFilePlan(
val latestName: String?,
/** This series' earlier stable copies under a name it no longer uses (a rename), to delete. */
val staleLatest: List<String> = emptyList(),
/**
* Stable copies this series held that another series of the run now takes
* over: kept on record until that series has claimed them.
*/
val handOff: List<String> = emptyList(),
)
/**
@@ -190,6 +195,8 @@ fun planBackupFiles(
latestName = latest.takeIf { writable },
staleLatest = ledger[latestLedgerKey(target.key)].orEmpty()
.filter { it in existing && it != latest && it !in currentLatest },
handOff = ledger[latestLedgerKey(target.key)].orEmpty()
.filter { it in existing && it != latest && it in currentLatest },
)
}
}
@@ -188,7 +188,10 @@ fun BackupScreen(
calendars = ready.exportable,
selection = autoBackup.selection,
onCheckedChange = viewModel::setAutoBackupCalendar,
onDismiss = { showBackupCalendars = false },
onDismiss = {
showBackupCalendars = false
viewModel.autoBackupCalendarsChosen()
},
)
}
}
@@ -133,9 +133,13 @@ class CalendarsViewModel @Inject constructor(
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents(calendarIds)
// A one-calendar file is named after it, as the automatic backup's are.
val name = calendarIds.singleOrNull()?.let { id ->
repository.calendars().first().firstOrNull { it.id == id }?.displayName
}
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),
content = IcsWriter().writeCalendar(events, Clock.System.now(), name),
)
events.size
}
@@ -218,11 +222,28 @@ class CalendarsViewModel @Inject constructor(
}
fun setAutoBackupCombined(combined: Boolean) {
viewModelScope.launch { settingsPrefs.setAutoBackupCombined(combined) }
viewModelScope.launch {
settingsPrefs.setAutoBackupCombined(combined)
runBackupIfActive()
}
}
fun setAutoBackupKeep(keep: Int) {
viewModelScope.launch { settingsPrefs.setAutoBackupKeep(keep) }
viewModelScope.launch {
settingsPrefs.setAutoBackupKeep(keep)
runBackupIfActive()
}
}
/** After the calendar choice is closed, back up what is now selected rather than at the next interval. */
fun autoBackupCalendarsChosen() {
viewModelScope.launch { runBackupIfActive() }
}
private suspend fun runBackupIfActive() {
if (settingsPrefs.autoBackupEnabled.first() && settingsPrefs.autoBackupFolderUri.first() != null) {
BackupScheduler.runNow(context)
}
}
private suspend fun reschedule() {
@@ -726,13 +726,11 @@ class SettingsPrefsTest {
}
assertThat(prefs.autoBackupCombined.first()).isTrue()
assertThat(prefs.autoBackupLegacyInstall.first()).isTrue()
// Picking another folder pins the layout instead of flipping it, and
// keeps the old file recognised as this install's own.
prefs.setAutoBackupFolderUri("content://tree/new")
assertThat(prefs.autoBackupCombined.first()).isTrue()
assertThat(prefs.autoBackupLegacyInstall.first()).isFalse()
// A new folder starts a new ledger: the old file there isn't known to be ours.
assertThat(prefs.autoBackupLedger.first()).isEmpty()
@@ -200,6 +200,7 @@ class BackupPlanTest {
keep = 1,
)
assertThat(plans[0].staleLatest).isEmpty()
assertThat(plans[0].handOff).containsExactly("Work.ics")
assertThat(plans[1].latestName).isEqualTo("Work.ics")
}
+2 -2
View File
@@ -479,8 +479,8 @@ Each series also keeps a stable undated copy (`<calendar name>.ics`, or
`calendula-backup.ics` combined) overwritten every run, so sync tools and
subscriptions can follow one path. Retention never touches it, and it is only
written over a file the series itself (or a series renaming away from that name
in the same run) recorded. A renamed calendar's old copy is deleted, and so is a
copy the layout switch replaced (per-calendar ↔ combined); a calendar taken out
in the same run) recorded. A renamed calendar's old copy is deleted once the new
one has landed, and so is a copy the layout switch replaced (per-calendar ↔ combined); a calendar taken out
of the backup keeps its last one. The pre-#411 fixed `calendula-backup.ics` is
the combined series' stable copy for the install that wrote it, and an
install that already had a folder stays on the combined layout.