Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00a8040efe | ||
|
|
07a2bb632f | ||
|
|
281d511402 | ||
|
|
ad5f0f4662 | ||
|
|
a902f5f862 | ||
|
|
783916e3a7 | ||
|
|
09c20ee90f | ||
|
|
fb178a779e | ||
|
|
34cd75e9a3 | ||
|
|
6758b7fada | ||
|
|
70f72d03db | ||
|
|
dca98bbb2d | ||
|
|
93b795ed72 | ||
|
|
f18ae694a7 | ||
|
|
b2498d362b | ||
|
|
8e99c46f3e | ||
|
|
1e83000696 | ||
|
|
05887d2ed0 | ||
|
|
028d8d0e1e | ||
|
|
30af150197 | ||
|
|
041f8ec2f7 | ||
|
|
10208a332e | ||
|
|
0a36a6d287 | ||
|
|
d5079f0618 | ||
|
|
3ed56b8596 | ||
|
|
3f4bf65b1a | ||
|
|
026970501e | ||
|
|
46882d154e | ||
|
|
e5d9b4965b | ||
|
|
c6901c9c63 | ||
|
|
8754f046f2 | ||
|
|
bb075d052e | ||
|
|
5c05563785 | ||
|
|
88c64d5786 | ||
|
|
d86a09b744 | ||
|
|
f314761e45 | ||
|
|
46818cd0ae | ||
|
|
240ed5237a | ||
|
|
deb2fd4482 |
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
+110
-53
@@ -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
|
||||
|
||||
+57
-7
@@ -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
|
||||
|
||||
|
||||
+3
-29
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+145
-4
@@ -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") =
|
||||
|
||||
+7
-9
@@ -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")
|
||||
}
|
||||
}
|
||||
+18
@@ -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
@@ -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
|
||||
|
||||
+1
-1
Submodule floret-kit updated: a039ab5bd4...389cc0298f.
Reference in new issue
Block a user