feat(#147, #411): automatic backup of chosen calendars, one dated file each

This commit is contained in:
makiolaj committed 2026-11-13 16:18:53 +01:00
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>
@@ -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)
}
}
+18 -2
View File
@@ -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>
@@ -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") =
@@ -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()
}
}
+13
View File
@@ -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