From 6758b7fada9869eedc0dcf68e0b2e7be31f15cda Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 14 Nov 2026 11:53:08 +0100 Subject: [PATCH] feat(#516): change runs report on the status line, failure notified once per streak --- .../calendula/data/backup/AutoBackup.kt | 53 ++++++++++++------- docs/ARCHITECTURE.md | 20 +++---- 2 files changed, 46 insertions(+), 27 deletions(-) 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 33970053..6cbf620b 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 @@ -201,21 +201,25 @@ class BackupWorker( } /** - * One run under the shared lock. A change run (#516) refreshes only the - * stable copies and stays off the status line and the failure count, which - * report on the scheduled backup; a broken folder fails that one too. + * One run under the shared lock, recorded for the status line. A change + * run (#516) refreshes only the stable copies and never retries. The + * failure notification comes once per failure streak, when it reaches + * [FAILURE_NOTIFY_THRESHOLD], and goes with the next success of any run. */ private suspend fun backUp(deps: Deps, prefs: SettingsPrefs, changeRun: Boolean): Result { val now = System.currentTimeMillis() // The periodic, the immediate and the change runs are separate unique // works; one at a time, or each would overwrite the other's ledger. + // The record is made under the lock too, so each streak reaches the + // threshold in exactly one run. + var failures = 0 val outcome = runLock.withLock { // Respect the toggles even for already-queued work, so a lingering // run can't revive itself. if (!prefs.autoBackupEnabled.first()) return Result.success() if (changeRun && !prefs.autoBackupOnChange.first()) return Result.success() val folder = prefs.autoBackupFolderUri.first() ?: return Result.failure() - try { + val outcome = try { runBackup(deps, prefs, folder.toUri(), latestOnly = changeRun) } catch (e: CancellationException) { throw e @@ -223,24 +227,29 @@ class BackupWorker( Log.w(TAG, "Automatic backup failed", e) RunOutcome.Failed } + if (outcome != RunOutcome.NothingToDo) { + prefs.recordAutoBackupRun(success = outcome == RunOutcome.Written, atMillis = now) + failures = prefs.autoBackupStatus.first().consecutiveFailures + } + outcome } - if (changeRun || outcome == RunOutcome.NothingToDo) return Result.success() - val success = outcome == RunOutcome.Written - prefs.recordAutoBackupRun(success = success, atMillis = now) - if (!success && prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) { - notifyFailure(applicationContext) + when { + outcome == RunOutcome.Written -> clearFailureNotification(applicationContext) + failures == FAILURE_NOTIFY_THRESHOLD -> notifyFailure(applicationContext) } // Only a run that wrote nothing retries: a retry rewrites every series. - return if (outcome == RunOutcome.Failed) Result.retry() else Result.success() + return if (outcome == RunOutcome.Failed && !changeRun) Result.retry() else Result.success() } private enum class RunOutcome { Written, PartlyFailed, Failed, NothingToDo } + /** A stable copy's write: whether it [landed] under its own name, or [failed] before it was written. */ + private data class LatestWrite(val landed: Boolean, val failed: Boolean) + /** * Writes each series and prunes it only once its new file has landed. A * [latestOnly] run (#516) leaves the dated files and their retention to the - * schedule and rewrites only the stable copies whose calendars changed; - * its outcome isn't reported. + * schedule and rewrites only the stable copies whose calendars changed. */ private suspend fun runBackup(deps: Deps, prefs: SettingsPrefs, folder: Uri, latestOnly: Boolean): RunOutcome { val repository = deps.repository() @@ -279,9 +288,9 @@ class BackupWorker( false } - // Whether the stable copy landed under its own name. An old copy goes - // only once the new one has landed, so a series is never left without one. - suspend fun writeLatest(plan: BackupFilePlan, bytes: ByteArray, fingerprint: String): Boolean { + // An old copy goes only once the new one has landed, so a series is + // never left without one. + suspend fun writeLatest(plan: BackupFilePlan, bytes: ByteArray, fingerprint: String): LatestWrite { val target = plan.target val latest = checkNotNull(plan.latestName) var landed: String? = null @@ -308,10 +317,10 @@ class BackupWorker( recordLatest(plan.staleLatest, fingerprint.takeIf { landed == latest }) val staleLeft = exporter.deleteFromFolder(listing, plan.staleLatest) recordLatest(plan.staleLatest.filter { it in staleLeft }) - landed == latest + LatestWrite(landed = landed == latest, failed = false) } catch (e: Exception) { Log.w(TAG, "Stable copy of one series failed", e) - landed == latest + LatestWrite(landed = landed == latest, failed = landed == null) } } @@ -344,7 +353,11 @@ class BackupWorker( if (writeDated(plan, bytes)) written++ else failed++ } // Best-effort in a full run: it never holds up the run's status. - if (plan.latestName != null && writeLatest(plan, bytes, fingerprint)) landedLatest += target.key + val latest = plan.latestName?.let { writeLatest(plan, bytes, fingerprint) } + if (latest?.landed == true) landedLatest += target.key + if (latestOnly && latest != null) { + if (latest.failed) failed++ else written++ + } } } // A layout switch retires the other layout's copies once every new one is there. @@ -364,6 +377,10 @@ class BackupWorker( } } + private fun clearFailureNotification(context: Context) { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + private fun notifyFailure(context: Context) { val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || context.hasPermission(Manifest.permission.POST_NOTIFICATIONS) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bce7cc14..055b2c91 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -492,15 +492,17 @@ this app, a sync adapter or another app), a 2-minute settle delay capped at 15 minutes, and battery-not-low. A trigger fires once and observes only while its job waits, so there are two slots: a change run arms the other one as it starts (catching changes made during the run) and again as it ends. Runs share the -scheduled backup's lock. A change run only rewrites the stable copies and stays -off the status line: the dated files and their retention stay on the interval, -so a day of edits can't rotate the history out. Since the trigger fires for any -provider change (reminders, other calendars, sync bookkeeping), each series' -file is hashed without its `DTSTAMP`s (`auto_backup_fingerprints`, reset with -the folder) and a change run skips the series whose hash and stable copy are -unchanged; a failed write drops the hash. A copy a still-loading folder listing -doesn't show, or one edited in place outside the app, is left to the next -scheduled run. +scheduled backup's lock. A change run only rewrites the stable copies and never +retries: the dated files and their retention stay on the interval, so a day of +edits can't rotate the history out. Every run that writes something is recorded +on the status line; the failure notification comes once per failure streak, as +it reaches the threshold, and the next success of any run clears it. Since the +trigger fires for any provider change (reminders, other calendars, sync +bookkeeping), each series' file (events in a fixed order) is hashed without its +`DTSTAMP`s (`auto_backup_fingerprints`, reset with the folder) and a change run +skips the series whose hash and stable copy are unchanged; the hash is dropped +before a copy is rewritten. A copy a still-loading folder listing doesn't show, +or one edited in place outside the app, is left to the next scheduled run. **A backup carries the series' deleted and moved occurrences.** `EXDATE` lives on the master row and is part of `EventExportProjection`; `IcsWriter` marks an