diff --git a/build.gradle.kts b/build.gradle.kts index ab8ead4..e1cd7b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { allprojects { group = "com.thelightphone.backup" - version = "0.0.7" + version = "0.0.8" plugins.withId("com.vanniktech.maven.publish") { extensions.configure { diff --git a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupPreferences.kt b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupPreferences.kt index 1c33637..c23d552 100644 --- a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupPreferences.kt +++ b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupPreferences.kt @@ -1,20 +1,44 @@ package com.thelightphone.backup +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlin.time.Duration import kotlin.time.Instant -// Outcome of the most recent BackupWorker run, written by BackupWorker itself. +// Outcome of the most recent (or currently running) BackupWorker run, written by BackupWorker itself. +@Serializable sealed class BackupStatus { - data class Succeeded(val completedAt: Instant) : BackupStatus() + // A backup run is currently underway. + @Serializable + data class InProgress(val startedAt: Instant) : BackupStatus() + + // A backup run that has finished, success or failure + sealed interface Terminal { + val finishedAt: Instant + } + + @Serializable + data class Succeeded(override val finishedAt: Instant) : BackupStatus(), Terminal // The linked account's credentials are dead (RemoteBackupError.Unauthorized). Shouldn't automatically retry - data object NeedsReauth : BackupStatus() + @Serializable + data class NeedsReauth(override val finishedAt: Instant) : BackupStatus(), Terminal // The remote account is out of space (RemoteBackupError.QuotaExceeded). Shouldn't automatically retry - data object QuotaExceeded : BackupStatus() + @Serializable + data class QuotaExceeded(override val finishedAt: Instant) : BackupStatus(), Terminal // Anything else, likely transient so retries are ok - data class Failed(val message: String) : BackupStatus() + @Serializable + data class Failed(val message: String, override val finishedAt: Instant) : BackupStatus(), Terminal + + companion object { + private val json = Json { ignoreUnknownKeys = true } + fun encode(status: BackupStatus): String = json.encodeToString(status) + fun decode(value: String): BackupStatus? = runCatching { json.decodeFromString(value) }.getOrNull() + } } // Persistent storage for Backup diff --git a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt index eec192e..30f8e43 100644 --- a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt +++ b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt @@ -20,6 +20,9 @@ import androidx.work.WorkManager import androidx.work.WorkerFactory import androidx.work.WorkerParameters import androidx.work.workDataOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes @@ -43,6 +46,7 @@ class BackupWorker( ensureNotificationChannel() setForeground(buildForegroundInfo()) + preferences.setLastBackupStatus(BackupStatus.InProgress(clock.now())) val runner = BackupRunner( remoteBackup, @@ -57,14 +61,25 @@ class BackupWorker( ) }, ) - return when (val result = runner.run()) { - is BackupResult.Completed -> { - preferences.setLastBackupStatus(BackupStatus.Succeeded(clock.now())) - Result.success() + return try { + when (val result = runner.run()) { + is BackupResult.Completed -> { + preferences.setLastBackupStatus(BackupStatus.Succeeded(clock.now())) + Result.success() + } + + is BackupResult.Partial -> recordFailureAndDecide(result.abortedBy?.cause) + is BackupResult.Failed -> recordFailureAndDecide(result.cause.cause) } - - is BackupResult.Partial -> recordFailureAndDecide(result.abortedBy?.cause) - is BackupResult.Failed -> recordFailureAndDecide(result.cause.cause) + } catch (e: CancellationException) { + // Job's already cancelled, so writing the status needs NonCancellable to run at all. + withContext(NonCancellable) { + preferences.setLastBackupStatus(BackupStatus.Failed(e.message ?: "backup cancelled", clock.now())) + } + throw e + } catch (e: Exception) { + preferences.setLastBackupStatus(BackupStatus.Failed(e.message ?: "backup failed", clock.now())) + Result.retry() } } @@ -88,17 +103,17 @@ class BackupWorker( private suspend fun recordFailureAndDecide(cause: Throwable?): Result = when (cause) { is RemoteBackupError.Unauthorized -> { - preferences.setLastBackupStatus(BackupStatus.NeedsReauth) + preferences.setLastBackupStatus(BackupStatus.NeedsReauth(clock.now())) Result.failure() } is RemoteBackupError.QuotaExceeded -> { - preferences.setLastBackupStatus(BackupStatus.QuotaExceeded) + preferences.setLastBackupStatus(BackupStatus.QuotaExceeded(clock.now())) Result.failure() } else -> { - preferences.setLastBackupStatus(BackupStatus.Failed(cause?.message ?: "backup failed")) + preferences.setLastBackupStatus(BackupStatus.Failed(cause?.message ?: "backup failed", clock.now())) Result.retry() } }