diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt index 8c504d93..ed435566 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 7f96a33e..45eca514 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -90,15 +90,15 @@ interface CalendarDataSource { fun publishedEventColors(calendarId: Long): List /** - * 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?, allDayReminderTimeMinutes: Int): List + fun calendarEvents(calendarIds: Set, allDayReminderTimeMinutes: Int): List /** * 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?, + override fun calendarEvents( + calendarIds: Set, allDayReminderTimeMinutes: Int, ): List { - // 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() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 3d1308c7..703fe2fb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -61,9 +61,11 @@ interface CalendarRepository { suspend fun setCalendarsVisible(ids: Collection, 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? = null): List diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 481ae7a4..f7d1b89c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -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?) = - 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt index 5ad0967a..213aa2ff 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt @@ -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 = 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) { + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 793c01e9..dcce794d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = prefs.flow(SettingsKeys.autoBackupIntervalMinutes) val autoBackupFolderUri: Flow = prefs.flow(SettingsKeys.autoBackupFolderUri) val autoBackupStatus: Flow = prefs.snapshot { it.autoBackupStatus } + val autoBackupSelection: Flow = prefs.snapshot { it.autoBackupSelection } + val autoBackupCombined: Flow = prefs.snapshot { it.autoBackupCombined } + val autoBackupKeep: Flow = prefs.flow(SettingsKeys.autoBackupKeep) val specialDatesEnabled: Flow = prefs.flow(SettingsKeys.specialDatesEnabled) val specialDatesTypes: Flow> = prefs.snapshot { it.specialDatesTypes } val specialDatesCalendars: Flow> = 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 = booleanPref("auto_backup_last_success", true) val autoBackupFailures: Pref = 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> = idSetPref("auto_backup_included_calendars") + val autoBackupExcluded: Pref> = 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 = nullableBooleanPref("auto_backup_combined") + + /** How many files of each backup series to keep (#411). */ + val autoBackupKeep: Pref = + intPref("auto_backup_keep", DEFAULT_BACKUP_KEEP).coerced(::normalizeBackupKeep) + /** Master switch for the contact special-dates mirror (issue #15). Opt-in. */ val specialDatesEnabled: Pref = 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 get() = SettingsKeys.specialDateTypeEnabled.filterValues { this[it] }.keys @@ -785,6 +850,12 @@ private fun Pref.coerced(clamp: (T) -> T): Pref = map(clamp, clamp) private fun agendaRangePref(name: String): Pref = nullableStringPref(name).map({ parseAgendaRange(it, AgendaRange.Month) }, { it.storageValue() }) +private fun idSetPref(name: String): Pref> = + 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> = nullableStringPref(name).map({ it.toReminderList() }, { it.toStoredReminders() }) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlan.kt new file mode 100644 index 00000000..81d4a23d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlan.kt @@ -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 = emptySet(), + val excluded: Set = 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): Set = + 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, + 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, +) + +/** 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 = 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, combined: Boolean): List { + 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 — `_.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, + existing: Collection, + at: LocalDateTime, + keep: Int, +): List { + 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt index 2d5ee6ba..250e2df5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -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, dtStamp: Instant): String { + /** [calendarName], when given, names the whole file (`X-WR-CALNAME`), as a one-calendar backup does. */ + fun writeCalendar(events: List, 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") } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index d319ff0c..84903e74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -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, + 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? { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt index 3be7c6bc..2b61a618 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiState.kt @@ -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): 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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 861f4525..0d56c3cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -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 = 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? = 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index af5c46df..f4ae7e9b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -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, + 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, + 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, - 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) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 278cc7ca..edc861fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -880,7 +880,7 @@ Couldn\'t save the change. Backup - Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy. + Export calendars to an .ics file to keep a copy. Local calendars aren\'t synced anywhere, so they need it most. Export as .ics file Export calendars Choose which calendars to include in the .ics file. @@ -890,12 +890,28 @@ Import events from a backup or another calendar app. No calendar can take the imported events yet. Automatic backup - Periodically export your local calendars to a folder as an .ics file. + Periodically export your calendars to a folder as .ics files. Backup folder Tap to choose a folder Interval Every %1$s Minimum 30 minutes. + Calendars + + %d calendar + %d calendars + + No calendar selected + 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. + Single file + All calendars in one file + One file per calendar, named after it + Backups to keep + + Only the latest + The latest %d + + 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. No automatic backup yet Last backup: %1$s Last backup failed: %1$s diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index fcf402c6..a78b88fa 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -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") = diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index e22ec492..3921fca1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -23,9 +23,9 @@ internal class FakeCalendarDataSource : CalendarDataSource { var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var publishedEventColorsResult: (Long) -> List = { emptyList() } - var exportableEventsResult: List = emptyList() - /** The [calendarIds] the last [exportableEvents] call received (null = all). */ - var lastExportableEventsCalendarIds: Set? = null + var calendarEventsResult: List = emptyList() + /** The [calendarIds] the last [calendarEvents] call received. */ + var lastCalendarEventsIds: Set? = null /** UIDs the target calendar already holds, for import dedup. */ var existingUidsResult: Set = 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 = publishedEventColorsResult(calendarId) - override fun exportableEvents( - calendarIds: Set?, + override fun calendarEvents( + calendarIds: Set, allDayReminderTimeMinutes: Int, ): List { - lastExportableEventsCalendarIds = calendarIds - return exportableEventsResult + lastCalendarEventsIds = calendarIds + return calendarEventsResult } override fun existingUids(calendarId: Long): Set = existingUidsResult diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index d2fb8e65..10efbe77 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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, + ) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlanTest.kt new file mode 100644 index 00000000..4d37a467 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/backup/BackupPlanTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt index 6206ea34..196125ca 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/calendars/BackupUiStateTest.kt @@ -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() } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 771d9bd7..37852ffb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 `__