Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6901c9c63 | ||
|
|
8754f046f2 | ||
|
|
bb075d052e | ||
|
|
5c05563785 | ||
|
|
88c64d5786 | ||
|
|
d86a09b744 | ||
|
|
f314761e45 | ||
|
|
46818cd0ae | ||
|
|
240ed5237a | ||
|
|
deb2fd4482 |
No files matched your search
@@ -318,16 +318,13 @@ jobs:
|
||||
# not provide node inside custom job containers, so JavaScript actions
|
||||
# (checkout, download-artifact) can't run there. The Renovate job gets away
|
||||
# with a container because its only step is a shell command. Ruby is installed
|
||||
# the same way sshpass, jq and fdroidserver are in the job above, from trixie:
|
||||
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
|
||||
# the same way sshpass, jq and fdroidserver are in the job above.
|
||||
play:
|
||||
needs: [detect, release]
|
||||
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
|
||||
# touch Play, so gate on a real release only.
|
||||
if: needs.detect.outputs.is_release == 'true'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:22-trixie
|
||||
env:
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -25,17 +26,29 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.backup.backupTargets
|
||||
import de.jeanlucmakiola.calendula.domain.backup.planBackupFiles
|
||||
import de.jeanlucmakiola.calendula.domain.backup.latestHeldElsewhere
|
||||
import de.jeanlucmakiola.calendula.domain.backup.latestLedgerKey
|
||||
import de.jeanlucmakiola.calendula.domain.backup.replacedLatestCopies
|
||||
import de.jeanlucmakiola.calendula.domain.backup.withLedgerEntry
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||
* everything is local provider reads + a local file write.
|
||||
* Schedules and runs the automatic periodic `.ics` backup (issue #8) of the
|
||||
* calendars the user picked (#147). One-way export only — it mirrors the manual
|
||||
* "Back up" into a user-chosen folder on an interval; it is not a sync. Stays
|
||||
* INTERNET-free: everything is local provider reads + local file writes.
|
||||
*/
|
||||
object BackupScheduler {
|
||||
|
||||
@@ -47,8 +60,8 @@ object BackupScheduler {
|
||||
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||
* it. WorkManager persists the request across reboots on its own.
|
||||
*
|
||||
* The first run is delayed by one interval so it never overlaps an immediate
|
||||
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||
* The first run is delayed by one interval so it doesn't follow an immediate
|
||||
* [runNow] with a near-identical copy.
|
||||
*/
|
||||
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
@@ -86,8 +99,9 @@ object BackupScheduler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||
* Exports the selected calendars into the configured folder — one dated file
|
||||
* per calendar, or one combined file (#411) — then prunes each series down to
|
||||
* the retention count. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||
* no custom factory / Application wiring needed. Records the outcome for the
|
||||
* settings status line, and notifies after repeated failures.
|
||||
@@ -108,25 +122,124 @@ class BackupWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work: if backup was turned
|
||||
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first()
|
||||
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val events = deps.repository().exportEvents()
|
||||
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
notifyFailure(applicationContext)
|
||||
// The periodic and the immediate run are separate unique works; one at
|
||||
// a time, or each would overwrite the other's ledger.
|
||||
val outcome = runLock.withLock {
|
||||
// Respect the toggle even for already-queued work, so a lingering
|
||||
// run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first() ?: return Result.failure()
|
||||
try {
|
||||
runBackup(deps, prefs, folder.toUri())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
RunOutcome.Failed
|
||||
}
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
if (outcome == RunOutcome.NothingSelected) return Result.success()
|
||||
val success = outcome == RunOutcome.Written
|
||||
prefs.recordAutoBackupRun(success = success, atMillis = now)
|
||||
if (!success && prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
notifyFailure(applicationContext)
|
||||
}
|
||||
// Only a run that wrote nothing retries: a retry rewrites every series.
|
||||
return if (outcome == RunOutcome.Failed) Result.retry() else Result.success()
|
||||
}
|
||||
|
||||
private enum class RunOutcome { Written, PartlyFailed, Failed, NothingSelected }
|
||||
|
||||
/** Writes each series and prunes it only once its new file has landed. */
|
||||
private suspend fun runBackup(deps: Deps, prefs: SettingsPrefs, folder: Uri): RunOutcome {
|
||||
val repository = deps.repository()
|
||||
val exporter = deps.exporter()
|
||||
if (!applicationContext.hasPermission(Manifest.permission.READ_CALENDAR)) return RunOutcome.Failed
|
||||
val all = repository.calendars().first()
|
||||
val selection = prefs.autoBackupSelection.first()
|
||||
val targets = backupTargets(all.filter(selection::selects), combined = prefs.autoBackupCombined.first())
|
||||
if (targets.isEmpty()) return RunOutcome.NothingSelected
|
||||
prefs.adoptLegacyBackupFile()
|
||||
val listing = exporter.listFolder(folder)
|
||||
val ledger = prefs.autoBackupLedger.first()
|
||||
// A listing still loading can't tell a gone file from a late one.
|
||||
val existing = if (listing.complete) listing.files.keys else listing.files.keys + ledger.values.flatten()
|
||||
val plans = planBackupFiles(
|
||||
targets = targets,
|
||||
ledger = ledger,
|
||||
existing = existing,
|
||||
at = LocalDateTime.now(),
|
||||
keep = prefs.autoBackupKeep.first(),
|
||||
)
|
||||
val stamp = Clock.System.now()
|
||||
val liveKeys = all.map { it.id.toString() }.toSet()
|
||||
suspend fun record(key: String, files: List<String>) =
|
||||
prefs.updateAutoBackupLedger(folder.toString()) { withLedgerEntry(it, key, files, liveKeys) }
|
||||
var written = 0
|
||||
val landedLatest = mutableSetOf<String>()
|
||||
for (plan in plans) {
|
||||
val target = plan.target
|
||||
val bytes = try {
|
||||
IcsWriter()
|
||||
.writeCalendar(repository.exportEvents(target.calendarIds), stamp, target.calendarName)
|
||||
.toByteArray(Charsets.UTF_8)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reading one series failed", e)
|
||||
continue
|
||||
}
|
||||
// Not cancellable once a file exists: it must reach the ledger, or
|
||||
// nothing would ever prune it.
|
||||
withContext(NonCancellable) {
|
||||
try {
|
||||
val name = exporter.writeToFolder(folder, listing, plan.fileName, bytes)
|
||||
record(target.key, plan.prune + plan.keep + name)
|
||||
val undeleted = exporter.deleteFromFolder(listing, plan.prune)
|
||||
record(target.key, plan.prune.filter { it in undeleted } + plan.keep + name)
|
||||
written++
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Backup of one series failed", e)
|
||||
}
|
||||
// Best-effort: the stable copy never holds up the dated backup,
|
||||
// its pruning or the run's status. An old copy goes only once the
|
||||
// new one has landed, so a series is never left without one.
|
||||
try {
|
||||
val latest = plan.latestName ?: return@withContext
|
||||
val latestKey = latestLedgerKey(target.key)
|
||||
val landed = exporter.writeToFolder(folder, listing, latest, bytes)
|
||||
// A provider that renamed it leaves a copy to clean up next
|
||||
// run; the intended name stays ours only if it already was.
|
||||
val ownsLatest = landed == latest || latest in ledger[latestKey].orEmpty()
|
||||
val current = listOfNotNull(landed, latest.takeIf { ownsLatest })
|
||||
// A hand-off stays on record only until its new owner claimed it.
|
||||
suspend fun recordLatest(stale: List<String>) = prefs.updateAutoBackupLedger(folder.toString()) {
|
||||
val pending = plan.handOff - it.latestHeldElsewhere(target.key)
|
||||
withLedgerEntry(it, latestKey, stale + pending + current, liveKeys)
|
||||
}
|
||||
recordLatest(plan.staleLatest)
|
||||
if (landed == latest) landedLatest += target.key
|
||||
val staleLeft = exporter.deleteFromFolder(listing, plan.staleLatest)
|
||||
recordLatest(plan.staleLatest.filter { it in staleLeft })
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Stable copy of one series failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A layout switch retires the other layout's copies once every new one is there.
|
||||
if (landedLatest.size == plans.size) {
|
||||
replacedLatestCopies(targets, prefs.autoBackupLedger.first(), existing).forEach { (key, names) ->
|
||||
withContext(NonCancellable) {
|
||||
val left = exporter.deleteFromFolder(listing, names)
|
||||
record(latestLedgerKey(key), names.filter { it in left })
|
||||
}
|
||||
}
|
||||
}
|
||||
return when (written) {
|
||||
plans.size -> RunOutcome.Written
|
||||
0 -> RunOutcome.Failed
|
||||
else -> RunOutcome.PartlyFailed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,10 +278,10 @@ class BackupWorker(
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||
private const val CHANNEL_ID = "backup"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val TAG = "BackupWorker"
|
||||
private val runLock = Mutex()
|
||||
}
|
||||
}
|
||||
@@ -90,15 +90,15 @@ interface CalendarDataSource {
|
||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
|
||||
* lead times rather than raw provider offsets (see [toIcsEvent]).
|
||||
* Every master/one-off event of [calendarIds], whatever kind of calendar
|
||||
* they are (local, synced, read-only), mapped to [IcsEvent]s. A plain read:
|
||||
* which calendars may be read for what is the caller's call. Modified-
|
||||
* occurrence and cancelled-exception rows are excluded (see
|
||||
* [EventExportProjection]). [allDayReminderTimeMinutes]: needed to write
|
||||
* all-day reminders as whole-day lead times rather than raw provider
|
||||
* offsets (see [toIcsEvent]).
|
||||
*/
|
||||
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
fun calendarEvents(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -776,34 +776,36 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
override fun calendarEvents(
|
||||
calendarIds: Set<Long>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
// here, and re-materialise from the contact sync — backing them up would
|
||||
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||
// export to the user's chosen subset. Map id → display name for the
|
||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||
if (calendarIds.isEmpty()) return emptyList()
|
||||
// Id → display name for the X-CALENDULA-CALENDAR tag a restore uses to
|
||||
// fan back out; an id that no longer exists drops out here.
|
||||
val names = calendars()
|
||||
.filter {
|
||||
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||
(calendarIds == null || it.id in calendarIds)
|
||||
}
|
||||
.filter { it.id in calendarIds }
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
|
||||
val idList = names.keys.joinToString(",")
|
||||
// Masters + one-offs only: no soft-deleted or exception rows.
|
||||
val selection = "${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL"
|
||||
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)
|
||||
return 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 ->
|
||||
@@ -811,7 +813,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||
toIcsEvent(
|
||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||
reminderMinutes = reminders[eventId].orEmpty(),
|
||||
calendarName = names[calendarId],
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
|
||||
@@ -61,11 +61,11 @@ interface CalendarRepository {
|
||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||
* Every event of [calendarIds], ready to serialise into an `.ics` backup
|
||||
* (see [CalendarDataSource.calendarEvents]). Any calendar can be read,
|
||||
* synced and read-only ones included (#147).
|
||||
*/
|
||||
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
|
||||
|
||||
+2
-2
@@ -247,8 +247,8 @@ 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) { dataSource.calendarEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.provider.CalendarContract
|
||||
|
||||
/** Ids per `IN (…)` list: they go into the SQL literally, which SQLite bounds. */
|
||||
private const val EVENT_ID_BATCH = 50
|
||||
|
||||
/**
|
||||
* Reminder minutes of [eventIds], keyed by event, a batched query at a time
|
||||
* rather than one per event. [alertsOnly] keeps just the notification ones.
|
||||
*/
|
||||
internal fun ContentResolver.reminderMinutesByEvent(
|
||||
eventIds: Collection<Long>,
|
||||
alertsOnly: Boolean,
|
||||
): Map<Long, List<Int>> {
|
||||
if (eventIds.isEmpty()) return emptyMap()
|
||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||
val method = if (alertsOnly) {
|
||||
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT} AND "
|
||||
} else {
|
||||
""
|
||||
}
|
||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||
query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.EVENT_ID, CalendarContract.Reminders.MINUTES),
|
||||
"$method${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.android.TreeListing
|
||||
import de.jeanlucmakiola.floret.android.deleteTreeFiles
|
||||
import de.jeanlucmakiola.floret.android.listTreeFiles
|
||||
import de.jeanlucmakiola.floret.android.writeTreeFile
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
@@ -29,34 +33,27 @@ class IcsExporter @Inject constructor(
|
||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||
* overwriting it if it already exists (the automatic-backup destination).
|
||||
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||
*/
|
||||
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||
/** The files in the persisted SAF tree [folder], the automatic-backup destination. Throws when unusable. */
|
||||
fun listFolder(folder: Uri): TreeListing {
|
||||
writableFolder(folder)
|
||||
return context.contentResolver.listTreeFiles(folder)
|
||||
}
|
||||
|
||||
/** Write the `.ics` [bytes] as [fileName] into [folder]; returns the name the file ended up with. */
|
||||
fun writeToFolder(folder: Uri, listing: TreeListing, fileName: String, bytes: ByteArray): String =
|
||||
context.contentResolver.writeTreeFile(folder, listing, fileName, MIME_CALENDAR, bytes)
|
||||
|
||||
/** Delete [names] from the listed folder; returns the ones that didn't go. */
|
||||
fun deleteFromFolder(listing: TreeListing, names: Collection<String>): Set<String> =
|
||||
context.contentResolver.deleteTreeFiles(listing, names)
|
||||
|
||||
private fun writableFolder(folder: Uri): DocumentFile {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||
// duplicates SAF may have created if two runs ever raced — so we always
|
||||
// converge on a single overwritten file.
|
||||
val base = fileName.substringBeforeLast('.')
|
||||
val ext = fileName.substringAfterLast('.', "")
|
||||
var target: DocumentFile? = null
|
||||
for (child in dir.listFiles()) {
|
||||
val name = child.name ?: continue
|
||||
when {
|
||||
name == fileName -> target = child
|
||||
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||
}
|
||||
}
|
||||
val file = target
|
||||
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||
?: throw IOException("Could not create backup file in the chosen folder")
|
||||
writeDocument(file.uri, content)
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
|
||||
)
|
||||
@@ -1,9 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.COMBINED_SERIES
|
||||
import de.jeanlucmakiola.calendula.domain.backup.DEFAULT_BACKUP_KEEP
|
||||
import de.jeanlucmakiola.calendula.domain.backup.LEGACY_BACKUP_FILE
|
||||
import de.jeanlucmakiola.calendula.domain.backup.latestLedgerKey
|
||||
import de.jeanlucmakiola.calendula.domain.backup.normalizeBackupKeep
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupLedger
|
||||
import de.jeanlucmakiola.calendula.domain.backup.parseBackupLedger
|
||||
import de.jeanlucmakiola.calendula.domain.backup.persistBackupLedger
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
@@ -29,6 +40,7 @@ import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import de.jeanlucmakiola.floret.prefs.intPref
|
||||
import de.jeanlucmakiola.floret.prefs.longPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import de.jeanlucmakiola.floret.prefs.set
|
||||
@@ -180,6 +192,10 @@ class SettingsPrefs @Inject constructor(
|
||||
val autoBackupIntervalMinutes: Flow<Long> = prefs.flow(SettingsKeys.autoBackupIntervalMinutes)
|
||||
val autoBackupFolderUri: Flow<String?> = prefs.flow(SettingsKeys.autoBackupFolderUri)
|
||||
val autoBackupStatus: Flow<BackupStatus> = prefs.snapshot { it.autoBackupStatus }
|
||||
val autoBackupSelection: Flow<BackupSelection> = prefs.snapshot { it.autoBackupSelection }
|
||||
val autoBackupCombined: Flow<Boolean> = prefs.snapshot { it.autoBackupCombined }
|
||||
val autoBackupKeep: Flow<Int> = prefs.flow(SettingsKeys.autoBackupKeep)
|
||||
val autoBackupLedger: Flow<BackupLedger> = prefs.flow(SettingsKeys.autoBackupLedger)
|
||||
val specialDatesEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.specialDatesEnabled)
|
||||
val specialDatesTypes: Flow<Set<SpecialDateType>> = prefs.snapshot { it.specialDatesTypes }
|
||||
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = prefs.snapshot { it.specialDatesCalendars }
|
||||
@@ -329,7 +345,56 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
|
||||
prefs.set(SettingsKeys.autoBackupIntervalMinutes, minutes)
|
||||
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) = prefs.set(SettingsKeys.autoBackupFolderUri, uri)
|
||||
/**
|
||||
* Also pins the file layout the first time it is written: an install that
|
||||
* already had a folder before #411 keeps its single combined file, a new
|
||||
* one starts on one file per calendar (see [Preferences.autoBackupCombined]).
|
||||
*/
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||
prefs.edit {
|
||||
it.pinBackupLayout()
|
||||
// The ledger says which files in the old folder are ours; in a new
|
||||
// one a file of the same name may be the user's.
|
||||
if (it[SettingsKeys.autoBackupFolderUri] != uri) it[SettingsKeys.autoBackupLedger] = emptyMap()
|
||||
it[SettingsKeys.autoBackupFolderUri] = uri
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupCombined(combined: Boolean) {
|
||||
prefs.edit {
|
||||
it.pinBackupLayout()
|
||||
it[SettingsKeys.autoBackupCombinedStored] = combined
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupKeep(keep: Int) = prefs.set(SettingsKeys.autoBackupKeep, keep)
|
||||
|
||||
/**
|
||||
* Atomic, so a run's ledger writes never undo one made meanwhile (pinning
|
||||
* the layout). Dropped when the folder is no longer [folder]: a run still
|
||||
* finishing must not carry the old folder's names into the new one's ledger.
|
||||
*/
|
||||
suspend fun updateAutoBackupLedger(folder: String, transform: (BackupLedger) -> BackupLedger) {
|
||||
prefs.edit {
|
||||
if (it[SettingsKeys.autoBackupFolderUri] == folder) {
|
||||
it[SettingsKeys.autoBackupLedger] = transform(it[SettingsKeys.autoBackupLedger])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a pre-#411 install's old file as its combined series' stable copy (see [pinBackupLayout]). */
|
||||
suspend fun adoptLegacyBackupFile() {
|
||||
prefs.edit { if (it.autoBackupLegacyInstall) it.adoptLegacyBackupFile() }
|
||||
}
|
||||
|
||||
/** Switch [calendar] in or out of the automatic backup (#147). */
|
||||
suspend fun setAutoBackupCalendar(calendar: CalendarSource, included: Boolean) {
|
||||
prefs.edit {
|
||||
val next = it.autoBackupSelection.with(calendar, included)
|
||||
it[SettingsKeys.autoBackupIncluded] = next.included
|
||||
it[SettingsKeys.autoBackupExcluded] = next.excluded
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||
@@ -700,6 +765,27 @@ object SettingsKeys {
|
||||
val autoBackupLastSuccess: Pref<Boolean> = booleanPref("auto_backup_last_success", true)
|
||||
val autoBackupFailures: Pref<Int> = intPref("auto_backup_failures", 0)
|
||||
|
||||
/**
|
||||
* The automatic backup's calendar choice as deviations from the default
|
||||
* (#147), read together as [Preferences.autoBackupSelection].
|
||||
*/
|
||||
val autoBackupIncluded: Pref<Set<Long>> = idSetPref("auto_backup_included_calendars")
|
||||
val autoBackupExcluded: Pref<Set<Long>> = idSetPref("auto_backup_excluded_calendars")
|
||||
|
||||
/**
|
||||
* Whether the automatic backup writes one combined file instead of one per
|
||||
* calendar (#411). Unset until first pinned; read as [Preferences.autoBackupCombined].
|
||||
*/
|
||||
val autoBackupCombinedStored: Pref<Boolean?> = nullableBooleanPref("auto_backup_combined")
|
||||
|
||||
/** How many files of each backup series to keep (#411). */
|
||||
val autoBackupKeep: Pref<Int> =
|
||||
intPref("auto_backup_keep", DEFAULT_BACKUP_KEEP).coerced(::normalizeBackupKeep)
|
||||
|
||||
/** The files the automatic backup wrote, per series; the only ones it ever prunes (#411). */
|
||||
val autoBackupLedger: Pref<BackupLedger> =
|
||||
nullableStringPref("auto_backup_files").map(::parseBackupLedger, ::persistBackupLedger)
|
||||
|
||||
/** Master switch for the contact special-dates mirror (issue #15). Opt-in. */
|
||||
val specialDatesEnabled: Pref<Boolean> = booleanPref("special_dates_enabled", false)
|
||||
|
||||
@@ -749,6 +835,42 @@ val Preferences.autoBackupStatus: BackupStatus
|
||||
consecutiveFailures = this[SettingsKeys.autoBackupFailures],
|
||||
)
|
||||
|
||||
val Preferences.autoBackupSelection: BackupSelection
|
||||
get() = BackupSelection(
|
||||
included = this[SettingsKeys.autoBackupIncluded],
|
||||
excluded = this[SettingsKeys.autoBackupExcluded],
|
||||
)
|
||||
|
||||
/**
|
||||
* Unset means an install from before #411: one that already had a backup
|
||||
* folder keeps the single file it was writing, anything else gets per-calendar
|
||||
* files. Choosing a folder pins the answer (see [SettingsPrefs.setAutoBackupFolderUri]).
|
||||
*/
|
||||
val Preferences.autoBackupCombined: Boolean
|
||||
get() = this[SettingsKeys.autoBackupCombinedStored] ?: autoBackupLegacyInstall
|
||||
|
||||
/**
|
||||
* Store the layout the install is on, if it's still derived. Pinning forgets
|
||||
* it was a pre-#411 install, so its old file is recorded as the combined
|
||||
* series' own stable copy at the same time.
|
||||
*/
|
||||
private fun MutablePreferences.pinBackupLayout() {
|
||||
if (this[SettingsKeys.autoBackupCombinedStored] != null) return
|
||||
val legacy = autoBackupLegacyInstall
|
||||
if (legacy) adoptLegacyBackupFile()
|
||||
this[SettingsKeys.autoBackupCombinedStored] = legacy
|
||||
}
|
||||
|
||||
private fun MutablePreferences.adoptLegacyBackupFile() {
|
||||
val key = latestLedgerKey(COMBINED_SERIES)
|
||||
val ledger = this[SettingsKeys.autoBackupLedger]
|
||||
this[SettingsKeys.autoBackupLedger] = ledger + (key to (ledger[key].orEmpty() + LEGACY_BACKUP_FILE).distinct())
|
||||
}
|
||||
|
||||
/** An install that set up automatic backup before #411 and hasn't been pinned since. */
|
||||
val Preferences.autoBackupLegacyInstall: Boolean
|
||||
get() = this[SettingsKeys.autoBackupCombinedStored] == null && this[SettingsKeys.autoBackupFolderUri] != null
|
||||
|
||||
val Preferences.specialDatesTypes: Set<SpecialDateType>
|
||||
get() = SettingsKeys.specialDateTypeEnabled.filterValues { this[it] }.keys
|
||||
|
||||
|
||||
+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,279 @@
|
||||
package de.jeanlucmakiola.calendula.domain.backup
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||
import de.jeanlucmakiola.floret.android.safeFileName
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Whether this calendar can be backed up at all. Any calendar the provider
|
||||
* holds can be *read* (#147), whatever its visibility or access level. Left out:
|
||||
* the managed special-dates mirrors, rebuilt from contacts so a restore would
|
||||
* only duplicate them, and calendars whose events aren't synced to the device.
|
||||
*/
|
||||
val CalendarSource.isBackupCandidate: Boolean
|
||||
get() = !isManaged && !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether a backup includes this calendar until the user says otherwise: the
|
||||
* app's own writable local calendars, which have no other copy anywhere. Synced
|
||||
* calendars already live on their server, so they are opt-in.
|
||||
*/
|
||||
val CalendarSource.isBackedUpByDefault: Boolean
|
||||
get() = isLocal && canModifyContents && !isManaged
|
||||
|
||||
/**
|
||||
* Which calendars the automatic backup covers, stored as deviations from
|
||||
* [isBackedUpByDefault] so a calendar created later still follows the default:
|
||||
* [included] opts a calendar in, [excluded] opts one out.
|
||||
*/
|
||||
data class BackupSelection(
|
||||
val included: Set<Long> = emptySet(),
|
||||
val excluded: Set<Long> = emptySet(),
|
||||
) {
|
||||
fun selects(calendar: CalendarSource): Boolean = when {
|
||||
!calendar.isBackupCandidate -> false
|
||||
calendar.id in included -> true
|
||||
calendar.id in excluded -> false
|
||||
else -> calendar.isBackedUpByDefault
|
||||
}
|
||||
|
||||
/** The selection with [calendar] switched [on]; a choice matching the default leaves no trace. */
|
||||
fun with(calendar: CalendarSource, on: Boolean): BackupSelection {
|
||||
val id = calendar.id
|
||||
val default = calendar.isBackedUpByDefault
|
||||
return BackupSelection(
|
||||
included = if (on && !default) included + id else included - id,
|
||||
excluded = if (!on && default) excluded + id else excluded - id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One backup file series: the calendars it holds and the name its files start with. */
|
||||
data class BackupTarget(
|
||||
/** Stable identity of the series in the [BackupLedger]: the calendar id, or [COMBINED_SERIES]. */
|
||||
val key: String,
|
||||
val calendarIds: Set<Long>,
|
||||
val baseName: String,
|
||||
/** The calendar name written into the file, or null for a combined file. */
|
||||
val calendarName: String?,
|
||||
)
|
||||
|
||||
/** What one run writes and which earlier files it removes afterwards. */
|
||||
data class BackupFilePlan(
|
||||
val target: BackupTarget,
|
||||
val fileName: String,
|
||||
/** Earlier files of this series that stay, oldest first. */
|
||||
val keep: List<String>,
|
||||
/** Earlier files of this series beyond the retention count, oldest first. */
|
||||
val prune: List<String>,
|
||||
/**
|
||||
* The stable, undated copy overwritten every run (`<base>.ics`), for tools
|
||||
* that follow one path. Null when a file of that name is in the folder but
|
||||
* isn't one this backup wrote: the user's own file is never overwritten.
|
||||
*/
|
||||
val latestName: String?,
|
||||
/** This series' earlier stable copies under a name it no longer uses (a rename), to delete. */
|
||||
val staleLatest: List<String> = emptyList(),
|
||||
/**
|
||||
* Stable copies this series held that another series of the run now takes
|
||||
* over: kept on record until that series has claimed them.
|
||||
*/
|
||||
val handOff: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The files the automatic backup has written, per series key, oldest first.
|
||||
* Pruning only ever deletes names recorded here, so a series is its files, not
|
||||
* whatever currently matches a calendar's name: renames, same-named calendars
|
||||
* and the user's own files in the folder can't pull in the wrong ones. A
|
||||
* series' stable copy is recorded apart, under [latestLedgerKey].
|
||||
*/
|
||||
typealias BackupLedger = Map<String, List<String>>
|
||||
|
||||
const val COMBINED_SERIES = "combined"
|
||||
|
||||
/** The combined file's series name; also the fixed name used before #411. */
|
||||
const val COMBINED_BACKUP_BASE = "calendula-backup"
|
||||
|
||||
/**
|
||||
* The single file every automatic backup overwrote before #411: the combined
|
||||
* series' stable copy now, so an old setup keeps its path.
|
||||
*/
|
||||
val LEGACY_BACKUP_FILE: String = latestFileName(COMBINED_BACKUP_BASE)
|
||||
|
||||
private const val LATEST_PREFIX = "latest-"
|
||||
|
||||
/** Where the ledger records the stable copy of the series [key]. */
|
||||
fun latestLedgerKey(key: String): String = "$LATEST_PREFIX$key"
|
||||
|
||||
/** The retention choices the settings offer, newest kept first. */
|
||||
val BACKUP_KEEP_OPTIONS: List<Int> = listOf(1, 2, 3, 5, 7, 10)
|
||||
|
||||
const val DEFAULT_BACKUP_KEEP = 3
|
||||
|
||||
/** Room for the id suffix and the stamp within a file system's 255-byte name limit. */
|
||||
private const val MAX_BASE_BYTES = 120
|
||||
private const val ICS_EXTENSION = ".ics"
|
||||
private const val STAMP_PATTERN = "yyyy-MM-dd_HH-mm-ss"
|
||||
private val STAMP_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern(STAMP_PATTERN)
|
||||
|
||||
/**
|
||||
* The file series the automatic backup writes: one combined file, or one per
|
||||
* calendar named after it. Calendars whose names clash case-insensitively (a
|
||||
* local and a synced "Personal") take their id as a suffix until every name in
|
||||
* the run is distinct, so no two series write the same file.
|
||||
*/
|
||||
fun backupTargets(calendars: List<CalendarSource>, combined: Boolean): List<BackupTarget> {
|
||||
if (calendars.isEmpty()) return emptyList()
|
||||
if (combined) {
|
||||
return listOf(
|
||||
BackupTarget(COMBINED_SERIES, calendars.map { it.id }.toSet(), COMBINED_BACKUP_BASE, calendarName = null),
|
||||
)
|
||||
}
|
||||
val bases = calendars.associateTo(LinkedHashMap()) {
|
||||
it.id to safeFileName(it.displayName, MAX_BASE_BYTES, fallback = "calendar")
|
||||
}
|
||||
while (true) {
|
||||
val counts = bases.values.groupingBy { it.lowercase() }.eachCount()
|
||||
val clashing = bases.filterValues {
|
||||
counts.getValue(it.lowercase()) > 1 || it.equals(COMBINED_BACKUP_BASE, ignoreCase = true)
|
||||
}
|
||||
if (clashing.isEmpty()) break
|
||||
clashing.forEach { (id, base) -> bases[id] = "$base-$id" }
|
||||
}
|
||||
return calendars.map { calendar ->
|
||||
BackupTarget(
|
||||
key = calendar.id.toString(),
|
||||
calendarIds = setOf(calendar.id),
|
||||
baseName = bases.getValue(calendar.id),
|
||||
calendarName = calendar.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `Work_2026-11-13_14-30-05.ics`: the series name and the local time of the run. */
|
||||
fun backupFileName(baseName: String, at: LocalDateTime): String =
|
||||
"${baseName}_${at.format(STAMP_FORMAT)}$ICS_EXTENSION"
|
||||
|
||||
/**
|
||||
* Plan one run: a new dated file per target, and which of the series' recorded
|
||||
* files ([ledger]) fall out of the newest [keep], the new one included. Recorded
|
||||
* files no longer in the folder ([existing]) are forgotten. Each target also
|
||||
* gets its stable copy, which retention never touches. A stable name is only
|
||||
* written when the file is absent or this series owns it — or a series of this
|
||||
* run that is moving off it (a rename hand-off).
|
||||
*/
|
||||
fun planBackupFiles(
|
||||
targets: List<BackupTarget>,
|
||||
ledger: BackupLedger,
|
||||
existing: Set<String>,
|
||||
at: LocalDateTime,
|
||||
keep: Int,
|
||||
): List<BackupFilePlan> {
|
||||
val retain = keep.coerceAtLeast(1)
|
||||
val runKeys = targets.map { it.key }.toSet()
|
||||
val owners = ledger.filterKeys { it.startsWith(LATEST_PREFIX) }
|
||||
.flatMap { (key, names) -> names.map { it to key.removePrefix(LATEST_PREFIX) } }
|
||||
.toMap()
|
||||
val currentLatest = targets.map { latestFileName(it.baseName) }.toSet()
|
||||
return targets.map { target ->
|
||||
val fileName = backupFileName(target.baseName, at)
|
||||
val latest = latestFileName(target.baseName)
|
||||
val earlier = ledger[target.key].orEmpty()
|
||||
.filter { it in existing && it != fileName && it != latest }
|
||||
.distinct()
|
||||
val kept = earlier.takeLast(retain - 1)
|
||||
val owner = owners[latest]
|
||||
val writable = latest !in existing || (owner != null && owner in runKeys)
|
||||
BackupFilePlan(
|
||||
target = target,
|
||||
fileName = fileName,
|
||||
keep = kept,
|
||||
prune = earlier.dropLast(kept.size),
|
||||
latestName = latest.takeIf { writable },
|
||||
staleLatest = ledger[latestLedgerKey(target.key)].orEmpty()
|
||||
.filter { it in existing && it != latest && it !in currentLatest },
|
||||
handOff = ledger[latestLedgerKey(target.key)].orEmpty()
|
||||
.filter { it in existing && it != latest && it in currentLatest },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable copies the current layout replaced, per ledger key: a calendar's own
|
||||
* copy once the combined file holds it, or the combined copy once each calendar
|
||||
* has its own. Left in place they would keep serving old data under a path
|
||||
* something may follow. A calendar taken out of the backup keeps its copy.
|
||||
*/
|
||||
fun replacedLatestCopies(
|
||||
targets: List<BackupTarget>,
|
||||
ledger: BackupLedger,
|
||||
existing: Set<String>,
|
||||
): Map<String, List<String>> {
|
||||
val runKeys = targets.map { it.key }.toSet()
|
||||
val covered = targets.flatMap { it.calendarIds }.map { it.toString() }.toSet()
|
||||
val currentLatest = targets.map { latestFileName(it.baseName) }.toSet()
|
||||
val combinedRun = COMBINED_SERIES in runKeys
|
||||
return ledger.filterKeys { it.startsWith(LATEST_PREFIX) }
|
||||
.mapKeys { it.key.removePrefix(LATEST_PREFIX) }
|
||||
.filterKeys { key ->
|
||||
key !in runKeys && (if (key == COMBINED_SERIES) !combinedRun else combinedRun && key in covered)
|
||||
}
|
||||
.mapValues { (_, names) -> names.filter { it in existing && it !in currentLatest } }
|
||||
.filterValues { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The ledger with [key]'s files set to [files] (empty drops the entry). Entries
|
||||
* of deleted calendars go ([liveKeys]); their files stay, unrecorded. Others
|
||||
* stay, so a calendar taken out of the backup still has its files pruned if it
|
||||
* is put back.
|
||||
*/
|
||||
fun withLedgerEntry(
|
||||
previous: BackupLedger,
|
||||
key: String,
|
||||
files: List<String>,
|
||||
liveKeys: Set<String>,
|
||||
): BackupLedger {
|
||||
val next = previous.filterKeys { entry ->
|
||||
val series = entry.removePrefix(LATEST_PREFIX)
|
||||
series in liveKeys || series == COMBINED_SERIES
|
||||
}.toMutableMap()
|
||||
next[key] = files.distinct()
|
||||
// A stable copy has one owner: claiming it takes it off any other series.
|
||||
if (key.startsWith(LATEST_PREFIX)) {
|
||||
next.keys.filter { it != key && it.startsWith(LATEST_PREFIX) }.forEach { other ->
|
||||
next[other] = next.getValue(other) - files.toSet()
|
||||
}
|
||||
}
|
||||
return next.filterValues { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Stable copies recorded under any series but [key] — a hand-off that has already been claimed. */
|
||||
fun BackupLedger.latestHeldElsewhere(key: String): Set<String> =
|
||||
filterKeys { it.startsWith(LATEST_PREFIX) && it != latestLedgerKey(key) }.values.flatten().toSet()
|
||||
|
||||
/** `Work.ics`: the series' stable copy. */
|
||||
fun latestFileName(baseName: String): String = "$baseName$ICS_EXTENSION"
|
||||
|
||||
private const val LEDGER_ENTRY_SEP = "|"
|
||||
private const val LEDGER_KEY_SEP = "/"
|
||||
|
||||
/** Stores the ledger; both separators are characters no name this app writes can hold. */
|
||||
fun persistBackupLedger(ledger: BackupLedger): String? =
|
||||
ledger.filterValues { it.isNotEmpty() }.takeIf { it.isNotEmpty() }
|
||||
?.entries?.sortedBy { it.key }
|
||||
?.joinToString(LEDGER_ENTRY_SEP) { (key, names) -> (listOf(key) + names).joinToString(LEDGER_KEY_SEP) }
|
||||
|
||||
fun parseBackupLedger(stored: String?): BackupLedger =
|
||||
stored?.split(LEDGER_ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split(LEDGER_KEY_SEP)
|
||||
val names = parts.drop(1).filter { it.isNotEmpty() }
|
||||
parts.first().takeIf { it.isNotEmpty() && names.isNotEmpty() }?.let { it to names }
|
||||
}.toMap()
|
||||
|
||||
/** Closest offered retention count to [stored], so a stray value still shows a row. */
|
||||
fun normalizeBackupKeep(stored: Int): Int =
|
||||
BACKUP_KEEP_OPTIONS.minBy { kotlin.math.abs(it - stored) }
|
||||
@@ -25,12 +25,14 @@ const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||
*/
|
||||
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
|
||||
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||
/** [calendarName], when given, names the whole file (`X-WR-CALNAME`), as a one-calendar backup does. */
|
||||
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant, calendarName: String? = null): String {
|
||||
val lines = buildList {
|
||||
add("BEGIN:VCALENDAR")
|
||||
add("VERSION:2.0")
|
||||
add("PRODID:$prodId")
|
||||
add("CALSCALE:GREGORIAN")
|
||||
calendarName?.takeIf { it.isNotBlank() }?.let { add("X-WR-CALNAME:${escapeText(it)}") }
|
||||
events.forEach { appendEvent(it, dtStamp) }
|
||||
add("END:VCALENDAR")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -49,11 +48,13 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BACKUP_KEEP_OPTIONS
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarCheckGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
@@ -61,8 +62,9 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
|
||||
import java.time.LocalDate
|
||||
|
||||
@@ -75,7 +77,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||
* Backup & restore (#69): `.ics` export and restore, with
|
||||
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||
*
|
||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||
@@ -95,11 +97,15 @@ fun BackupScreen(
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Exports everything eligible (null); the per-calendar selector owns its
|
||||
// own launcher.
|
||||
// The one-calendar shortcut; the per-calendar selector owns its own launcher.
|
||||
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 +118,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) {
|
||||
@@ -153,11 +162,17 @@ fun BackupScreen(
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -172,6 +187,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 +240,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.
|
||||
@@ -210,9 +253,11 @@ private fun BackupContent(
|
||||
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
|
||||
@@ -242,7 +287,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),
|
||||
@@ -272,22 +317,59 @@ private fun BackupContent(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
|
||||
val selectedCount = exportable.count(autoBackup.selection::selects)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_calendars),
|
||||
summary = if (selectedCount == 0) {
|
||||
stringResource(R.string.calendars_auto_backup_calendars_none)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.calendars_auto_backup_calendars_count, selectedCount, selectedCount)
|
||||
},
|
||||
position = Position.Middle,
|
||||
onClick = onEditCalendars,
|
||||
)
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_combined),
|
||||
summary = stringResource(
|
||||
if (autoBackup.combined) {
|
||||
R.string.calendars_auto_backup_combined_on
|
||||
} else {
|
||||
R.string.calendars_auto_backup_combined_off
|
||||
},
|
||||
),
|
||||
checked = autoBackup.combined,
|
||||
onCheckedChange = viewModel::setAutoBackupCombined,
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_keep),
|
||||
summary = backupKeepLabel(autoBackup.keep),
|
||||
position = Position.Bottom,
|
||||
onClick = onEditKeep,
|
||||
)
|
||||
GroupedHint(
|
||||
if (selectedCount == 0) {
|
||||
stringResource(R.string.calendars_auto_backup_nothing_selected)
|
||||
} else {
|
||||
backupStatusText(autoBackup.status)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +380,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 +398,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 +419,39 @@ private fun ExportCalendarPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which calendars the automatic backup covers (#147). Each tick is saved
|
||||
* at once, as a deviation from the default, so there is nothing to confirm.
|
||||
*/
|
||||
@Composable
|
||||
private fun AutoBackupCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
selection: BackupSelection,
|
||||
onCheckedChange: (CalendarSource, Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_auto_backup_calendars),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedHint(
|
||||
stringResource(R.string.calendars_auto_backup_calendars_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
CalendarCheckGroups(
|
||||
calendars = calendars,
|
||||
isChecked = selection::selects,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Only the latest" / "The latest 3". */
|
||||
@Composable
|
||||
private fun backupKeepLabel(keep: Int): String =
|
||||
pluralStringResource(R.plurals.calendars_auto_backup_keep_count, keep, keep)
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackupCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
|
||||
@@ -29,20 +30,20 @@ sealed interface BackupUiState {
|
||||
/**
|
||||
* What the screen can offer for this calendar list.
|
||||
*
|
||||
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||
* import picker offers ([isEventTarget]).
|
||||
* Export can read any calendar (#147) — the local ones are only its default;
|
||||
* managed special-dates mirrors are rebuilt from contacts, so they are excluded
|
||||
* ([isBackupCandidate]). Restore can target anything the import picker offers
|
||||
* ([isEventTarget]).
|
||||
*
|
||||
* A failure is raised only when *neither* is possible — deliberately not
|
||||
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||
* failure while its local calendars are still perfectly exportable. When both
|
||||
* 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(
|
||||
|
||||
@@ -14,10 +14,14 @@ import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.DEFAULT_BACKUP_KEEP
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -73,12 +77,19 @@ class CalendarsViewModel @Inject constructor(
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
},
|
||||
settingsPrefs.autoBackupSelection,
|
||||
settingsPrefs.autoBackupCombined,
|
||||
settingsPrefs.autoBackupKeep,
|
||||
) { base, selection, combined, keep ->
|
||||
base.copy(selection = selection, combined = combined, keep = keep)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateWhileSubscribed(
|
||||
@@ -115,18 +126,22 @@ class CalendarsViewModel @Inject constructor(
|
||||
fun consumeBackupResult() { _backupResult.value = null }
|
||||
|
||||
/**
|
||||
* Serialise every event of the writable local calendars into the chosen SAF
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* Serialise every event of [calendarIds] into the chosen SAF document [uri]
|
||||
* as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>) {
|
||||
viewModelScope.launch {
|
||||
_backupResult.value = try {
|
||||
val count = withContext(io) {
|
||||
val events = repository.exportEvents(calendarIds)
|
||||
// A one-calendar file is named after it, as the automatic backup's are.
|
||||
val name = calendarIds.singleOrNull()?.let { id ->
|
||||
repository.calendars().first().firstOrNull { it.id == id }?.displayName
|
||||
}
|
||||
icsExporter.writeDocument(
|
||||
uri = uri,
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now(), name),
|
||||
)
|
||||
events.size
|
||||
}
|
||||
@@ -204,6 +219,49 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection writes since the calendar picker opened; the run after it waits for them. */
|
||||
private val selectionWrites = mutableListOf<Job>()
|
||||
|
||||
fun setAutoBackupCalendar(calendar: CalendarSource, included: Boolean) {
|
||||
selectionWrites += viewModelScope.launch { settingsPrefs.setAutoBackupCalendar(calendar, included) }
|
||||
}
|
||||
|
||||
fun setAutoBackupCombined(combined: Boolean) {
|
||||
viewModelScope.launch {
|
||||
if (settingsPrefs.autoBackupCombined.first() == combined) return@launch
|
||||
settingsPrefs.setAutoBackupCombined(combined)
|
||||
runBackupIfActive()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupKeep(keep: Int) {
|
||||
viewModelScope.launch {
|
||||
if (settingsPrefs.autoBackupKeep.first() == keep) return@launch
|
||||
settingsPrefs.setAutoBackupKeep(keep)
|
||||
runBackupIfActive()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After the calendar choice is closed, back up what is now selected rather
|
||||
* than at the next interval — only if it changed, since every run rotates
|
||||
* an older backup out.
|
||||
*/
|
||||
fun autoBackupCalendarsChosen(before: BackupSelection) {
|
||||
val pending = selectionWrites.toList()
|
||||
selectionWrites.clear()
|
||||
viewModelScope.launch {
|
||||
pending.joinAll()
|
||||
if (settingsPrefs.autoBackupSelection.first() != before) runBackupIfActive()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runBackupIfActive() {
|
||||
if (settingsPrefs.autoBackupEnabled.first() && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||
BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reschedule() {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
@@ -232,6 +290,9 @@ data class AutoBackupUiState(
|
||||
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||
val folderUri: String? = null,
|
||||
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||
val selection: BackupSelection = BackupSelection(),
|
||||
val combined: Boolean = false,
|
||||
val keep: Int = DEFAULT_BACKUP_KEEP,
|
||||
)
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -55,28 +56,13 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarPickerGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarPickerGroup(
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
CalendarAccountSections(calendars) { calendar, position ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = position,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
if (onManageCalendars != null) {
|
||||
@@ -98,14 +84,68 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
/**
|
||||
* A multi-select take on [CalendarPickerGroups]: the same sections, with a
|
||||
* checkbox per calendar instead of a single check.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarPickerGroup(
|
||||
fun ColumnScope.CalendarCheckGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
isChecked: (CalendarSource) -> Boolean,
|
||||
onCheckedChange: (CalendarSource, Boolean) -> Unit,
|
||||
) {
|
||||
CalendarAccountSections(calendars) { calendar, position ->
|
||||
val checked = isChecked(calendar)
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendar.description,
|
||||
position = position,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(checked = checked, onCheckedChange = { onCheckedChange(calendar, it) })
|
||||
},
|
||||
onClick = { onCheckedChange(calendar, !checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own calendars, then one section per synced account, each under its category header. */
|
||||
@Composable
|
||||
private fun CalendarAccountSections(
|
||||
calendars: List<CalendarSource>,
|
||||
row: @Composable (CalendarSource, Position) -> Unit,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarAccountSection(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
row = row,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarAccountSection(
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
row = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its calendars. */
|
||||
@Composable
|
||||
private fun CalendarAccountSection(
|
||||
title: String,
|
||||
leading: @Composable () -> Unit,
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
row: @Composable (CalendarSource, Position) -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
@@ -113,13 +153,7 @@ private fun CalendarPickerGroup(
|
||||
leading = leading,
|
||||
)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
row(calendar, if (index == calendars.lastIndex) Position.Bottom else Position.Middle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
@@ -483,7 +484,7 @@ private fun List<CalendarSource>.allHidden(): Boolean =
|
||||
* A read-only subscription is not a backup, so it does not count.
|
||||
*/
|
||||
private fun List<CalendarSource>.backupApplies(): Boolean {
|
||||
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val exportable = any { it.isBackedUpByDefault }
|
||||
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
||||
return exportable && !syncedTarget
|
||||
}
|
||||
@@ -880,7 +880,7 @@
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
<!-- Backup (whole-calendar .ics export) -->
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_hint">Export calendars to an .ics file to keep a copy. Local calendars aren\'t synced anywhere, so they need it most.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_export_title">Export calendars</string>
|
||||
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
|
||||
@@ -890,12 +890,29 @@
|
||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
||||
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your calendars to a folder as .ics files.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||
<string name="calendars_auto_backup_interval">Interval</string>
|
||||
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_calendars">Calendars</string>
|
||||
<plurals name="calendars_auto_backup_calendars_count">
|
||||
<item quantity="one">%d calendar</item>
|
||||
<item quantity="other">%d calendars</item>
|
||||
</plurals>
|
||||
<string name="calendars_auto_backup_calendars_none">No calendar selected</string>
|
||||
<string name="calendars_auto_backup_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>
|
||||
|
||||
+4
-4
@@ -946,19 +946,19 @@ class CalendarRepositoryImplTest {
|
||||
|
||||
repo.exportEvents(calendarIds = setOf(1L, 4L))
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
|
||||
assertThat(fake.lastCalendarEventsIds).containsExactly(1L, 4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents defaults to null so every eligible calendar is exported`(
|
||||
fun `exportEvents reads synced calendars when asked for them`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
|
||||
repo.exportEvents()
|
||||
repo.exportEvents(calendarIds = setOf(2L))
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||
assertThat(fake.lastCalendarEventsIds).containsExactly(2L)
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||
|
||||
+7
-7
@@ -23,9 +23,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
||||
var lastExportableEventsCalendarIds: Set<Long>? = null
|
||||
var calendarEventsResult: List<IcsEvent> = emptyList()
|
||||
/** The [calendarIds] the last [calendarEvents] call received. */
|
||||
var lastCalendarEventsIds: Set<Long>? = null
|
||||
/** UIDs the target calendar already holds, for import dedup. */
|
||||
var existingUidsResult: Set<String> = emptySet()
|
||||
/** Set to make the next write call throw. */
|
||||
@@ -81,12 +81,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
eventDetailResult(eventId)
|
||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
|
||||
publishedEventColorsResult(calendarId)
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
override fun calendarEvents(
|
||||
calendarIds: Set<Long>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
lastExportableEventsCalendarIds = calendarIds
|
||||
return exportableEventsResult
|
||||
lastCalendarEventsIds = calendarIds
|
||||
return calendarEventsResult
|
||||
}
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -705,4 +706,85 @@ class SettingsPrefsTest {
|
||||
prefs.recordSpecialDatesRun(2000L, null)
|
||||
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh install backs up one file per calendar, keeping three`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
prefs.setAutoBackupFolderUri("content://tree/a")
|
||||
assertThat(prefs.autoBackupCombined.first()).isFalse()
|
||||
assertThat(prefs.autoBackupKeep.first()).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an install that had a backup folder before 411 keeps its single file`(@TempDir tempDir: Path) = runTest {
|
||||
val store = testDataStore(tempDir, "test_settings")
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[stringPreferencesKey("auto_backup_folder_uri")] = "content://tree/old"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.autoBackupCombined.first()).isTrue()
|
||||
|
||||
|
||||
// Picking another folder pins the layout instead of flipping it, and
|
||||
// keeps the old file recognised as this install's own.
|
||||
prefs.setAutoBackupFolderUri("content://tree/new")
|
||||
assertThat(prefs.autoBackupCombined.first()).isTrue()
|
||||
// A new folder starts a new ledger: the old file there isn't known to be ours.
|
||||
assertThat(prefs.autoBackupLedger.first()).isEmpty()
|
||||
|
||||
prefs.setAutoBackupCombined(false)
|
||||
assertThat(prefs.autoBackupCombined.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup calendar choices are stored as deviations from the default`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
val local = backupCalendar(1L, local = true)
|
||||
val synced = backupCalendar(2L, local = false)
|
||||
|
||||
prefs.setAutoBackupCalendar(synced, true)
|
||||
prefs.setAutoBackupCalendar(local, false)
|
||||
val selection = prefs.autoBackupSelection.first()
|
||||
assertThat(selection.included).containsExactly(2L)
|
||||
assertThat(selection.excluded).containsExactly(1L)
|
||||
|
||||
prefs.setAutoBackupCalendar(local, true)
|
||||
assertThat(prefs.autoBackupSelection.first().excluded).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stray retention count snaps to an offered one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
prefs.setAutoBackupKeep(4)
|
||||
assertThat(prefs.autoBackupKeep.first()).isIn(listOf(3, 5))
|
||||
prefs.setAutoBackupKeep(99)
|
||||
assertThat(prefs.autoBackupKeep.first()).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pre-411 install adopts its old file when the layout is pinned in place`(@TempDir tempDir: Path) = runTest {
|
||||
val store = testDataStore(tempDir, "test_settings")
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[stringPreferencesKey("auto_backup_folder_uri")] = "content://tree/old"
|
||||
m
|
||||
}
|
||||
prefs.setAutoBackupCombined(false)
|
||||
assertThat(prefs.autoBackupLedger.first())
|
||||
.containsExactly("latest-combined", listOf("calendula-backup.ics"))
|
||||
}
|
||||
|
||||
private fun backupCalendar(id: Long, local: Boolean) = CalendarSource(
|
||||
id = id,
|
||||
displayName = "C$id",
|
||||
accountName = "a",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = true,
|
||||
isLocal = local,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package de.jeanlucmakiola.calendula.domain.backup
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class BackupPlanTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
name: String = "Calendar $id",
|
||||
local: Boolean = false,
|
||||
writable: Boolean = true,
|
||||
managed: Boolean = false,
|
||||
syncs: Boolean = true,
|
||||
) = CalendarSource(
|
||||
id = id,
|
||||
displayName = name,
|
||||
accountName = "acc",
|
||||
accountType = if (local) "LOCAL" else "bitfire.at.davdroid",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = writable,
|
||||
isLocal = local,
|
||||
isManaged = managed,
|
||||
syncsEvents = syncs,
|
||||
)
|
||||
|
||||
private val at = LocalDateTime.of(2026, 11, 13, 14, 30, 5)
|
||||
|
||||
@Test
|
||||
fun `local calendars are selected by default, synced ones are opt-in`() {
|
||||
val selection = BackupSelection()
|
||||
assertThat(selection.selects(cal(1L, local = true))).isTrue()
|
||||
assertThat(selection.selects(cal(2L))).isFalse()
|
||||
assertThat(selection.selects(cal(3L, writable = false))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managed and unsynced calendars are never backed up`() {
|
||||
val selection = BackupSelection(included = setOf(1L, 2L))
|
||||
assertThat(selection.selects(cal(1L, local = true, managed = true))).isFalse()
|
||||
assertThat(selection.selects(cal(2L, syncs = false))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling back to the default leaves no trace`() {
|
||||
val synced = cal(2L)
|
||||
val local = cal(1L, local = true)
|
||||
val chosen = BackupSelection().with(synced, true).with(local, false)
|
||||
assertThat(chosen).isEqualTo(BackupSelection(included = setOf(2L), excluded = setOf(1L)))
|
||||
assertThat(chosen.with(synced, false).with(local, true)).isEqualTo(BackupSelection())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one target per calendar, clashing names told apart by id`() {
|
||||
val targets = backupTargets(
|
||||
listOf(cal(1L, "Personal", local = true), cal(2L, "personal"), cal(3L, "Work")),
|
||||
combined = false,
|
||||
)
|
||||
assertThat(targets.map { it.baseName }).containsExactly("Personal-1", "personal-2", "Work").inOrder()
|
||||
assertThat(targets.map { it.calendarName }).containsExactly("Personal", "personal", "Work").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar named like the combined file doesn't share its series`() {
|
||||
val target = backupTargets(listOf(cal(4L, COMBINED_BACKUP_BASE)), combined = false).single()
|
||||
assertThat(target.baseName).isEqualTo("$COMBINED_BACKUP_BASE-4")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combined mode is one target holding every calendar`() {
|
||||
val target = backupTargets(listOf(cal(1L), cal(2L)), combined = true).single()
|
||||
assertThat(target.key).isEqualTo(COMBINED_SERIES)
|
||||
assertThat(target.calendarIds).containsExactly(1L, 2L)
|
||||
assertThat(target.baseName).isEqualTo(COMBINED_BACKUP_BASE)
|
||||
assertThat(target.calendarName).isNull()
|
||||
assertThat(backupTargets(emptyList(), combined = true)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the new file carries the date and time of the run`() {
|
||||
assertThat(backupFileName("Work", at)).isEqualTo("Work_2026-11-13_14-30-05.ics")
|
||||
}
|
||||
|
||||
private val work = BackupTarget("1", setOf(1L), "Work", "Work")
|
||||
|
||||
@Test
|
||||
fun `pruning keeps the newest recorded files and nothing else`() {
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics", "Work_c.ics"))
|
||||
val existing = setOf("Work_a.ics", "Work_b.ics", "Work_c.ics", "Work_2026-11-01_09-00-00.ics", "Work notes.ics")
|
||||
val plan = planBackupFiles(listOf(work), ledger, existing, at, keep = 3).single()
|
||||
assertThat(plan.fileName).isEqualTo("Work_2026-11-13_14-30-05.ics")
|
||||
assertThat(plan.keep).containsExactly("Work_b.ics", "Work_c.ics").inOrder()
|
||||
assertThat(plan.prune).containsExactly("Work_a.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeping one replaces every earlier file`() {
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics"))
|
||||
val plan = planBackupFiles(listOf(work), ledger, setOf("Work_a.ics", "Work_b.ics"), at, keep = 1).single()
|
||||
assertThat(plan.keep).isEmpty()
|
||||
assertThat(plan.prune).containsExactly("Work_a.ics", "Work_b.ics").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `files gone from the folder are forgotten, not counted`() {
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics"))
|
||||
val plan = planBackupFiles(listOf(work), ledger, setOf("Work_b.ics"), at, keep = 2).single()
|
||||
assertThat(plan.keep).containsExactly("Work_b.ics")
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a same-named calendar never prunes another calendar's files`() {
|
||||
// Local "Work" (id 1) was backed up; the user now backs up a synced "Work" (id 9) instead.
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics"))
|
||||
val synced = backupTargets(listOf(cal(9L, "Work")), combined = false).single()
|
||||
val plan = planBackupFiles(listOf(synced), ledger, setOf("Work_a.ics"), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rerun in the same second never prunes the file it writes`() {
|
||||
val name = "Work_2026-11-13_14-30-05.ics"
|
||||
val plan = planBackupFiles(listOf(work), mapOf("1" to listOf(name)), setOf(name), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
private val combined = BackupTarget(COMBINED_SERIES, setOf(1L, 2L), COMBINED_BACKUP_BASE, null)
|
||||
|
||||
@Test
|
||||
fun `the pre-411 file is the combined stable copy only for the install that wrote it`() {
|
||||
val adopted = mapOf(latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE))
|
||||
val owned = planBackupFiles(listOf(combined), adopted, setOf(LEGACY_BACKUP_FILE), at, 1)
|
||||
assertThat(owned.single().latestName).isEqualTo(LEGACY_BACKUP_FILE)
|
||||
assertThat(owned.single().prune).isEmpty()
|
||||
|
||||
val foreign = planBackupFiles(listOf(combined), emptyMap(), setOf(LEGACY_BACKUP_FILE), at, 1)
|
||||
assertThat(foreign.single().latestName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stable name recorded as a dated file by an earlier build is never pruned`() {
|
||||
val ledger = mapOf(COMBINED_SERIES to listOf(LEGACY_BACKUP_FILE, "calendula-backup_a.ics"))
|
||||
val plan = planBackupFiles(
|
||||
listOf(combined), ledger, setOf(LEGACY_BACKUP_FILE, "calendula-backup_a.ics"), at, 1,
|
||||
).single()
|
||||
assertThat(plan.prune).containsExactly("calendula-backup_a.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar series leave the legacy combined file alone`() {
|
||||
val plan = planBackupFiles(listOf(work), emptyMap(), setOf(LEGACY_BACKUP_FILE), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
assertThat(plan.staleLatest).isEmpty()
|
||||
assertThat(plan.latestName).isEqualTo("Work.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stable copy is never pruned and never overwrites the user's own file`() {
|
||||
val ours = planBackupFiles(
|
||||
listOf(work),
|
||||
mapOf("1" to listOf("Work_a.ics"), latestLedgerKey("1") to listOf("Work.ics")),
|
||||
setOf("Work_a.ics", "Work.ics"),
|
||||
at,
|
||||
keep = 1,
|
||||
).single()
|
||||
assertThat(ours.latestName).isEqualTo("Work.ics")
|
||||
assertThat(ours.prune).containsExactly("Work_a.ics")
|
||||
|
||||
val foreign = planBackupFiles(listOf(work), emptyMap(), setOf("Work.ics"), at, keep = 1).single()
|
||||
assertThat(foreign.latestName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a renamed calendar replaces its old stable copy`() {
|
||||
val job = BackupTarget("1", setOf(1L), "Job", "Job")
|
||||
val plan = planBackupFiles(
|
||||
listOf(job),
|
||||
mapOf(latestLedgerKey("1") to listOf("Work.ics")),
|
||||
setOf("Work.ics"),
|
||||
at,
|
||||
keep = 3,
|
||||
).single()
|
||||
assertThat(plan.latestName).isEqualTo("Job.ics")
|
||||
assertThat(plan.staleLatest).containsExactly("Work.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stable copy is handed over to the calendar that takes its name in the same run`() {
|
||||
// Calendar 1 was "Work", is now "Job"; calendar 2 is now called "Work".
|
||||
val ledger = mapOf(latestLedgerKey("1") to listOf("Work.ics"))
|
||||
val plans = planBackupFiles(
|
||||
listOf(BackupTarget("1", setOf(1L), "Job", "Job"), BackupTarget("2", setOf(2L), "Work", "Work")),
|
||||
ledger,
|
||||
setOf("Work.ics"),
|
||||
at,
|
||||
keep = 1,
|
||||
)
|
||||
assertThat(plans[0].staleLatest).isEmpty()
|
||||
assertThat(plans[0].handOff).containsExactly("Work.ics")
|
||||
assertThat(plans[1].latestName).isEqualTo("Work.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deselected calendar's stable copy is not taken over by a same-named one`() {
|
||||
// Calendar 3 "Personal" is out of the backup; calendar 9, also "Personal", is in.
|
||||
val ledger = mapOf(latestLedgerKey("3") to listOf("Personal.ics"))
|
||||
val plan = planBackupFiles(
|
||||
listOf(BackupTarget("9", setOf(9L), "Personal", "Personal")), ledger, setOf("Personal.ics"), at, 1,
|
||||
).single()
|
||||
assertThat(plan.latestName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a layout switch replaces the other layout's stable copies, a deselected calendar keeps its own`() {
|
||||
val ledger = mapOf(
|
||||
latestLedgerKey("1") to listOf("Work.ics"),
|
||||
latestLedgerKey("5") to listOf("Old.ics"),
|
||||
latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE),
|
||||
)
|
||||
val existing = setOf("Work.ics", "Old.ics", LEGACY_BACKUP_FILE)
|
||||
assertThat(replacedLatestCopies(listOf(combined), ledger, existing))
|
||||
.containsExactly("1", listOf("Work.ics"))
|
||||
assertThat(replacedLatestCopies(listOf(work), ledger, existing))
|
||||
.containsExactly(COMBINED_SERIES, listOf(LEGACY_BACKUP_FILE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ledger entry is set, emptied away, and dropped with its calendar`() {
|
||||
val previous = mapOf(
|
||||
"1" to listOf("Work_a.ics"),
|
||||
latestLedgerKey("7") to listOf("Gone.ics"),
|
||||
latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE),
|
||||
)
|
||||
val next = withLedgerEntry(previous, "1", listOf("Work_a.ics", "Work_b.ics", "Work_b.ics"), setOf("1"))
|
||||
assertThat(next).containsExactly(
|
||||
"1", listOf("Work_a.ics", "Work_b.ics"),
|
||||
latestLedgerKey(COMBINED_SERIES), listOf(LEGACY_BACKUP_FILE),
|
||||
)
|
||||
assertThat(withLedgerEntry(next, "1", emptyList(), setOf("1")).keys)
|
||||
.containsExactly(latestLedgerKey(COMBINED_SERIES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ledger round-trips through storage`() {
|
||||
val ledger = mapOf("3" to listOf("Work_b.ics", "Work_a (1).ics"), COMBINED_SERIES to listOf("calendula-backup_x.ics"))
|
||||
assertThat(parseBackupLedger(persistBackupLedger(ledger))).isEqualTo(ledger)
|
||||
assertThat(persistBackupLedger(emptyMap())).isNull()
|
||||
assertThat(parseBackupLedger("garbage|/x|4/")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retention snaps to the offered choices`() {
|
||||
assertThat(normalizeBackupKeep(0)).isEqualTo(1)
|
||||
assertThat(normalizeBackupKeep(3)).isEqualTo(3)
|
||||
assertThat(normalizeBackupKeep(50)).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a suffixed name that meets a real one is told apart again`() {
|
||||
val targets = backupTargets(
|
||||
listOf(cal(5L, "Work"), cal(6L, "Work"), cal(9L, "Work-5")),
|
||||
combined = false,
|
||||
)
|
||||
assertThat(targets.map { it.baseName }).containsExactly("Work-5-5", "Work-6", "Work-5-9").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `claiming a stable copy takes it off its previous owner`() {
|
||||
val previous = mapOf(latestLedgerKey("1") to listOf("Work.ics", "Job.ics"))
|
||||
val next = withLedgerEntry(previous, latestLedgerKey("2"), listOf("Work.ics"), setOf("1", "2"))
|
||||
assertThat(next).containsExactly(
|
||||
latestLedgerKey("1"), listOf("Job.ics"),
|
||||
latestLedgerKey("2"), listOf("Work.ics"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a claimed hand-off shows as held elsewhere`() {
|
||||
val ledger = mapOf(latestLedgerKey("1") to listOf("Job.ics"), latestLedgerKey("2") to listOf("Work.ics"), "2" to listOf("Work_a.ics"))
|
||||
assertThat(ledger.latestHeldElsewhere("1")).containsExactly("Work.ics")
|
||||
assertThat(ledger.latestHeldElsewhere("2")).containsExactly("Job.ics")
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,10 @@ class BackupUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synced writable calendar can receive a restore`() {
|
||||
fun `a synced writable calendar can receive a restore and be backed up`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L))))
|
||||
assertThat(state.canImport).isTrue()
|
||||
assertThat(state.exportable).isEmpty()
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -62,16 +62,18 @@ class BackupUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only subscription is no import target either`() {
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
|
||||
.isEqualTo(FailureReason.NoImportTarget)
|
||||
fun `a read-only subscription is no import target but can be backed up`() {
|
||||
// #147: export only reads, so a subscription is fair game.
|
||||
val state = ready(backupUiState(listOf(cal(1L, writable = false))))
|
||||
assertThat(state.canImport).isFalse()
|
||||
assertThat(state.exportable).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything switched off is reported as hidden, not as a missing target`() {
|
||||
// The remedy differs: a visibility switch fixes this one.
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, visible = false)))))
|
||||
.isEqualTo(FailureReason.AllCalendarsHidden)
|
||||
fun `a switched-off synced calendar still backs up`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L, visible = false))))
|
||||
assertThat(state.canImport).isFalse()
|
||||
assertThat(state.exportable).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,7 +94,7 @@ class BackupUiStateTest {
|
||||
@Test
|
||||
fun `a local calendar alongside a synced one offers both halves`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L))))
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L, 2L)
|
||||
assertThat(state.canImport).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,29 @@ after the event — so `toIcsEvent` decodes it back through
|
||||
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
|
||||
its own setting.
|
||||
|
||||
**Backups read any calendar; restores still write only into targets.**
|
||||
`CalendarDataSource.calendarEvents` reads the events of whatever calendar ids it
|
||||
is given — local, synced or read-only — and leaves the choice to its callers:
|
||||
export and the automatic backup offer every calendar but the managed mirrors
|
||||
(#147), defaulting to the writable local ones (`domain/backup/BackupPlan.kt`). A
|
||||
restore runs through the normal import, whose targets stay `isEventTarget`. The
|
||||
automatic backup writes one `<calendar name>_<date>_<time>.ics` series per
|
||||
calendar, or one `calendula-backup_…` series in combined mode (#411), and prunes
|
||||
each series to the retention count after its new file has landed. Pruning only
|
||||
deletes names recorded in a per-series ledger of files the backup wrote
|
||||
(`auto_backup_files`, keyed by calendar id, reset when the folder changes),
|
||||
never whatever happens to match a
|
||||
name, so renames, same-named calendars and the user's own files are safe.
|
||||
Each series also keeps a stable undated copy (`<calendar name>.ics`, or
|
||||
`calendula-backup.ics` combined) overwritten every run, so sync tools and
|
||||
subscriptions can follow one path. Retention never touches it, and it is only
|
||||
written over a file the series itself (or a series renaming away from that name
|
||||
in the same run) recorded. A renamed calendar's old copy is deleted once the new
|
||||
one has landed, and so is a copy the layout switch replaced (per-calendar ↔ combined); a calendar taken out
|
||||
of the backup keeps its last one. The pre-#411 fixed `calendula-backup.ics` is
|
||||
the combined series' stable copy for the install that wrote it, and an
|
||||
install that already had a folder stays on the combined layout.
|
||||
|
||||
**A backup carries the series' deleted 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
|
||||
|
||||
+1
-1
Submodule floret-kit updated: a039ab5bd4...389cc0298f.
Reference in new issue
Block a user