Compare commits

...
Author SHA1 Message Date
makiolaj 00a8040efe fix(#516): review round 12 - a watch that fails early is retried 2026-11-14 13:17:13 +01:00
makiolaj 07a2bb632f fix(#516): review round 11 - watch re-armed even when queueing fails or the job is stopped, reconcile off the main thread 2026-11-14 13:05:20 +01:00
makiolaj 281d511402 fix(#516): WorkManager's JobScheduler ids kept below the change watch's 2026-11-14 12:55:49 +01:00
makiolaj ad5f0f4662 fix(#516): review round 10 - the change watch is one JobScheduler job that re-arms itself, startup reconcile can't crash the app 2026-11-14 12:53:58 +01:00
makiolaj a902f5f862 fix(#516): review round 9 - one rule keeps exactly one watch waiting, retired copies lose their hash 2026-11-14 12:42:26 +01:00
makiolaj 783916e3a7 fix(#516): review round 8 - one watch stays armed after runs fire together, notification decided under the lock, lists sorted before ordering 2026-11-14 12:31:41 +01:00
makiolaj 09c20ee90f fix(#516): review round 7 - re-arm queues behind a running slot, hashes only while the option is on, one series in memory at a time 2026-11-14 12:19:24 +01:00
makiolaj fb178a779e fix(#516): review round 6 - early exit holds for blocked and renamed copies, only changed texts kept, run time taken under the lock 2026-11-14 12:11:19 +01:00
makiolaj 34cd75e9a3 fix(#516): review round 5 - hash bound to the copy's name, folder untouched when nothing moved, re-arm under the reconcile lock 2026-11-14 12:03:23 +01:00
makiolaj 6758b7fada feat(#516): change runs report on the status line, failure notified once per streak 2026-11-14 11:53:08 +01:00
makiolaj 70f72d03db fix(#516): review round 4 - hash dropped before a stable copy is rewritten, overrides stay first in the backup order 2026-11-14 04:17:57 +01:00
makiolaj dca98bbb2d fix(#516): review round 3 - change runs stay off the status, unlisted copies don't retire the old layout, stable export order for the hash 2026-11-14 04:09:05 +01:00
makiolaj 93b795ed72 fix(#516): review round 2 - serialised reconcile, change runs skip copies a loading listing hides, failed copies lose their hash, change-run failures reported 2026-11-14 04:01:14 +01:00
makiolaj f18ae694a7 fix(#516): review round 1 - two watch slots so changes during a run count, change runs stay off the status line, one serialisation per series 2026-11-14 03:47:47 +01:00
makiolaj b2498d362b feat(#516): back up after changes, refreshing the stable copies 2026-11-14 03:39:16 +01:00
makiolaj 8e99c46f3e fix(#515, #325): review round 4 - declined rows dropped by row, not by UID 2026-11-14 03:31:47 +01:00
makiolaj 1e83000696 fix(#515, #325): review round 3 - bare-day EXDATE/RDATE on timed series resolved when read, unaccepted occurrences of declined series not copied 2026-11-14 03:22:45 +01:00
makiolaj 05887d2ed0 fix(#515, #325): review round 2 - multi-line and unreadable EXDATE handled, imports all or nothing per event, running copy survives list failures 2026-11-14 03:12:43 +01:00
makiolaj 028d8d0e1e fix(#515, #325): review round 1 - multi-line RDATE, accepted occurrences of declined series copied, partial copies say so 2026-11-14 03:01:37 +01:00
makiolaj 30af150197 feat(#515): write RDATE into .ics backups and read it back 2026-11-14 02:51:25 +01:00
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
40 changed files with 3503 additions and 329 deletions

No files matched your search

+18
View File
@@ -296,6 +296,12 @@
</intent-filter>
</service>
<!-- The change watch behind "Also back up after changes" (#516). -->
<service
android:name=".data.backup.BackupChangeJob"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
Instances + Reminders instead of waiting for the provider's
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
@@ -480,6 +486,18 @@
</intent-filter>
</receiver>
<!-- WorkManager initialises on demand from CalendulaApp's configuration. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Hands .ics files we stage in the cache to other apps via a content
Uri (single-event share). Authority tracks applicationId so the
debug suffix doesn't break getUriForFile. -->
@@ -1,8 +1,10 @@
package de.jeanlucmakiola.calendula
import android.app.Application
import androidx.work.Configuration
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupChangeJob
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
@@ -27,7 +29,11 @@ import kotlinx.coroutines.launch
* in AndroidManifest.xml. Hilt initializes its component graph here.
*/
@HiltAndroidApp
class CalendulaApp : Application() {
class CalendulaApp : Application(), Configuration.Provider {
/** Keeps WorkManager's JobScheduler ids clear of [BackupChangeJob]'s. */
override val workManagerConfiguration: Configuration =
Configuration.Builder().setJobSchedulerJobIdRange(0, BackupChangeJob.JOB_ID - 1).build()
override fun onCreate() {
super.onCreate()
@@ -110,12 +116,7 @@ class CalendulaApp : Application() {
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
val prefs = deps.settingsPrefs()
BackupScheduler.apply(
context = this@CalendulaApp,
enabled = prefs.autoBackupEnabled.first(),
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
hasFolder = prefs.autoBackupFolderUri.first() != null,
)
BackupScheduler.reconcile(this@CalendulaApp, prefs)
}
}
@@ -5,18 +5,23 @@ 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
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import androidx.work.workDataOf
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -25,42 +30,87 @@ 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.BackupFilePlan
import de.jeanlucmakiola.calendula.domain.backup.BackupTarget
import de.jeanlucmakiola.calendula.domain.backup.backupFingerprint
import de.jeanlucmakiola.calendula.domain.backup.backupTargets
import de.jeanlucmakiola.calendula.domain.backup.inBackupOrder
import de.jeanlucmakiola.calendula.domain.backup.latestHeldElsewhere
import de.jeanlucmakiola.calendula.domain.backup.latestFileName
import de.jeanlucmakiola.calendula.domain.backup.latestLedgerKey
import de.jeanlucmakiola.calendula.domain.backup.needsRefresh
import de.jeanlucmakiola.calendula.domain.backup.planBackupFiles
import de.jeanlucmakiola.calendula.domain.backup.replacedLatestCopies
import de.jeanlucmakiola.calendula.domain.backup.withFingerprint
import de.jeanlucmakiola.calendula.domain.backup.withLedgerEntry
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.Dispatchers
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 {
private const val WORK_NAME = "auto-backup"
private const val WORK_NAME_NOW = "auto-backup-now"
private const val WORK_NAME_CHANGE = "auto-backup-change"
internal const val KEY_CHANGE_RUN = "change_run"
private val reconcileLock = Mutex()
/** A watch that fired while the option was off: still pending until it finishes, but not re-armed. */
private var watchDropped = false
/**
* Reconcile the scheduled work with the current settings: enqueue a unique
* Reconcile the scheduled work with the saved settings: enqueue a unique
* 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. With backing up after changes on
* (#516), the change watch ([BackupChangeJob]) is armed unless it already
* waits; it doesn't survive a reboot, and this runs on every process start.
* Serialised and not cancellable, so quick toggles settle on the last one.
*/
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
suspend fun reconcile(context: Context, prefs: SettingsPrefs) = withContext(NonCancellable + Dispatchers.IO) {
reconcileLock.withLock {
try {
apply(context, prefs)
} catch (e: Exception) {
Log.w(TAG, "Scheduling the automatic backup failed", e)
}
}
}
private suspend fun apply(context: Context, prefs: SettingsPrefs) {
val workManager = WorkManager.getInstance(context)
if (!enabled || !hasFolder) {
fun stopWatching() {
BackupChangeJob.cancel(context)
workManager.cancelUniqueWork(WORK_NAME_CHANGE)
}
if (!prefs.autoBackupEnabled.first() || prefs.autoBackupFolderUri.first() == null) {
workManager.cancelUniqueWork(WORK_NAME)
// Also cancel any pending immediate run — otherwise a failing run-now
// keeps retrying after the user has turned backup off.
workManager.cancelUniqueWork(WORK_NAME_NOW)
stopWatching()
return
}
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
val interval = intervalMinutes.coerceAtLeast(15L)
val interval = prefs.autoBackupIntervalMinutes.first().coerceAtLeast(15L)
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
.setInitialDelay(interval, TimeUnit.MINUTES)
.build()
@@ -69,6 +119,44 @@ object BackupScheduler {
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
if (!prefs.autoBackupOnChange.first()) {
stopWatching()
} else if (watchDropped || !BackupChangeJob.isPending(context)) {
// Replacing a waiting watch would drop the changes it has seen.
BackupChangeJob.schedule(context)
watchDropped = false
}
}
/**
* The watch fired: queue a change run and arm the next watch, unless the
* option went off meanwhile. Under [reconcileLock], so it can't re-arm
* after a switch-off.
*/
internal suspend fun changed(context: Context, prefs: SettingsPrefs) = withContext(NonCancellable) {
reconcileLock.withLock {
// Re-armed even when queueing the run fails: a trigger fires only once.
var wanted = true
try {
wanted = prefs.autoBackupEnabled.first() && prefs.autoBackupFolderUri.first() != null &&
prefs.autoBackupOnChange.first()
if (wanted) queueChangeRun(context)
} finally {
if (wanted) BackupChangeJob.schedule(context)
watchDropped = !wanted
}
}
}
private suspend fun queueChangeRun(context: Context) {
val workManager = WorkManager.getInstance(context)
val states = workManager.getWorkInfosForUniqueWorkFlow(WORK_NAME_CHANGE).first().map { it.state }
val policy = changeRunPolicy(states) ?: return
val run = OneTimeWorkRequestBuilder<BackupWorker>()
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
.setInputData(workDataOf(KEY_CHANGE_RUN to true))
.build()
workManager.enqueueUniqueWork(WORK_NAME_CHANGE, policy, run).await()
}
/**
@@ -83,11 +171,25 @@ object BackupScheduler {
OneTimeWorkRequestBuilder<BackupWorker>().build(),
)
}
private const val TAG = "BackupScheduler"
}
/**
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
* overwriting the previous file. Pulls its collaborators through a Hilt
* How a fired change watch queues its run (#516), from the change runs' work
* [states]: a run that hasn't started yet will see the change anyway (null);
* one already going may have read too early, so the new one queues behind it.
*/
internal fun changeRunPolicy(states: List<WorkInfo.State>): ExistingWorkPolicy? = when {
states.any { it == WorkInfo.State.ENQUEUED || it == WorkInfo.State.BLOCKED } -> null
WorkInfo.State.RUNNING in states -> ExistingWorkPolicy.APPEND_OR_REPLACE
else -> ExistingWorkPolicy.REPLACE
}
/**
* 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.
@@ -107,27 +209,230 @@ 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()
val changeRun = inputData.getBoolean(BackupScheduler.KEY_CHANGE_RUN, false)
if (!changeRun) return backUp(deps, deps.settingsPrefs(), changeRun = false)
// A failed change run would fail the one queued behind it too.
try {
backUp(deps, deps.settingsPrefs(), changeRun = true)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
prefs.recordAutoBackupRun(success = false, atMillis = now)
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
notifyFailure(applicationContext)
}
Log.w(TAG, "Automatic backup failed", e)
Result.retry()
Log.w(TAG, "Backup after a change failed", e)
}
return Result.success()
}
/**
* One run under the shared lock, recorded for the status line. A change
* run (#516) refreshes only the stable copies and never retries. The
* failure notification comes once per failure streak, when it reaches
* [FAILURE_NOTIFY_THRESHOLD], and goes with the next success of any run.
*/
private suspend fun backUp(deps: Deps, prefs: SettingsPrefs, changeRun: Boolean): Result {
// One run at a time, or each would overwrite the other's ledger.
val outcome = runLock.withLock {
val now = System.currentTimeMillis()
// Respect the toggles even for already-queued work, so a lingering
// run can't revive itself.
if (!prefs.autoBackupEnabled.first()) return Result.success()
if (changeRun && !prefs.autoBackupOnChange.first()) return Result.success()
val folder = prefs.autoBackupFolderUri.first() ?: return Result.failure()
val outcome = try {
runBackup(deps, prefs, folder.toUri(), latestOnly = changeRun)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Automatic backup failed", e)
RunOutcome.Failed
}
if (outcome != RunOutcome.NothingToDo) {
withContext(NonCancellable) {
prefs.recordAutoBackupRun(success = outcome == RunOutcome.Written, atMillis = now)
when {
outcome == RunOutcome.Written -> clearFailureNotification(applicationContext)
prefs.autoBackupStatus.first().consecutiveFailures == FAILURE_NOTIFY_THRESHOLD ->
notifyFailure(applicationContext)
}
}
}
outcome
}
// Only a run that wrote nothing retries: a retry rewrites every series.
return if (outcome == RunOutcome.Failed && !changeRun) Result.retry() else Result.success()
}
private enum class RunOutcome { Written, PartlyFailed, Failed, NothingToDo }
/** How a stable copy's write went: under its own name, under one the provider picked, or not at all. */
private enum class LatestWrite { Landed, Renamed, Failed }
/**
* Writes each series and prunes it only once its new file has landed. A
* [latestOnly] run (#516) leaves the dated files and their retention to the
* schedule and rewrites only the stable copies whose calendars changed.
*/
private suspend fun runBackup(deps: Deps, prefs: SettingsPrefs, folder: Uri, latestOnly: Boolean): 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.NothingToDo
val stamp = Clock.System.now()
val fingerprints = prefs.autoBackupFingerprints.first()
suspend fun export(target: BackupTarget): String? = try {
val events = repository.exportEvents(target.calendarIds).inBackupOrder()
IcsWriter().writeCalendar(events, stamp, target.calendarName)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Reading one series failed", e)
null
}
fun fingerprintOf(target: BackupTarget, text: String) =
backupFingerprint(text, latestFileName(target.baseName))
// Hashes only serve change runs; without them a stale one could pass later.
val trackHashes = prefs.autoBackupOnChange.first()
if (!trackHashes && fingerprints.isNotEmpty()) prefs.updateAutoBackupFingerprints(folder.toString()) { emptyMap() }
// A change run hashes first (null: unreadable) and leaves the folder alone when nothing moved.
val preHashes: Map<String, String?> = if (latestOnly) {
targets.associate { target -> target.key to export(target)?.let { fingerprintOf(target, it) } }
} else {
emptyMap()
}
if (latestOnly && targets.all { preHashes[it.key].let { hash -> hash != null && hash == fingerprints[it.key] } }) {
return RunOutcome.NothingToDo
}
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 liveKeys = all.map { it.id.toString() }.toSet()
suspend fun record(key: String, files: List<String>) =
prefs.updateAutoBackupLedger(folder.toString()) { withLedgerEntry(it, key, files, liveKeys) }
suspend fun writeDated(plan: BackupFilePlan, bytes: ByteArray): Boolean = try {
val name = exporter.writeToFolder(folder, listing, plan.fileName, bytes)
record(plan.target.key, plan.prune + plan.keep + name)
val undeleted = exporter.deleteFromFolder(listing, plan.prune)
record(plan.target.key, plan.prune.filter { it in undeleted } + plan.keep + name)
true
} catch (e: Exception) {
Log.w(TAG, "Backup of one series failed", e)
false
}
// An old copy goes only once the new one has landed.
suspend fun writeLatest(plan: BackupFilePlan, bytes: ByteArray, fingerprint: String): LatestWrite {
val target = plan.target
val latest = checkNotNull(plan.latestName)
var landed: String? = null
try {
val latestKey = latestLedgerKey(target.key)
if (trackHashes && target.key in fingerprints) {
prefs.updateAutoBackupFingerprints(folder.toString()) { it - target.key }
}
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>, landedFingerprint: String? = null) =
prefs.updateAutoBackupLedger(
folder.toString(),
fingerprints = landedFingerprint?.let { hash ->
{ old -> withFingerprint(old, target.key, hash, liveKeys) }
},
) {
val pending = plan.handOff - it.latestHeldElsewhere(target.key)
withLedgerEntry(it, latestKey, stale + pending + current, liveKeys)
}
recordLatest(plan.staleLatest, fingerprint.takeIf { trackHashes })
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)
}
return when (landed) {
null -> LatestWrite.Failed
latest -> LatestWrite.Landed
else -> LatestWrite.Renamed
}
}
// A copy blocked by a file of the user's keeps its hash, so a later change run can stop early.
suspend fun rememberBlocked(plan: BackupFilePlan, hash: String) {
val key = plan.target.key
if (plan.latestName == null && trackHashes && fingerprints[key] != hash) {
prefs.updateAutoBackupFingerprints(folder.toString()) { withFingerprint(it, key, hash, liveKeys) }
}
}
var written = 0
var failed = 0
val landedLatest = mutableSetOf<String>()
for (plan in plans) {
val target = plan.target
if (latestOnly) {
val hash = preHashes[target.key] ?: run {
failed++
continue
}
rememberBlocked(plan, hash)
if (!plan.needsRefresh(hash, fingerprints, listing.files.keys, listing.complete)) {
if (plan.latestName in listing.files) landedLatest += target.key
continue
}
}
// Read again rather than held since the pre-pass: one series' text in memory at a time.
val text = export(target) ?: run {
failed++
continue
}
val fingerprint = fingerprintOf(target, text)
if (!latestOnly) rememberBlocked(plan, fingerprint)
val bytes = text.toByteArray(Charsets.UTF_8)
// Not cancellable once a file exists: it must reach the ledger.
withContext(NonCancellable) {
val dated = if (latestOnly) null else writeDated(plan, bytes)
val latest = plan.latestName?.let { writeLatest(plan, bytes, fingerprint) }
if (latest == LatestWrite.Landed) landedLatest += target.key
// A full run's status follows its dated file; the stable copy is best-effort there.
val ok = dated ?: (latest != LatestWrite.Failed)
if (ok) written++ else failed++
}
}
// 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 })
// Its copy is gone, so its hash mustn't let a change run stop early.
prefs.updateAutoBackupFingerprints(folder.toString()) { it - key }
}
}
}
return when {
failed == 0 && written == 0 -> RunOutcome.NothingToDo
failed == 0 -> RunOutcome.Written
written == 0 -> RunOutcome.Failed
else -> RunOutcome.PartlyFailed
}
}
private fun clearFailureNotification(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
private fun notifyFailure(context: Context) {
@@ -165,10 +470,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()
}
}
@@ -0,0 +1,87 @@
package de.jeanlucmakiola.calendula.data.backup
import android.app.job.JobInfo
import android.app.job.JobParameters
import android.app.job.JobScheduler
import android.app.job.JobService
import android.content.ComponentName
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
/**
* The watch behind "Also back up after changes" (#516): a JobScheduler job
* with a content trigger on the provider's root URI, where it reports every
* change, whoever made it (this app, a sync adapter or another app). It waits
* for [SETTLE_MINUTES] of quiet, at most [MAX_WAIT_MINUTES], so a burst makes
* one run. A trigger fires once; each firing queues a change run and schedules
* the next watch under the same id before finishing, which hands over the
* changes seen meanwhile, so exactly one watch exists and none are lost.
*/
class BackupChangeJob : JobService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onStartJob(params: JobParameters): Boolean {
scope.launch {
// Retried when it failed early, so the watch isn't lost; once re-armed, the retry is moot.
val failed = try {
val deps = EntryPointAccessors.fromApplication(applicationContext, BackupWorker.Deps::class.java)
BackupScheduler.changed(applicationContext, deps.settingsPrefs())
false
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Queueing a backup after a change failed", e)
true
}
jobFinished(params, failed)
}
return true
}
/** Stopped before it could re-arm: run again rather than leave the provider unwatched. */
override fun onStopJob(params: JobParameters): Boolean = true
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
companion object {
/** Above the range WorkManager is given (see CalendulaApp). */
const val JOB_ID = 0x0516_0001
private const val SETTLE_MINUTES = 2L
private const val MAX_WAIT_MINUTES = 15L
private const val TAG = "BackupChangeJob"
fun schedule(context: Context) {
val job = JobInfo.Builder(JOB_ID, ComponentName(context, BackupChangeJob::class.java))
.addTriggerContentUri(
JobInfo.TriggerContentUri(
CalendarContract.CONTENT_URI,
JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS,
),
)
.setTriggerContentUpdateDelay(TimeUnit.MINUTES.toMillis(SETTLE_MINUTES))
.setTriggerContentMaxDelay(TimeUnit.MINUTES.toMillis(MAX_WAIT_MINUTES))
.build()
if (context.getSystemService(JobScheduler::class.java).schedule(job) != JobScheduler.RESULT_SUCCESS) {
Log.w(TAG, "The change watch could not be scheduled")
}
}
fun isPending(context: Context): Boolean =
context.getSystemService(JobScheduler::class.java).getPendingJob(JOB_ID) != null
fun cancel(context: Context) = context.getSystemService(JobScheduler::class.java).cancel(JOB_ID)
}
}
@@ -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,94 @@ 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 selfStatus = getIntOrNull(EventExportProjection.IDX_SELF_ATTENDEE_STATUS)
val declined = selfStatus == 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,
accepted = selfStatus == CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED ||
selfStatus == CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
)
}
}
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(
@@ -865,18 +915,25 @@ class AndroidCalendarDataSource @Inject constructor(
} else {
null
}
event.semanticReminderMinutes().forEach { minutes ->
val providerMinutes = if (reminderDate != null) {
toProviderAllDayMinutes(
semanticMinutes = minutes,
startDate = reminderDate,
zone = zone,
timeOfDayMinutes = allDayReminderTimeMinutes,
)
} else {
minutes
// All or nothing: a row whose reminders failed would count as not added
// and still be there.
try {
event.semanticReminderMinutes().forEach { minutes ->
val providerMinutes = if (reminderDate != null) {
toProviderAllDayMinutes(
semanticMinutes = minutes,
startDate = reminderDate,
zone = zone,
timeOfDayMinutes = allDayReminderTimeMinutes,
)
} else {
minutes
}
insertReminderRow(eventId, providerMinutes)
}
insertReminderRow(eventId, providerMinutes)
} catch (t: Throwable) {
runCatching { resolver.delete(eventUri(eventId), null, null) }
throw t
}
return eventId
}
@@ -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,33 @@ 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) {
flattenOverrides(dataSource.calendarRows(calendarIds, allDayReminderTimeMinutes()), ::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 +290,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 +314,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 +324,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 +411,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,12 @@ 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.isEmpty()) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
event.recurrenceRule?.let { put(CalendarContract.Events.RRULE, it) }
event.recurrenceDates.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.RDATE, it.joinToString(",")) }
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
@@ -866,6 +868,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,9 +1,13 @@
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.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -28,6 +32,8 @@ internal fun ColumnReader.toIcsEvent(
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
val timezone = getString(EventExportProjection.IDX_EVENT_TIMEZONE)
val rdates = exportRecurrenceDates(getString(EventExportProjection.IDX_RDATE), isAllDay, timezone, dtStart)
// Unclamped: the writer renders DTEND as stored.
val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
@@ -38,17 +44,12 @@ internal fun ColumnReader.toIcsEvent(
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = isAllDay,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
zoneId = timezone?.takeIf { it.isNotBlank() } ?: "UTC",
recurrenceRule = rrule,
exDates = if (rrule == null) {
exDates = if (rrule == null && rdates.isEmpty()) {
emptyList()
} else {
exportExDates(
getString(EventExportProjection.IDX_EXDATE),
isAllDay,
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
)
exportExDates(getString(EventExportProjection.IDX_EXDATE), isAllDay, timezone, dtStart)
},
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
@@ -63,6 +64,8 @@ internal fun ColumnReader.toIcsEvent(
status = eventStatus(EventExportProjection.IDX_STATUS),
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
recurrenceDates = rdates,
accessLevel = getIntOrNull(EventExportProjection.IDX_ACCESS_LEVEL)?.let(::mapAccessLevel) ?: AccessLevel.Default,
)
}
@@ -94,16 +97,53 @@ internal fun exportExDates(
exdate: String?,
isAllDay: Boolean,
eventTimezone: String? = null,
seriesStartMillis: Long? = null,
): List<String> {
if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone)
return exdateStamps(exdate)
.map { stamp ->
if (isAllDay) {
stamp.substringBefore('T')
} else {
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
// Several properties are stored one per line, each with its own zone prefix.
return exdate.lines().filter { it.isNotBlank() }.flatMap { line ->
val zone = exdateListZone(line, eventTimezone)
exdateStamps(line).mapNotNull { stamp ->
when {
isAllDay -> stamp.substringBefore('T')
// A bare day on a timed series means the occurrence at the series' time of day.
seriesStartMillis != null && BARE_DAY.matches(stamp) -> atSeriesTime(stamp, seriesStartMillis, zone)
else -> canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
}
}
}
.filter { (if (isAllDay) BARE_DAY else UTC_STAMP).matches(it) }
.distinct()
}
/**
* The row's `RDATE` in the same shape [exportExDates] gives `EXDATE` — the list
* shares its `<zone>;<stamps>` form. A `PERIOD` entry (`start/end`) keeps only its
* start: the occurrence then runs the series' own length. Anything that still
* isn't a date or a UTC stamp is dropped, since a file can't name it.
*/
internal fun exportRecurrenceDates(
rdate: String?,
isAllDay: Boolean,
eventTimezone: String? = null,
seriesStartMillis: Long? = null,
): List<String> {
if (rdate.isNullOrBlank()) return emptyList()
// The zone prefix may hold a '/' of its own (Europe/Berlin), so only the stamps lose their period end.
val starts = rdate.lines().joinToString("\n") { line ->
val stamps = line.withoutExdateParameters()
line.dropLast(stamps.length) + stamps.split(',').joinToString(",") { it.substringBefore('/') }
}
return exportExDates(starts, isAllDay, eventTimezone, seriesStartMillis)
}
/** [day] (`yyyyMMdd`) at the wall-clock time the series starts at in [zone], as a UTC stamp. */
private fun atSeriesTime(day: String, seriesStartMillis: Long, zone: ZoneId): String? {
val date = runCatching { LocalDate.parse(day, DateTimeFormatter.BASIC_ISO_DATE) }.getOrNull() ?: return null
val time = Instant.ofEpochMilli(seriesStartMillis).atZone(zone).toLocalTime()
return formatExdateStamp(date.atTime(time).atZone(zone).toInstant().toEpochMilli(), isAllDay = false)
}
private val BARE_DAY = Regex("""\d{8}""")
private val UTC_STAMP = Regex("""\d{8}T\d{6}Z""")
@@ -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,23 @@
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.backup.BackupFingerprints
import de.jeanlucmakiola.calendula.domain.backup.parseBackupFingerprints
import de.jeanlucmakiola.calendula.domain.backup.persistBackupFingerprints
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
@@ -29,6 +43,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 +195,12 @@ 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 autoBackupOnChange: Flow<Boolean> = prefs.flow(SettingsKeys.autoBackupOnChange)
val autoBackupFingerprints: Flow<BackupFingerprints> = prefs.flow(SettingsKeys.autoBackupFingerprints)
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 +350,78 @@ 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.autoBackupFingerprints] = 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)
suspend fun setAutoBackupOnChange(enabled: Boolean) = prefs.set(SettingsKeys.autoBackupOnChange, enabled)
/**
* 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.
* [fingerprints] updates the series' hashes (#516) in the same edit.
*/
suspend fun updateAutoBackupLedger(
folder: String,
fingerprints: ((BackupFingerprints) -> BackupFingerprints)? = null,
transform: (BackupLedger) -> BackupLedger,
) {
prefs.edit {
if (it[SettingsKeys.autoBackupFolderUri] == folder) {
it[SettingsKeys.autoBackupLedger] = transform(it[SettingsKeys.autoBackupLedger])
if (fingerprints != null) {
it[SettingsKeys.autoBackupFingerprints] = fingerprints(it[SettingsKeys.autoBackupFingerprints])
}
}
}
}
/** Like [updateAutoBackupLedger], for the series' hashes (#516) alone. */
suspend fun updateAutoBackupFingerprints(folder: String, transform: (BackupFingerprints) -> BackupFingerprints) {
prefs.edit {
if (it[SettingsKeys.autoBackupFolderUri] == folder) {
it[SettingsKeys.autoBackupFingerprints] = transform(it[SettingsKeys.autoBackupFingerprints])
}
}
}
/** 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 +792,34 @@ 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)
/** Whether a change to a backed-up calendar also refreshes the stable copies (#516). Opt-in. */
val autoBackupOnChange: Pref<Boolean> = booleanPref("auto_backup_on_change", false)
/** Content hash of each series' stable copy, so a change run skips untouched ones (#516). */
val autoBackupFingerprints: Pref<BackupFingerprints> =
nullableStringPref("auto_backup_fingerprints").map(::parseBackupFingerprints, ::persistBackupFingerprints)
/** Master switch for the contact special-dates mirror (issue #15). Opt-in. */
val specialDatesEnabled: Pref<Boolean> = booleanPref("special_dates_enabled", false)
@@ -749,6 +869,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,368 @@
package de.jeanlucmakiola.calendula.domain.backup
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.flattenOverrides
import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.floret.android.safeFileName
import java.security.MessageDigest
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) }
/**
* Content hash of a series' file, per series key (#516), so a run started by a
* change can tell whether the calendars it covers changed at all.
*/
typealias BackupFingerprints = Map<String, String>
/**
* Hash of a series' file, leaving out the run's `DTSTAMP`s so equal events give
* an equal hash, and bound to the stable copy's [fileName], so a copy under
* another name never passes as current.
*/
fun backupFingerprint(ics: String, fileName: String): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(fileName.toByteArray(Charsets.UTF_8))
digest.update(LINE_BREAK)
ics.lineSequence().filterNot { it.startsWith(DTSTAMP_PREFIX) }
.forEach {
digest.update(it.toByteArray(Charsets.UTF_8))
digest.update(LINE_BREAK)
}
return digest.digest().take(FINGERPRINT_BYTES).joinToString("") { "%02x".format(it) }
}
/** [previous] with [key] set to [fingerprint]; entries of deleted calendars go, as in [withLedgerEntry]. */
fun withFingerprint(
previous: BackupFingerprints,
key: String,
fingerprint: String,
liveKeys: Set<String>,
): BackupFingerprints =
previous.filterKeys { it in liveKeys || it == COMBINED_SERIES } + (key to fingerprint)
private const val FINGERPRINT_BYTES = 16
private const val DTSTAMP_PREFIX = "DTSTAMP:"
private val LINE_BREAK = byteArrayOf('\n'.code.toByte())
private const val FINGERPRINT_SEP = "="
fun persistBackupFingerprints(fingerprints: BackupFingerprints): String? =
fingerprints.takeIf { it.isNotEmpty() }
?.entries?.sortedBy { it.key }
?.joinToString(LEDGER_ENTRY_SEP) { (key, hash) -> "$key$FINGERPRINT_SEP$hash" }
fun parseBackupFingerprints(stored: String?): BackupFingerprints =
stored?.split(LEDGER_ENTRY_SEP).orEmpty().mapNotNull { entry ->
val key = entry.substringBefore(FINGERPRINT_SEP, "")
val hash = entry.substringAfter(FINGERPRINT_SEP, "")
if (key.isEmpty() || hash.isEmpty()) null else key to hash
}.toMap()
/**
* Whether a run started by a change (#516) rewrites this series' stable copy:
* only when its content moved on from what the copy holds (a rename changes
* the name written into it), or the copy is missing. Never when the name belongs to a file of the user's,
* nor when a [complete]-less listing doesn't show the copy: writing then would
* add a duplicate beside it, so that is left to the scheduled run.
*/
fun BackupFilePlan.needsRefresh(
fingerprint: String,
stored: BackupFingerprints,
listed: Set<String>,
complete: Boolean,
): Boolean {
val latest = latestName ?: return false
if (latest !in listed) return complete
return stored[target.key] != fingerprint
}
/**
* The events in a fixed order, their lists sorted, so the same content always
* writes the same file and hashes the same, however the provider returned it.
* Standalone occurrences stay first, as [flattenOverrides] puts them.
*/
fun List<IcsEvent>.inBackupOrder(): List<IcsEvent> =
map {
it.copy(
reminderMinutes = it.reminderMinutes.sorted(),
exDates = it.exDates.sorted(),
recurrenceDates = it.recurrenceDates.sorted(),
)
}.sortedWith(
compareBy<IcsEvent>(
{ it.overrideOf == null }, { it.start }, { it.uid }, { it.overrideAt }, { it.calendarName },
{ it.toString() },
),
)
@@ -0,0 +1,74 @@
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 declinedSeries = rows.series.filter { it.declined }
val declinedIds = declinedSeries.map { it.eventId }.toSet()
val declinedSyncIds = declinedSeries.filter { it.syncId != null }.map { it.calendarId to it.syncId }.toSet()
fun OverrideRow.ofDeclinedSeries() =
originalId in declinedIds || (originalSyncId != null && (calendarId to originalSyncId) in declinedSyncIds)
val live = CalendarRows(
series = rows.series.filterNot { it.event.status == EventStatus.Cancelled },
// A declined occurrence is as good as deleted here, and so is one of a
// declined series the user didn't accept on its own.
overrides = rows.overrides.map {
if (it.declined || (it.ofDeclinedSeries() && !it.accepted)) {
it.copy(event = it.event.copy(status = EventStatus.Cancelled))
} else {
it
}
},
)
// A declined series still hands its accepted occurrences on before it goes.
// The flattened series follow the standalone occurrences, one per row in order.
val flat = flattenOverrides(live, exdateStamp)
val standalone = flat.size - live.series.size
return flat.filterIndexed { i, _ -> i < standalone || live.series[i - standalone].eventId !in declinedIds }
.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.filter { it.isNotBlank() }.distinct(),
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,99 @@
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,
/** The user accepted (or tentatively accepted) this occurrence on its own. */
val accepted: 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; `importEvents` enforces that order for
* any file, 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 = emptyList(),
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,
/** Extra occurrences (`RDATE`), in the same shape as [exDates]. */
val recurrenceDates: List<String> = emptyList(),
/** 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.isNotEmpty()
/**
* 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,
/** Extra occurrences (`RDATE`), in the same shape as [exDates]. */
val recurrenceDates: List<String> = emptyList(),
val accessLevel: AccessLevel = AccessLevel.Default,
)
/**
@@ -212,7 +224,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
var calendarColor: Int? = null
val reminders = mutableListOf<Int>()
val exDateLines = mutableListOf<IcsContentLine>()
val rDateLines = mutableListOf<IcsContentLine>()
var skipAsOverride = false
var overrideOf: String? = null
var overrideAt: Instant? = null
var i = 0
while (i < body.size) {
@@ -233,6 +248,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"DURATION" -> duration = line.value.trim()
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
"EXDATE" -> exDateLines.add(line)
"RDATE" -> rDateLines.add(line)
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
"STATUS" -> status = mapIcsStatus(line.value)
@@ -241,6 +257,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
@@ -279,6 +297,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val recurrence = sanitizeRrule(rrule)
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
val end = resolveEnd(start, dtEnd, duration)
val recurrenceDates = normalizeExDates(rDateLines, start)
return ParsedIcsEvent(
uid = uid,
summary = summary,
@@ -294,12 +313,15 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
availability = availability,
calendarName = calendarName ?: category,
color = eventColor ?: calendarColor,
exDates = if (recurrence.rule == null) {
exDates = if (recurrence.rule == null && recurrenceDates.isEmpty()) {
emptyList()
} else {
normalizeExDates(exDateLines, start)
},
recurrenceDates = recurrenceDates,
isTask = isTask,
overrideOf = overrideOf,
overrideAt = overrideAt,
)
}
@@ -334,13 +356,16 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
* still means the day it spells out; resolving it to an instant first and
* reading *that* back in UTC would roll a midnight east of UTC onto the day
* before and exclude an occurrence that doesn't exist.
*
* `RDATE` shares the value form and is read here too.
*/
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList()
val startZone = zoneOrNull(start.zoneId) ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time
return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
// An RDATE PERIOD (`start/end`) counts by its start.
.flatMap { line -> line.value.split(',').map { line to it.trim().substringBefore('/') } }
.mapNotNull { (line, token) ->
when {
token.isEmpty() -> null
@@ -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")
}
@@ -45,6 +47,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() }
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
appendRecurrenceDates(event)
appendExDates(event)
event.location?.takeIf { it.isNotBlank() }
?.let { add("LOCATION:${escapeText(it)}") }
@@ -54,6 +57,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)
}
@@ -66,7 +72,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
}
// Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> {
event.recurs -> {
val zone = zoneOrNull(event.zoneId)
if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
@@ -83,6 +89,14 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
}
}
/** Extra occurrences: the same UTC stamps / `VALUE=DATE` days as [appendExDates]. */
private fun MutableList<String>.appendRecurrenceDates(event: IcsEvent) {
val stamps = event.recurrenceDates.filter { it.isNotBlank() }.distinct()
if (stamps.isEmpty()) return
val prefix = if (event.isAllDay) "RDATE;VALUE=DATE:" else "RDATE:"
add(prefix + stamps.joinToString(","))
}
/**
* The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to
* `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without
@@ -90,7 +104,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
* recurring event: an exclusion names an occurrence, and a one-off has none.
*/
private fun MutableList<String>.appendExDates(event: IcsEvent) {
if (event.recurrenceRule.isNullOrBlank()) return
if (!event.recurs) return
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
if (stamps.isEmpty()) return
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
@@ -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,83 @@ 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)
GroupedSwitchRow(
title = stringResource(R.string.calendars_auto_backup_on_change),
summary = stringResource(R.string.calendars_auto_backup_on_change_summary),
checked = autoBackup.onChange,
onCheckedChange = viewModel::setAutoBackupOnChange,
position = Position.Middle,
)
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(copyRun.titleRes)
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 +435,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 +453,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 +474,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,20 @@ 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,
settingsPrefs.autoBackupOnChange,
) { base, selection, combined, keep, onChange ->
base.copy(selection = selection, combined = combined, keep = keep, onChange = onChange)
}
.flowOn(io)
.stateWhileSubscribed(
@@ -115,18 +127,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,13 +220,58 @@ 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 setAutoBackupOnChange(enabled: Boolean) {
viewModelScope.launch {
settingsPrefs.setAutoBackupOnChange(enabled)
reschedule()
}
}
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,
enabled = settingsPrefs.autoBackupEnabled.first(),
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
)
BackupScheduler.reconcile(context, settingsPrefs)
}
private inline fun write(crossinline block: suspend () -> Unit) {
@@ -232,6 +293,10 @@ 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,
val onChange: Boolean = false,
)
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
@@ -0,0 +1,247 @@
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 running = run is CopyRunState.Running
val sources = remember(list) { copySources(list) }
// A running copy keeps showing its source even if it stopped qualifying meanwhile.
val source = (if (running) list else sources).firstOrNull { it.id == selection.sourceId }
val targets = remember(list, source?.id) { copyTargets(list, source?.id) }
val target = targets.firstOrNull { it.id == selection.targetId }
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)
// A copy under way keeps its progress and Cancel whatever the list does.
if (run == CopyRunState.Idle) 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(run.titleRes),
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,150 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.util.Log
import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.R
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
/** How the run ended, the same words on the row and in the sheet. */
@get:StringRes
val titleRes: Int get() = when {
stopped -> R.string.calendars_copy_stopped_title
summary.failed > 0 -> R.string.calendars_copy_partial
else -> R.string.calendars_copy_done
}
}
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
@@ -339,12 +340,7 @@ class OnboardingViewModel @Inject constructor(
}
prefs.setAutoBackupFolderUri(folder.toString())
prefs.setAutoBackupEnabled(true)
BackupScheduler.apply(
context = context,
enabled = true,
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
hasFolder = true,
)
BackupScheduler.reconcile(context, prefs)
// Immediate feedback: the first backup lands while onboarding runs.
BackupScheduler.runNow(context)
prefs.setOnboardingDone(OnboardingStep.Backup)
@@ -483,7 +479,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
}
+45 -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,56 @@
<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_partial">Copy finished, some events couldn\'t be added</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_on_change">Also back up after changes</string>
<string name="calendars_auto_backup_on_change_summary">Updates the undated copy a few minutes after events change. Dated backups stay on the interval.</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>
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.calendula.data.backup
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkInfo.State.BLOCKED
import androidx.work.WorkInfo.State.CANCELLED
import androidx.work.WorkInfo.State.ENQUEUED
import androidx.work.WorkInfo.State.RUNNING
import androidx.work.WorkInfo.State.SUCCEEDED
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ChangeRunPolicyTest {
@Test
fun `a run that hasn't started yet covers the change`() {
assertThat(changeRunPolicy(listOf(ENQUEUED))).isNull()
assertThat(changeRunPolicy(listOf(RUNNING, BLOCKED))).isNull()
}
@Test
fun `a run already going gets the next one queued behind it`() {
assertThat(changeRunPolicy(listOf(RUNNING))).isEqualTo(ExistingWorkPolicy.APPEND_OR_REPLACE)
}
@Test
fun `with nothing pending a fresh run starts`() {
assertThat(changeRunPolicy(emptyList())).isEqualTo(ExistingWorkPolicy.REPLACE)
assertThat(changeRunPolicy(listOf(SUCCEEDED, CANCELLED))).isEqualTo(ExistingWorkPolicy.REPLACE)
}
}
@@ -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,153 @@ 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 keeps an RDATE-only series and its exceptions`(
@TempDir tempDir: Path,
) = runTest {
val start = Instant.parse("2026-01-05T09:00:00Z")
val other = Instant.parse("2026-01-12T09:00:00Z")
val event = IcsEvent(
"s", "S", start, Instant.parse("2026-01-05T10:00:00Z"), false, "UTC",
recurrenceDates = listOf("20260112T090000Z", "20260119T090000Z"),
)
val fake = FakeCalendarDataSource().apply {
calendarRowsResult = CalendarRows(
listOf(MasterRow(1, 1, null, event)),
listOf(OverrideRow(1, 1, null, other, event.copy(recurrenceDates = emptyList(), status = EventStatus.Cancelled))),
)
}
val exported = testRepository(fake, tempDir, Dispatchers.Unconfined).exportEvents(setOf(1L))
val restored = IcsParser().parse(IcsWriter().writeCalendar(exported, start)).events.single()
assertThat(restored.recurrenceDates).containsExactly("20260112T090000Z", "20260119T090000Z")
assertThat(restored.exDates).containsExactly("20260112T090000Z")
}
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
@@ -294,4 +294,39 @@ class IcsExportMapperTest {
assertThat(event.exDates).isEmpty()
}
@Test
fun `a zoned RDATE list is written as UTC stamps, a period by its start`() {
assertThat(exportRecurrenceDates("Europe/Berlin;20260110T100000,20260111T100000/PT1H", isAllDay = false))
.containsExactly("20260110T090000Z", "20260111T090000Z").inOrder()
}
@Test
fun `an all-day RDATE keeps its days, junk is dropped`() {
assertThat(exportRecurrenceDates("20260110T000000Z,20260111,garbage", isAllDay = true))
.containsExactly("20260110", "20260111").inOrder()
}
@Test
fun `an RDATE stored over several lines keeps every line's dates`() {
assertThat(exportRecurrenceDates("Europe/Berlin;20260110T100000\nAmerica/New_York;20260112T090000", isAllDay = false))
.containsExactly("20260110T090000Z", "20260112T140000Z").inOrder()
}
@Test
fun `an EXDATE stored over several lines keeps every line, unreadable stamps go`() {
assertThat(exportExDates("Europe/Berlin;20260110T100000\nEurope/Berlin;20260117T100000,junk", isAllDay = false))
.containsExactly("20260110T090000Z", "20260117T090000Z").inOrder()
}
@Test
fun `a bare day on a timed series is read at the series' time of day`() {
val tenBerlin = java.time.ZonedDateTime.of(2026, 1, 5, 10, 0, 0, 0, java.time.ZoneId.of("Europe/Berlin"))
.toInstant().toEpochMilli()
assertThat(exportExDates("20260112", isAllDay = false, "Europe/Berlin", tenBerlin))
.containsExactly("20260112T090000Z")
assertThat(exportRecurrenceDates("20260712", isAllDay = false, "Europe/Berlin", tenBerlin))
.containsExactly("20260712T080000Z")
}
}
@@ -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 = listOf("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,364 @@
package de.jeanlucmakiola.calendula.domain.backup
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import kotlin.time.Instant
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")
}
@Test
fun `fingerprints survive storage and drop deleted calendars`() {
val stored = persistBackupFingerprints(mapOf("1" to "ab", COMBINED_SERIES to "cd"))
assertThat(parseBackupFingerprints(stored)).containsExactly("1", "ab", COMBINED_SERIES, "cd")
assertThat(persistBackupFingerprints(emptyMap())).isNull()
assertThat(parseBackupFingerprints(null)).isEmpty()
val next = withFingerprint(mapOf("1" to "ab", "9" to "ef", COMBINED_SERIES to "cd"), "2", "gh", setOf("1", "2"))
assertThat(next).containsExactly("1", "ab", COMBINED_SERIES, "cd", "2", "gh")
}
@Test
fun `a fingerprint follows the content`() {
val first = "BEGIN:VEVENT\r\nDTSTAMP:20261113T143005Z\r\nSUMMARY:A\r\nEND:VEVENT\r\n"
val later = first.replace("20261113T143005Z", "20261114T090000Z")
assertThat(backupFingerprint(later, "Work.ics")).isEqualTo(backupFingerprint(first, "Work.ics"))
assertThat(backupFingerprint(first.replace("SUMMARY:A", "SUMMARY:B"), "Work.ics")).isNotEqualTo(backupFingerprint(first, "Work.ics"))
assertThat(backupFingerprint(first, "Work.ics")).hasLength(32)
assertThat(backupFingerprint(first, "Job.ics")).isNotEqualTo(backupFingerprint(first, "Work.ics"))
}
@Test
fun `the writer's output hashes the same at any stamp`() {
val event = IcsEvent(
uid = "u1", summary = "A", start = Instant.fromEpochSeconds(0), end = Instant.fromEpochSeconds(3600),
isAllDay = false, zoneId = "UTC",
)
val early = IcsWriter().writeCalendar(listOf(event), Instant.fromEpochSeconds(0), "Work")
val late = IcsWriter().writeCalendar(listOf(event), Instant.fromEpochSeconds(1_800_000_000), "Work")
assertThat(early).isNotEqualTo(late)
assertThat(backupFingerprint(late, "Work.ics")).isEqualTo(backupFingerprint(early, "Work.ics"))
}
@Test
fun `a change run refreshes only a changed or missing stable copy`() {
val target = backupTargets(listOf(cal(1L, "Work")), combined = false).single()
val ledger = mapOf(latestLedgerKey("1") to listOf("Work.ics"))
val plan = planBackupFiles(listOf(target), ledger, setOf("Work.ics"), at, keep = 3).single()
val stored = mapOf("1" to "h1")
val listed = setOf("Work.ics")
assertThat(plan.needsRefresh("h1", stored, listed, complete = true)).isFalse()
assertThat(plan.needsRefresh("h2", stored, listed, complete = true)).isTrue()
assertThat(plan.needsRefresh("h1", emptyMap(), listed, complete = true)).isTrue()
assertThat(plan.needsRefresh("h1", stored, emptySet(), complete = true)).isTrue()
}
@Test
fun `a change run leaves a copy a loading listing doesn't show to the schedule`() {
val target = backupTargets(listOf(cal(1L, "Work")), combined = false).single()
val ledger = mapOf(latestLedgerKey("1") to listOf("Work.ics"))
val plan = planBackupFiles(listOf(target), ledger, setOf("Work.ics"), at, keep = 3).single()
assertThat(plan.needsRefresh("h2", mapOf("1" to "h1"), emptySet(), complete = false)).isFalse()
assertThat(plan.needsRefresh("h2", mapOf("1" to "h1"), setOf("Work.ics"), complete = false)).isTrue()
}
@Test
fun `a change run never writes over a file of the user's`() {
val target = backupTargets(listOf(cal(1L, "Work")), combined = false).single()
val plan = planBackupFiles(listOf(target), emptyMap(), setOf("Work.ics"), at, keep = 3).single()
assertThat(plan.latestName).isNull()
assertThat(plan.needsRefresh("h1", emptyMap(), setOf("Work.ics"), complete = true)).isFalse()
}
@Test
fun `backup order doesn't depend on the provider's`() {
fun event(uid: String, reminders: List<Int>) = IcsEvent(
uid = uid, summary = uid, start = Instant.fromEpochSeconds(0), end = Instant.fromEpochSeconds(3600),
isAllDay = false, zoneId = "UTC", reminderMinutes = reminders,
)
val one = listOf(event("b", listOf(15, 10)), event("a", listOf(5)))
val other = listOf(event("a", listOf(5)), event("b", listOf(10, 15)))
assertThat(one.inBackupOrder()).isEqualTo(other.inBackupOrder())
assertThat(one.inBackupOrder().map { it.uid }).containsExactly("a", "b").inOrder()
}
}
@@ -0,0 +1,287 @@
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 = listOf("20260112T090000Z")))),
listOf(OverrideRow(1, 1, null, occurrence(1), event("s", status = EventStatus.Cancelled))),
)
assertThat(flat.single().recurrenceDates).containsExactly("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 but keeps an occurrence accepted on its own`() {
val rows = CalendarRows(
listOf(series("d").copy(declined = true), series("k", id = 2)),
listOf(
OverrideRow(1, 1, null, occurrence(1), event("accepted"), accepted = true),
OverrideRow(1, 1, null, occurrence(2), event("moved by the organizer")),
),
)
val plan = planCalendarCopy(rows, ::formatExdateStamp)
assertThat(plan.map { it.summary }).containsExactly("accepted", "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")
}
@Test
fun `RDATE and its exclusions survive the file`() {
val timed = event("t").copy(
recurrenceDates = listOf("20260112T090000Z", "20260120T090000Z"),
exDates = listOf("20260112T090000Z"),
)
val day = Instant.parse("2026-01-05T00:00:00Z")
val allDay = event("a", allDay = true, at = day).copy(
end = Instant.parse("2026-01-06T00:00:00Z"),
recurrenceDates = listOf("20260110"),
exDates = listOf("20260110"),
)
val text = IcsWriter().writeCalendar(listOf(timed, allDay), start)
assertThat(text).contains("RDATE;VALUE=DATE:20260110")
assertThat(text).contains("DTSTART;TZID=Europe/Berlin:20260105T100000")
val (t, a) = IcsParser().parse(text).events
assertThat(t.recurrenceDates).containsExactly("20260112T090000Z", "20260120T090000Z")
assertThat(t.exDates).containsExactly("20260112T090000Z")
assertThat(a.recurrenceDates).containsExactly("20260110")
assertThat(a.exDates).containsExactly("20260110")
}
@Test
fun `a foreign RDATE is read in its own zone, a period by its start`() {
val text = """
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:f
DTSTART;TZID=Europe/Berlin:20260105T100000
DTEND;TZID=Europe/Berlin:20260105T110000
RDATE;TZID=America/New_York:20260110T090000
RDATE;VALUE=PERIOD:20260111T090000Z/PT1H
END:VEVENT
END:VCALENDAR
""".trimIndent().replace("\n", "\r\n")
assertThat(IcsParser().parse(text).events.single().recurrenceDates)
.containsExactly("20260110T140000Z", "20260111T090000Z")
}
@Test
fun `a declined row takes no other row with its UID along`() {
val rows = CalendarRows(listOf(series("u").copy(declined = true), series("u", id = 2)), emptyList())
assertThat(planCalendarCopy(rows, ::formatExdateStamp)).hasSize(1)
}
}
@@ -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))
}
}
+103 -9
View File
@@ -462,15 +462,109 @@ 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.
**Backing up after a change (#516)** is opt-in. `BackupChangeJob` is a
JobScheduler job with a content trigger on the provider's root URI (the provider
notifies every change there, whoever made it: this app, a sync adapter or
another app), a 2-minute settle delay capped at 15 minutes. A trigger fires
once, so each firing queues a change run (a unique `BackupWorker`, battery not
low, queued behind one already running) and schedules the next watch under the
same id before it finishes, which carries over the changes seen meanwhile. Such
a job can't be persisted; `BackupScheduler.reconcile` re-arms it on every
process start (the boot receivers start one), without replacing a waiting watch.
Runs share the scheduled backup's lock. A change run only rewrites the stable
copies and never retries: the dated files and their retention stay on the
interval, so a day of edits can't rotate the history out. Every run that writes
something is recorded on the status line; the failure notification comes once
per failure streak, as it reaches the threshold, and the next success of any run
clears it. Since the trigger fires for any provider change (reminders, other
calendars, sync bookkeeping), each series' file (events in a fixed order) is
hashed without its `DTSTAMP`s (`auto_backup_fingerprints`, reset with the folder
and kept only while the option is on) and a change run skips the series whose
hash (bound to the copy's name) and stable copy are unchanged, without even
listing the folder when no hash moved; the hash is dropped before a copy is
rewritten. A copy that went missing is restored by the next run that lists the
folder; one a still-loading listing doesn't show, or one edited in place outside
the app, is left to the next scheduled run.
**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. Backups made before this carry no exceptions and restore exactly
as before.
**`RDATE` travels like `EXDATE` (#515).** A series can repeat by listed dates
alone. The provider stores `RDATE` in the same `<zone>;<stamps>` form as
`EXDATE`, so `exportRecurrenceDates` resolves it the same way to UTC stamps (or
bare days for an all-day series), line by line where several `RDATE`s were
stored; a `PERIOD` entry keeps its start, and a bare day on a timed series
becomes the occurrence at the series' time of day, so the copy (which never
goes through the parser) and a file get the same stamps. `IcsWriter`
writes it as `RDATE:` / `RDATE;VALUE=DATE:`, anchors such a series to its zone
like an RRULE series, and writes its `EXDATE` too; `IcsParser` reads `RDATE`
through the same normalisation as `EXDATE`, so a foreign `TZID` or floating
value lands on the right instant. Older backups wrote such a series as a single
event and still restore as one.
**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 or visibility; the direct mapping keeps them.
Attendees and organizer are never read, and cancelled events and declined
invitations are left out (an insert can't be cancelled; it would arrive live, and
a declined occurrence counts as deleted, as does one of a declined series the
user didn't accept on its own). 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