Compare commits

...
Author SHA1 Message Date
makiolaj 041f8ec2f7 fix(#147, #325): review round 4 - standalone occurrences imported first in any file order, copy sheet independent of the list state 2026-11-14 01:50:22 +01:00
makiolaj 10208a332e fix(#147, #325): review round 3 - a failed moved occurrence stays at its old time, declined occurrences not copied, unlinked rows kept 2026-11-14 01:41:31 +01:00
makiolaj 0a36a6d287 fix(#147, #325): review round 2 - empty sync-id masters kept, series held when its occurrence fails, declined invites not copied 2026-11-14 01:32:21 +01:00
makiolaj d5079f0618 fix(#147, #325): review round 1 - overrides per calendar, RDATE-only series in files, chosen colours kept, copy row stays while running 2026-11-14 01:23:45 +01:00
makiolaj 3ed56b8596 fix(#147, #325): backups keep moved and deleted occurrences of synced series 2026-11-14 01:15:46 +01:00
makiolaj 3f4bf65b1a fix(#325): review round 3 - sheet failure state, unlinkable exceptions treated as the backup does, one-row colour lookup 2026-11-13 22:28:12 +01:00
makiolaj 026970501e fix(#325): review round 2 - cancelled and orphaned rows left out, re-runs add no duplicate overrides, stopped copies say so 2026-11-13 22:28:12 +01:00
makiolaj 46882d154e fix(#325): review round 1 - visibility and RDATE copied, cancel only stops a copy, sources need a target 2026-11-13 22:28:12 +01:00
makiolaj e5d9b4965b feat(#325): copy events from one calendar into another 2026-11-13 22:28:12 +01:00
makiolaj c6901c9c63 fix(#147, #411): review round 8 - hand-offs end once claimed, back up only after a real settings change 2026-11-13 22:27:10 +01:00
makiolaj 8754f046f2 fix(#147, #411): review round 7 - stable copy ownership on provider renames and hand-offs, ledger writes tied to their folder, back up after settings change 2026-11-13 22:20:58 +01:00
makiolaj bb075d052e fix(#147, #411): review round 6 - ledger reset per folder, atomic ledger updates, stable copies replaced only once the new one landed 2026-11-13 22:14:34 +01:00
makiolaj 5c05563785 fix(#147, #411): review round 5 - stable copy ownership per series, best-effort writes, layout-switch cleanup 2026-11-13 22:06:46 +01:00
makiolaj 88c64d5786 feat(#147, #411): stable undated latest copy per backup series 2026-11-13 21:56:54 +01:00
makiolaj d86a09b744 fix(#147, #411): review round 4 - settings read under the run lock, ledger saved before pruning, SAF helpers moved to floret-kit 2026-11-13 16:54:48 +01:00
makiolaj f314761e45 fix(#147, #411): review round 3 - serialised runs, per-series ledger saves, folder change resets ledger 2026-11-13 16:46:38 +01:00
makiolaj 46818cd0ae fix(#147, #411): review round 2 - prune from a ledger of written files, one read per run 2026-11-13 16:38:06 +01:00
makiolaj 240ed5237a fix(#147, #411): review round 1 - unique series names, rename-aware pruning, one folder listing, batched reminders 2026-11-13 16:28:42 +01:00
makiolaj deb2fd4482 feat(#147, #411): automatic backup of chosen calendars, one dated file each 2026-11-13 16:18:53 +01:00
35 changed files with 2694 additions and 275 deletions

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,29 @@ 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.backup.latestHeldElsewhere
import de.jeanlucmakiola.calendula.domain.backup.latestLedgerKey
import de.jeanlucmakiola.calendula.domain.backup.replacedLatestCopies
import de.jeanlucmakiola.calendula.domain.backup.withLedgerEntry
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
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 {
@@ -47,8 +60,8 @@ object BackupScheduler {
* periodic request when backup is on and a folder is set, otherwise cancel
* it. WorkManager persists the request across reboots on its own.
*
* The first run is delayed by one interval so it never overlaps an immediate
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
* The first run is delayed by one interval so it doesn't follow an immediate
* [runNow] with a near-identical copy.
*/
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
val workManager = WorkManager.getInstance(context)
@@ -86,8 +99,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.
@@ -108,25 +122,124 @@ class BackupWorker(
override suspend fun doWork(): Result {
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
val prefs = deps.settingsPrefs()
// Respect the toggle even for already-queued work: if backup was turned
// off, no-op (and don't retry) so a lingering run can't revive itself.
if (!prefs.autoBackupEnabled.first()) return Result.success()
val folder = prefs.autoBackupFolderUri.first()
?: 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)
prefs.recordAutoBackupRun(success = true, atMillis = now)
Result.success()
} catch (e: Exception) {
prefs.recordAutoBackupRun(success = false, atMillis = now)
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
notifyFailure(applicationContext)
// The periodic and the immediate run are separate unique works; one at
// a time, or each would overwrite the other's ledger.
val outcome = runLock.withLock {
// Respect the toggle even for already-queued work, so a lingering
// run can't revive itself.
if (!prefs.autoBackupEnabled.first()) return Result.success()
val folder = prefs.autoBackupFolderUri.first() ?: return Result.failure()
try {
runBackup(deps, prefs, folder.toUri())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Automatic backup failed", e)
RunOutcome.Failed
}
Log.w(TAG, "Automatic backup failed", e)
Result.retry()
}
if (outcome == RunOutcome.NothingSelected) return Result.success()
val success = outcome == RunOutcome.Written
prefs.recordAutoBackupRun(success = success, atMillis = now)
if (!success && prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
notifyFailure(applicationContext)
}
// Only a run that wrote nothing retries: a retry rewrites every series.
return if (outcome == RunOutcome.Failed) Result.retry() else Result.success()
}
private enum class RunOutcome { Written, PartlyFailed, Failed, NothingSelected }
/** Writes each series and prunes it only once its new file has landed. */
private suspend fun runBackup(deps: Deps, prefs: SettingsPrefs, folder: Uri): RunOutcome {
val repository = deps.repository()
val exporter = deps.exporter()
if (!applicationContext.hasPermission(Manifest.permission.READ_CALENDAR)) return RunOutcome.Failed
val all = repository.calendars().first()
val selection = prefs.autoBackupSelection.first()
val targets = backupTargets(all.filter(selection::selects), combined = prefs.autoBackupCombined.first())
if (targets.isEmpty()) return RunOutcome.NothingSelected
prefs.adoptLegacyBackupFile()
val listing = exporter.listFolder(folder)
val ledger = prefs.autoBackupLedger.first()
// A listing still loading can't tell a gone file from a late one.
val existing = if (listing.complete) listing.files.keys else listing.files.keys + ledger.values.flatten()
val plans = planBackupFiles(
targets = targets,
ledger = ledger,
existing = existing,
at = LocalDateTime.now(),
keep = prefs.autoBackupKeep.first(),
)
val stamp = Clock.System.now()
val liveKeys = all.map { it.id.toString() }.toSet()
suspend fun record(key: String, files: List<String>) =
prefs.updateAutoBackupLedger(folder.toString()) { withLedgerEntry(it, key, files, liveKeys) }
var written = 0
val landedLatest = mutableSetOf<String>()
for (plan in plans) {
val target = plan.target
val bytes = try {
IcsWriter()
.writeCalendar(repository.exportEvents(target.calendarIds), stamp, target.calendarName)
.toByteArray(Charsets.UTF_8)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Reading one series failed", e)
continue
}
// Not cancellable once a file exists: it must reach the ledger, or
// nothing would ever prune it.
withContext(NonCancellable) {
try {
val name = exporter.writeToFolder(folder, listing, plan.fileName, bytes)
record(target.key, plan.prune + plan.keep + name)
val undeleted = exporter.deleteFromFolder(listing, plan.prune)
record(target.key, plan.prune.filter { it in undeleted } + plan.keep + name)
written++
} catch (e: Exception) {
Log.w(TAG, "Backup of one series failed", e)
}
// Best-effort: the stable copy never holds up the dated backup,
// its pruning or the run's status. An old copy goes only once the
// new one has landed, so a series is never left without one.
try {
val latest = plan.latestName ?: return@withContext
val latestKey = latestLedgerKey(target.key)
val landed = exporter.writeToFolder(folder, listing, latest, bytes)
// A provider that renamed it leaves a copy to clean up next
// run; the intended name stays ours only if it already was.
val ownsLatest = landed == latest || latest in ledger[latestKey].orEmpty()
val current = listOfNotNull(landed, latest.takeIf { ownsLatest })
// A hand-off stays on record only until its new owner claimed it.
suspend fun recordLatest(stale: List<String>) = prefs.updateAutoBackupLedger(folder.toString()) {
val pending = plan.handOff - it.latestHeldElsewhere(target.key)
withLedgerEntry(it, latestKey, stale + pending + current, liveKeys)
}
recordLatest(plan.staleLatest)
if (landed == latest) landedLatest += target.key
val staleLeft = exporter.deleteFromFolder(listing, plan.staleLatest)
recordLatest(plan.staleLatest.filter { it in staleLeft })
} catch (e: Exception) {
Log.w(TAG, "Stable copy of one series failed", e)
}
}
}
// A layout switch retires the other layout's copies once every new one is there.
if (landedLatest.size == plans.size) {
replacedLatestCopies(targets, prefs.autoBackupLedger.first(), existing).forEach { (key, names) ->
withContext(NonCancellable) {
val left = exporter.deleteFromFolder(listing, names)
record(latestLedgerKey(key), names.filter { it in left })
}
}
}
return when (written) {
plans.size -> RunOutcome.Written
0 -> RunOutcome.Failed
else -> RunOutcome.PartlyFailed
}
}
@@ -165,10 +278,10 @@ 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
private const val TAG = "BackupWorker"
private val runLock = Mutex()
}
}
@@ -30,6 +30,9 @@ import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.CalendarRows
import de.jeanlucmakiola.calendula.domain.ics.OverrideRow
import de.jeanlucmakiola.calendula.domain.ics.MasterRow
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
@@ -90,15 +93,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 event row of [calendarIds], whatever kind of calendar they are
* (local, synced, read-only): the masters/one-offs and the modified and
* cancelled exception rows, each with the keys that link it to its series
* and tagged with its calendar's name. A plain read: which calendars may be
* read for what is the caller's call. [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 calendarRows(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): CalendarRows
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -776,47 +779,92 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(
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.
val names = calendars()
.filter {
it.isLocal && it.canModifyContents && !it.isManaged &&
(calendarIds == null || it.id in calendarIds)
}
.associate { it.id to it.displayName }
if (names.isEmpty()) return emptyList()
override fun calendarRows(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): CalendarRows {
if (calendarIds.isEmpty()) return CalendarRows(emptyList(), emptyList())
// The name tags each event for a combined backup's restore to fan back
// out; an id that no longer exists drops out here.
val calendars = calendars().filter { it.id in calendarIds }.associateBy { it.id }
if (calendars.isEmpty()) return CalendarRows(emptyList(), emptyList())
val idList = names.keys.joinToString(",")
return resolver.query(
val selection = "${CalendarContract.Events.CALENDAR_ID} IN (${calendars.keys.joinToString(",")}) AND " +
"${CalendarContract.Events.DELETED} = 0"
val series = mutableListOf<MasterRow>()
val overrides = mutableListOf<OverrideRow>()
forEachExportRow(selection, allDayReminderTimeMinutes) { event ->
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
val calendar = calendars[calendarId]
// An adapter may stamp the calendar colour on every row; a palette key means it was chosen.
val color = getIntOrNull(EventExportProjection.IDX_EVENT_COLOR)?.takeIf {
it != calendar?.color || !getString(EventExportProjection.IDX_EVENT_COLOR_KEY).isNullOrEmpty()
}
val mapped = event(calendar?.displayName).copy(color = color)
val declined = getIntOrNull(EventExportProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED
val originalId = getLongOrNull(EventExportProjection.IDX_ORIGINAL_ID)
val originalSyncId = getString(EventExportProjection.IDX_ORIGINAL_SYNC_ID)?.takeIf { it.isNotEmpty() }
// Without a link the provider shows the row as an event of its own.
if (originalId == null && originalSyncId == null) {
series += MasterRow(
eventId = getLong(EventExportProjection.IDX_ID),
calendarId = calendarId,
syncId = getString(EventExportProjection.IDX_SYNC_ID)?.takeIf { it.isNotEmpty() },
event = mapped,
declined = declined,
)
} else {
// An exception without its occurrence time can't name what it replaces.
val originalInstance = getLongOrNull(EventExportProjection.IDX_ORIGINAL_INSTANCE_TIME)
?: return@forEachExportRow
overrides += OverrideRow(
calendarId = calendarId,
originalId = originalId,
originalSyncId = originalSyncId,
originalInstance = originalInstance.toKotlinInstantFromEpochMillis(),
event = mapped,
declined = declined,
)
}
}
return CalendarRows(series, overrides)
}
/**
* Hand each Events row matching [selection], oldest first, to [row] with a
* mapper to [IcsEvent] that takes the calendar name to tag it with.
* Reminders are read in one batched pass ahead of the rows.
*/
private fun forEachExportRow(
selection: String,
allDayReminderTimeMinutes: Int,
row: ColumnReader.(event: (calendarName: String?) -> IcsEvent) -> Unit,
) {
val eventIds = resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID),
selection,
null,
null,
)?.use { c -> c.mapRows { getLong(0) } }.orEmpty()
val reminders = resolver.reminderMinutesByEvent(eventIds, alertsOnly = false)
resolver.query(
CalendarContract.Events.CONTENT_URI,
EventExportProjection.COLUMNS,
// Skip soft-deleted rows and exception rows (modified occurrences /
// cancellations) — v1 exports masters + one-offs only.
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
selection,
null,
CalendarContract.Events.DTSTART + " ASC",
)?.use { c ->
c.mapRows {
val eventId = getLong(EventExportProjection.IDX_ID)
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
val reader = CursorColumnReader(c)
while (c.moveToNext()) {
val eventId = reader.getLong(EventExportProjection.IDX_ID)
reader.row { calendarName ->
reader.toIcsEvent(
reminderMinutes = reminders[eventId].orEmpty(),
calendarName = calendarName,
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
}
}
} ?: emptyList()
}
}
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
@@ -61,18 +61,41 @@ 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.calendarRows]). Any calendar can be read,
* synced and read-only ones included (#147). Exception rows come out
* flattened ([de.jeanlucmakiola.calendula.domain.ics.flattenOverrides]),
* so a restore keeps moved and deleted occurrences of a series.
*/
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
suspend fun exportEvents(calendarIds: Set<Long>): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
* UID already exists in the target are skipped (idempotent restore); the
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
* rest are inserted, as is a standalone occurrence whose series is already
* there ([ParsedIcsEvent.overrideOf]). See [CalendarDataSource.insertImportedEvent].
* [onProgress] gets the count of events handled so far before each event
* after the first and once more when all were handled; it may throw to stop
* the import between events.
*/
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
onProgress: (done: Int) -> Unit = {},
): IcsImportSummary
/**
* Copy every event of [sourceCalendarId] into [targetCalendarId] (#325),
* exceptions included (see
* [de.jeanlucmakiola.calendula.domain.ics.planCalendarCopy]). The source is only read. Runs
* through [importEvents], so a re-run skips what an earlier one copied.
* [onProgress] gets (done, total) once the source is read and after each event.
*/
suspend fun copyEvents(
sourceCalendarId: Long,
targetCalendarId: Long,
onProgress: (done: Int, total: Int) -> Unit = { _, _ -> },
): IcsImportSummary
/** Create a new event from a validated form; returns the new `Events._ID`. */
suspend fun createEvent(form: EventForm): Long
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.flattenOverrides
import de.jeanlucmakiola.calendula.domain.ics.planCalendarCopy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
@@ -30,6 +32,8 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Instant
@@ -247,12 +251,38 @@ class CalendarRepositoryImpl @Inject constructor(
}
}
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
override suspend fun exportEvents(calendarIds: Set<Long>) = withContext(io) {
// A file can't carry RDATE: such a series is written as the one-off it reads as.
val rows = dataSource.calendarRows(calendarIds, allDayReminderTimeMinutes())
flattenOverrides(
rows.copy(series = rows.series.map { it.copy(event = it.event.copy(recurrenceDates = null)) }),
::formatExdateStamp,
)
}
override suspend fun copyEvents(
sourceCalendarId: Long,
targetCalendarId: Long,
onProgress: (done: Int, total: Int) -> Unit,
): IcsImportSummary {
val events = withContext(io) {
planCalendarCopy(
dataSource.calendarRows(setOf(sourceCalendarId), allDayReminderTimeMinutes()),
::formatExdateStamp,
)
}
onProgress(0, events.size)
val context = currentCoroutineContext()
return importEvents(targetCalendarId, events) { done ->
if (done < events.size) context.ensureActive()
onProgress(done, events.size)
}
}
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
onProgress: (done: Int) -> Unit,
): IcsImportSummary = write {
val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a
@@ -265,15 +295,21 @@ class CalendarRepositoryImpl @Inject constructor(
var skipped = 0
var failed = 0
var notAttempted = 0
for ((index, event) in events.withIndex()) {
// Occurrences whose standalone copy didn't land, by series UID: the series
// keeps them instead of excluding them for good.
val unexcluded = mutableMapOf<String, MutableList<Instant>>()
// Standalone occurrences ahead of the series they were cut from, however the file is ordered.
val ordered = events.sortedBy { it.overrideOf == null }
for ((index, event) in ordered.withIndex()) {
if (index > 0) onProgress(index)
// A known UID means the event is already in this calendar — skip,
// keeping a restore idempotent (no overwrite this pass).
if (event.uid != null && event.uid in existing) {
if (event.isAlreadyIn(existing)) {
skipped++
continue
}
try {
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
dataSource.insertImportedEvent(event.keeping(unexcluded), targetCalendarId, allDayMinutes, palette)
imported++
} catch (e: CancellationException) {
throw e
@@ -283,8 +319,8 @@ class CalendarRepositoryImpl @Inject constructor(
Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
// A known UID would have been skipped whatever happened, so it is
// no part of what a retry still has to import.
val remaining = events.drop(index)
val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
val remaining = ordered.drop(index)
val duplicates = remaining.count { it.isAlreadyIn(existing) }
skipped += duplicates
notAttempted = remaining.size - duplicates
break
@@ -293,9 +329,13 @@ class CalendarRepositoryImpl @Inject constructor(
// rest of the file — foreign exports do carry rows it rejects
// outright (a malformed RRULE throws straight out of insert).
failed++
if (event.overrideOf != null && event.overrideAt != null) {
unexcluded.getOrPut(event.overrideOf) { mutableListOf() } += event.overrideAt
}
Log.w(TAG, "Skipped an unimportable event", e)
}
}
if (notAttempted == 0) onProgress(events.size)
IcsImportSummary(
imported = imported,
skippedDuplicate = skipped,
@@ -376,3 +416,18 @@ class CalendarRepositoryImpl @Inject constructor(
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
collect { emit(block()) }
}
/**
* Whether [existing] UIDs already hold this event: its own UID, or — for a
* standalone occurrence of a backed-up or copied series — the series, which
* there carries the override itself.
*/
private fun ParsedIcsEvent.isAlreadyIn(existing: Set<String>): Boolean =
(uid != null && uid in existing) || (overrideOf != null && overrideOf in existing)
/** This series without the exclusions of [unexcluded] occurrences whose standalone copy failed. */
private fun ParsedIcsEvent.keeping(unexcluded: Map<String, List<Instant>>): ParsedIcsEvent {
val kept = uid?.let(unexcluded::get) ?: return this
val stamps = kept.map { formatExdateStamp(it.toEpochMilliseconds(), isAllDay) }.toSet()
return copy(exDates = exDates.filterNot { it in stamps })
}
@@ -684,7 +684,7 @@ private const val TZID_PREFIX = "TZID="
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
* reads off the UTC calendar day.
*/
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
internal fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
formatExdateStamp(
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
isAllDay = isAllDay,
@@ -851,10 +851,11 @@ internal fun buildImportedEventValues(
put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
if (event.recurrenceRule == null && event.recurrenceDates == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
event.recurrenceRule?.let { put(CalendarContract.Events.RRULE, it) }
event.recurrenceDates?.let { put(CalendarContract.Events.RDATE, it) }
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
@@ -866,6 +867,9 @@ internal fun buildImportedEventValues(
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
if (event.accessLevel != AccessLevel.Default) {
put(CalendarContract.Events.ACCESS_LEVEL, event.accessLevel.toProviderValue())
}
put(CalendarContract.Events.STATUS, event.status.forNewEvent().toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import java.time.ZoneId
@@ -27,6 +28,7 @@ internal fun ColumnReader.toIcsEvent(
val eventId = getLong(EventExportProjection.IDX_ID)
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val rdate = getString(EventExportProjection.IDX_RDATE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Unclamped: the writer renders DTEND as stored.
@@ -41,7 +43,7 @@ internal fun ColumnReader.toIcsEvent(
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
recurrenceRule = rrule,
exDates = if (rrule == null) {
exDates = if (rrule == null && rdate == null) {
emptyList()
} else {
exportExDates(
@@ -63,6 +65,8 @@ internal fun ColumnReader.toIcsEvent(
status = eventStatus(EventExportProjection.IDX_STATUS),
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
recurrenceDates = rdate,
accessLevel = getIntOrNull(EventExportProjection.IDX_ACCESS_LEVEL)?.let(::mapAccessLevel) ?: AccessLevel.Default,
)
}
@@ -152,14 +152,13 @@ internal object EventDetailProjection {
}
/**
* Master/one-off Events rows for a whole-calendar backup. Unlike
* Every Events row of a whole-calendar backup or copy. Unlike
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
* backups) and `DURATION` (recurring rows carry it instead of DTEND), plus
* `EXDATE` so a series exports the occurrences deleted from it. Modified-
* occurrence and cancelled-exception rows are filtered out by the query
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
* documented v1 limit (import skips them too). EXDATE is unaffected by that
* filter: it lives on the master row, not on an exception of its own.
* `EXDATE` so a series exports the occurrences deleted from it. Exception rows
* are read too and told apart by their series-link columns; see
* [de.jeanlucmakiola.calendula.domain.ics.flattenOverrides] for how they are
* written out.
*/
internal object EventExportProjection {
val COLUMNS: Array<String> = arrayOf(
@@ -178,6 +177,15 @@ internal object EventExportProjection {
CalendarContract.Events.STATUS,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events._SYNC_ID,
CalendarContract.Events.ORIGINAL_ID,
CalendarContract.Events.ORIGINAL_SYNC_ID,
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
CalendarContract.Events.RDATE,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_COLOR_KEY,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
)
const val IDX_ID = 0
@@ -195,6 +203,15 @@ internal object EventExportProjection {
const val IDX_STATUS = 12
const val IDX_AVAILABILITY = 13
const val IDX_CALENDAR_ID = 14
const val IDX_EVENT_COLOR = 15
const val IDX_SYNC_ID = 16
const val IDX_ORIGINAL_ID = 17
const val IDX_ORIGINAL_SYNC_ID = 18
const val IDX_ORIGINAL_INSTANCE_TIME = 19
const val IDX_RDATE = 20
const val IDX_ACCESS_LEVEL = 21
const val IDX_EVENT_COLOR_KEY = 22
const val IDX_SELF_ATTENDEE_STATUS = 23
}
/**
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentResolver
import android.provider.CalendarContract
/** Ids per `IN (…)` list: they go into the SQL literally, which SQLite bounds. */
private const val EVENT_ID_BATCH = 50
/**
* Reminder minutes of [eventIds], keyed by event, a batched query at a time
* rather than one per event. [alertsOnly] keeps just the notification ones.
*/
internal fun ContentResolver.reminderMinutesByEvent(
eventIds: Collection<Long>,
alertsOnly: Boolean,
): Map<Long, List<Int>> {
if (eventIds.isEmpty()) return emptyMap()
val out = mutableMapOf<Long, MutableList<Int>>()
val method = if (alertsOnly) {
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT} AND "
} else {
""
}
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
query(
CalendarContract.Reminders.CONTENT_URI,
arrayOf(CalendarContract.Reminders.EVENT_ID, CalendarContract.Reminders.MINUTES),
"$method${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
null,
null,
)?.use { c ->
while (c.moveToNext()) out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
}
}
return out
}
@@ -5,6 +5,10 @@ import android.net.Uri
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.TreeListing
import de.jeanlucmakiola.floret.android.deleteTreeFiles
import de.jeanlucmakiola.floret.android.listTreeFiles
import de.jeanlucmakiola.floret.android.writeTreeFile
import java.io.File
import java.io.IOException
import javax.inject.Inject
@@ -29,34 +33,27 @@ class IcsExporter @Inject constructor(
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
}
/**
* Write [content] to [fileName] inside the persisted SAF tree [folder],
* overwriting it if it already exists (the automatic-backup destination).
* Requires a persisted write grant on [folder]. Throws on failure.
*/
fun writeToFolder(folder: Uri, fileName: String, content: String) {
/** The files in the persisted SAF tree [folder], the automatic-backup destination. Throws when unusable. */
fun listFolder(folder: Uri): TreeListing {
writableFolder(folder)
return context.contentResolver.listTreeFiles(folder)
}
/** Write the `.ics` [bytes] as [fileName] into [folder]; returns the name the file ended up with. */
fun writeToFolder(folder: Uri, listing: TreeListing, fileName: String, bytes: ByteArray): String =
context.contentResolver.writeTreeFile(folder, listing, fileName, MIME_CALENDAR, bytes)
/** Delete [names] from the listed folder; returns the ones that didn't go. */
fun deleteFromFolder(listing: TreeListing, names: Collection<String>): Set<String> =
context.contentResolver.deleteTreeFiles(listing, names)
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")
}
// 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 base = fileName.substringBeforeLast('.')
val ext = fileName.substringAfterLast('.', "")
var target: DocumentFile? = null
for (child in dir.listFiles()) {
val name = child.name ?: continue
when {
name == fileName -> target = child
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
}
}
val file = target
?: dir.createFile(MIME_CALENDAR, fileName)
?: throw IOException("Could not create backup file in the chosen folder")
writeDocument(file.uri, content)
return dir
}
/**
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
@@ -1,9 +1,20 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
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.COMBINED_SERIES
import de.jeanlucmakiola.calendula.domain.backup.DEFAULT_BACKUP_KEEP
import de.jeanlucmakiola.calendula.domain.backup.LEGACY_BACKUP_FILE
import de.jeanlucmakiola.calendula.domain.backup.latestLedgerKey
import de.jeanlucmakiola.calendula.domain.backup.normalizeBackupKeep
import de.jeanlucmakiola.calendula.domain.backup.BackupLedger
import de.jeanlucmakiola.calendula.domain.backup.parseBackupLedger
import de.jeanlucmakiola.calendula.domain.backup.persistBackupLedger
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
@@ -29,6 +40,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 +192,10 @@ 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 autoBackupLedger: Flow<BackupLedger> = prefs.flow(SettingsKeys.autoBackupLedger)
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 +345,56 @@ 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 {
it.pinBackupLayout()
// The ledger says which files in the old folder are ours; in a new
// one a file of the same name may be the user's.
if (it[SettingsKeys.autoBackupFolderUri] != uri) it[SettingsKeys.autoBackupLedger] = emptyMap()
it[SettingsKeys.autoBackupFolderUri] = uri
}
}
suspend fun setAutoBackupCombined(combined: Boolean) {
prefs.edit {
it.pinBackupLayout()
it[SettingsKeys.autoBackupCombinedStored] = combined
}
}
suspend fun setAutoBackupKeep(keep: Int) = prefs.set(SettingsKeys.autoBackupKeep, keep)
/**
* Atomic, so a run's ledger writes never undo one made meanwhile (pinning
* the layout). Dropped when the folder is no longer [folder]: a run still
* finishing must not carry the old folder's names into the new one's ledger.
*/
suspend fun updateAutoBackupLedger(folder: String, transform: (BackupLedger) -> BackupLedger) {
prefs.edit {
if (it[SettingsKeys.autoBackupFolderUri] == folder) {
it[SettingsKeys.autoBackupLedger] = transform(it[SettingsKeys.autoBackupLedger])
}
}
}
/** Record a pre-#411 install's old file as its combined series' stable copy (see [pinBackupLayout]). */
suspend fun adoptLegacyBackupFile() {
prefs.edit { if (it.autoBackupLegacyInstall) it.adoptLegacyBackupFile() }
}
/** 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 +765,27 @@ 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)
/** The files the automatic backup wrote, per series; the only ones it ever prunes (#411). */
val autoBackupLedger: Pref<BackupLedger> =
nullableStringPref("auto_backup_files").map(::parseBackupLedger, ::persistBackupLedger)
/** Master switch for the contact special-dates mirror (issue #15). Opt-in. */
val specialDatesEnabled: Pref<Boolean> = booleanPref("special_dates_enabled", false)
@@ -749,6 +835,42 @@ 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] ?: autoBackupLegacyInstall
/**
* Store the layout the install is on, if it's still derived. Pinning forgets
* it was a pre-#411 install, so its old file is recorded as the combined
* series' own stable copy at the same time.
*/
private fun MutablePreferences.pinBackupLayout() {
if (this[SettingsKeys.autoBackupCombinedStored] != null) return
val legacy = autoBackupLegacyInstall
if (legacy) adoptLegacyBackupFile()
this[SettingsKeys.autoBackupCombinedStored] = legacy
}
private fun MutablePreferences.adoptLegacyBackupFile() {
val key = latestLedgerKey(COMBINED_SERIES)
val ledger = this[SettingsKeys.autoBackupLedger]
this[SettingsKeys.autoBackupLedger] = ledger + (key to (ledger[key].orEmpty() + LEGACY_BACKUP_FILE).distinct())
}
/** An install that set up automatic backup before #411 and hasn't been pinned since. */
val Preferences.autoBackupLegacyInstall: Boolean
get() = this[SettingsKeys.autoBackupCombinedStored] == null && this[SettingsKeys.autoBackupFolderUri] != null
val Preferences.specialDatesTypes: Set<SpecialDateType>
get() = SettingsKeys.specialDateTypeEnabled.filterValues { this[it] }.keys
@@ -4,6 +4,7 @@ import android.content.Context
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
import de.jeanlucmakiola.calendula.data.calendar.reminderMinutesByEvent
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject
import javax.inject.Singleton
@@ -72,28 +73,8 @@ class ProviderReminderInstanceSource @Inject constructor(
} ?: emptyList()
}
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
if (eventIds.isEmpty()) return emptyMap()
val out = mutableMapOf<Long, MutableList<Int>>()
// Batched: the ids go into the selection literally, and an unbounded
// `IN (...)` would grow the SQL past what SQLite takes.
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
REMINDER_PROJECTION,
"${CalendarContract.Reminders.METHOD} = " +
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
null,
null,
)?.use { c ->
while (c.moveToNext()) {
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
}
}
}
return out
}
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> =
context.contentResolver.reminderMinutesByEvent(eventIds, alertsOnly = true)
override fun longestReminderMinutes(): Int = context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
@@ -105,8 +86,6 @@ class ProviderReminderInstanceSource @Inject constructor(
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
private companion object {
const val EVENT_ID_BATCH = 50
val OCCURRENCE_PROJECTION = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
@@ -116,10 +95,5 @@ class ProviderReminderInstanceSource @Inject constructor(
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.ALL_DAY,
)
val REMINDER_PROJECTION = arrayOf(
CalendarContract.Reminders.EVENT_ID,
CalendarContract.Reminders.MINUTES,
)
}
}
@@ -0,0 +1,279 @@
package de.jeanlucmakiola.calendula.domain.backup
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.floret.android.safeFileName
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,
)
}
}
/** One backup file series: the calendars it holds and the name its files start with. */
data class BackupTarget(
/** Stable identity of the series in the [BackupLedger]: the calendar id, or [COMBINED_SERIES]. */
val key: String,
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 that stay, oldest first. */
val keep: List<String>,
/** Earlier files of this series beyond the retention count, oldest first. */
val prune: List<String>,
/**
* The stable, undated copy overwritten every run (`<base>.ics`), for tools
* that follow one path. Null when a file of that name is in the folder but
* isn't one this backup wrote: the user's own file is never overwritten.
*/
val latestName: String?,
/** This series' earlier stable copies under a name it no longer uses (a rename), to delete. */
val staleLatest: List<String> = emptyList(),
/**
* Stable copies this series held that another series of the run now takes
* over: kept on record until that series has claimed them.
*/
val handOff: List<String> = emptyList(),
)
/**
* The files the automatic backup has written, per series key, oldest first.
* Pruning only ever deletes names recorded here, so a series is its files, not
* whatever currently matches a calendar's name: renames, same-named calendars
* and the user's own files in the folder can't pull in the wrong ones. A
* series' stable copy is recorded apart, under [latestLedgerKey].
*/
typealias BackupLedger = Map<String, List<String>>
const val COMBINED_SERIES = "combined"
/** 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: the combined
* series' stable copy now, so an old setup keeps its path.
*/
val LEGACY_BACKUP_FILE: String = latestFileName(COMBINED_BACKUP_BASE)
private const val LATEST_PREFIX = "latest-"
/** Where the ledger records the stable copy of the series [key]. */
fun latestLedgerKey(key: String): String = "$LATEST_PREFIX$key"
/** 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
/** Room for the id suffix and the stamp within a file system's 255-byte name limit. */
private const val MAX_BASE_BYTES = 120
private const val ICS_EXTENSION = ".ics"
private const val STAMP_PATTERN = "yyyy-MM-dd_HH-mm-ss"
private val STAMP_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern(STAMP_PATTERN)
/**
* The file series the automatic backup writes: one combined file, or one per
* calendar named after it. Calendars whose names clash case-insensitively (a
* local and a synced "Personal") take their id as a suffix until every name in
* the run is distinct, so no two series write the same file.
*/
fun backupTargets(calendars: List<CalendarSource>, combined: Boolean): List<BackupTarget> {
if (calendars.isEmpty()) return emptyList()
if (combined) {
return listOf(
BackupTarget(COMBINED_SERIES, calendars.map { it.id }.toSet(), COMBINED_BACKUP_BASE, calendarName = null),
)
}
val bases = calendars.associateTo(LinkedHashMap()) {
it.id to safeFileName(it.displayName, MAX_BASE_BYTES, fallback = "calendar")
}
while (true) {
val counts = bases.values.groupingBy { it.lowercase() }.eachCount()
val clashing = bases.filterValues {
counts.getValue(it.lowercase()) > 1 || it.equals(COMBINED_BACKUP_BASE, ignoreCase = true)
}
if (clashing.isEmpty()) break
clashing.forEach { (id, base) -> bases[id] = "$base-$id" }
}
return calendars.map { calendar ->
BackupTarget(
key = calendar.id.toString(),
calendarIds = setOf(calendar.id),
baseName = bases.getValue(calendar.id),
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 dated file per target, and which of the series' recorded
* files ([ledger]) fall out of the newest [keep], the new one included. Recorded
* files no longer in the folder ([existing]) are forgotten. Each target also
* gets its stable copy, which retention never touches. A stable name is only
* written when the file is absent or this series owns it — or a series of this
* run that is moving off it (a rename hand-off).
*/
fun planBackupFiles(
targets: List<BackupTarget>,
ledger: BackupLedger,
existing: Set<String>,
at: LocalDateTime,
keep: Int,
): List<BackupFilePlan> {
val retain = keep.coerceAtLeast(1)
val runKeys = targets.map { it.key }.toSet()
val owners = ledger.filterKeys { it.startsWith(LATEST_PREFIX) }
.flatMap { (key, names) -> names.map { it to key.removePrefix(LATEST_PREFIX) } }
.toMap()
val currentLatest = targets.map { latestFileName(it.baseName) }.toSet()
return targets.map { target ->
val fileName = backupFileName(target.baseName, at)
val latest = latestFileName(target.baseName)
val earlier = ledger[target.key].orEmpty()
.filter { it in existing && it != fileName && it != latest }
.distinct()
val kept = earlier.takeLast(retain - 1)
val owner = owners[latest]
val writable = latest !in existing || (owner != null && owner in runKeys)
BackupFilePlan(
target = target,
fileName = fileName,
keep = kept,
prune = earlier.dropLast(kept.size),
latestName = latest.takeIf { writable },
staleLatest = ledger[latestLedgerKey(target.key)].orEmpty()
.filter { it in existing && it != latest && it !in currentLatest },
handOff = ledger[latestLedgerKey(target.key)].orEmpty()
.filter { it in existing && it != latest && it in currentLatest },
)
}
}
/**
* Stable copies the current layout replaced, per ledger key: a calendar's own
* copy once the combined file holds it, or the combined copy once each calendar
* has its own. Left in place they would keep serving old data under a path
* something may follow. A calendar taken out of the backup keeps its copy.
*/
fun replacedLatestCopies(
targets: List<BackupTarget>,
ledger: BackupLedger,
existing: Set<String>,
): Map<String, List<String>> {
val runKeys = targets.map { it.key }.toSet()
val covered = targets.flatMap { it.calendarIds }.map { it.toString() }.toSet()
val currentLatest = targets.map { latestFileName(it.baseName) }.toSet()
val combinedRun = COMBINED_SERIES in runKeys
return ledger.filterKeys { it.startsWith(LATEST_PREFIX) }
.mapKeys { it.key.removePrefix(LATEST_PREFIX) }
.filterKeys { key ->
key !in runKeys && (if (key == COMBINED_SERIES) !combinedRun else combinedRun && key in covered)
}
.mapValues { (_, names) -> names.filter { it in existing && it !in currentLatest } }
.filterValues { it.isNotEmpty() }
}
/**
* The ledger with [key]'s files set to [files] (empty drops the entry). Entries
* of deleted calendars go ([liveKeys]); their files stay, unrecorded. Others
* stay, so a calendar taken out of the backup still has its files pruned if it
* is put back.
*/
fun withLedgerEntry(
previous: BackupLedger,
key: String,
files: List<String>,
liveKeys: Set<String>,
): BackupLedger {
val next = previous.filterKeys { entry ->
val series = entry.removePrefix(LATEST_PREFIX)
series in liveKeys || series == COMBINED_SERIES
}.toMutableMap()
next[key] = files.distinct()
// A stable copy has one owner: claiming it takes it off any other series.
if (key.startsWith(LATEST_PREFIX)) {
next.keys.filter { it != key && it.startsWith(LATEST_PREFIX) }.forEach { other ->
next[other] = next.getValue(other) - files.toSet()
}
}
return next.filterValues { it.isNotEmpty() }
}
/** Stable copies recorded under any series but [key] — a hand-off that has already been claimed. */
fun BackupLedger.latestHeldElsewhere(key: String): Set<String> =
filterKeys { it.startsWith(LATEST_PREFIX) && it != latestLedgerKey(key) }.values.flatten().toSet()
/** `Work.ics`: the series' stable copy. */
fun latestFileName(baseName: String): String = "$baseName$ICS_EXTENSION"
private const val LEDGER_ENTRY_SEP = "|"
private const val LEDGER_KEY_SEP = "/"
/** Stores the ledger; both separators are characters no name this app writes can hold. */
fun persistBackupLedger(ledger: BackupLedger): String? =
ledger.filterValues { it.isNotEmpty() }.takeIf { it.isNotEmpty() }
?.entries?.sortedBy { it.key }
?.joinToString(LEDGER_ENTRY_SEP) { (key, names) -> (listOf(key) + names).joinToString(LEDGER_KEY_SEP) }
fun parseBackupLedger(stored: String?): BackupLedger =
stored?.split(LEDGER_ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(LEDGER_KEY_SEP)
val names = parts.drop(1).filter { it.isNotEmpty() }
parts.first().takeIf { it.isNotEmpty() && names.isNotEmpty() }?.let { it to names }
}.toMap()
/** 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) }
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlin.time.Duration.Companion.days
/**
* The events a calendar copy (#325) inserts into its target, in the shape
* `importEvents` takes: the source's rows flattened as [flattenOverrides] does
* for a backup, minus cancelled series and one-offs, which would arrive live
* (an insert can't be cancelled), and declined invitations and occurrences. Attendees and organizer are never read.
*
* Idempotent per snapshot: `importEvents` skips a UID the target already holds,
* and an [IcsEvent.overrideOf] occurrence whose series it holds, so a re-run
* keeps the earlier copy of an occurrence changed since rather than adding a
* second one beside it.
*/
fun planCalendarCopy(
rows: CalendarRows,
exdateStamp: (occurrenceMillis: Long, isAllDay: Boolean) -> String,
): List<ParsedIcsEvent> {
val live = CalendarRows(
series = rows.series.filterNot { it.event.status == EventStatus.Cancelled || it.declined },
// A declined occurrence is as good as deleted here.
overrides = rows.overrides.map {
if (it.declined) it.copy(event = it.event.copy(status = EventStatus.Cancelled)) else it
},
)
return flattenOverrides(live, exdateStamp).map { it.toParsedIcsEvent() }
}
/**
* [IcsEvent] read back as if [IcsWriter] had written it and [IcsParser] had read
* it, without the text in between. Unlike that round trip it keeps a one-off's
* own time zone (the writer pins it to UTC) and the event's colour (not part of
* the file format).
*/
fun IcsEvent.toParsedIcsEvent(): ParsedIcsEvent = ParsedIcsEvent(
uid = uid,
summary = summary,
start = start,
// RFC 5545 §3.6.1: an all-day event lasts at least its one day; zero-length
// it expands into no instance at all.
end = if (isAllDay && end <= start) start + 1.days else end,
isAllDay = isAllDay,
zoneId = zoneId,
recurrenceRule = recurrenceRule?.takeIf { it.isNotBlank() }?.removePrefix("RRULE:"),
recurrenceDates = recurrenceDates?.takeIf { it.isNotBlank() },
location = location,
description = description,
// The writer emits only lead times; a negative one would be an alarm after the start.
reminderMinutes = reminderMinutes.filter { it >= 0 }.distinct(),
status = status,
availability = availability,
accessLevel = accessLevel,
color = color,
overrideOf = overrideOf,
overrideAt = overrideAt,
exDates = if (recurs) exDates.filter { it.isNotBlank() }.distinct() else emptyList(),
)
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlin.time.Instant
/**
* A master or one-off Events row of a calendar being backed up or copied, with
* the provider keys its exception rows point back through.
*/
data class MasterRow(
val eventId: Long,
val calendarId: Long,
val syncId: String?,
val event: IcsEvent,
/** The user declined this invitation. */
val declined: Boolean = false,
)
/**
* An exception row: a modified or cancelled occurrence of the series named by
* [originalId] / [originalSyncId] in [calendarId], standing in for the
* occurrence that would have started at [originalInstance].
*/
data class OverrideRow(
val calendarId: Long,
val originalId: Long?,
val originalSyncId: String?,
val originalInstance: Instant,
val event: IcsEvent,
/** The user declined this occurrence. */
val declined: Boolean = false,
) {
val isCancelled: Boolean get() = event.status == EventStatus.Cancelled
}
/** Every event row a backup or copy reads: series/one-offs and their exception rows. */
data class CalendarRows(
val series: List<MasterRow>,
val overrides: List<OverrideRow>,
)
/**
* [rows] as plain events with no exception rows left, the shape both a backup
* file and a copied calendar need: neither can hold an exception row, since one
* only attaches to its series through a `_sync_id` (and an `.ics` restore skips
* `RECURRENCE-ID` overrides).
*
* Every override excludes its occurrence from the series (`EXDATE`), and a
* modified one comes along as a standalone event at its own times, marked
* [IcsEvent.overrideOf] its series — what Calendula itself writes for "only this
* event" on an unsynced series. Its UID is derived from the series' UID and the
* occurrence, so it is the same on every run. Overrides whose series isn't
* among the rows are left out; the provider doesn't show them either.
*
* Standalone occurrences come first, so an import cut short leaves at worst an
* occurrence missing its series, never a series missing an occurrence.
*
* [exdateStamp] formats an occurrence as an `EXDATE` entry in the provider's
* shape, the same one Calendula's own exclusions use. A cancelled series'
* modified occurrences stay cancelled.
*/
fun flattenOverrides(
rows: CalendarRows,
exdateStamp: (occurrenceMillis: Long, isAllDay: Boolean) -> String,
): List<IcsEvent> {
val byId = rows.series.associateBy { it.eventId }
// Sync ids are only unique within an account, so the link is per calendar.
val bySyncId = rows.series.filter { it.syncId != null }.associateBy { it.calendarId to it.syncId }
val excluded = mutableMapOf<Long, MutableList<String>>()
val detached = mutableListOf<Pair<Long, IcsEvent>>()
for (override in rows.overrides) {
val parent = override.originalId?.let(byId::get)?.takeIf { it.calendarId == override.calendarId }
?: override.originalSyncId?.let { bySyncId[override.calendarId to it] }
?: continue
// An exception to a one-off overrides nothing a plain event could carry.
if (!parent.event.recurs) continue
val occurrence = override.originalInstance.toEpochMilliseconds()
excluded.getOrPut(parent.eventId) { mutableListOf() } += exdateStamp(occurrence, parent.event.isAllDay)
if (override.isCancelled || parent.event.status == EventStatus.Cancelled) continue
detached += override.calendarId to override.event.copy(
uid = "$occurrence-${parent.event.uid}",
recurrenceRule = null,
recurrenceDates = null,
exDates = emptyList(),
overrideOf = parent.event.uid,
overrideAt = override.originalInstance,
)
}
// Two exception rows for one occurrence would derive the same UID; the same
// series in two calendars legitimately does.
return detached.distinctBy { (calendarId, event) -> calendarId to event.uid }.map { it.second } +
rows.series.map { s ->
val extra = excluded[s.eventId].orEmpty()
if (extra.isEmpty()) s.event else s.event.copy(exDates = (s.event.exDates + extra).distinct())
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlin.time.Instant
@@ -36,8 +37,25 @@ data class IcsEvent(
val availability: Availability = Availability.Busy,
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
val calendarName: String? = null,
/** The provider's raw `RDATE`; carried by a calendar copy, not written to a file. */
val recurrenceDates: String? = null,
/** Carried by a calendar copy, not written to a file. */
val accessLevel: AccessLevel = AccessLevel.Default,
/** The event's own colour, null when it shows its calendar's; carried by a calendar copy, not written to a file. */
val color: Int? = null,
/**
* For a modified occurrence written out on its own ([flattenOverrides]): the
* UID of the series it replaces an occurrence of, as `X-CALENDULA-OVERRIDE-OF`.
*/
val overrideOf: String? = null,
/** The occurrence of [overrideOf] this event replaces, as `X-CALENDULA-OVERRIDE-AT`. */
val overrideAt: Instant? = null,
)
/** Whether this is a series: by rule, or by listed dates alone. */
val IcsEvent.recurs: Boolean
get() = !recurrenceRule.isNullOrBlank() || !recurrenceDates.isNullOrBlank()
/**
* The UID to export for a provider event. A row that already carries a UID
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.zoneOrNull
@@ -45,6 +46,17 @@ data class ParsedIcsEvent(
val exDates: List<String> = emptyList(),
/** True when this came from a `VTODO` rather than a `VEVENT`. */
val isTask: Boolean = false,
/**
* The UID of the series this standalone occurrence replaces one occurrence
* of (`X-CALENDULA-OVERRIDE-OF`). An import skips it where that series
* already is, since the series there carries its own override.
*/
val overrideOf: String? = null,
/** The occurrence of [overrideOf] this event replaces (`X-CALENDULA-OVERRIDE-AT`). */
val overrideAt: Instant? = null,
/** The provider's raw `RDATE`, only set by a calendar copy. */
val recurrenceDates: String? = null,
val accessLevel: AccessLevel = AccessLevel.Default,
)
/**
@@ -213,6 +225,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val reminders = mutableListOf<Int>()
val exDateLines = mutableListOf<IcsContentLine>()
var skipAsOverride = false
var overrideOf: String? = null
var overrideAt: Instant? = null
var i = 0
while (i < body.size) {
@@ -241,6 +255,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
else Availability.Busy
"RECURRENCE-ID" -> skipAsOverride = true
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
"X-CALENDULA-OVERRIDE-OF" -> overrideOf = unescapeText(line.value).trim().ifEmpty { null }
"X-CALENDULA-OVERRIDE-AT" -> overrideAt = parseBasicDateTime(line.value.trim())?.toInstant(TimeZone.UTC)
"X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
// Fossify names the source calendar per event; it never writes
@@ -300,6 +316,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
normalizeExDates(exDateLines, start)
},
isTask = isTask,
overrideOf = overrideOf,
overrideAt = overrideAt,
)
}
@@ -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")
}
@@ -54,6 +56,9 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
add("TRANSP:${transpValue(event.availability)}")
event.calendarName?.takeIf { it.isNotBlank() }
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
event.overrideOf?.takeIf { it.isNotBlank() }
?.let { add("X-CALENDULA-OVERRIDE-OF:${escapeText(it)}") }
event.overrideAt?.let { add("X-CALENDULA-OVERRIDE-AT:${it.toUtcBasic()}") }
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
appendAlarm(minutes, event.summary)
}
@@ -13,12 +13,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.FileDownload
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 +49,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 +63,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 +78,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,
@@ -87,19 +90,26 @@ fun BackupScreen(
onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
copyViewModel: CopyEventsViewModel = hiltViewModel(),
) {
val state by viewModel.backupState.collectAsStateWithLifecycle()
val copyRun by copyViewModel.run.collectAsStateWithLifecycle()
var showCopy by rememberSaveable { mutableStateOf(false) }
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
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.
var shortcutExportId by rememberSaveable { mutableStateOf<Long?>(null) }
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
) { uri ->
val id = shortcutExportId
if (uri != null && id != null) viewModel.exportBackup(uri, setOf(id))
shortcutExportId = null
}
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// Restore runs the picked file through the normal .ics import flow.
@@ -112,6 +122,9 @@ fun BackupScreen(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
var showBackupCalendars by rememberSaveable { mutableStateOf(false) }
var selectionBefore by remember { mutableStateOf<BackupSelection?>(null) }
var showKeep by rememberSaveable { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
@@ -149,19 +162,46 @@ fun BackupScreen(
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
canCopy = s.canCopy,
copyRun = copyRun,
onCopy = { showCopy = true },
autoBackup = autoBackup,
viewModel = viewModel,
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
onPickFolder = { runCatching { pickFolder.launch(null) } },
onExportAll = {
onExportOnly = {
shortcutExportId = s.exportable.singleOrNull()?.id
runCatching { createBackup.launch(defaultBackupName()) }
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
onEditCalendars = {
selectionBefore = autoBackup.selection
showBackupCalendars = true
},
onEditKeep = { showKeep = true },
)
}
}
// Not gated on Ready: a running copy keeps its progress and Cancel whatever the list does.
if (showCopy) {
val calendars by copyViewModel.calendars.collectAsStateWithLifecycle()
val selection by copyViewModel.selection.collectAsStateWithLifecycle()
CopyEventsSheet(
calendars = calendars,
selection = selection,
run = copyRun,
onSelectSource = copyViewModel::setSource,
onSelectTarget = copyViewModel::setTarget,
onStart = copyViewModel::start,
onCancel = copyViewModel::cancel,
onDismiss = {
showCopy = false
copyViewModel.clearFinished()
},
)
}
// Gated on Ready rather than resetting the flag when the state leaves it:
// flipping it here would be a write during composition.
(state as? BackupUiState.Ready)?.let { ready ->
@@ -172,6 +212,34 @@ fun BackupScreen(
onDismiss = { showExportPicker = false },
)
}
if (showBackupCalendars) {
AutoBackupCalendarPicker(
calendars = ready.exportable,
selection = autoBackup.selection,
onCheckedChange = viewModel::setAutoBackupCalendar,
onDismiss = {
showBackupCalendars = false
viewModel.autoBackupCalendarsChosen(selectionBefore ?: autoBackup.selection)
},
)
}
}
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(
@@ -197,7 +265,7 @@ private fun BackupLoading() {
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
/**
* The working screen: export (when there are local calendars to export), restore,
* The working screen: export (when any calendar can be backed up), restore,
* and the automatic-backup block. Restore is always on screen — when nothing can
* receive the events it says so and routes to the calendar manager rather than
* disappearing, which is what #304 reported as an invisible button.
@@ -206,13 +274,18 @@ private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.i
private fun BackupContent(
exportable: List<CalendarSource>,
canImport: Boolean,
canCopy: Boolean,
copyRun: CopyRunState,
onCopy: () -> Unit,
autoBackup: AutoBackupUiState,
viewModel: CalendarsViewModel,
onRestore: () -> Unit,
onPickFolder: () -> Unit,
onExportAll: () -> Unit,
onExportOnly: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
onEditCalendars: () -> Unit,
onEditKeep: () -> Unit,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
@@ -229,10 +302,11 @@ private fun BackupContent(
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
position = if (copyRun != CopyRunState.Idle) Position.Top else Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
if (copyRun != CopyRunState.Idle) CopyEventsRow(copyRun, Position.Bottom, onCopy)
return
}
@@ -242,7 +316,7 @@ private fun BackupContent(
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
// A single exportable calendar skips the selector.
onClick = if (exportable.size == 1) onExportAll else onExportPick,
onClick = if (exportable.size == 1) onExportOnly else onExportPick,
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
@@ -251,6 +325,8 @@ private fun BackupContent(
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
// A running or finished copy keeps its row, the way back to its sheet.
if (canCopy || copyRun != CopyRunState.Idle) CopyEventsRow(copyRun, Position.Middle, onCopy)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
@@ -272,22 +348,78 @@ private fun BackupContent(
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
position = Position.Middle,
onClick = onEditInterval,
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
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(
if (selectedCount == 0) {
stringResource(R.string.calendars_auto_backup_nothing_selected)
} else {
backupStatusText(autoBackup.status)
},
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun CopyEventsRow(copyRun: CopyRunState, position: Position, onClick: () -> Unit) {
GroupedRow(
title = stringResource(R.string.calendars_copy_action),
summary = when (copyRun) {
is CopyRunState.Running -> copyProgressText(copyRun)
is CopyRunState.Done -> stringResource(
if (copyRun.stopped) R.string.calendars_copy_stopped_title else R.string.calendars_copy_done,
)
CopyRunState.Cancelled -> stringResource(R.string.calendars_copy_cancelled)
CopyRunState.Failed -> stringResource(R.string.calendars_copy_failed)
CopyRunState.Idle -> stringResource(R.string.calendars_copy_summary)
},
position = position,
leading = { LeadingAvatar(Icons.Default.ContentCopy) },
onClick = onClick,
)
}
/**
* 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
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (Uri, Set<Long>?) -> Unit,
onExport: (Uri, Set<Long>) -> Unit,
onDismiss: () -> Unit,
) {
// Deliberately not keyed on [calendars]: that list is observer-driven, so
@@ -298,7 +430,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,29 +448,16 @@ 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") }
runCatching { createBackup.launch(defaultBackupName()) }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
@@ -349,6 +469,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
@@ -23,31 +24,52 @@ sealed interface BackupUiState {
data class Ready(
val exportable: List<CalendarSource>,
val canImport: Boolean,
/** Some calendar can be copied into a different one (#325). */
val canCopy: Boolean = false,
) : 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
* 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.
* failure while its calendars are still perfectly exportable. Since export
* reads hidden and read-only calendars too, both halves are dead only when there
* is no calendar at all, or every one is managed or not synced to the device
* ([FailureReason.NoImportTarget] unless the list itself says why).
*/
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(
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
)
}
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
return BackupUiState.Ready(
exportable = exportable,
canImport = canImport,
canCopy = copySources(calendars).isNotEmpty(),
)
}
/**
* Calendars a copy can read from: any a backup can (#325), synced and read-only
* ones included, as long as some other calendar can take their events.
*/
fun copySources(calendars: List<CalendarSource>): List<CalendarSource> {
val targets = calendars.filter { it.isEventTarget }
val onlyTarget = targets.singleOrNull()?.id
if (targets.isEmpty()) return emptyList()
return calendars.filter { it.isBackupCandidate && it.id != onlyTarget }
}
/** Calendars a copy from [sourceId] can write into: the import targets, minus the source itself. */
fun copyTargets(calendars: List<CalendarSource>, sourceId: Long?): List<CalendarSource> =
calendars.filter { it.isEventTarget && it.id != sourceId }
@@ -14,10 +14,14 @@ 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
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -73,12 +77,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,18 +126,22 @@ 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] 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) {
fun exportBackup(uri: Uri, calendarIds: Set<Long>) {
viewModelScope.launch {
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents(calendarIds)
// A one-calendar file is named after it, as the automatic backup's are.
val name = calendarIds.singleOrNull()?.let { id ->
repository.calendars().first().firstOrNull { it.id == id }?.displayName
}
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),
content = IcsWriter().writeCalendar(events, Clock.System.now(), name),
)
events.size
}
@@ -204,6 +219,49 @@ class CalendarsViewModel @Inject constructor(
}
}
/** Selection writes since the calendar picker opened; the run after it waits for them. */
private val selectionWrites = mutableListOf<Job>()
fun setAutoBackupCalendar(calendar: CalendarSource, included: Boolean) {
selectionWrites += viewModelScope.launch { settingsPrefs.setAutoBackupCalendar(calendar, included) }
}
fun setAutoBackupCombined(combined: Boolean) {
viewModelScope.launch {
if (settingsPrefs.autoBackupCombined.first() == combined) return@launch
settingsPrefs.setAutoBackupCombined(combined)
runBackupIfActive()
}
}
fun setAutoBackupKeep(keep: Int) {
viewModelScope.launch {
if (settingsPrefs.autoBackupKeep.first() == keep) return@launch
settingsPrefs.setAutoBackupKeep(keep)
runBackupIfActive()
}
}
/**
* After the calendar choice is closed, back up what is now selected rather
* than at the next interval — only if it changed, since every run rotates
* an older backup out.
*/
fun autoBackupCalendarsChosen(before: BackupSelection) {
val pending = selectionWrites.toList()
selectionWrites.clear()
viewModelScope.launch {
pending.joinAll()
if (settingsPrefs.autoBackupSelection.first() != before) runBackupIfActive()
}
}
private suspend fun runBackupIfActive() {
if (settingsPrefs.autoBackupEnabled.first() && settingsPrefs.autoBackupFolderUri.first() != null) {
BackupScheduler.runNow(context)
}
}
private suspend fun reschedule() {
BackupScheduler.apply(
context = context,
@@ -232,6 +290,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. */
@@ -0,0 +1,245 @@
package de.jeanlucmakiola.calendula.ui.calendars
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
/**
* Copy every event of one calendar into another (#325): pick the source and the
* target, copy, watch it run. The run lives in [CopyEventsViewModel], so this
* sheet can be closed and reopened mid-copy.
*/
@Composable
fun CopyEventsSheet(
calendars: CopyCalendars,
selection: CopySelection,
run: CopyRunState,
onSelectSource: (Long) -> Unit,
onSelectTarget: (Long) -> Unit,
onStart: () -> Unit,
onCancel: () -> Unit,
onDismiss: () -> Unit,
) {
val list = (calendars as? CopyCalendars.Ready)?.calendars.orEmpty()
val sources = remember(list) { copySources(list) }
val source = sources.firstOrNull { it.id == selection.sourceId }
val targets = remember(list, source?.id) { copyTargets(list, source?.id) }
val target = targets.firstOrNull { it.id == selection.targetId }
val running = run is CopyRunState.Running
var picking by rememberSaveable { mutableStateOf<CopyEnd?>(null) }
FullScreenPicker(
title = stringResource(R.string.calendars_copy_action),
onDismiss = onDismiss,
predictiveBack = true,
actions = {
if (!running) {
TextButton(onClick = onStart, enabled = source != null && target != null) {
Text(stringResource(R.string.calendars_copy_start))
}
}
},
) {
GroupedHint(stringResource(R.string.calendars_copy_hint), style = MaterialTheme.typography.bodySmall)
when (calendars) {
CopyCalendars.Loading -> {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@FullScreenPicker
}
CopyCalendars.Failure -> {
CalendarFailure(reason = FailureReason.ProviderUnavailable, onRetry = onDismiss)
return@FullScreenPicker
}
is CopyCalendars.Ready -> Unit
}
if (sources.isEmpty() && run == CopyRunState.Idle) {
GroupedHint(stringResource(R.string.calendars_copy_none))
return@FullScreenPicker
}
CopyEndRow(
label = stringResource(R.string.calendars_copy_from),
calendar = source,
position = Position.Top,
onClick = if (running) null else ({ picking = CopyEnd.Source }),
)
CopyEndRow(
label = stringResource(R.string.calendars_copy_to),
calendar = target,
position = Position.Bottom,
onClick = if (running || source == null) null else ({ picking = CopyEnd.Target }),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
when (run) {
is CopyRunState.Running -> CopyProgress(run, onCancel)
is CopyRunState.Done -> CopySummary(run)
CopyRunState.Cancelled -> CopyNote(stringResource(R.string.calendars_copy_cancelled))
CopyRunState.Failed -> CopyNote(
stringResource(R.string.calendars_copy_failed),
error = true,
)
CopyRunState.Idle -> Unit
}
}
}
when (picking) {
CopyEnd.Source -> FullScreenPicker(
title = stringResource(R.string.calendars_copy_from_title),
onDismiss = { picking = null },
predictiveBack = true,
) {
CalendarPickerGroups(
calendars = sources,
selectedId = source?.id,
onSelect = {
onSelectSource(it)
picking = null
},
)
}
CopyEnd.Target -> FullScreenPicker(
title = stringResource(R.string.calendars_copy_to_title),
onDismiss = { picking = null },
predictiveBack = true,
) {
CalendarPickerGroups(
calendars = targets,
selectedId = target?.id,
onSelect = {
onSelectTarget(it)
picking = null
},
)
}
null -> Unit
}
}
private enum class CopyEnd { Source, Target }
@Composable
private fun CopyEndRow(
label: String,
calendar: CalendarSource?,
position: Position,
onClick: (() -> Unit)?,
) {
GroupedRow(
title = label,
summary = calendar?.displayName?.orUnnamed() ?: stringResource(R.string.calendars_copy_choose),
position = position,
dimmed = onClick == null,
leading = calendar?.let { { CalendarColorChip(it.color) } },
onClick = onClick,
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun CopyProgress(run: CopyRunState.Running, onCancel: () -> Unit) {
val total = run.total
if (total == null || total == 0) {
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearWavyProgressIndicator(
progress = { run.done.toFloat() / total },
modifier = Modifier.fillMaxWidth(),
)
}
Text(
text = copyProgressText(run),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dialog_cancel))
}
}
/** "Reading events…" until the source is read, then "120 of 2,400 events". */
@Composable
fun copyProgressText(run: CopyRunState.Running): String {
val total = run.total ?: return stringResource(R.string.calendars_copy_reading)
return pluralStringResource(R.plurals.calendars_copy_progress, total, run.done, total)
}
@Composable
private fun CopySummary(run: CopyRunState.Done) {
val summary = run.summary
Text(
stringResource(if (run.stopped) R.string.calendars_copy_stopped_title else R.string.calendars_copy_done),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
CopyCount(stringResource(R.string.import_done_added_label), summary.imported)
if (summary.skippedDuplicate > 0) {
CopyCount(stringResource(R.string.import_done_skipped_label), summary.skippedDuplicate)
}
if (summary.failed > 0) CopyCount(stringResource(R.string.import_done_failed_label), summary.failed)
}
if (summary.skippedDuplicate > 0) CopyNote(stringResource(R.string.import_done_dedup_note))
if (summary.failed > 0) CopyNote(stringResource(R.string.import_done_failed_note))
if (summary.notAttempted > 0) {
CopyNote(
pluralStringResource(R.plurals.calendars_copy_stopped, summary.notAttempted, summary.notAttempted),
error = true,
)
}
}
@Composable
private fun CopyCount(label: String, count: Int) {
Column {
Text(count.toString(), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface)
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun CopyNote(text: String, error: Boolean = false) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -0,0 +1,140 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/**
* Copy events from one calendar into another (#325). Scoped to the activity, so
* a copy keeps running across rotation and while the Backup screen is closed;
* finishing the activity or losing the process stops it like [cancel] does,
* and a re-run picks up where it ended.
*/
@HiltViewModel
class CopyEventsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val savedState: SavedStateHandle,
) : ViewModel() {
val calendars: StateFlow<CopyCalendars> =
repository.calendars()
.map<List<CalendarSource>, CopyCalendars> { CopyCalendars.Ready(it) }
.catch { emit(CopyCalendars.Failure) }
.stateWhileSubscribed(scope = viewModelScope, initialValue = CopyCalendars.Loading)
val selection: StateFlow<CopySelection> = combine(
savedState.getStateFlow<Long?>(KEY_SOURCE, null),
savedState.getStateFlow<Long?>(KEY_TARGET, null),
) { source, target -> CopySelection(source, target) }
.stateIn(viewModelScope, SharingStarted.Eagerly, CopySelection())
private val _run = MutableStateFlow<CopyRunState>(CopyRunState.Idle)
val run: StateFlow<CopyRunState> = _run.asStateFlow()
private var job: Job? = null
private val busy: Boolean get() = job?.isCompleted == false
private fun current() = CopySelection(savedState[KEY_SOURCE], savedState[KEY_TARGET])
private fun select(selection: CopySelection) {
savedState[KEY_SOURCE] = selection.sourceId
savedState[KEY_TARGET] = selection.targetId
}
fun setSource(id: Long) {
if (busy) return
select(current().withSource(id))
clearFinished()
}
fun setTarget(id: Long) {
if (busy) return
select(current().copy(targetId = id))
clearFinished()
}
fun start() {
val source = current().sourceId ?: return
val target = current().targetId ?: return
if (busy) return
_run.value = CopyRunState.Running(done = 0, total = null)
job = viewModelScope.launch {
try {
val summary = repository.copyEvents(source, target) { done, total ->
if (done == 0 || done == total || done % (total / 200).coerceAtLeast(1) == 0) {
_run.value = CopyRunState.Running(done, total)
}
}
_run.value = CopyRunState.Done(summary)
} catch (e: CancellationException) {
_run.value = CopyRunState.Cancelled
throw e
} catch (e: Exception) {
Log.w(TAG, "Copy events failed", e)
_run.value = CopyRunState.Failed
}
}
}
fun cancel() {
job?.cancel()
}
/** Back to idle once a finished run's outcome has been seen. */
fun clearFinished() {
if (!busy) _run.value = CopyRunState.Idle
}
private companion object {
const val KEY_SOURCE = "copy_source"
const val KEY_TARGET = "copy_target"
const val TAG = "CopyEvents"
}
}
/** The calendars a copy runs between. */
data class CopySelection(val sourceId: Long? = null, val targetId: Long? = null) {
/** A target equal to the new source is dropped: a calendar is never copied into itself. */
fun withSource(id: Long): CopySelection =
CopySelection(sourceId = id, targetId = targetId?.takeIf { it != id })
}
/** The calendar list the copy sheet picks from. */
sealed interface CopyCalendars {
data object Loading : CopyCalendars
data object Failure : CopyCalendars
data class Ready(val calendars: List<CalendarSource>) : CopyCalendars
}
sealed interface CopyRunState {
data object Idle : CopyRunState
/** [total] is null while the source calendar is still being read. */
data class Running(val done: Int, val total: Int?) : CopyRunState
data class Done(val summary: IcsImportSummary) : CopyRunState {
/** The target stopped taking events part way, so some were never tried. */
val stopped: Boolean get() = summary.notAttempted > 0
}
data object Cancelled : CopyRunState
data object Failed : CopyRunState
}
@@ -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)
}
}
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -483,7 +484,7 @@ private fun List<CalendarSource>.allHidden(): Boolean =
* A read-only subscription is not a backup, so it does not count.
*/
private fun List<CalendarSource>.backupApplies(): Boolean {
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
val exportable = any { it.isBackedUpByDefault }
val syncedTarget = any { !it.isLocal && it.canModifyContents }
return exportable && !syncedTarget
}
+42 -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>
@@ -889,13 +889,53 @@
<string name="calendars_restore_action">Restore from .ics file</string>
<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_copy_action">Copy events</string>
<string name="calendars_copy_summary">Copy one calendar\'s events into another, for example to move off Google.</string>
<string name="calendars_copy_hint">Copies your events, including repeating ones with the occurrences you changed or deleted. Cancelled events and invitations you declined are left out, guests stay behind, and the original calendar is left as it is.</string>
<string name="calendars_copy_from">From</string>
<string name="calendars_copy_to">To</string>
<string name="calendars_copy_choose">Choose a calendar</string>
<string name="calendars_copy_from_title">Copy from</string>
<string name="calendars_copy_to_title">Copy into</string>
<string name="calendars_copy_start">Copy</string>
<string name="calendars_copy_reading">Reading events…</string>
<plurals name="calendars_copy_progress">
<item quantity="one">%1$d of %2$d event</item>
<item quantity="other">%1$d of %2$d events</item>
</plurals>
<string name="calendars_copy_done">Copy complete</string>
<string name="calendars_copy_stopped_title">Copy stopped</string>
<string name="calendars_copy_none">No calendar can be copied into another one right now.</string>
<string name="calendars_copy_cancelled">Copy cancelled. Run it again to finish: events that were already copied are skipped.</string>
<string name="calendars_copy_failed">Couldn\'t copy the events.</string>
<plurals name="calendars_copy_stopped">
<item quantity="one">The target calendar stopped accepting events part way through. %d event was never copied.</item>
<item quantity="other">The target calendar stopped accepting events part way through. %d events were never copied.</item>
</plurals>
<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_nothing_selected">No calendar is selected, so nothing is backed up.</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 dated copies, plus one undated copy of the latest backup unless a file of yours already has that name. Older backups Calendula wrote are deleted after each run; a calendar you stop backing up keeps its last files, and nothing else in the folder is touched.</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>
@@ -8,6 +8,13 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.CalendarRows
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.OverrideRow
import de.jeanlucmakiola.calendula.domain.ics.MasterRow
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
@@ -946,19 +953,148 @@ class CalendarRepositoryImplTest {
repo.exportEvents(calendarIds = setOf(1L, 4L))
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
assertThat(fake.lastCalendarRowsIds).containsExactly(1L, 4L)
}
@Test
fun `exportEvents defaults to null so every eligible calendar is exported`(
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.lastCalendarRowsIds).containsExactly(2L)
}
@Test
fun `copyEvents reports progress and skips what an earlier run copied`(
@TempDir tempDir: Path,
) = runTest {
fun icsEvent(uid: String) = IcsEvent(
uid = uid,
summary = uid,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,
zoneId = "UTC",
)
val fake = FakeCalendarDataSource().apply {
existingUidsResult = setOf("a")
calendarRowsResult = CalendarRows(
series = listOf(MasterRow(1, 1, null, icsEvent("a")), MasterRow(2, 1, null, icsEvent("b"))),
overrides = emptyList(),
)
}
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val progress = mutableListOf<Pair<Int, Int>>()
val summary = repo.copyEvents(sourceCalendarId = 1L, targetCalendarId = 3L) { done, total ->
progress += done to total
}
assertThat(summary.imported).isEqualTo(1)
assertThat(summary.skippedDuplicate).isEqualTo(1)
assertThat(fake.importedEvents.map { it.first.uid to it.second }).containsExactly("b" to 3L)
assertThat(progress.first()).isEqualTo(0 to 2)
assertThat(progress.last()).isEqualTo(2 to 2)
}
@Test
fun `a backup restores a series' moved and deleted occurrences, and skips them where the series is`(
@TempDir tempDir: Path,
) = runTest {
val start = Instant.parse("2026-01-05T09:00:00Z")
fun at(week: Int) = Instant.fromEpochMilliseconds(start.toEpochMilliseconds() + week * 7 * 86_400_000L)
fun event(at: Instant, status: EventStatus = EventStatus.Confirmed, rrule: String? = null) = IcsEvent(
uid = "s@x",
summary = "Standup",
start = at,
end = Instant.fromEpochMilliseconds(at.toEpochMilliseconds() + 3_600_000L),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = rrule,
status = status,
)
val moved = Instant.fromEpochMilliseconds(at(1).toEpochMilliseconds() + 3_600_000L)
val source = FakeCalendarDataSource().apply {
calendarRowsResult = CalendarRows(
series = listOf(MasterRow(1, 1, "sync-1", event(start, rrule = "FREQ=WEEKLY"))),
overrides = listOf(
OverrideRow(1, null, "sync-1", at(1), event(moved)),
OverrideRow(1, null, "sync-1", at(2), event(at(2), EventStatus.Cancelled)),
),
)
}
val file = IcsWriter().writeCalendar(
testRepository(source, tempDir.resolve("a"), Dispatchers.Unconfined).exportEvents(setOf(1L)),
start,
)
val parsed = IcsParser().parse(file).events
val empty = FakeCalendarDataSource()
testRepository(empty, tempDir.resolve("b"), Dispatchers.Unconfined).importEvents(3L, parsed)
val (detached, series) = empty.importedEvents.map { it.first }
assertThat(detached.start).isEqualTo(moved)
assertThat(detached.recurrenceRule).isNull()
assertThat(series.exDates).containsExactly("20260112T090000Z", "20260119T090000Z")
val original = FakeCalendarDataSource().apply { existingUidsResult = setOf("s@x") }
val summary = testRepository(original, tempDir.resolve("c"), Dispatchers.Unconfined).importEvents(3L, parsed)
assertThat(original.importedEvents).isEmpty()
assertThat(summary.skippedDuplicate).isEqualTo(2)
}
@Test
fun `a series keeps an occurrence whose standalone copy didn't land`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "moved" }
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
val at = Instant.parse("2026-01-12T09:00:00Z")
val summary = repo.importEvents(
3L,
listOf(
parsedEvent("1-s", summary = "moved").copy(overrideOf = "s", overrideAt = at),
parsedEvent("s").copy(recurrenceRule = "FREQ=WEEKLY", exDates = listOf("20260112T090000Z", "20260119T090000Z")),
),
)
assertThat(summary.failed).isEqualTo(1)
assertThat(fake.importedEvents.single().first.exDates).containsExactly("20260119T090000Z")
}
@Test
fun `a standalone occurrence listed after its series still goes first`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.importEvents(3L, listOf(parsedEvent("s"), parsedEvent("1-s").copy(overrideOf = "s")))
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("1-s", "s").inOrder()
}
@Test
fun `a backup writes an RDATE-only series as the one-off a file can hold`(
@TempDir tempDir: Path,
) = runTest {
val start = Instant.parse("2026-01-05T09:00:00Z")
val event = IcsEvent("s", "S", start, Instant.parse("2026-01-05T10:00:00Z"), false, "UTC", recurrenceDates = "20260112T090000Z")
val fake = FakeCalendarDataSource().apply {
calendarRowsResult = CalendarRows(
listOf(MasterRow(1, 1, null, event)),
listOf(OverrideRow(1, 1, null, start, event.copy(recurrenceDates = null))),
)
}
val exported = testRepository(fake, tempDir, Dispatchers.Unconfined).exportEvents(setOf(1L))
assertThat(exported.map { it.uid }).containsExactly("s")
assertThat(exported.single().recurrenceDates).isNull()
}
private fun parsedEvent(uid: String?, summary: String = "E") =
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.CalendarRows
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import java.time.ZoneId
@@ -23,9 +24,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 calendarRowsResult: CalendarRows = CalendarRows(emptyList(), emptyList())
/** The [calendarIds] the last [calendarRows] call received. */
var lastCalendarRowsIds: 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 +82,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId)
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
publishedEventColorsResult(calendarId)
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
override fun calendarRows(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): CalendarRows {
lastCalendarRowsIds = calendarIds
return calendarRowsResult
}
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
@@ -123,4 +124,21 @@ class ImportedEventValuesTest {
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
@Test
fun `a copied RDATE-only series recurs by its dates`() {
val v = values(allDay().copy(recurrenceDates = "20260820", exDates = listOf("20260820")))
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(v).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(v[CalendarContract.Events.RDATE]).isEqualTo("20260820")
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `a private event stays private, a default one leaves the calendar's say`() {
assertThat(values(allDay().copy(accessLevel = AccessLevel.Private))[CalendarContract.Events.ACCESS_LEVEL])
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
assertThat(values(allDay())).doesNotContainKey(CalendarContract.Events.ACCESS_LEVEL)
}
}
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
@@ -705,4 +706,85 @@ 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, and
// keeps the old file recognised as this install's own.
prefs.setAutoBackupFolderUri("content://tree/new")
assertThat(prefs.autoBackupCombined.first()).isTrue()
// A new folder starts a new ledger: the old file there isn't known to be ours.
assertThat(prefs.autoBackupLedger.first()).isEmpty()
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)
}
@Test
fun `a pre-411 install adopts its old file when the layout is pinned in place`(@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
}
prefs.setAutoBackupCombined(false)
assertThat(prefs.autoBackupLedger.first())
.containsExactly("latest-combined", listOf("calendula-backup.ics"))
}
private fun backupCalendar(id: Long, local: Boolean) = 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,287 @@
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 `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.key).isEqualTo(COMBINED_SERIES)
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")
}
private val work = BackupTarget("1", setOf(1L), "Work", "Work")
@Test
fun `pruning keeps the newest recorded files and nothing else`() {
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics", "Work_c.ics"))
val existing = setOf("Work_a.ics", "Work_b.ics", "Work_c.ics", "Work_2026-11-01_09-00-00.ics", "Work notes.ics")
val plan = planBackupFiles(listOf(work), ledger, existing, at, keep = 3).single()
assertThat(plan.fileName).isEqualTo("Work_2026-11-13_14-30-05.ics")
assertThat(plan.keep).containsExactly("Work_b.ics", "Work_c.ics").inOrder()
assertThat(plan.prune).containsExactly("Work_a.ics")
}
@Test
fun `keeping one replaces every earlier file`() {
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics"))
val plan = planBackupFiles(listOf(work), ledger, setOf("Work_a.ics", "Work_b.ics"), at, keep = 1).single()
assertThat(plan.keep).isEmpty()
assertThat(plan.prune).containsExactly("Work_a.ics", "Work_b.ics").inOrder()
}
@Test
fun `files gone from the folder are forgotten, not counted`() {
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics"))
val plan = planBackupFiles(listOf(work), ledger, setOf("Work_b.ics"), at, keep = 2).single()
assertThat(plan.keep).containsExactly("Work_b.ics")
assertThat(plan.prune).isEmpty()
}
@Test
fun `a same-named calendar never prunes another calendar's files`() {
// Local "Work" (id 1) was backed up; the user now backs up a synced "Work" (id 9) instead.
val ledger = mapOf("1" to listOf("Work_a.ics"))
val synced = backupTargets(listOf(cal(9L, "Work")), combined = false).single()
val plan = planBackupFiles(listOf(synced), ledger, setOf("Work_a.ics"), at, keep = 1).single()
assertThat(plan.prune).isEmpty()
}
@Test
fun `a rerun in the same second never prunes the file it writes`() {
val name = "Work_2026-11-13_14-30-05.ics"
val plan = planBackupFiles(listOf(work), mapOf("1" to listOf(name)), setOf(name), at, keep = 1).single()
assertThat(plan.prune).isEmpty()
}
private val combined = BackupTarget(COMBINED_SERIES, setOf(1L, 2L), COMBINED_BACKUP_BASE, null)
@Test
fun `the pre-411 file is the combined stable copy only for the install that wrote it`() {
val adopted = mapOf(latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE))
val owned = planBackupFiles(listOf(combined), adopted, setOf(LEGACY_BACKUP_FILE), at, 1)
assertThat(owned.single().latestName).isEqualTo(LEGACY_BACKUP_FILE)
assertThat(owned.single().prune).isEmpty()
val foreign = planBackupFiles(listOf(combined), emptyMap(), setOf(LEGACY_BACKUP_FILE), at, 1)
assertThat(foreign.single().latestName).isNull()
}
@Test
fun `a stable name recorded as a dated file by an earlier build is never pruned`() {
val ledger = mapOf(COMBINED_SERIES to listOf(LEGACY_BACKUP_FILE, "calendula-backup_a.ics"))
val plan = planBackupFiles(
listOf(combined), ledger, setOf(LEGACY_BACKUP_FILE, "calendula-backup_a.ics"), at, 1,
).single()
assertThat(plan.prune).containsExactly("calendula-backup_a.ics")
}
@Test
fun `per-calendar series leave the legacy combined file alone`() {
val plan = planBackupFiles(listOf(work), emptyMap(), setOf(LEGACY_BACKUP_FILE), at, keep = 1).single()
assertThat(plan.prune).isEmpty()
assertThat(plan.staleLatest).isEmpty()
assertThat(plan.latestName).isEqualTo("Work.ics")
}
@Test
fun `the stable copy is never pruned and never overwrites the user's own file`() {
val ours = planBackupFiles(
listOf(work),
mapOf("1" to listOf("Work_a.ics"), latestLedgerKey("1") to listOf("Work.ics")),
setOf("Work_a.ics", "Work.ics"),
at,
keep = 1,
).single()
assertThat(ours.latestName).isEqualTo("Work.ics")
assertThat(ours.prune).containsExactly("Work_a.ics")
val foreign = planBackupFiles(listOf(work), emptyMap(), setOf("Work.ics"), at, keep = 1).single()
assertThat(foreign.latestName).isNull()
}
@Test
fun `a renamed calendar replaces its old stable copy`() {
val job = BackupTarget("1", setOf(1L), "Job", "Job")
val plan = planBackupFiles(
listOf(job),
mapOf(latestLedgerKey("1") to listOf("Work.ics")),
setOf("Work.ics"),
at,
keep = 3,
).single()
assertThat(plan.latestName).isEqualTo("Job.ics")
assertThat(plan.staleLatest).containsExactly("Work.ics")
}
@Test
fun `a stable copy is handed over to the calendar that takes its name in the same run`() {
// Calendar 1 was "Work", is now "Job"; calendar 2 is now called "Work".
val ledger = mapOf(latestLedgerKey("1") to listOf("Work.ics"))
val plans = planBackupFiles(
listOf(BackupTarget("1", setOf(1L), "Job", "Job"), BackupTarget("2", setOf(2L), "Work", "Work")),
ledger,
setOf("Work.ics"),
at,
keep = 1,
)
assertThat(plans[0].staleLatest).isEmpty()
assertThat(plans[0].handOff).containsExactly("Work.ics")
assertThat(plans[1].latestName).isEqualTo("Work.ics")
}
@Test
fun `a deselected calendar's stable copy is not taken over by a same-named one`() {
// Calendar 3 "Personal" is out of the backup; calendar 9, also "Personal", is in.
val ledger = mapOf(latestLedgerKey("3") to listOf("Personal.ics"))
val plan = planBackupFiles(
listOf(BackupTarget("9", setOf(9L), "Personal", "Personal")), ledger, setOf("Personal.ics"), at, 1,
).single()
assertThat(plan.latestName).isNull()
}
@Test
fun `a layout switch replaces the other layout's stable copies, a deselected calendar keeps its own`() {
val ledger = mapOf(
latestLedgerKey("1") to listOf("Work.ics"),
latestLedgerKey("5") to listOf("Old.ics"),
latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE),
)
val existing = setOf("Work.ics", "Old.ics", LEGACY_BACKUP_FILE)
assertThat(replacedLatestCopies(listOf(combined), ledger, existing))
.containsExactly("1", listOf("Work.ics"))
assertThat(replacedLatestCopies(listOf(work), ledger, existing))
.containsExactly(COMBINED_SERIES, listOf(LEGACY_BACKUP_FILE))
}
@Test
fun `a ledger entry is set, emptied away, and dropped with its calendar`() {
val previous = mapOf(
"1" to listOf("Work_a.ics"),
latestLedgerKey("7") to listOf("Gone.ics"),
latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE),
)
val next = withLedgerEntry(previous, "1", listOf("Work_a.ics", "Work_b.ics", "Work_b.ics"), setOf("1"))
assertThat(next).containsExactly(
"1", listOf("Work_a.ics", "Work_b.ics"),
latestLedgerKey(COMBINED_SERIES), listOf(LEGACY_BACKUP_FILE),
)
assertThat(withLedgerEntry(next, "1", emptyList(), setOf("1")).keys)
.containsExactly(latestLedgerKey(COMBINED_SERIES))
}
@Test
fun `the ledger round-trips through storage`() {
val ledger = mapOf("3" to listOf("Work_b.ics", "Work_a (1).ics"), COMBINED_SERIES to listOf("calendula-backup_x.ics"))
assertThat(parseBackupLedger(persistBackupLedger(ledger))).isEqualTo(ledger)
assertThat(persistBackupLedger(emptyMap())).isNull()
assertThat(parseBackupLedger("garbage|/x|4/")).isEmpty()
}
@Test
fun `retention snaps to the offered choices`() {
assertThat(normalizeBackupKeep(0)).isEqualTo(1)
assertThat(normalizeBackupKeep(3)).isEqualTo(3)
assertThat(normalizeBackupKeep(50)).isEqualTo(10)
}
@Test
fun `a suffixed name that meets a real one is told apart again`() {
val targets = backupTargets(
listOf(cal(5L, "Work"), cal(6L, "Work"), cal(9L, "Work-5")),
combined = false,
)
assertThat(targets.map { it.baseName }).containsExactly("Work-5-5", "Work-6", "Work-5-9").inOrder()
}
@Test
fun `claiming a stable copy takes it off its previous owner`() {
val previous = mapOf(latestLedgerKey("1") to listOf("Work.ics", "Job.ics"))
val next = withLedgerEntry(previous, latestLedgerKey("2"), listOf("Work.ics"), setOf("1", "2"))
assertThat(next).containsExactly(
latestLedgerKey("1"), listOf("Job.ics"),
latestLedgerKey("2"), listOf("Work.ics"),
)
}
@Test
fun `a claimed hand-off shows as held elsewhere`() {
val ledger = mapOf(latestLedgerKey("1") to listOf("Job.ics"), latestLedgerKey("2") to listOf("Work.ics"), "2" to listOf("Work_a.ics"))
assertThat(ledger.latestHeldElsewhere("1")).containsExactly("Work.ics")
assertThat(ledger.latestHeldElsewhere("2")).containsExactly("Job.ics")
}
}
@@ -0,0 +1,232 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.formatExdateStamp
import de.jeanlucmakiola.calendula.domain.EventStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/** How a calendar's exception rows are written out for a backup (#147) or a copy (#325). */
class CalendarRowsTest {
private val start = Instant.parse("2026-01-05T09:00:00Z")
private val week = 7 * 24 * 3_600_000L
private fun event(
uid: String,
rrule: String? = null,
allDay: Boolean = false,
at: Instant = start,
status: EventStatus = EventStatus.Confirmed,
exDates: List<String> = emptyList(),
reminders: List<Int> = emptyList(),
color: Int? = null,
) = IcsEvent(
uid = uid,
summary = uid,
start = at,
end = Instant.fromEpochMilliseconds(at.toEpochMilliseconds() + 3_600_000L),
isAllDay = allDay,
zoneId = "Europe/Berlin",
recurrenceRule = rrule,
exDates = exDates,
reminderMinutes = reminders,
status = status,
color = color,
calendarName = "Work",
)
private fun occurrence(n: Int) = Instant.fromEpochMilliseconds(start.toEpochMilliseconds() + n * week)
private fun series(uid: String, rrule: String? = "FREQ=WEEKLY", id: Long = 1, calendar: Long = 1, syncId: String? = null, status: EventStatus = EventStatus.Confirmed) =
MasterRow(id, calendar, syncId, event(uid, rrule = rrule, status = status))
private fun flatten(series: List<MasterRow>, overrides: List<OverrideRow>) =
flattenOverrides(CalendarRows(series, overrides), ::formatExdateStamp)
@Test
fun `a cancelled occurrence becomes an EXDATE on the series`() {
val flat = flatten(
listOf(MasterRow(1, 1, "sync-1", event("s", rrule = "FREQ=WEEKLY", exDates = listOf("20260112T080000Z")))),
listOf(OverrideRow(1, null, "sync-1", occurrence(2), event("s", at = occurrence(2), status = EventStatus.Cancelled))),
)
assertThat(flat.single().exDates).containsExactly("20260112T080000Z", "20260119T090000Z").inOrder()
}
@Test
fun `a modified occurrence is excluded from the series and written on its own, first`() {
val moved = Instant.fromEpochMilliseconds(occurrence(1).toEpochMilliseconds() + 3_600_000L)
val flat = flatten(
listOf(series("s")),
listOf(OverrideRow(1, 1, null, occurrence(1), event("s", rrule = "FREQ=WEEKLY", at = moved))),
)
assertThat(flat).hasSize(2)
val detached = flat[0]
assertThat(detached.uid).isEqualTo("${occurrence(1).toEpochMilliseconds()}-s")
assertThat(detached.overrideOf).isEqualTo("s")
assertThat(detached.recurrenceRule).isNull()
assertThat(detached.start).isEqualTo(moved)
assertThat(detached.calendarName).isEqualTo("Work")
assertThat(flat[1].exDates).containsExactly("20260112T090000Z")
}
@Test
fun `an all-day series excludes the occurrence by its day`() {
val day = Instant.parse("2026-01-05T00:00:00Z")
val flat = flatten(
listOf(MasterRow(1, 1, null, event("s", rrule = "FREQ=DAILY", allDay = true, at = day))),
listOf(OverrideRow(1, 1, null, Instant.parse("2026-01-07T00:00:00Z"), event("s", allDay = true, status = EventStatus.Cancelled))),
)
assertThat(flat.single().exDates).containsExactly("20260107")
}
@Test
fun `exceptions without their series are left out`() {
val flat = flatten(
emptyList(),
listOf(
OverrideRow(1, 5, null, occurrence(1), event("x")),
OverrideRow(1, 5, null, occurrence(2), event("y", status = EventStatus.Cancelled)),
),
)
assertThat(flat).isEmpty()
}
@Test
fun `a sync id links an exception only within its own calendar`() {
val flat = flatten(
listOf(series("a", calendar = 1, syncId = "same"), series("b", id = 2, calendar = 2, syncId = "same")),
listOf(OverrideRow(2, null, "same", occurrence(1), event("x", status = EventStatus.Cancelled))),
)
assertThat(flat.first { it.uid == "a" }.exDates).isEmpty()
assertThat(flat.first { it.uid == "b" }.exDates).containsExactly("20260112T090000Z")
}
@Test
fun `an exception to a one-off is ignored`() {
val flat = flatten(
listOf(series("o", rrule = null)),
listOf(OverrideRow(1, 1, null, occurrence(0), event("x"))),
)
assertThat(flat.map { it.uid }).containsExactly("o")
}
@Test
fun `an RDATE-only series keeps its dates and takes its exceptions`() {
val flat = flatten(
listOf(MasterRow(1, 1, null, event("s").copy(recurrenceDates = "20260112T090000Z"))),
listOf(OverrideRow(1, 1, null, occurrence(1), event("s", status = EventStatus.Cancelled))),
)
assertThat(flat.single().recurrenceDates).isEqualTo("20260112T090000Z")
assertThat(flat.single().exDates).containsExactly("20260112T090000Z")
}
@Test
fun `two exception rows for one occurrence are written once`() {
val flat = flatten(
listOf(series("s")),
listOf(OverrideRow(1, 1, null, occurrence(1), event("x")), OverrideRow(1, 1, null, occurrence(1), event("y"))),
)
assertThat(flat.map { it.summary }).containsExactly("x", "s").inOrder()
}
@Test
fun `the output is stable, so a re-run dedups every event`() {
val rows = CalendarRows(listOf(series("s")), listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))))
assertThat(flattenOverrides(rows, ::formatExdateStamp).map { it.uid })
.isEqualTo(flattenOverrides(rows, ::formatExdateStamp).map { it.uid })
}
@Test
fun `a backup keeps a cancelled series, a copy leaves it out with its exceptions`() {
val rows = CalendarRows(
listOf(series("s", status = EventStatus.Cancelled)),
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))),
)
assertThat(flattenOverrides(rows, ::formatExdateStamp).map { it.uid }).contains("s")
assertThat(planCalendarCopy(rows, ::formatExdateStamp)).isEmpty()
}
@Test
fun `a copy keeps the zone, colour and override link`() {
val rows = CalendarRows(
listOf(MasterRow(1, 1, null, event("s", rrule = "FREQ=WEEKLY", reminders = listOf(10, -5, 10), color = 0xFF112233.toInt()))),
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))),
)
val (detached, copied) = planCalendarCopy(rows, ::formatExdateStamp)
assertThat(detached.overrideOf).isEqualTo("s")
assertThat(copied.zoneId).isEqualTo("Europe/Berlin")
assertThat(copied.color).isEqualTo(0xFF112233.toInt())
assertThat(copied.reminderMinutes).containsExactly(10)
}
@Test
fun `a zero-length all-day event lasts its day`() {
val day = Instant.parse("2026-01-05T00:00:00Z")
val parsed = event("a", allDay = true, at = day).copy(end = day).toParsedIcsEvent()
assertThat(parsed.end).isEqualTo(Instant.parse("2026-01-06T00:00:00Z"))
}
@Test
fun `the override link survives the file`() {
val text = IcsWriter().writeCalendar(listOf(event("d").copy(overrideOf = "s;1", overrideAt = occurrence(1))), start)
val parsed = IcsParser().parse(text).events.single()
assertThat(parsed.overrideOf).isEqualTo("s;1")
assertThat(parsed.overrideAt).isEqualTo(occurrence(1))
}
@Test
fun `a cancelled series' moved occurrence isn't brought back`() {
val flat = flatten(
listOf(series("s", status = EventStatus.Cancelled)),
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))),
)
assertThat(flat.map { it.uid }).containsExactly("s")
}
@Test
fun `the same series in two calendars keeps each calendar's moved occurrence`() {
val flat = flatten(
listOf(series("s", id = 1, calendar = 1), series("s", id = 2, calendar = 2)),
listOf(
OverrideRow(1, 1, null, occurrence(1), event("x")),
OverrideRow(2, 2, null, occurrence(1), event("x")),
),
)
assertThat(flat.count { it.overrideOf == "s" }).isEqualTo(2)
}
@Test
fun `a copy leaves declined invitations out`() {
val rows = CalendarRows(listOf(series("d").copy(declined = true), series("k", id = 2)), emptyList())
assertThat(planCalendarCopy(rows, ::formatExdateStamp).map { it.uid }).containsExactly("k")
}
@Test
fun `a copy treats a declined occurrence as deleted`() {
val rows = CalendarRows(
listOf(series("s")),
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"), declined = true)),
)
val plan = planCalendarCopy(rows, ::formatExdateStamp)
assertThat(plan.map { it.uid }).containsExactly("s")
assertThat(plan.single().exDates).containsExactly("20260112T090000Z")
}
}
@@ -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,51 @@ 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()
}
@Test
fun `a read-only calendar can be copied into a writable one`() {
val readOnly = cal(1, writable = false)
val local = cal(2, local = true)
val state = ready(backupUiState(listOf(readOnly, local)))
assertThat(state.canCopy).isTrue()
assertThat(copySources(listOf(readOnly, local)).map { it.id }).containsExactly(1L)
assertThat(copyTargets(listOf(readOnly, local), 1L).map { it.id }).containsExactly(2L)
}
@Test
fun `a single writable calendar has nothing to copy into`() {
val only = cal(1, local = true)
assertThat(ready(backupUiState(listOf(only))).canCopy).isFalse()
assertThat(copyTargets(listOf(only), 1L)).isEmpty()
}
@Test
fun `the only writable calendar is not offered as a source`() {
val local = cal(1, local = true)
val holidays = cal(2, writable = false)
assertThat(copySources(listOf(local, holidays)).map { it.id }).containsExactly(2L)
}
@Test
fun `a copy never targets a managed or switched-off calendar`() {
val source = cal(1)
val calendars = listOf(source, cal(2, managed = true), cal(3, visible = false))
assertThat(copyTargets(calendars, source.id)).isEmpty()
}
@Test
fun `choosing the target as source drops it`() {
assertThat(CopySelection(sourceId = 1, targetId = 2).withSource(2))
.isEqualTo(CopySelection(sourceId = 2, targetId = null))
assertThat(CopySelection(sourceId = 1, targetId = 2).withSource(3))
.isEqualTo(CopySelection(sourceId = 3, targetId = 2))
}
}
+65 -9
View File
@@ -462,15 +462,71 @@ after the event — so `toIcsEvent` decodes it back through
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
its own setting.
**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
of `EventExportProjection`, and `IcsWriter` marks an all-day series' exclusions
`VALUE=DATE`, because RFC 5545 ties `EXDATE`'s value type to `DTSTART`'s and a
bare day code without it reads as a malformed DATE-TIME. Sync adapters disagree
on whether an all-day exclusion is `yyyyMMdd` or a padded midnight stamp, so
`exportExDates` drops the time part on the way out. Without this, an in-app
backup→restore resurrected every occurrence the user had deleted.
**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
deletes names recorded in a per-series ledger of files the backup wrote
(`auto_backup_files`, keyed by calendar id, reset when the folder changes),
never whatever happens to match a
name, so renames, same-named calendars and the user's own files are safe.
Each series also keeps a stable undated copy (`<calendar name>.ics`, or
`calendula-backup.ics` combined) overwritten every run, so sync tools and
subscriptions can follow one path. Retention never touches it, and it is only
written over a file the series itself (or a series renaming away from that name
in the same run) recorded. A renamed calendar's old copy is deleted once the new
one has landed, and so is a copy the layout switch replaced (per-calendar ↔ combined); a calendar taken out
of the backup keeps its last one. The pre-#411 fixed `calendula-backup.ics` is
the combined series' stable copy for the install that wrote it, and an
install that already had a folder stays on the combined layout.
**A backup carries the series' deleted and moved occurrences.** `EXDATE` lives on
the master row and is part of `EventExportProjection`; `IcsWriter` marks an
all-day series' exclusions `VALUE=DATE`, because RFC 5545 ties `EXDATE`'s value
type to `DTSTART`'s and a bare day code without it reads as a malformed
DATE-TIME. Sync adapters disagree on whether an all-day exclusion is `yyyyMMdd`
or a padded midnight stamp, so `exportExDates` drops the time part on the way
out.
A synced calendar records a deleted or moved occurrence as an *exception row*
(`ORIGINAL_ID` / `ORIGINAL_SYNC_ID` = the series, `ORIGINAL_INSTANCE_TIME` = the
occurrence) instead, and neither a backup file nor a copied calendar can hold
one: an `.ics` restore skips `RECURRENCE-ID` overrides, and an inserted row has
no `_sync_id` for an exception to attach through. `CalendarDataSource.calendarRows`
therefore reads every row, exceptions included, and `flattenOverrides` — the one
place this mapping lives, shared by the backup (`exportEvents`) and Copy events
(#325, `planCalendarCopy`) — writes them the way Calendula writes "only this
event" on an unsynced series: every exception adds its occurrence to the
series' `EXDATE`, and a modified one becomes a standalone event at its own
times. Its UID is derived from the series UID and the occurrence, so it is the
same in every backup, and it carries the series UID as `X-CALENDULA-OVERRIDE-OF`.
`importEvents` skips such an occurrence wherever its series already is — that
series there has the override itself — so restoring into the calendar a backup
came from adds nothing twice, while a restore into an empty one gets both. The
standalone event also names its occurrence (`X-CALENDULA-OVERRIDE-AT`); if it
fails to insert, the series is inserted without that exclusion, so the
occurrence stays at its original time rather than vanishing for good. The
import inserts every such event ahead of the series, whatever order the file
lists them in.
Exceptions whose series isn't among the rows are left out, a cancelled
series' moved occurrences stay cancelled, and sync ids link only within their
own calendar. A file can't carry `RDATE`, so a backup treats a series that
repeats by listed dates alone as the one-off it writes it as. Backups made before this carry no exceptions
and restore exactly as before.
**Copying a calendar.** Backup & restore → Copy events (#325) runs the source's
flattened rows straight into `importEvents`, so the UID dedup makes a re-run skip
what landed before. It skips the `.ics` text: the writer pins a timed one-off to
UTC and carries no colour, visibility or `RDATE`; the direct mapping keeps them.
Attendees and organizer are never read, and cancelled events and declined
invitations are left out (a declined occurrence counts as deleted) (an insert can't be cancelled; it would arrive live). A re-run is idempotent per
snapshot: a series already in the target keeps its overrides, so an occurrence
changed since keeps its earlier copy rather than gaining a second one.
That re-encode is sampled where the reminder will actually *fire*, not at
`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an