diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt index eb1dd734..27a3ee73 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt @@ -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) = - prefs.updateAutoBackupLedger { withLedgerEntry(it, key, files, liveKeys) } + prefs.updateAutoBackupLedger(folder.toString()) { withLedgerEntry(it, key, files, liveKeys) } var written = 0 val landedLatest = mutableSetOf() 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 223e3cfa..eaeb8cf5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -195,7 +195,6 @@ class SettingsPrefs @Inject constructor( val autoBackupSelection: Flow = prefs.snapshot { it.autoBackupSelection } val autoBackupCombined: Flow = prefs.snapshot { it.autoBackupCombined } val autoBackupKeep: Flow = prefs.flow(SettingsKeys.autoBackupKeep) - val autoBackupLegacyInstall: Flow = prefs.snapshot { it.autoBackupLegacyInstall } val autoBackupLedger: Flow = prefs.flow(SettingsKeys.autoBackupLedger) val specialDatesEnabled: Flow = prefs.flow(SettingsKeys.specialDatesEnabled) val specialDatesTypes: Flow> = 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]). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlan.kt index 9cc56820..dec835d4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlan.kt @@ -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 = 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 = 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 }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 70a0e8c6..39819b50 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -188,7 +188,10 @@ fun BackupScreen( calendars = ready.exportable, selection = autoBackup.selection, onCheckedChange = viewModel::setAutoBackupCalendar, - onDismiss = { showBackupCalendars = false }, + onDismiss = { + showBackupCalendars = false + viewModel.autoBackupCalendarsChosen() + }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 36a18ae6..fbf49bf7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -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() { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index d1ee8689..1b5191ad 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlanTest.kt index 6734bc99..84a04429 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlanTest.kt @@ -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") } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49828e1d..5214f44b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -479,8 +479,8 @@ Each series also keeps a stable undated copy (`.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.