feat(#516): change runs report on the status line, failure notified once per streak

This commit is contained in:
makiolaj committed 2026-11-14 11:53:08 +01:00
1 parent 70f72d03db
commit 6758b7fada
2 files changed
+46 -27

No files matched your search

@@ -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)
+11 -9
View File
@@ -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