This commit is contained in:
1 parent
e9cf7d66ba
commit
deb2fd4482
19 files changed
+866
-146
No files matched your search
@@ -5,6 +5,7 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -25,17 +26,21 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.backup.backupTargets
|
||||
import de.jeanlucmakiola.calendula.domain.backup.planBackupFiles
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||
* everything is local provider reads + a local file write.
|
||||
* Schedules and runs the automatic periodic `.ics` backup (issue #8) of the
|
||||
* calendars the user picked (#147). One-way export only — it mirrors the manual
|
||||
* "Back up" into a user-chosen folder on an interval; it is not a sync. Stays
|
||||
* INTERNET-free: everything is local provider reads + local file writes.
|
||||
*/
|
||||
object BackupScheduler {
|
||||
|
||||
@@ -86,8 +91,9 @@ object BackupScheduler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||
* Exports the selected calendars into the configured folder — one dated file
|
||||
* per calendar, or one combined file (#411) — then prunes each series down to
|
||||
* the retention count. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||
* no custom factory / Application wiring needed. Records the outcome for the
|
||||
* settings status line, and notifies after repeated failures.
|
||||
@@ -115,11 +121,11 @@ class BackupWorker(
|
||||
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val events = deps.repository().exportEvents()
|
||||
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||
runBackup(deps, prefs, folder.toUri())
|
||||
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||
Result.success()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
@@ -130,6 +136,41 @@ class BackupWorker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes every file first and prunes afterwards, and only a series whose
|
||||
* new file landed, so a failed run never costs an older backup. Throws
|
||||
* after the rest are done if any series failed.
|
||||
*/
|
||||
private suspend fun runBackup(deps: Deps, prefs: SettingsPrefs, folder: Uri) {
|
||||
val repository = deps.repository()
|
||||
val exporter = deps.exporter()
|
||||
val selection = prefs.autoBackupSelection.first()
|
||||
val calendars = repository.calendars().first().filter(selection::selects)
|
||||
val targets = backupTargets(calendars, combined = prefs.autoBackupCombined.first())
|
||||
val plans = planBackupFiles(
|
||||
targets = targets,
|
||||
existing = exporter.fileNames(folder),
|
||||
at = LocalDateTime.now(),
|
||||
keep = prefs.autoBackupKeep.first(),
|
||||
)
|
||||
val stamp = Clock.System.now()
|
||||
var failure: Exception? = null
|
||||
for (plan in plans) {
|
||||
try {
|
||||
val events = repository.exportEvents(plan.target.calendarIds)
|
||||
val content = IcsWriter().writeCalendar(events, stamp, plan.target.calendarName)
|
||||
exporter.writeToFolder(folder, plan.fileName, content)
|
||||
exporter.deleteFromFolder(folder, plan.prune)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Backup of one calendar failed", e)
|
||||
failure = failure ?: e
|
||||
}
|
||||
}
|
||||
failure?.let { throw it }
|
||||
}
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -165,7 +206,6 @@ class BackupWorker(
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||
private const val CHANNEL_ID = "backup"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
|
||||
@@ -90,15 +90,15 @@ interface CalendarDataSource {
|
||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
|
||||
* lead times rather than raw provider offsets (see [toIcsEvent]).
|
||||
* Every master/one-off event of [calendarIds], whatever kind of calendar
|
||||
* they are (local, synced, read-only), mapped to [IcsEvent]s. A plain read:
|
||||
* which calendars may be read for what is the caller's call. Modified-
|
||||
* occurrence and cancelled-exception rows are excluded (see
|
||||
* [EventExportProjection]). [allDayReminderTimeMinutes]: needed to write
|
||||
* all-day reminders as whole-day lead times rather than raw provider
|
||||
* offsets (see [toIcsEvent]).
|
||||
*/
|
||||
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
fun calendarEvents(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -776,22 +776,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
override fun calendarEvents(
|
||||
calendarIds: Set<Long>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
// here, and re-materialise from the contact sync — backing them up would
|
||||
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||
// export to the user's chosen subset. Map id → display name for the
|
||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||
if (calendarIds.isEmpty()) return emptyList()
|
||||
// Id → display name for the X-CALENDULA-CALENDAR tag a restore uses to
|
||||
// fan back out; an id that no longer exists drops out here.
|
||||
val names = calendars()
|
||||
.filter {
|
||||
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||
(calendarIds == null || it.id in calendarIds)
|
||||
}
|
||||
.filter { it.id in calendarIds }
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
|
||||
|
||||
@@ -61,9 +61,11 @@ interface CalendarRepository {
|
||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||
* Every event of [calendarIds], ready to serialise into an `.ics` backup
|
||||
* (see [CalendarDataSource.calendarEvents]). Any calendar can be read,
|
||||
* synced and read-only ones included (#147); `null` means the calendars a
|
||||
* backup covers by default
|
||||
* ([de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault]).
|
||||
*/
|
||||
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
|
||||
+6
-1
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -248,7 +249,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||
withContext(io) {
|
||||
val ids = calendarIds
|
||||
?: dataSource.calendars().filter { it.isBackedUpByDefault }.map { it.id }.toSet()
|
||||
dataSource.calendarEvents(ids, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
|
||||
@@ -35,14 +35,10 @@ class IcsExporter @Inject constructor(
|
||||
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||
*/
|
||||
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||
// duplicates SAF may have created if two runs ever raced — so we always
|
||||
// converge on a single overwritten file.
|
||||
val dir = writableFolder(folder)
|
||||
// Reuse the file if present and clean up any "name (1).ics" duplicates
|
||||
// SAF may have created if two runs ever raced, so a name converges on a
|
||||
// single overwritten file.
|
||||
val base = fileName.substringBeforeLast('.')
|
||||
val ext = fileName.substringAfterLast('.', "")
|
||||
var target: DocumentFile? = null
|
||||
@@ -59,6 +55,27 @@ class IcsExporter @Inject constructor(
|
||||
writeDocument(file.uri, content)
|
||||
}
|
||||
|
||||
/** Names of the files directly inside [folder]. Throws when it can't be read. */
|
||||
fun fileNames(folder: Uri): List<String> = writableFolder(folder).listFiles().mapNotNull { it.name }
|
||||
|
||||
/** Delete the files called [names] from [folder]; one that won't go is left behind. */
|
||||
fun deleteFromFolder(folder: Uri, names: Collection<String>) {
|
||||
if (names.isEmpty()) return
|
||||
val wanted = names.toSet()
|
||||
writableFolder(folder).listFiles()
|
||||
.filter { it.isFile && it.name in wanted }
|
||||
.forEach { runCatching { it.delete() } }
|
||||
}
|
||||
|
||||
private fun writableFolder(folder: Uri): DocumentFile {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage [content] in a private cache file and return a shareable content
|
||||
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||
|
||||
@@ -2,8 +2,12 @@ package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.DEFAULT_BACKUP_KEEP
|
||||
import de.jeanlucmakiola.calendula.domain.backup.normalizeBackupKeep
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
@@ -29,6 +33,7 @@ import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import de.jeanlucmakiola.floret.prefs.intPref
|
||||
import de.jeanlucmakiola.floret.prefs.longPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import de.jeanlucmakiola.floret.prefs.set
|
||||
@@ -180,6 +185,9 @@ class SettingsPrefs @Inject constructor(
|
||||
val autoBackupIntervalMinutes: Flow<Long> = prefs.flow(SettingsKeys.autoBackupIntervalMinutes)
|
||||
val autoBackupFolderUri: Flow<String?> = prefs.flow(SettingsKeys.autoBackupFolderUri)
|
||||
val autoBackupStatus: Flow<BackupStatus> = prefs.snapshot { it.autoBackupStatus }
|
||||
val autoBackupSelection: Flow<BackupSelection> = prefs.snapshot { it.autoBackupSelection }
|
||||
val autoBackupCombined: Flow<Boolean> = prefs.snapshot { it.autoBackupCombined }
|
||||
val autoBackupKeep: Flow<Int> = prefs.flow(SettingsKeys.autoBackupKeep)
|
||||
val specialDatesEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.specialDatesEnabled)
|
||||
val specialDatesTypes: Flow<Set<SpecialDateType>> = prefs.snapshot { it.specialDatesTypes }
|
||||
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = prefs.snapshot { it.specialDatesCalendars }
|
||||
@@ -329,7 +337,33 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
|
||||
prefs.set(SettingsKeys.autoBackupIntervalMinutes, minutes)
|
||||
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) = prefs.set(SettingsKeys.autoBackupFolderUri, uri)
|
||||
/**
|
||||
* Also pins the file layout the first time it is written: an install that
|
||||
* already had a folder before #411 keeps its single combined file, a new
|
||||
* one starts on one file per calendar (see [Preferences.autoBackupCombined]).
|
||||
*/
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||
prefs.edit {
|
||||
if (it[SettingsKeys.autoBackupCombinedStored] == null) {
|
||||
it[SettingsKeys.autoBackupCombinedStored] = it[SettingsKeys.autoBackupFolderUri] != null
|
||||
}
|
||||
it[SettingsKeys.autoBackupFolderUri] = uri
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupCombined(combined: Boolean) =
|
||||
prefs.set(SettingsKeys.autoBackupCombinedStored, combined)
|
||||
|
||||
suspend fun setAutoBackupKeep(keep: Int) = prefs.set(SettingsKeys.autoBackupKeep, keep)
|
||||
|
||||
/** Switch [calendar] in or out of the automatic backup (#147). */
|
||||
suspend fun setAutoBackupCalendar(calendar: CalendarSource, included: Boolean) {
|
||||
prefs.edit {
|
||||
val next = it.autoBackupSelection.with(calendar, included)
|
||||
it[SettingsKeys.autoBackupIncluded] = next.included
|
||||
it[SettingsKeys.autoBackupExcluded] = next.excluded
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||
@@ -700,6 +734,23 @@ object SettingsKeys {
|
||||
val autoBackupLastSuccess: Pref<Boolean> = booleanPref("auto_backup_last_success", true)
|
||||
val autoBackupFailures: Pref<Int> = intPref("auto_backup_failures", 0)
|
||||
|
||||
/**
|
||||
* The automatic backup's calendar choice as deviations from the default
|
||||
* (#147), read together as [Preferences.autoBackupSelection].
|
||||
*/
|
||||
val autoBackupIncluded: Pref<Set<Long>> = idSetPref("auto_backup_included_calendars")
|
||||
val autoBackupExcluded: Pref<Set<Long>> = idSetPref("auto_backup_excluded_calendars")
|
||||
|
||||
/**
|
||||
* Whether the automatic backup writes one combined file instead of one per
|
||||
* calendar (#411). Unset until first pinned; read as [Preferences.autoBackupCombined].
|
||||
*/
|
||||
val autoBackupCombinedStored: Pref<Boolean?> = nullableBooleanPref("auto_backup_combined")
|
||||
|
||||
/** How many files of each backup series to keep (#411). */
|
||||
val autoBackupKeep: Pref<Int> =
|
||||
intPref("auto_backup_keep", DEFAULT_BACKUP_KEEP).coerced(::normalizeBackupKeep)
|
||||
|
||||
/** Master switch for the contact special-dates mirror (issue #15). Opt-in. */
|
||||
val specialDatesEnabled: Pref<Boolean> = booleanPref("special_dates_enabled", false)
|
||||
|
||||
@@ -749,6 +800,20 @@ val Preferences.autoBackupStatus: BackupStatus
|
||||
consecutiveFailures = this[SettingsKeys.autoBackupFailures],
|
||||
)
|
||||
|
||||
val Preferences.autoBackupSelection: BackupSelection
|
||||
get() = BackupSelection(
|
||||
included = this[SettingsKeys.autoBackupIncluded],
|
||||
excluded = this[SettingsKeys.autoBackupExcluded],
|
||||
)
|
||||
|
||||
/**
|
||||
* Unset means an install from before #411: one that already had a backup
|
||||
* folder keeps the single file it was writing, anything else gets per-calendar
|
||||
* files. Choosing a folder pins the answer (see [SettingsPrefs.setAutoBackupFolderUri]).
|
||||
*/
|
||||
val Preferences.autoBackupCombined: Boolean
|
||||
get() = this[SettingsKeys.autoBackupCombinedStored] ?: (this[SettingsKeys.autoBackupFolderUri] != null)
|
||||
|
||||
val Preferences.specialDatesTypes: Set<SpecialDateType>
|
||||
get() = SettingsKeys.specialDateTypeEnabled.filterValues { this[it] }.keys
|
||||
|
||||
@@ -785,6 +850,12 @@ private fun <T> Pref<T>.coerced(clamp: (T) -> T): Pref<T> = map(clamp, clamp)
|
||||
private fun agendaRangePref(name: String): Pref<AgendaRange> =
|
||||
nullableStringPref(name).map({ parseAgendaRange(it, AgendaRange.Month) }, { it.storageValue() })
|
||||
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> =
|
||||
nullableStringPref(name).map(
|
||||
{ stored -> stored?.split(LIST_SEP).orEmpty().mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> ids.takeIf { it.isNotEmpty() }?.sorted()?.joinToString(LIST_SEP) },
|
||||
)
|
||||
|
||||
private fun reminderListPref(name: String): Pref<List<Int>> =
|
||||
nullableStringPref(name).map({ it.toReminderList() }, { it.toStoredReminders() })
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package de.jeanlucmakiola.calendula.domain.backup
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Whether this calendar can be backed up at all. Any calendar the provider
|
||||
* holds can be *read* (#147), whatever its visibility or access level. Left out:
|
||||
* the managed special-dates mirrors, rebuilt from contacts so a restore would
|
||||
* only duplicate them, and calendars whose events aren't synced to the device.
|
||||
*/
|
||||
val CalendarSource.isBackupCandidate: Boolean
|
||||
get() = !isManaged && !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether a backup includes this calendar until the user says otherwise: the
|
||||
* app's own writable local calendars, which have no other copy anywhere. Synced
|
||||
* calendars already live on their server, so they are opt-in.
|
||||
*/
|
||||
val CalendarSource.isBackedUpByDefault: Boolean
|
||||
get() = isLocal && canModifyContents && !isManaged
|
||||
|
||||
/**
|
||||
* Which calendars the automatic backup covers, stored as deviations from
|
||||
* [isBackedUpByDefault] so a calendar created later still follows the default:
|
||||
* [included] opts a calendar in, [excluded] opts one out.
|
||||
*/
|
||||
data class BackupSelection(
|
||||
val included: Set<Long> = emptySet(),
|
||||
val excluded: Set<Long> = emptySet(),
|
||||
) {
|
||||
fun selects(calendar: CalendarSource): Boolean = when {
|
||||
!calendar.isBackupCandidate -> false
|
||||
calendar.id in included -> true
|
||||
calendar.id in excluded -> false
|
||||
else -> calendar.isBackedUpByDefault
|
||||
}
|
||||
|
||||
/** The selection with [calendar] switched [on]; a choice matching the default leaves no trace. */
|
||||
fun with(calendar: CalendarSource, on: Boolean): BackupSelection {
|
||||
val id = calendar.id
|
||||
val default = calendar.isBackedUpByDefault
|
||||
return BackupSelection(
|
||||
included = if (on && !default) included + id else included - id,
|
||||
excluded = if (!on && default) excluded + id else excluded - id,
|
||||
)
|
||||
}
|
||||
|
||||
/** Ids of the selected calendars among [calendars]. */
|
||||
fun selectedIds(calendars: List<CalendarSource>): Set<Long> =
|
||||
calendars.filter(::selects).map { it.id }.toSet()
|
||||
}
|
||||
|
||||
/** One backup file series: the calendars it holds and the name its files start with. */
|
||||
data class BackupTarget(
|
||||
val calendarIds: Set<Long>,
|
||||
val baseName: String,
|
||||
/** The calendar name written into the file, or null for a combined file. */
|
||||
val calendarName: String?,
|
||||
)
|
||||
|
||||
/** What one run writes and which earlier files it removes afterwards. */
|
||||
data class BackupFilePlan(
|
||||
val target: BackupTarget,
|
||||
val fileName: String,
|
||||
/** Earlier files of this series beyond the retention count, oldest first. */
|
||||
val prune: List<String>,
|
||||
)
|
||||
|
||||
/** The combined file's series name; also the fixed name used before #411. */
|
||||
const val COMBINED_BACKUP_BASE = "calendula-backup"
|
||||
|
||||
/** The single file every automatic backup overwrote before #411. */
|
||||
const val LEGACY_BACKUP_FILE = "$COMBINED_BACKUP_BASE.ics"
|
||||
|
||||
/** The retention choices the settings offer, newest kept first. */
|
||||
val BACKUP_KEEP_OPTIONS: List<Int> = listOf(1, 2, 3, 5, 7, 10)
|
||||
|
||||
const val DEFAULT_BACKUP_KEEP = 3
|
||||
|
||||
private const val ICS_EXTENSION = ".ics"
|
||||
private const val MAX_BASE_LENGTH = 60
|
||||
private const val STAMP_PATTERN = "yyyy-MM-dd_HH-mm-ss"
|
||||
private val STAMP_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern(STAMP_PATTERN)
|
||||
private const val STAMP_REGEX = "\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}"
|
||||
private val FORBIDDEN_CHARS = Regex("[\\\\/:*?\"<>|\\p{Cntrl}]")
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
/**
|
||||
* [name] made safe as a file name on every storage provider: path separators,
|
||||
* reserved and control characters become `_`, whitespace collapses, and leading
|
||||
* dots go so the file can't turn hidden. Falls back to [fallback] when nothing
|
||||
* is left.
|
||||
*/
|
||||
fun sanitizeBackupName(name: String, fallback: String = "calendar"): String {
|
||||
val cleaned = name
|
||||
.replace(FORBIDDEN_CHARS, "_")
|
||||
.replace(WHITESPACE, " ")
|
||||
.trim()
|
||||
.trimStart('.')
|
||||
.take(MAX_BASE_LENGTH)
|
||||
.trim()
|
||||
return cleaned.ifEmpty { fallback }
|
||||
}
|
||||
|
||||
/**
|
||||
* The file series the automatic backup writes: one combined file, or one per
|
||||
* calendar named after it. Two calendars that sanitise to the same name (a local
|
||||
* and a synced "Personal") keep apart by their id, so neither prunes the other's
|
||||
* files.
|
||||
*/
|
||||
fun backupTargets(calendars: List<CalendarSource>, combined: Boolean): List<BackupTarget> {
|
||||
if (calendars.isEmpty()) return emptyList()
|
||||
if (combined) {
|
||||
return listOf(BackupTarget(calendars.map { it.id }.toSet(), COMBINED_BACKUP_BASE, calendarName = null))
|
||||
}
|
||||
val bases = calendars.associate { it.id to sanitizeBackupName(it.displayName) }
|
||||
val taken = bases.values.groupingBy { it.lowercase() }.eachCount()
|
||||
return calendars.map { calendar ->
|
||||
val base = bases.getValue(calendar.id)
|
||||
val clash = taken.getValue(base.lowercase()) > 1 || base.equals(COMBINED_BACKUP_BASE, ignoreCase = true)
|
||||
BackupTarget(
|
||||
calendarIds = setOf(calendar.id),
|
||||
baseName = if (clash) "$base-${calendar.id}" else base,
|
||||
calendarName = calendar.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `Work_2026-11-13_14-30-05.ics`: the series name and the local time of the run. */
|
||||
fun backupFileName(baseName: String, at: LocalDateTime): String =
|
||||
"${baseName}_${at.format(STAMP_FORMAT)}$ICS_EXTENSION"
|
||||
|
||||
/**
|
||||
* Plan one run: a new file per target, and which of that target's earlier files
|
||||
* fall out of the newest [keep] (the new one included). Only names this app
|
||||
* writes for the target count — `<base>_<stamp>.ics`, plus the pre-#411
|
||||
* [LEGACY_BACKUP_FILE] for the combined series, which ranks oldest — so a user's
|
||||
* own files in the folder are never touched.
|
||||
*/
|
||||
fun planBackupFiles(
|
||||
targets: List<BackupTarget>,
|
||||
existing: Collection<String>,
|
||||
at: LocalDateTime,
|
||||
keep: Int,
|
||||
): List<BackupFilePlan> {
|
||||
val retain = keep.coerceAtLeast(1)
|
||||
return targets.map { target ->
|
||||
val fileName = backupFileName(target.baseName, at)
|
||||
val series = Regex("^${Regex.escape(target.baseName)}_($STAMP_REGEX)${Regex.escape(ICS_EXTENSION)}$")
|
||||
val earlier = existing
|
||||
.filter { it != fileName }
|
||||
.mapNotNull { name ->
|
||||
val stamp = series.find(name)?.groupValues?.get(1)
|
||||
?: if (target.baseName == COMBINED_BACKUP_BASE && name == LEGACY_BACKUP_FILE) "" else null
|
||||
stamp?.let { name to it }
|
||||
}
|
||||
.sortedByDescending { it.second }
|
||||
.map { it.first }
|
||||
BackupFilePlan(
|
||||
target = target,
|
||||
fileName = fileName,
|
||||
prune = earlier.drop(retain - 1).reversed(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Closest offered retention count to [stored], so a stray value still shows a row. */
|
||||
fun normalizeBackupKeep(stored: Int): Int =
|
||||
BACKUP_KEEP_OPTIONS.minBy { kotlin.math.abs(it - stored) }
|
||||
@@ -25,12 +25,14 @@ const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||
*/
|
||||
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
|
||||
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||
/** [calendarName], when given, names the whole file (`X-WR-CALNAME`), as a one-calendar backup does. */
|
||||
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant, calendarName: String? = null): String {
|
||||
val lines = buildList {
|
||||
add("BEGIN:VCALENDAR")
|
||||
add("VERSION:2.0")
|
||||
add("PRODID:$prodId")
|
||||
add("CALSCALE:GREGORIAN")
|
||||
calendarName?.takeIf { it.isNotBlank() }?.let { add("X-WR-CALNAME:${escapeText(it)}") }
|
||||
events.forEach { appendEvent(it, dtStamp) }
|
||||
add("END:VCALENDAR")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -49,11 +48,13 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BACKUP_KEEP_OPTIONS
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarCheckGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
@@ -61,8 +62,9 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
|
||||
import java.time.LocalDate
|
||||
|
||||
@@ -75,7 +77,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||
* Backup & restore (#69): `.ics` export and restore, with
|
||||
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||
*
|
||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||
@@ -95,11 +97,13 @@ fun BackupScreen(
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Exports everything eligible (null); the per-calendar selector owns its
|
||||
// own launcher.
|
||||
// The one-calendar shortcut; the per-calendar selector owns its own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||
) { uri ->
|
||||
val ids = (state as? BackupUiState.Ready)?.exportable?.map { it.id }?.toSet()
|
||||
if (uri != null && ids != null) viewModel.exportBackup(uri, ids)
|
||||
}
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Restore runs the picked file through the normal .ics import flow.
|
||||
@@ -112,6 +116,8 @@ fun BackupScreen(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
var showBackupCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
var showKeep by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
@@ -158,6 +164,8 @@ fun BackupScreen(
|
||||
},
|
||||
onExportPick = { showExportPicker = true },
|
||||
onEditInterval = { showInterval = true },
|
||||
onEditCalendars = { showBackupCalendars = true },
|
||||
onEditKeep = { showKeep = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -172,6 +180,31 @@ fun BackupScreen(
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
if (showBackupCalendars) {
|
||||
AutoBackupCalendarPicker(
|
||||
calendars = ready.exportable,
|
||||
selection = autoBackup.selection,
|
||||
onCheckedChange = viewModel::setAutoBackupCalendar,
|
||||
onDismiss = { showBackupCalendars = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showKeep) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.calendars_auto_backup_keep),
|
||||
header = {
|
||||
GroupedHint(
|
||||
stringResource(R.string.calendars_auto_backup_keep_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
predictiveBack = true,
|
||||
options = BACKUP_KEEP_OPTIONS,
|
||||
selected = autoBackup.keep,
|
||||
label = { backupKeepLabel(it) },
|
||||
onSelect = viewModel::setAutoBackupKeep,
|
||||
onDismiss = { showKeep = false },
|
||||
)
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
@@ -213,6 +246,8 @@ private fun BackupContent(
|
||||
onExportAll: () -> Unit,
|
||||
onExportPick: () -> Unit,
|
||||
onEditInterval: () -> Unit,
|
||||
onEditCalendars: () -> Unit,
|
||||
onEditKeep: () -> Unit,
|
||||
) {
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
// Restore never depends on export eligibility, and never disappears: when no
|
||||
@@ -272,16 +307,46 @@ private fun BackupContent(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
val selectedCount = exportable.count(autoBackup.selection::selects)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_calendars),
|
||||
summary = if (selectedCount == 0) {
|
||||
stringResource(R.string.calendars_auto_backup_calendars_none)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.calendars_auto_backup_calendars_count, selectedCount, selectedCount)
|
||||
},
|
||||
position = Position.Middle,
|
||||
onClick = onEditCalendars,
|
||||
)
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_combined),
|
||||
summary = stringResource(
|
||||
if (autoBackup.combined) {
|
||||
R.string.calendars_auto_backup_combined_on
|
||||
} else {
|
||||
R.string.calendars_auto_backup_combined_off
|
||||
},
|
||||
),
|
||||
checked = autoBackup.combined,
|
||||
onCheckedChange = viewModel::setAutoBackupCombined,
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_keep),
|
||||
summary = backupKeepLabel(autoBackup.keep),
|
||||
position = Position.Bottom,
|
||||
onClick = onEditKeep,
|
||||
)
|
||||
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* Choose which calendars to include in a one-time `.ics` export. Starts on the
|
||||
* calendars a backup covers by default, or on all of them when none is; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
@@ -298,7 +363,8 @@ private fun ExportCalendarPicker(
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
val defaults = calendars.filter { it.isBackedUpByDefault }.ifEmpty { calendars }
|
||||
mutableStateOf(defaults.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
@@ -315,26 +381,13 @@ private fun ExportCalendarPicker(
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
CalendarCheckGroups(
|
||||
calendars = calendars,
|
||||
isChecked = { it.id in selected },
|
||||
onCheckedChange = { calendar, checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
@@ -349,6 +402,39 @@ private fun ExportCalendarPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which calendars the automatic backup covers (#147). Each tick is saved
|
||||
* at once, as a deviation from the default, so there is nothing to confirm.
|
||||
*/
|
||||
@Composable
|
||||
private fun AutoBackupCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
selection: BackupSelection,
|
||||
onCheckedChange: (CalendarSource, Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_auto_backup_calendars),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedHint(
|
||||
stringResource(R.string.calendars_auto_backup_calendars_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
CalendarCheckGroups(
|
||||
calendars = calendars,
|
||||
isChecked = selection::selects,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Only the latest" / "The latest 3". */
|
||||
@Composable
|
||||
private fun backupKeepLabel(keep: Int): String =
|
||||
pluralStringResource(R.plurals.calendars_auto_backup_keep_count, keep, keep)
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackupCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
|
||||
@@ -29,20 +30,21 @@ sealed interface BackupUiState {
|
||||
/**
|
||||
* What the screen can offer for this calendar list.
|
||||
*
|
||||
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||
* import picker offers ([isEventTarget]).
|
||||
* Export can read any calendar (#147) — the local ones are only its default;
|
||||
* managed special-dates mirrors are rebuilt from contacts, so they are excluded
|
||||
* ([isBackupCandidate]). Restore can target anything the import picker offers
|
||||
* ([isEventTarget]).
|
||||
*
|
||||
* A failure is raised only when *neither* is possible — deliberately not
|
||||
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||
* failure while its local calendars are still perfectly exportable. When both
|
||||
* failure while its calendars are still perfectly exportable. When both
|
||||
* halves are dead the list itself usually says why (no calendars, everything
|
||||
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||
* calendars exist and are visible but every one of them is read-only, managed or
|
||||
* not synced to the device.
|
||||
*/
|
||||
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val exportable = calendars.filter { it.isBackupCandidate }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
if (exportable.isEmpty() && !canImport) {
|
||||
return BackupUiState.Failure(
|
||||
|
||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.DEFAULT_BACKUP_KEEP
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
@@ -73,12 +75,19 @@ class CalendarsViewModel @Inject constructor(
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
},
|
||||
settingsPrefs.autoBackupSelection,
|
||||
settingsPrefs.autoBackupCombined,
|
||||
settingsPrefs.autoBackupKeep,
|
||||
) { base, selection, combined, keep ->
|
||||
base.copy(selection = selection, combined = combined, keep = keep)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateWhileSubscribed(
|
||||
@@ -115,8 +124,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
fun consumeBackupResult() { _backupResult.value = null }
|
||||
|
||||
/**
|
||||
* Serialise every event of the writable local calendars into the chosen SAF
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* Serialise every event of [calendarIds] (null: the local calendars a backup
|
||||
* covers by default) into the chosen SAF document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||
@@ -204,6 +213,18 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupCalendar(calendar: CalendarSource, included: Boolean) {
|
||||
viewModelScope.launch { settingsPrefs.setAutoBackupCalendar(calendar, included) }
|
||||
}
|
||||
|
||||
fun setAutoBackupCombined(combined: Boolean) {
|
||||
viewModelScope.launch { settingsPrefs.setAutoBackupCombined(combined) }
|
||||
}
|
||||
|
||||
fun setAutoBackupKeep(keep: Int) {
|
||||
viewModelScope.launch { settingsPrefs.setAutoBackupKeep(keep) }
|
||||
}
|
||||
|
||||
private suspend fun reschedule() {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
@@ -232,6 +253,9 @@ data class AutoBackupUiState(
|
||||
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||
val folderUri: String? = null,
|
||||
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||
val selection: BackupSelection = BackupSelection(),
|
||||
val combined: Boolean = false,
|
||||
val keep: Int = DEFAULT_BACKUP_KEEP,
|
||||
)
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -55,28 +56,13 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarPickerGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarPickerGroup(
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
CalendarAccountSections(calendars) { calendar, position ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = position,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
if (onManageCalendars != null) {
|
||||
@@ -98,14 +84,68 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
/**
|
||||
* A multi-select take on [CalendarPickerGroups]: the same sections, with a
|
||||
* checkbox per calendar instead of a single check.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarPickerGroup(
|
||||
fun ColumnScope.CalendarCheckGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
isChecked: (CalendarSource) -> Boolean,
|
||||
onCheckedChange: (CalendarSource, Boolean) -> Unit,
|
||||
) {
|
||||
CalendarAccountSections(calendars) { calendar, position ->
|
||||
val checked = isChecked(calendar)
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendar.description,
|
||||
position = position,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(checked = checked, onCheckedChange = { onCheckedChange(calendar, it) })
|
||||
},
|
||||
onClick = { onCheckedChange(calendar, !checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own calendars, then one section per synced account, each under its category header. */
|
||||
@Composable
|
||||
private fun CalendarAccountSections(
|
||||
calendars: List<CalendarSource>,
|
||||
row: @Composable (CalendarSource, Position) -> Unit,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarAccountSection(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
row = row,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarAccountSection(
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
row = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its calendars. */
|
||||
@Composable
|
||||
private fun CalendarAccountSection(
|
||||
title: String,
|
||||
leading: @Composable () -> Unit,
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
row: @Composable (CalendarSource, Position) -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
@@ -113,13 +153,7 @@ private fun CalendarPickerGroup(
|
||||
leading = leading,
|
||||
)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
row(calendar, if (index == calendars.lastIndex) Position.Bottom else Position.Middle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -880,7 +880,7 @@
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
<!-- Backup (whole-calendar .ics export) -->
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_hint">Export calendars to an .ics file to keep a copy. Local calendars aren\'t synced anywhere, so they need it most.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_export_title">Export calendars</string>
|
||||
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
|
||||
@@ -890,12 +890,28 @@
|
||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
||||
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your calendars to a folder as .ics files.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||
<string name="calendars_auto_backup_interval">Interval</string>
|
||||
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_calendars">Calendars</string>
|
||||
<plurals name="calendars_auto_backup_calendars_count">
|
||||
<item quantity="one">%d calendar</item>
|
||||
<item quantity="other">%d calendars</item>
|
||||
</plurals>
|
||||
<string name="calendars_auto_backup_calendars_none">No calendar selected</string>
|
||||
<string name="calendars_auto_backup_calendars_hint">Local calendars are backed up unless you turn them off. Synced calendars already live on their server, so add them here only if you want a copy in the backup folder too.</string>
|
||||
<string name="calendars_auto_backup_combined">Single file</string>
|
||||
<string name="calendars_auto_backup_combined_on">All calendars in one file</string>
|
||||
<string name="calendars_auto_backup_combined_off">One file per calendar, named after it</string>
|
||||
<string name="calendars_auto_backup_keep">Backups to keep</string>
|
||||
<plurals name="calendars_auto_backup_keep_count">
|
||||
<item quantity="one">Only the latest</item>
|
||||
<item quantity="other">The latest %d</item>
|
||||
</plurals>
|
||||
<string name="calendars_auto_backup_keep_hint">Counted per file: with one file per calendar, each calendar keeps this many. Older backups Calendula wrote are deleted; other files in the folder are left alone.</string>
|
||||
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
|
||||
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
|
||||
|
||||
+35
-4
@@ -946,19 +946,50 @@ class CalendarRepositoryImplTest {
|
||||
|
||||
repo.exportEvents(calendarIds = setOf(1L, 4L))
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
|
||||
assertThat(fake.lastCalendarEventsIds).containsExactly(1L, 4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents defaults to null so every eligible calendar is exported`(
|
||||
fun `exportEvents without ids reads the writable local calendars only`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
fun cal(id: Long, local: Boolean, writable: Boolean = true, managed: Boolean = false) =
|
||||
de.jeanlucmakiola.calendula.domain.CalendarSource(
|
||||
id = id,
|
||||
displayName = "C$id",
|
||||
accountName = "a",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = writable,
|
||||
isLocal = local,
|
||||
isManaged = managed,
|
||||
)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(
|
||||
cal(1L, local = true),
|
||||
cal(2L, local = false),
|
||||
cal(3L, local = true, managed = true),
|
||||
cal(4L, local = true, writable = false),
|
||||
)
|
||||
}
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
|
||||
repo.exportEvents()
|
||||
|
||||
assertThat(fake.lastCalendarEventsIds).containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents reads synced calendars when asked for them`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
|
||||
repo.exportEvents()
|
||||
repo.exportEvents(calendarIds = setOf(2L))
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||
assertThat(fake.lastCalendarEventsIds).containsExactly(2L)
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||
|
||||
+7
-7
@@ -23,9 +23,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
||||
var lastExportableEventsCalendarIds: Set<Long>? = null
|
||||
var calendarEventsResult: List<IcsEvent> = emptyList()
|
||||
/** The [calendarIds] the last [calendarEvents] call received. */
|
||||
var lastCalendarEventsIds: Set<Long>? = null
|
||||
/** UIDs the target calendar already holds, for import dedup. */
|
||||
var existingUidsResult: Set<String> = emptySet()
|
||||
/** Set to make the next write call throw. */
|
||||
@@ -81,12 +81,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
eventDetailResult(eventId)
|
||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
|
||||
publishedEventColorsResult(calendarId)
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
override fun calendarEvents(
|
||||
calendarIds: Set<Long>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
lastExportableEventsCalendarIds = calendarIds
|
||||
return exportableEventsResult
|
||||
lastCalendarEventsIds = calendarIds
|
||||
return calendarEventsResult
|
||||
}
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||
|
||||
@@ -705,4 +705,66 @@ class SettingsPrefsTest {
|
||||
prefs.recordSpecialDatesRun(2000L, null)
|
||||
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||
}
|
||||
@Test
|
||||
fun `a fresh install backs up one file per calendar, keeping three`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
prefs.setAutoBackupFolderUri("content://tree/a")
|
||||
assertThat(prefs.autoBackupCombined.first()).isFalse()
|
||||
assertThat(prefs.autoBackupKeep.first()).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an install that had a backup folder before 411 keeps its single file`(@TempDir tempDir: Path) = runTest {
|
||||
val store = testDataStore(tempDir, "test_settings")
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[stringPreferencesKey("auto_backup_folder_uri")] = "content://tree/old"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.autoBackupCombined.first()).isTrue()
|
||||
|
||||
// Picking another folder pins the layout instead of flipping it.
|
||||
prefs.setAutoBackupFolderUri("content://tree/new")
|
||||
assertThat(prefs.autoBackupCombined.first()).isTrue()
|
||||
|
||||
prefs.setAutoBackupCombined(false)
|
||||
assertThat(prefs.autoBackupCombined.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup calendar choices are stored as deviations from the default`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
val local = backupCalendar(1L, local = true)
|
||||
val synced = backupCalendar(2L, local = false)
|
||||
|
||||
prefs.setAutoBackupCalendar(synced, true)
|
||||
prefs.setAutoBackupCalendar(local, false)
|
||||
val selection = prefs.autoBackupSelection.first()
|
||||
assertThat(selection.included).containsExactly(2L)
|
||||
assertThat(selection.excluded).containsExactly(1L)
|
||||
|
||||
prefs.setAutoBackupCalendar(local, true)
|
||||
assertThat(prefs.autoBackupSelection.first().excluded).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stray retention count snaps to an offered one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
prefs.setAutoBackupKeep(4)
|
||||
assertThat(prefs.autoBackupKeep.first()).isIn(listOf(3, 5))
|
||||
prefs.setAutoBackupKeep(99)
|
||||
assertThat(prefs.autoBackupKeep.first()).isEqualTo(10)
|
||||
}
|
||||
|
||||
private fun backupCalendar(id: Long, local: Boolean) = de.jeanlucmakiola.calendula.domain.CalendarSource(
|
||||
id = id,
|
||||
displayName = "C$id",
|
||||
accountName = "a",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = true,
|
||||
isLocal = local,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package de.jeanlucmakiola.calendula.domain.backup
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class BackupPlanTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
name: String = "Calendar $id",
|
||||
local: Boolean = false,
|
||||
writable: Boolean = true,
|
||||
managed: Boolean = false,
|
||||
syncs: Boolean = true,
|
||||
) = CalendarSource(
|
||||
id = id,
|
||||
displayName = name,
|
||||
accountName = "acc",
|
||||
accountType = if (local) "LOCAL" else "bitfire.at.davdroid",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = writable,
|
||||
isLocal = local,
|
||||
isManaged = managed,
|
||||
syncsEvents = syncs,
|
||||
)
|
||||
|
||||
private val at = LocalDateTime.of(2026, 11, 13, 14, 30, 5)
|
||||
|
||||
@Test
|
||||
fun `local calendars are selected by default, synced ones are opt-in`() {
|
||||
val selection = BackupSelection()
|
||||
assertThat(selection.selects(cal(1L, local = true))).isTrue()
|
||||
assertThat(selection.selects(cal(2L))).isFalse()
|
||||
assertThat(selection.selects(cal(3L, writable = false))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managed and unsynced calendars are never backed up`() {
|
||||
val selection = BackupSelection(included = setOf(1L, 2L))
|
||||
assertThat(selection.selects(cal(1L, local = true, managed = true))).isFalse()
|
||||
assertThat(selection.selects(cal(2L, syncs = false))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling back to the default leaves no trace`() {
|
||||
val synced = cal(2L)
|
||||
val local = cal(1L, local = true)
|
||||
val chosen = BackupSelection().with(synced, true).with(local, false)
|
||||
assertThat(chosen).isEqualTo(BackupSelection(included = setOf(2L), excluded = setOf(1L)))
|
||||
assertThat(chosen.with(synced, false).with(local, true)).isEqualTo(BackupSelection())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file names are sanitised calendar names`() {
|
||||
assertThat(sanitizeBackupName("Work/Home: \"plans\"")).isEqualTo("Work_Home_ _plans_")
|
||||
assertThat(sanitizeBackupName(" ..hidden ")).isEqualTo("hidden")
|
||||
assertThat(sanitizeBackupName(" ")).isEqualTo("calendar")
|
||||
assertThat(sanitizeBackupName("a\tb\nc")).isEqualTo("a_b_c")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one target per calendar, clashing names told apart by id`() {
|
||||
val targets = backupTargets(
|
||||
listOf(cal(1L, "Personal", local = true), cal(2L, "personal"), cal(3L, "Work")),
|
||||
combined = false,
|
||||
)
|
||||
assertThat(targets.map { it.baseName }).containsExactly("Personal-1", "personal-2", "Work").inOrder()
|
||||
assertThat(targets.map { it.calendarName }).containsExactly("Personal", "personal", "Work").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar named like the combined file doesn't share its series`() {
|
||||
val target = backupTargets(listOf(cal(4L, COMBINED_BACKUP_BASE)), combined = false).single()
|
||||
assertThat(target.baseName).isEqualTo("$COMBINED_BACKUP_BASE-4")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combined mode is one target holding every calendar`() {
|
||||
val target = backupTargets(listOf(cal(1L), cal(2L)), combined = true).single()
|
||||
assertThat(target.calendarIds).containsExactly(1L, 2L)
|
||||
assertThat(target.baseName).isEqualTo(COMBINED_BACKUP_BASE)
|
||||
assertThat(target.calendarName).isNull()
|
||||
assertThat(backupTargets(emptyList(), combined = true)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the new file carries the date and time of the run`() {
|
||||
assertThat(backupFileName("Work", at)).isEqualTo("Work_2026-11-13_14-30-05.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pruning keeps the newest files of the series and nothing else`() {
|
||||
val target = BackupTarget(setOf(1L), "Work", "Work")
|
||||
val existing = listOf(
|
||||
"Work_2026-11-10_09-00-00.ics",
|
||||
"Work_2026-11-12_09-00-00.ics",
|
||||
"Work_2026-11-11_09-00-00.ics",
|
||||
"Work notes.ics",
|
||||
"Work_backup.ics",
|
||||
"Workshop_2026-11-01_09-00-00.ics",
|
||||
"Work-7_2026-11-01_09-00-00.ics",
|
||||
)
|
||||
val plan = planBackupFiles(listOf(target), existing, at, keep = 3).single()
|
||||
assertThat(plan.fileName).isEqualTo("Work_2026-11-13_14-30-05.ics")
|
||||
assertThat(plan.prune).containsExactly("Work_2026-11-10_09-00-00.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeping one replaces every earlier file`() {
|
||||
val target = BackupTarget(setOf(1L), "Work", "Work")
|
||||
val existing = listOf("Work_2026-11-11_09-00-00.ics", "Work_2026-11-12_09-00-00.ics")
|
||||
val plan = planBackupFiles(listOf(target), existing, at, keep = 1).single()
|
||||
assertThat(plan.prune).containsExactly("Work_2026-11-11_09-00-00.ics", "Work_2026-11-12_09-00-00.ics").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rerun in the same second never prunes the file it writes`() {
|
||||
val target = BackupTarget(setOf(1L), "Work", "Work")
|
||||
val plan = planBackupFiles(listOf(target), listOf("Work_2026-11-13_14-30-05.ics"), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pre-411 backup file is the combined series' oldest member`() {
|
||||
val target = BackupTarget(setOf(1L, 2L), COMBINED_BACKUP_BASE, null)
|
||||
val existing = listOf(LEGACY_BACKUP_FILE, "calendula-backup_2026-11-12_09-00-00.ics")
|
||||
assertThat(planBackupFiles(listOf(target), existing, at, keep = 3).single().prune).isEmpty()
|
||||
assertThat(planBackupFiles(listOf(target), existing, at, keep = 2).single().prune)
|
||||
.containsExactly(LEGACY_BACKUP_FILE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar series leave the legacy combined file alone`() {
|
||||
val target = BackupTarget(setOf(1L), "Work", "Work")
|
||||
val plan = planBackupFiles(listOf(target), listOf(LEGACY_BACKUP_FILE), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retention snaps to the offered choices`() {
|
||||
assertThat(normalizeBackupKeep(0)).isEqualTo(1)
|
||||
assertThat(normalizeBackupKeep(3)).isEqualTo(3)
|
||||
assertThat(normalizeBackupKeep(50)).isEqualTo(10)
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,10 @@ class BackupUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synced writable calendar can receive a restore`() {
|
||||
fun `a synced writable calendar can receive a restore and be backed up`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L))))
|
||||
assertThat(state.canImport).isTrue()
|
||||
assertThat(state.exportable).isEmpty()
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -62,16 +62,18 @@ class BackupUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only subscription is no import target either`() {
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
|
||||
.isEqualTo(FailureReason.NoImportTarget)
|
||||
fun `a read-only subscription is no import target but can be backed up`() {
|
||||
// #147: export only reads, so a subscription is fair game.
|
||||
val state = ready(backupUiState(listOf(cal(1L, writable = false))))
|
||||
assertThat(state.canImport).isFalse()
|
||||
assertThat(state.exportable).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything switched off is reported as hidden, not as a missing target`() {
|
||||
// The remedy differs: a visibility switch fixes this one.
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, visible = false)))))
|
||||
.isEqualTo(FailureReason.AllCalendarsHidden)
|
||||
fun `a switched-off synced calendar still backs up`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L, visible = false))))
|
||||
assertThat(state.canImport).isFalse()
|
||||
assertThat(state.exportable).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,7 +94,7 @@ class BackupUiStateTest {
|
||||
@Test
|
||||
fun `a local calendar alongside a synced one offers both halves`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L))))
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L, 2L)
|
||||
assertThat(state.canImport).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,19 @@ after the event — so `toIcsEvent` decodes it back through
|
||||
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
|
||||
its own setting.
|
||||
|
||||
**Backups read any calendar; restores still write only into targets.**
|
||||
`CalendarDataSource.calendarEvents` reads the events of whatever calendar ids it
|
||||
is given — local, synced or read-only — and leaves the choice to its callers:
|
||||
export and the automatic backup offer every calendar but the managed mirrors
|
||||
(#147), defaulting to the writable local ones (`domain/backup/BackupPlan.kt`). A
|
||||
restore runs through the normal import, whose targets stay `isEventTarget`. The
|
||||
automatic backup writes one `<calendar name>_<date>_<time>.ics` series per
|
||||
calendar, or one `calendula-backup_…` series in combined mode (#411), and prunes
|
||||
each series to the retention count after its new file has landed. Pruning only
|
||||
matches names of that series, so nothing else in the folder is touched; the
|
||||
pre-#411 fixed `calendula-backup.ics` counts as the combined series' oldest file,
|
||||
and an install that already had a folder stays on the combined layout.
|
||||
|
||||
**A backup carries the series' deleted occurrences.** `EXDATE` lives on the
|
||||
master row, so the export query's `ORIGINAL_ID IS NULL` filter — which does drop
|
||||
`RECURRENCE-ID` overrides — never hid it; it simply wasn't read. It is now part
|
||||
|
||||
Reference in new issue
Block a user