feat(#516): change runs report on the status line, failure notified once per streak
This commit is contained in:
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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user