Compare commits

..
Author SHA1 Message Date
makiolaj 754745586d Run the Play job on trixie for fastlane's Ruby 3.2 requirement (#509) 2026-11-13 19:17:46 +01:00
Jean-Luc Makiola 1a1df07dc3 Release v2.24.2 (#511)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Renovate / renovate (push) Successful in 1m53s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m38s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 34s
- Queued drops: moves made while an earlier one is still saving are queued instead of dropped, with messages for stale and skipped moves (#494)
- Day view title names the month (#501)
- Dependency updates: kotlinx-datetime, AppCompat, fastlane, Renovate image (#507–#510)
- Version 2.24.2 (2240299), CHANGELOG, What's New in all store locales

#494 stays open until the reporter answers; the fix covers the ways a quick move could get lost, the original report isn't reproduced.

Refs #494
Closes #501
2026-11-13 18:56:38 +01:00
makiolaj 75315d92a7 Merge main into release/2.24.2
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-13 16:10:53 +01:00
95ad3a30cf Release v2.24.1 (#504)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 1m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m58s
- Agenda widget: Cards / Classic style option, opacity slider for all Solid/Tinted widgets (#489)
- Month widget fits events into short week rows (#491)
- Week numbers follow the locale's rules and the row's first day (#493)
- Readable dimmed events in dark widgets, pinned Light/Dark theme matches system colours (#475)
- Dropped unused WorkManager permissions: network state, wake lock, foreground service (#448)
- Weblate translations (#434), incl. the Arabic string from after the 2.24.0 squash
- Version 2.24.1 (2240199), CHANGELOG, What's New in all store locales

#489 deviates from the issue: the classic look comes back as a style option instead of replacing the new design.

Closes #489
Closes #491
Closes #493
Closes #475
Closes #448

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Louis Leblanc <sandraev@posteo.dk>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/504
2026-11-13 11:17:48 +01:00
Jean-Luc Makiola 8ce4831efa Translations update from Weblate (#434)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m59s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/434
2026-11-13 11:07:42 +01:00
25 changed files with 234 additions and 1367 deletions

No files matched your search

+4 -1
View File
@@ -318,13 +318,16 @@ 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.
# 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.
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,7 +5,6 @@ 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
@@ -26,29 +25,17 @@ 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 (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.
* 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.
*/
object BackupScheduler {
@@ -60,8 +47,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 doesn't follow an immediate
* [runNow] with a near-identical copy.
* 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).
*/
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
val workManager = WorkManager.getInstance(context)
@@ -99,9 +86,8 @@ object BackupScheduler {
}
/**
* 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
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
* overwriting the previous file. 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.
@@ -122,124 +108,25 @@ 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()
// 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
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)
}
}
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
Log.w(TAG, "Automatic backup failed", e)
Result.retry()
}
}
@@ -278,10 +165,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 [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]).
* 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]).
*/
fun calendarEvents(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): List<IcsEvent>
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -776,36 +776,34 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun calendarEvents(
calendarIds: Set<Long>,
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
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.
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored
// here, and re-materialise from the contact sync — backing them up would
// just duplicate them on restore. A non-null [calendarIds] narrows the
// export to the user's chosen subset. Map id → display name for the
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
val names = calendars()
.filter { it.id in calendarIds }
.filter {
it.isLocal && it.canModifyContents && !it.isManaged &&
(calendarIds == null || 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,
selection,
// 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",
null,
CalendarContract.Events.DTSTART + " ASC",
)?.use { c ->
@@ -813,7 +811,7 @@ class AndroidCalendarDataSource @Inject constructor(
val eventId = getLong(EventExportProjection.IDX_ID)
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
toIcsEvent(
reminderMinutes = reminders[eventId].orEmpty(),
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
@@ -61,11 +61,11 @@ interface CalendarRepository {
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
/**
* 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).
* 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.
*/
suspend fun exportEvents(calendarIds: Set<Long>): List<IcsEvent>
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
@@ -247,8 +247,8 @@ class CalendarRepositoryImpl @Inject constructor(
}
}
override suspend fun exportEvents(calendarIds: Set<Long>) =
withContext(io) { dataSource.calendarEvents(calendarIds, allDayReminderTimeMinutes()) }
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
override suspend fun importEvents(
targetCalendarId: Long,
@@ -1,36 +0,0 @@
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,10 +5,6 @@ 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
@@ -33,27 +29,34 @@ class IcsExporter @Inject constructor(
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
}
/** 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 {
/**
* 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) {
val dir = DocumentFile.fromTreeUri(context, folder)
?: throw IOException("Backup folder is not accessible")
if (!dir.exists() || !dir.canWrite()) {
throw IOException("Backup folder is missing or not writable")
}
return dir
// 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)
}
/**
@@ -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. */
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
private 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,20 +1,9 @@
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
@@ -40,7 +29,6 @@ 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
@@ -192,10 +180,6 @@ 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 }
@@ -345,56 +329,7 @@ class SettingsPrefs @Inject constructor(
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
prefs.set(SettingsKeys.autoBackupIntervalMinutes, minutes)
/**
* 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
}
}
suspend fun setAutoBackupFolderUri(uri: String?) = prefs.set(SettingsKeys.autoBackupFolderUri, uri)
/** Record an automatic run's outcome; resets the failure streak on success. */
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
@@ -765,27 +700,6 @@ 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)
@@ -835,42 +749,6 @@ val Preferences.autoBackupStatus: BackupStatus
consecutiveFailures = this[SettingsKeys.autoBackupFailures],
)
val Preferences.autoBackupSelection: BackupSelection
get() = BackupSelection(
included = this[SettingsKeys.autoBackupIncluded],
excluded = this[SettingsKeys.autoBackupExcluded],
)
/**
* Unset means an install from before #411: one that already had a backup
* folder keeps the single file it was writing, anything else gets per-calendar
* files. Choosing a folder pins the answer (see [SettingsPrefs.setAutoBackupFolderUri]).
*/
val Preferences.autoBackupCombined: Boolean
get() = this[SettingsKeys.autoBackupCombinedStored] ?: autoBackupLegacyInstall
/**
* Store the layout the install is on, if it's still derived. Pinning forgets
* it was a pre-#411 install, so its old file is recorded as the combined
* series' own stable copy at the same time.
*/
private fun MutablePreferences.pinBackupLayout() {
if (this[SettingsKeys.autoBackupCombinedStored] != null) return
val legacy = autoBackupLegacyInstall
if (legacy) adoptLegacyBackupFile()
this[SettingsKeys.autoBackupCombinedStored] = legacy
}
private fun MutablePreferences.adoptLegacyBackupFile() {
val key = latestLedgerKey(COMBINED_SERIES)
val ledger = this[SettingsKeys.autoBackupLedger]
this[SettingsKeys.autoBackupLedger] = ledger + (key to (ledger[key].orEmpty() + LEGACY_BACKUP_FILE).distinct())
}
/** An install that set up automatic backup before #411 and hasn't been pinned since. */
val Preferences.autoBackupLegacyInstall: Boolean
get() = this[SettingsKeys.autoBackupCombinedStored] == null && this[SettingsKeys.autoBackupFolderUri] != null
val Preferences.specialDatesTypes: Set<SpecialDateType>
get() = SettingsKeys.specialDateTypeEnabled.filterValues { this[it] }.keys
@@ -4,7 +4,6 @@ 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
@@ -73,8 +72,28 @@ class ProviderReminderInstanceSource @Inject constructor(
} ?: emptyList()
}
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> =
context.contentResolver.reminderMinutesByEvent(eventIds, alertsOnly = true)
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 longestReminderMinutes(): Int = context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
@@ -86,6 +105,8 @@ 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,
@@ -95,5 +116,10 @@ class ProviderReminderInstanceSource @Inject constructor(
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.ALL_DAY,
)
val REMINDER_PROJECTION = arrayOf(
CalendarContract.Reminders.EVENT_ID,
CalendarContract.Reminders.MINUTES,
)
}
}
@@ -1,279 +0,0 @@
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,14 +25,12 @@ const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
*/
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
/** [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 {
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): 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,6 +18,7 @@ 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
@@ -48,13 +49,11 @@ 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.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.CalendarColorChip
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
@@ -62,9 +61,8 @@ 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
@@ -77,7 +75,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
)
/**
* Backup & restore (#69): `.ics` export and restore, with
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
@@ -97,15 +95,11 @@ fun BackupScreen(
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// The one-calendar shortcut; the per-calendar selector owns its own launcher.
var shortcutExportId by rememberSaveable { mutableStateOf<Long?>(null) }
// Exports everything eligible (null); the per-calendar selector owns its
// own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
val id = shortcutExportId
if (uri != null && id != null) viewModel.exportBackup(uri, setOf(id))
shortcutExportId = null
}
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// Restore runs the picked file through the normal .ics import flow.
@@ -118,9 +112,6 @@ 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) {
@@ -162,17 +153,11 @@ fun BackupScreen(
viewModel = viewModel,
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
onPickFolder = { runCatching { pickFolder.launch(null) } },
onExportOnly = {
shortcutExportId = s.exportable.singleOrNull()?.id
onExportAll = {
runCatching { createBackup.launch(defaultBackupName()) }
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
onEditCalendars = {
selectionBefore = autoBackup.selection
showBackupCalendars = true
},
onEditKeep = { showKeep = true },
)
}
}
@@ -187,34 +172,6 @@ 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(
@@ -240,7 +197,7 @@ private fun BackupLoading() {
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
/**
* The working screen: export (when any calendar can be backed up), restore,
* The working screen: export (when there are local calendars to export), 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.
@@ -253,11 +210,9 @@ private fun BackupContent(
viewModel: CalendarsViewModel,
onRestore: () -> Unit,
onPickFolder: () -> Unit,
onExportOnly: () -> Unit,
onExportAll: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
onEditCalendars: () -> Unit,
onEditKeep: () -> Unit,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
@@ -287,7 +242,7 @@ private fun BackupContent(
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
// A single exportable calendar skips the selector.
onClick = if (exportable.size == 1) onExportOnly else onExportPick,
onClick = if (exportable.size == 1) onExportAll else onExportPick,
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
@@ -317,59 +272,22 @@ private fun BackupContent(
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Middle,
position = Position.Bottom,
onClick = onEditInterval,
)
val selectedCount = exportable.count(autoBackup.selection::selects)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_calendars),
summary = if (selectedCount == 0) {
stringResource(R.string.calendars_auto_backup_calendars_none)
} else {
pluralStringResource(R.plurals.calendars_auto_backup_calendars_count, selectedCount, selectedCount)
},
position = Position.Middle,
onClick = onEditCalendars,
)
GroupedSwitchRow(
title = stringResource(R.string.calendars_auto_backup_combined),
summary = stringResource(
if (autoBackup.combined) {
R.string.calendars_auto_backup_combined_on
} else {
R.string.calendars_auto_backup_combined_off
},
),
checked = autoBackup.combined,
onCheckedChange = viewModel::setAutoBackupCombined,
position = Position.Middle,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_keep),
summary = backupKeepLabel(autoBackup.keep),
position = Position.Bottom,
onClick = onEditKeep,
)
GroupedHint(
if (selectedCount == 0) {
stringResource(R.string.calendars_auto_backup_nothing_selected)
} else {
backupStatusText(autoBackup.status)
},
style = MaterialTheme.typography.bodySmall,
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
}
}
/**
* 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
* 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
* 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
@@ -380,8 +298,7 @@ private fun ExportCalendarPicker(
restore = { it.toSet() },
),
) {
val defaults = calendars.filter { it.isBackedUpByDefault }.ifEmpty { calendars }
mutableStateOf(defaults.map { it.id }.toSet())
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
@@ -398,16 +315,29 @@ private fun ExportCalendarPicker(
predictiveBack = true,
) {
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
CalendarCheckGroups(
calendars = calendars,
isChecked = { it.id in selected },
onCheckedChange = { calendar, checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
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
},
)
}
Button(
onClick = {
runCatching { createBackup.launch(defaultBackupName()) }
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
@@ -419,39 +349,6 @@ 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,7 +2,6 @@ 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
@@ -30,20 +29,20 @@ sealed interface BackupUiState {
/**
* What the screen can offer for this calendar list.
*
* 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]).
* 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]).
*
* 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 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).
* 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.
*/
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
val exportable = calendars.filter { it.isBackupCandidate }
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
if (exportable.isEmpty() && !canImport) {
return BackupUiState.Failure(
@@ -14,14 +14,10 @@ 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
@@ -77,19 +73,12 @@ class CalendarsViewModel @Inject constructor(
/** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine(
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)
settingsPrefs.autoBackupEnabled,
settingsPrefs.autoBackupIntervalMinutes,
settingsPrefs.autoBackupFolderUri,
settingsPrefs.autoBackupStatus,
) { enabled, interval, folder, status ->
AutoBackupUiState(enabled, interval, folder, status)
}
.flowOn(io)
.stateWhileSubscribed(
@@ -126,22 +115,18 @@ class CalendarsViewModel @Inject constructor(
fun consumeBackupResult() { _backupResult.value = null }
/**
* Serialise every event of [calendarIds] into the chosen SAF document [uri]
* as one `VCALENDAR`. Result (event count, or failure) lands
* Serialise every event of the writable local calendars 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>) {
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
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(), name),
content = IcsWriter().writeCalendar(events, Clock.System.now()),
)
events.size
}
@@ -219,49 +204,6 @@ 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,
@@ -290,9 +232,6 @@ 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,7 +15,6 @@ 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
@@ -56,13 +55,28 @@ fun ColumnScope.CalendarPickerGroups(
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
CalendarAccountSections(calendars) { calendar, position ->
GroupedRow(
title = calendar.displayName.orUnnamed(),
position = position,
checked = calendar.id == selectedId,
leading = { CalendarColorChip(calendar.color) },
onClick = { onSelect(calendar.id) },
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,
)
}
if (onManageCalendars != null) {
@@ -84,68 +98,14 @@ fun ColumnScope.CalendarPickerGroups(
}
}
/**
* A multi-select take on [CalendarPickerGroups]: the same sections, with a
* checkbox per calendar instead of a single check.
*/
/** One account's category header (avatar + name) atop its selectable calendars. */
@Composable
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(
private fun CalendarPickerGroup(
title: String,
leading: @Composable () -> Unit,
calendars: List<CalendarSource>,
row: @Composable (CalendarSource, Position) -> Unit,
selectedId: Long?,
onSelect: (Long) -> Unit,
) {
GroupedRow(
title = title,
@@ -153,7 +113,13 @@ private fun CalendarAccountSection(
leading = leading,
)
calendars.forEachIndexed { index, calendar ->
row(calendar, if (index == calendars.lastIndex) Position.Bottom else Position.Middle)
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) },
)
}
}
@@ -15,7 +15,6 @@ 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
@@ -484,7 +483,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.isBackedUpByDefault }
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
val syncedTarget = any { !it.isLocal && it.canModifyContents }
return exportable && !syncedTarget
}
+2 -19
View File
@@ -880,7 +880,7 @@
<string name="calendars_write_error">Couldn\'t save the change.</string>
<!-- Backup (whole-calendar .ics export) -->
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">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_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</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,29 +890,12 @@
<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 calendars to a folder as .ics files.</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_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>
@@ -946,19 +946,19 @@ class CalendarRepositoryImplTest {
repo.exportEvents(calendarIds = setOf(1L, 4L))
assertThat(fake.lastCalendarEventsIds).containsExactly(1L, 4L)
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
}
@Test
fun `exportEvents reads synced calendars when asked for them`(
fun `exportEvents defaults to null so every eligible calendar is exported`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
repo.exportEvents(calendarIds = setOf(2L))
repo.exportEvents()
assertThat(fake.lastCalendarEventsIds).containsExactly(2L)
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?, summary: String = "E") =
@@ -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 calendarEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [calendarEvents] call received. */
var lastCalendarEventsIds: Set<Long>? = null
var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: 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 calendarEvents(
calendarIds: Set<Long>,
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
lastCalendarEventsIds = calendarIds
return calendarEventsResult
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
@@ -7,7 +7,6 @@ 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
@@ -706,85 +705,4 @@ 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,
)
}
@@ -1,287 +0,0 @@
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 and be backed up`() {
fun `a synced writable calendar can receive a restore`() {
val state = ready(backupUiState(listOf(cal(1L))))
assertThat(state.canImport).isTrue()
assertThat(state.exportable.map { it.id }).containsExactly(1L)
assertThat(state.exportable).isEmpty()
}
@Test
@@ -62,18 +62,16 @@ class BackupUiStateTest {
}
@Test
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)
fun `a read-only subscription is no import target either`() {
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
.isEqualTo(FailureReason.NoImportTarget)
}
@Test
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)
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)
}
@Test
@@ -94,7 +92,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, 2L)
assertThat(state.exportable.map { it.id }).containsExactly(1L)
assertThat(state.canImport).isTrue()
}
}
-23
View File
@@ -462,29 +462,6 @@ 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