diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 46479d5..f194096 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -233,18 +233,25 @@ pass on the existing controls; new toggles ride in with their own features. 8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open **Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)* -9. **Reminders — defaults + delivery reliability** *(next)* — global default - reminder **+ per-calendar override**, bundled with exact-alarm / battery - hardening. Elevated above .ics: it's core to the "Calendula is your only - calendar app" promise. Full sketch in "Reminders — defaults & delivery - reliability" below. -10. **Local-calendar backup / export** — device-only calendars have no sync and - therefore **no backup**; losing the phone = total data loss. Whole-calendar - `.ics` export + restore. A data-integrity gap, not a feature; front-runs and - overlaps the single-event .ics work below. -11. Share event as .ics + receive/open .ics into a prefilled create form +9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global + default reminder **+ per-calendar override**, bundled with battery-exemption + hardening. Full sketch in "Reminders — defaults & delivery reliability" below. +10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one + hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`), + four surfaces: single-event share + whole-calendar backup (export), + open-`.ics`→form + whole-calendar restore (import). Closes the + device-local-calendar data-loss gap (#10/#11 merged here). Built as **two + sequential branches in one release**: `feat/ics-export` (write side + + UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup). + Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / + `RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`, + non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored + `EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on + import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`. +11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the + `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze + must re-fire an alarm). 12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog) -13. Snooze / dismiss notification actions — follows the reminders slice (#9) **Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)** - Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage) diff --git a/CHANGELOG.md b/CHANGELOG.md index 268dedc..fa1423c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Share a single event as an `.ics` file from the event detail screen — hands a + standard calendar file to any app via the system share sheet. +- Back up your local (device-only) calendars: Settings → Calendars → Export as + `.ics` file writes every event of your on-device calendars to a file you + choose. Local calendars aren't synced anywhere, so this is their only backup. + (Importing `.ics` files back in lands in the next update.) + +_Note: new events now carry a unique identifier so a future `.ics` import can +recognise them and avoid duplicates._ + ## [2.6.0] — 2026-06-18 ### Added diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c01f068..4bdb178 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -112,6 +112,19 @@ + + + + + + /** + * 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]). + */ + fun exportableEvents(): List + /** * Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns; * returns its `Calendars._ID`. Inserted through the sync-adapter URI so the @@ -265,6 +274,39 @@ class AndroidCalendarDataSource @Inject constructor( ?: emptyList() } + override fun exportableEvents(): List { + // Only the local calendars the app owns and can write — synced calendars + // already have a backup (their server). Map id → display name for the + // X-CALENDULA-CALENDAR tag a restore uses to fan back out. + val names = calendars() + .filter { it.isLocal && it.canModifyContents } + .associate { it.id to it.displayName } + if (names.isEmpty()) return emptyList() + + val idList = names.keys.joinToString(",") + 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", + null, + CalendarContract.Events.DTSTART + " ASC", + )?.use { c -> + c.mapAll { + val reader = CursorColumnReader(c) + val eventId = reader.getLong(EventExportProjection.IDX_ID) + val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID) + reader.toIcsEvent( + reminderMinutes = queryReminders(eventId).map { it.minutes }, + calendarName = names[calendarId], + ) + } + } ?: emptyList() + } + /** The account a calendar belongs to, for scoping a `Colors` lookup. */ private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query( ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId), @@ -316,6 +358,11 @@ class AndroidCalendarDataSource @Inject constructor( CalendarContract.Events.CALENDAR_ID, requireNotNull(form.calendarId) { "EventForm.calendarId is required" }, ) + // A globally-unique UID so a later .ics backup/restore can identify + // the event and not duplicate it on re-import (the provider leaves + // this null for events it didn't sync). Older rows without one fall + // back to a stable synthesised UID at export time (deriveIcsUid). + put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula") put(CalendarContract.Events.TITLE, form.title.trim()) put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) put(CalendarContract.Events.DTSTART, times.dtStartMillis) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 04ec73e..7b02429 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import kotlinx.coroutines.flow.Flow import kotlin.time.Instant @@ -28,6 +29,12 @@ interface CalendarRepository { /** Permanently delete a local calendar the app owns, with all its events. */ suspend fun deleteCalendar(id: Long) + /** + * Every event of the writable local calendars, ready to serialise into a + * whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]). + */ + suspend fun exportEvents(): List + /** Create a new event from a validated form; returns the new `Events._ID`. */ suspend fun createEvent(form: EventForm): Long diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 23b5629..7409d77 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -99,6 +99,8 @@ class CalendarRepositoryImpl @Inject constructor( override suspend fun deleteCalendar(id: Long) = withContext(io) { dataSource.deleteCalendar(id) } + override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() } + override suspend fun createEvent(form: EventForm): Long = withContext(io) { dataSource.insertEvent(form, allDayReminderTimeMinutes()) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt new file mode 100644 index 0000000..92ccf53 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt @@ -0,0 +1,91 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.ics.IcsEvent +import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid + +/** + * Map one Events row (read through [EventExportProjection]) into an [IcsEvent] + * for backup. [reminderMinutes] are the row's raw provider reminder offsets and + * [calendarName] the display name of its calendar (emitted as + * `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with + * MapColumnReader. + */ +internal fun ColumnReader.toIcsEvent( + reminderMinutes: List, + calendarName: String?, +): IcsEvent { + val eventId = getLong(EventExportProjection.IDX_ID) + val dtStart = getLong(EventExportProjection.IDX_DTSTART) + val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() } + + // Recurring rows store DURATION instead of DTEND; reconstruct the end from it + // so the writer can render DTEND. A missing/blank both means a zero-length event. + val end = when { + !isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND) + else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION)) + } + + // STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. + val status = if (isNull(EventExportProjection.IDX_STATUS)) { + EventStatus.Confirmed + } else { + mapEventStatus(getInt(EventExportProjection.IDX_STATUS)) + } + + return IcsEvent( + uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart), + summary = getString(EventExportProjection.IDX_TITLE).orEmpty(), + start = dtStart.toKotlinInstantFromEpochMillis(), + end = end.toKotlinInstantFromEpochMillis(), + isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0, + zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() } + ?: "UTC", + recurrenceRule = rrule, + location = getString(EventExportProjection.IDX_LOCATION), + description = getString(EventExportProjection.IDX_DESCRIPTION), + reminderMinutes = reminderMinutes, + status = status, + availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), + calendarName = calendarName, + ) +} + +// Android's calendar provider (and Calendula's own writes) use the non-standard +// single-unit forms PS / PD / PW — seconds without the RFC-required +// leading T. Matched first; anything else falls through to the general grammar. +private val DURATION_SINGLE_UNIT = Regex("""([+-]?)P(\d+)([WDS])""") +private val DURATION_GENERAL = + Regex("""([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?""") + +/** + * Milliseconds of a DURATION (`P1D`, `P3600S`, `PT1H30M`, `P1W`, …). Calendula + * only ever writes `PD` / `PS`, but the parser also handles the + * general RFC 5545 grammar so a foreign row on a local calendar still + * round-trips. Unparseable input is treated as zero. + */ +internal fun parseRfc2445DurationMillis(duration: String?): Long { + if (duration.isNullOrBlank()) return 0L + val s = duration.trim() + + DURATION_SINGLE_UNIT.matchEntire(s)?.let { m -> + val unitSeconds = when (m.groupValues[3]) { + "W" -> 7L * 24 * 60 * 60 + "D" -> 24L * 60 * 60 + else -> 1L // S + } + return m.signum() * m.groupValues[2].toLong() * unitSeconds * 1_000L + } + + val m = DURATION_GENERAL.matchEntire(s) ?: return 0L + val weeks = m.groupValues[2].toLongOrNull() ?: 0L + val days = m.groupValues[3].toLongOrNull() ?: 0L + val hours = m.groupValues[4].toLongOrNull() ?: 0L + val minutes = m.groupValues[5].toLongOrNull() ?: 0L + val seconds = m.groupValues[6].toLongOrNull() ?: 0L + val totalSeconds = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds) + return m.signum() * totalSeconds * 1_000L +} + +/** Sign carried by group 1 (`-` → -1, otherwise +1) of a duration match. */ +private fun MatchResult.signum(): Long = if (groupValues[1] == "-") -1L else 1L diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index d1e8403..0c190f7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -97,6 +97,48 @@ internal object EventDetailProjection { const val IDX_EVENT_COLOR_KEY = 17 } +/** + * Master/one-off Events rows for a whole-calendar backup. Unlike + * [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across + * backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified- + * occurrence and cancelled-exception rows are filtered out by the query + * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't + * exported yet — a documented v1 limit (import skips them too). + */ +internal object EventExportProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.UID_2445, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.EVENT_TIMEZONE, + CalendarContract.Events.RRULE, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.DESCRIPTION, + CalendarContract.Events.STATUS, + CalendarContract.Events.AVAILABILITY, + CalendarContract.Events.CALENDAR_ID, + ) + + const val IDX_ID = 0 + const val IDX_UID = 1 + const val IDX_TITLE = 2 + const val IDX_DTSTART = 3 + const val IDX_DTEND = 4 + const val IDX_DURATION = 5 + const val IDX_ALL_DAY = 6 + const val IDX_EVENT_TIMEZONE = 7 + const val IDX_RRULE = 8 + const val IDX_LOCATION = 9 + const val IDX_DESCRIPTION = 10 + const val IDX_STATUS = 11 + const val IDX_AVAILABILITY = 12 + const val IDX_CALENDAR_ID = 13 +} + internal object AttendeeProjection { val COLUMNS: Array = arrayOf( CalendarContract.Attendees.ATTENDEE_NAME, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt new file mode 100644 index 0000000..f593532 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.calendula.data.ics + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The Android IO edge of `.ics` export: writes a serialised calendar to a + * SAF document (whole-calendar backup) or stages it in a cache file behind a + * `FileProvider` content Uri (single-event share). The serialisation itself is + * the pure `domain.ics.IcsWriter`; this class only moves bytes. + */ +@Singleton +class IcsExporter @Inject constructor( + @ApplicationContext private val context: Context, +) { + + /** Write [content] to the SAF document at [uri] as UTF-8. Throws on failure. */ + fun writeDocument(uri: Uri, content: String) { + context.contentResolver.openOutputStream(uri)?.use { out -> + out.write(content.toByteArray(Charsets.UTF_8)) + } ?: throw IOException("Could not open $uri for writing") + } + + /** + * Stage [content] in a private cache file and return a shareable content + * Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to + * the receiving app. The authority mirrors the manifest's `FileProvider`. + */ + fun stageShareFile(fileName: String, content: String): Uri { + val dir = File(context.cacheDir, SHARE_DIR).apply { mkdirs() } + val file = File(dir, fileName) + file.writeText(content, Charsets.UTF_8) + return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + } + + private companion object { + const val SHARE_DIR = "shared_ics" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/EventDetailIcs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/EventDetailIcs.kt new file mode 100644 index 0000000..04e9246 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/EventDetailIcs.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import de.jeanlucmakiola.calendula.domain.EventDetail + +/** + * Build the [IcsEvent] for sharing a single event. We export the event the user + * is looking at as a **one-off** VEVENT (no RRULE): the detail screen shows one + * occurrence, so "share this event" should hand off exactly that instance, not + * a whole series anchored to a possibly-different DTSTART. Reminders are the + * already-decoded semantic lead times the detail screen holds. + */ +fun EventDetail.toShareIcsEvent(): IcsEvent { + val startMillis = instance.start.toEpochMilliseconds() + return IcsEvent( + uid = deriveIcsUid(existingUid = null, eventId = instance.eventId, dtStartMillis = startMillis), + summary = instance.title, + start = instance.start, + end = instance.end, + isAllDay = instance.isAllDay, + zoneId = eventTimezone?.takeIf { it.isNotBlank() } ?: "UTC", + recurrenceRule = null, + location = instance.location, + description = description, + reminderMinutes = reminders.map { it.minutes }, + status = status, + availability = availability, + calendarName = null, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt new file mode 100644 index 0000000..6e82538 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventStatus +import kotlin.time.Instant + +/** + * A single event ready to be serialised to a `VEVENT`, decoupled from the + * provider so [IcsWriter] stays pure-Kotlin and JVM-testable. [start]/[end] are + * absolute instants; [isAllDay] and [recurrenceRule] decide how they are + * rendered (see [IcsWriter]'s timezone rule). + */ +data class IcsEvent( + /** RFC 5545 UID — globally stable across exports (see [deriveIcsUid]). */ + val uid: String, + val summary: String, + val start: Instant, + /** Exclusive end (for all-day: the next-day midnight, as the provider stores it). */ + val end: Instant, + val isAllDay: Boolean, + /** IANA zone id (`Events.EVENT_TIMEZONE`); only used for recurring timed events. */ + val zoneId: String, + /** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */ + val recurrenceRule: String? = null, + val location: String? = null, + val description: String? = null, + /** Reminder lead times in minutes before start (raw provider offsets). */ + val reminderMinutes: List = emptyList(), + val status: EventStatus = EventStatus.Confirmed, + val availability: Availability = Availability.Busy, + /** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */ + val calendarName: String? = null, +) + +/** + * The UID to export for a provider event. A row that already carries a UID + * (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from + * the event id and its DTSTART so the same legacy event yields the same UID + * across repeated backups — which keeps a later restore from duplicating it. + */ +fun deriveIcsUid(existingUid: String?, eventId: Long, dtStartMillis: Long): String = + existingUid?.trim()?.takeIf { it.isNotEmpty() } + ?: "$eventId-$dtStartMillis@calendula" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt new file mode 100644 index 0000000..3bc098c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt @@ -0,0 +1,62 @@ +package de.jeanlucmakiola.calendula.domain.ics + +/** + * Low-level RFC 5545 text mechanics, kept separate from [IcsWriter] so the + * escaping and folding rules can be tested in isolation. Pure Kotlin — no + * Android, no time handling. + */ + +/** iCalendar mandates CRLF line breaks, not the platform separator. */ +const val ICS_CRLF: String = "\r\n" + +/** RFC 5545 §3.1: content lines SHOULD be folded at 75 octets (excluding the break). */ +private const val MAX_OCTETS = 75 + +/** + * Escape a TEXT value (SUMMARY, DESCRIPTION, LOCATION, …) per RFC 5545 §3.3.11: + * backslash, semicolon and comma are escaped, newlines become the literal `\n`. + * Backslash is handled first so it doesn't double-escape the others' markers. + */ +fun escapeText(value: String): String = buildString(value.length) { + for (ch in value) { + when (ch) { + '\\' -> append("\\\\") + ';' -> append("\\;") + ',' -> append("\\,") + '\n' -> append("\\n") + '\r' -> Unit // CR is dropped; a lone CRLF in source text folds to one \n + else -> append(ch) + } + } +} + +/** + * Fold a single content line to ≤75 octets per physical line, inserting + * `CRLF + space` between segments (the space is part of the 75-octet budget of + * the continuation line, so its content caps at 74). Folding counts UTF-8 + * octets, never splitting a multi-byte character across a boundary. + */ +fun foldLine(line: String): String { + if (line.toByteArray(Charsets.UTF_8).size <= MAX_OCTETS) return line + val out = StringBuilder() + var octetsThisLine = 0 + var first = true + var i = 0 + while (i < line.length) { + val cp = line.codePointAt(i) + val width = Character.charCount(cp) + val piece = line.substring(i, i + width) + val pieceOctets = piece.toByteArray(Charsets.UTF_8).size + // Continuation lines spend one octet on the leading space. + val budget = if (first) MAX_OCTETS else MAX_OCTETS - 1 + if (octetsThisLine + pieceOctets > budget) { + out.append(ICS_CRLF).append(' ') + octetsThisLine = 0 + first = false + } + out.append(piece) + octetsThisLine += pieceOctets + i += width + } + return out.toString() +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt new file mode 100644 index 0000000..8d3e604 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventStatus +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** Default `PRODID` advertising the writer that produced the file. */ +const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN" + +/** + * Hand-rolled RFC 5545 serialiser for the subset Calendula models. No iCal + * library: we stay on `kotlinx-datetime` and own the output, exactly as + * `domain/Recurrence.kt` owns RRULE. Pure and JVM-testable — the caller + * supplies [dtStamp] (the export moment) so the writer never reads a clock. + * + * Timezone rule (see plan 05, decision 1): + * - all-day → `VALUE=DATE`, no zone; + * - timed one-off → UTC instant with a `Z` suffix (an instant is an instant); + * - timed recurring → `TZID`-labelled local wall time, so the series stays + * anchored to wall-clock across DST. No `VTIMEZONE` block is emitted; import + * resolves the `TZID` against the OS tz database. + */ +class IcsWriter(private val prodId: String = ICS_PROD_ID) { + + fun writeCalendar(events: List, dtStamp: Instant): String { + val lines = buildList { + add("BEGIN:VCALENDAR") + add("VERSION:2.0") + add("PRODID:$prodId") + add("CALSCALE:GREGORIAN") + events.forEach { appendEvent(it, dtStamp) } + add("END:VCALENDAR") + } + return lines.joinToString(ICS_CRLF, postfix = ICS_CRLF) { foldLine(it) } + } + + private fun MutableList.appendEvent(event: IcsEvent, dtStamp: Instant) { + add("BEGIN:VEVENT") + add("UID:${event.uid}") + add("DTSTAMP:${utcStamp(dtStamp)}") + add("SUMMARY:${escapeText(event.summary)}") + appendTimes(event) + event.recurrenceRule?.takeIf { it.isNotBlank() } + ?.let { add("RRULE:${it.removePrefix("RRULE:")}") } + event.location?.takeIf { it.isNotBlank() } + ?.let { add("LOCATION:${escapeText(it)}") } + event.description?.takeIf { it.isNotBlank() } + ?.let { add("DESCRIPTION:${escapeText(it)}") } + add("STATUS:${statusValue(event.status)}") + add("TRANSP:${transpValue(event.availability)}") + event.calendarName?.takeIf { it.isNotBlank() } + ?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") } + event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes -> + appendAlarm(minutes, event.summary) + } + add("END:VEVENT") + } + + private fun MutableList.appendTimes(event: IcsEvent) = when { + event.isAllDay -> { + add("DTSTART;VALUE=DATE:${utcDate(event.start)}") + add("DTEND;VALUE=DATE:${utcDate(event.end)}") + } + // Recurring: anchor to wall-clock in the event's own zone. + event.recurrenceRule?.isNotBlank() == true -> { + val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull() + if (zone != null) { + add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}") + add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}") + } else { + // Unknown zone id → fall back to plain UTC instants. + add("DTSTART:${utcStamp(event.start)}") + add("DTEND:${utcStamp(event.end)}") + } + } + else -> { + add("DTSTART:${utcStamp(event.start)}") + add("DTEND:${utcStamp(event.end)}") + } + } + + private fun MutableList.appendAlarm(minutes: Int, summary: String) { + add("BEGIN:VALARM") + add("ACTION:DISPLAY") + add("DESCRIPTION:${escapeText(summary.ifBlank { "Reminder" })}") + add("TRIGGER:${triggerValue(minutes)}") + add("END:VALARM") + } + + private companion object { + fun statusValue(status: EventStatus): String = when (status) { + EventStatus.Confirmed -> "CONFIRMED" + EventStatus.Tentative -> "TENTATIVE" + EventStatus.Cancelled -> "CANCELLED" + } + + // iCal TRANSP is binary; only an explicitly free event is TRANSPARENT. + fun transpValue(availability: Availability): String = + if (availability == Availability.Free) "TRANSPARENT" else "OPAQUE" + + // A lead time of 0 fires at start (PT0M); anything positive is "before". + fun triggerValue(minutes: Int): String = + if (minutes <= 0) "PT0M" else "-PT${minutes}M" + + fun utcStamp(instant: Instant): String = + basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z" + + fun localStamp(instant: Instant, zone: TimeZone): String = + basic(instant.toLocalDateTime(zone)) + + fun utcDate(instant: Instant): String { + val dt = instant.toLocalDateTime(TimeZone.UTC) + return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day) + } + + fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format( + dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 5a6de7b..c0f02b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -5,6 +5,8 @@ import android.content.Context import android.content.Intent import android.provider.Settings import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -30,6 +32,7 @@ import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Palette import androidx.compose.material3.AlertDialog @@ -77,6 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.positionOf +import java.time.LocalDate /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ private const val NEW_CALENDAR_ID = Long.MIN_VALUE @@ -95,6 +99,7 @@ fun CalendarsScreen( ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() + val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() // null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar. // [editorSession] bumps on every open so the editor's field state resets for @@ -131,6 +136,9 @@ fun CalendarsScreen( synced = calendars.filterNot { it.isLocal }, error = error, onConsumeError = viewModel::consumeError, + backupResult = backupResult, + onExportBackup = viewModel::exportBackup, + onConsumeBackupResult = viewModel::consumeBackupResult, onBack = onBack, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, @@ -144,6 +152,9 @@ private fun CalendarsList( synced: List, error: Boolean, onConsumeError: () -> Unit, + backupResult: BackupResult?, + onExportBackup: (android.net.Uri) -> Unit, + onConsumeBackupResult: () -> Unit, onBack: () -> Unit, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, @@ -159,6 +170,31 @@ private fun CalendarsList( } } + // SAF "create document" target for the backup file. The picked Uri is handed + // to the VM to stream the .ics into. + val createBackup = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/calendar"), + ) { uri -> uri?.let(onExportBackup) } + + val backupFailedText = stringResource(R.string.calendars_backup_failed) + LaunchedEffect(backupResult) { + when (val r = backupResult) { + is BackupResult.Success -> { + snackbarHostState.showSnackbar( + context.resources.getQuantityString( + R.plurals.calendars_backup_done, r.eventCount, r.eventCount, + ), + ) + onConsumeBackupResult() + } + BackupResult.Failure -> { + snackbarHostState.showSnackbar(backupFailedText) + onConsumeBackupResult() + } + null -> Unit + } + } + CollapsingScaffold( title = stringResource(R.string.calendars_title), onBack = onBack, @@ -195,6 +231,22 @@ private fun CalendarsList( onClick = onAdd, ) + // Backup — local calendars have no sync, so a .ics export is their only + // safety net. Offered only when there is something to back up. + if (local.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.calendars_backup_header)) + HintText(stringResource(R.string.calendars_backup_hint)) + GroupedRow( + title = stringResource(R.string.calendars_backup_action), + position = Position.Alone, + leading = { LeadingAvatar(Icons.Default.FileDownload) }, + onClick = { + runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } + }, + ) + } + Spacer(Modifier.height(16.dp)) // Synced calendars — read-only, grouped by account, each with a @@ -429,6 +481,25 @@ private fun AccountHeader(account: String, accountType: String) { } } +/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */ +@Composable +private fun LeadingAvatar(icon: ImageVector) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } +} + /** Neutral circular chip with a "+" — the leading icon for add-actions. */ @Composable private fun AddAvatar() { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index ed50e04..c731d38 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -1,11 +1,14 @@ package de.jeanlucmakiola.calendula.ui.calendars +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -15,7 +18,9 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock import javax.inject.Inject /** @@ -27,6 +32,7 @@ import javax.inject.Inject @HiltViewModel class CalendarsViewModel @Inject constructor( private val repository: CalendarRepository, + private val icsExporter: IcsExporter, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -45,6 +51,36 @@ class CalendarsViewModel @Inject constructor( fun consumeError() { _error.value = false } + private val _backupResult = MutableStateFlow(null) + val backupResult: StateFlow = _backupResult.asStateFlow() + + 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 + * in [backupResult] for a one-shot message. + */ + fun exportBackup(uri: Uri) { + viewModelScope.launch { + _backupResult.value = try { + val count = withContext(io) { + val events = repository.exportEvents() + icsExporter.writeDocument( + uri = uri, + content = IcsWriter().writeCalendar(events, Clock.System.now()), + ) + events.size + } + BackupResult.Success(count) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + BackupResult.Failure + } + } + } + fun createCalendar(displayName: String, color: Int, description: String?) = write { repository.createLocalCalendar(displayName, color, description) } @@ -69,3 +105,9 @@ class CalendarsViewModel @Inject constructor( } } } + +/** Outcome of a whole-calendar backup, surfaced once to the screen. */ +sealed interface BackupResult { + data class Success(val eventCount: Int) : BackupResult + data object Failure : BackupResult +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index ee5e7a8..b9500f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -59,6 +60,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -96,6 +98,7 @@ import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel +import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -132,9 +135,30 @@ fun EventDetailScreen( BackHandler(onBack = onBack) val context = LocalContext.current + val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } var showDeleteDialog by rememberSaveable { mutableStateOf(false) } + // Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages + // an .ics in the cache and hands back a content Uri for the chooser. + val shareFailedMessage = stringResource(R.string.event_share_failed) + val shareChooserTitle = stringResource(R.string.event_share_chooser_title) + val onShareClick = { + scope.launch { + val uri = viewModel.shareUri() + val sent = uri != null && runCatching { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/calendar" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(send, shareChooserTitle)) + }.isSuccess + if (!sent) snackbarHostState.showSnackbar(shareFailedMessage) + } + Unit + } + // v1.0 installs only hold READ_CALENDAR; the first write asks for the // upgrade in place. Granting continues straight into the tapped action. var pendingEdit by remember { mutableStateOf(false) } @@ -203,9 +227,18 @@ fun EventDetailScreen( } }, actions = { - // Only writable calendars get actions — WebCal subscriptions, - // birthday calendars etc. are read-only at the provider level. val s = state + // Share works for any loaded event — it only reads the event. + if (s is EventDetailUiState.Success) { + IconButton(onClick = onShareClick) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.event_detail_share), + ) + } + } + // Edit/delete need a writable calendar — WebCal subscriptions, + // birthday calendars etc. are read-only at the provider level. if (s is EventDetailUiState.Success && s.canModify) { IconButton( onClick = onEditClick, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index a969140..40f7ce8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -1,14 +1,20 @@ package de.jeanlucmakiola.calendula.ui.detail +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.ics.IcsWriter +import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlin.time.Clock import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -34,6 +40,7 @@ import javax.inject.Inject @HiltViewModel class EventDetailViewModel @Inject constructor( private val repository: CalendarRepository, + private val icsExporter: IcsExporter, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -113,6 +120,24 @@ class EventDetailViewModel @Inject constructor( _deleteState.value = DeleteUiState.Idle } + /** + * Serialise the open event to a `.ics` cache file and return a shareable + * content Uri (for an ACTION_SEND), or null when nothing is loaded or the + * write fails. The event is exported as a one-off (see [toShareIcsEvent]). + */ + suspend fun shareUri(): Uri? { + val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null + return runCatching { + withContext(io) { + val ics = IcsWriter().writeCalendar( + events = listOf(detail.toShareIcsEvent()), + dtStamp = Clock.System.now(), + ) + icsExporter.stageShareFile(shareFileName(detail.instance.title), ics) + } + }.getOrNull() + } + private suspend fun loadDetail(target: Target): EventDetailUiState = try { val detail = repository.eventDetail(target.eventId) // The Events row holds the series start; replace it with this @@ -143,3 +168,13 @@ class EventDetailViewModel @Inject constructor( /** A tapped occurrence: the series [eventId] plus this occurrence's own times. */ private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long) } + +/** A filesystem-safe `.ics` file name from an event title (or a fallback). */ +private fun shareFileName(title: String): String { + val base = title.trim() + .replace(Regex("""[^\p{L}\p{Nd} _-]"""), "") + .replace(' ', '_') + .take(40) + .ifBlank { "event" } + return "$base.ics" +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8aeadd5..94bbca7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -47,6 +47,9 @@ Zurück Bearbeiten Löschen + Teilen + Termin teilen + Termin konnte nicht geteilt werden. Termin löschen? Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt. Wiederkehrenden Termin löschen @@ -297,4 +300,13 @@ Kalender löschen? \"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt. Änderung konnte nicht gespeichert werden. + + Sicherung + Lokale Kalender werden nirgends synchronisiert – exportiere sie als .ics-Datei, um eine Kopie zu behalten. + Als .ics-Datei exportieren + Sicherung konnte nicht exportiert werden. + + %d Termin exportiert. + %d Termine exportiert. + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f27b914..48ff3b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,6 +48,9 @@ Back Edit Delete + Share + Share event + Couldn\'t share this event. Delete event? The event is removed from your calendar and from every device it syncs to. Delete recurring event @@ -294,6 +297,15 @@ Delete calendar? \"%1$s\" and all of its events will be permanently removed from this device. Couldn\'t save the change. + + Backup + Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy. + Export as .ics file + Couldn\'t export the backup. + + Exported %d event. + Exported %d events. + New event Create a new event diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..26b1681 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index 625813e..f2dcd16 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.ics.IcsEvent /** * Test-only fake. Tunable via the three `var` properties; `tick()` simulates @@ -16,6 +17,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } + var exportableEventsResult: List = emptyList() /** Set to make the next write call throw. */ var writeError: Exception? = null /** Id returned by the next [insertEvent]. */ @@ -49,6 +51,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = eventColorPaletteResult(calendarId) + override fun exportableEvents(): List = exportableEventsResult override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long { writeError?.let { throw it } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt new file mode 100644 index 0000000..7102d11 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt @@ -0,0 +1,80 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import android.provider.CalendarContract +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventStatus +import org.junit.jupiter.api.Test + +class IcsExportMapperTest { + + @Test + fun `parses the duration forms Calendula writes plus general ones`() { + assertThat(parseRfc2445DurationMillis("P1D")).isEqualTo(86_400_000L) + assertThat(parseRfc2445DurationMillis("P3600S")).isEqualTo(3_600_000L) + assertThat(parseRfc2445DurationMillis("PT1H30M")).isEqualTo(5_400_000L) + assertThat(parseRfc2445DurationMillis("P1W")).isEqualTo(604_800_000L) + assertThat(parseRfc2445DurationMillis(null)).isEqualTo(0L) + assertThat(parseRfc2445DurationMillis("nonsense")).isEqualTo(0L) + } + + @Test + fun `timed one-off row maps with its DTEND and kept UID`() { + val reader = MapColumnReader( + EventExportProjection.IDX_ID to 42L, + EventExportProjection.IDX_UID to "abc@host", + EventExportProjection.IDX_TITLE to "Standup", + EventExportProjection.IDX_DTSTART to 1_000_000L, + EventExportProjection.IDX_DTEND to 1_900_000L, + EventExportProjection.IDX_ALL_DAY to 0, + EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin", + EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY, + ) + + val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal") + + assertThat(event.uid).isEqualTo("abc@host") + assertThat(event.summary).isEqualTo("Standup") + assertThat(event.start.toEpochMilliseconds()).isEqualTo(1_000_000L) + assertThat(event.end.toEpochMilliseconds()).isEqualTo(1_900_000L) + assertThat(event.isAllDay).isFalse() + assertThat(event.recurrenceRule).isNull() + assertThat(event.reminderMinutes).containsExactly(10) + assertThat(event.calendarName).isEqualTo("Personal") + assertThat(event.status).isEqualTo(EventStatus.Confirmed) + } + + @Test + fun `recurring row without DTEND reconstructs end from DURATION`() { + val reader = MapColumnReader( + EventExportProjection.IDX_ID to 7L, + // No UID column → synthesised stably from id + dtstart. + EventExportProjection.IDX_TITLE to "Weekly", + EventExportProjection.IDX_DTSTART to 1_000_000L, + // DTEND absent (null); DURATION carries the length. + EventExportProjection.IDX_DURATION to "P3600S", + EventExportProjection.IDX_ALL_DAY to 0, + EventExportProjection.IDX_RRULE to "FREQ=WEEKLY", + EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", + ) + + val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null) + + assertThat(event.uid).isEqualTo("7-1000000@calendula") + assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY") + assertThat(event.end.toEpochMilliseconds()).isEqualTo(1_000_000L + 3_600_000L) + } + + @Test + fun `all-day flag is carried through`() { + val reader = MapColumnReader( + EventExportProjection.IDX_ID to 1L, + EventExportProjection.IDX_TITLE to "Holiday", + EventExportProjection.IDX_DTSTART to 0L, + EventExportProjection.IDX_DTEND to 86_400_000L, + EventExportProjection.IDX_ALL_DAY to 1, + EventExportProjection.IDX_EVENT_TIMEZONE to "UTC", + ) + + assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsTextTest.kt new file mode 100644 index 0000000..b0c16b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsTextTest.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class IcsTextTest { + + @Test + fun `escapes backslash semicolon comma and newline`() { + assertThat(escapeText("a\\b;c,d\ne")) + .isEqualTo("a\\\\b\\;c\\,d\\ne") + } + + @Test + fun `backslash is escaped before its escape markers, not after`() { + // A single backslash must become exactly one escaped backslash, not + // accidentally combine with a following separator. + assertThat(escapeText("\\;")).isEqualTo("\\\\\\;") + } + + @Test + fun `short line is returned unfolded`() { + val line = "SUMMARY:short" + assertThat(foldLine(line)).isEqualTo(line) + } + + @Test + fun `long line folds into physical lines of at most 75 octets`() { + val line = "DESCRIPTION:" + "x".repeat(300) + val folded = foldLine(line) + + val physical = folded.split(ICS_CRLF) + assertThat(physical.size).isGreaterThan(1) + physical.forEach { assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75) } + // Every continuation line begins with the single folding space. + physical.drop(1).forEach { assertThat(it).startsWith(" ") } + } + + @Test + fun `unfolding a folded line restores the original`() { + val line = "DESCRIPTION:" + "abcdefg ".repeat(40).trim() + val unfolded = foldLine(line).replace(ICS_CRLF + " ", "") + assertThat(unfolded).isEqualTo(line) + } + + @Test + fun `folding never splits a multi-byte character`() { + // 100 emoji (4 UTF-8 octets each) — a naive byte split would corrupt one. + val line = "X-NOTE:" + "😀".repeat(100) + val folded = foldLine(line) + // The reassembled content must still decode to the same string. + assertThat(folded.replace(ICS_CRLF + " ", "")).isEqualTo(line) + folded.split(ICS_CRLF).forEach { + assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt new file mode 100644 index 0000000..89b06e7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt @@ -0,0 +1,152 @@ +package de.jeanlucmakiola.calendula.domain.ics + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventStatus +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class IcsWriterTest { + + private val writer = IcsWriter(prodId = "-//Test//Test//EN") + private val stamp = LocalDateTime(2026, 6, 18, 9, 30, 0).toInstant(TimeZone.UTC) + + private fun lines(events: List): List = + writer.writeCalendar(events, stamp).split(ICS_CRLF) + + private fun instantUtc(y: Int, mo: Int, d: Int, h: Int, mi: Int): Instant = + LocalDateTime(y, mo, d, h, mi, 0).toInstant(TimeZone.UTC) + + @Test + fun `calendar is wrapped with the required header and CRLF endings`() { + val out = writer.writeCalendar(emptyList(), stamp) + assertThat(out).startsWith("BEGIN:VCALENDAR\r\n") + assertThat(out).endsWith("END:VCALENDAR\r\n") + assertThat(out).contains("VERSION:2.0\r\n") + assertThat(out).contains("PRODID:-//Test//Test//EN\r\n") + } + + @Test + fun `timed one-off event writes UTC instants with a Z suffix`() { + val event = IcsEvent( + uid = "u1@calendula", + summary = "Standup", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 13, 30), + isAllDay = false, + zoneId = "Europe/Berlin", + ) + val l = lines(listOf(event)) + assertThat(l).contains("DTSTART:20260618T130000Z") + assertThat(l).contains("DTEND:20260618T133000Z") + assertThat(l).contains("UID:u1@calendula") + assertThat(l).contains("STATUS:CONFIRMED") + assertThat(l).contains("TRANSP:OPAQUE") + } + + @Test + fun `recurring timed event anchors to wall-clock with TZID`() { + // 13:00 UTC == 15:00 Berlin in summer; the series must read 15:00 local. + val event = IcsEvent( + uid = "u2@calendula", + summary = "Weekly", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 14, 0), + isAllDay = false, + zoneId = "Europe/Berlin", + recurrenceRule = "FREQ=WEEKLY", + ) + val l = lines(listOf(event)) + assertThat(l).contains("DTSTART;TZID=Europe/Berlin:20260618T150000") + assertThat(l).contains("DTEND;TZID=Europe/Berlin:20260618T160000") + assertThat(l).contains("RRULE:FREQ=WEEKLY") + } + + @Test + fun `recurring event with an unknown zone falls back to UTC instants`() { + val event = IcsEvent( + uid = "u3@calendula", + summary = "Weekly", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 14, 0), + isAllDay = false, + zoneId = "Mars/Olympus", + recurrenceRule = "FREQ=WEEKLY", + ) + val l = lines(listOf(event)) + assertThat(l).contains("DTSTART:20260618T130000Z") + assertThat(l).contains("DTEND:20260618T140000Z") + } + + @Test + fun `all-day event writes exclusive DATE values without a zone`() { + val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC) + val end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC) + val event = IcsEvent( + uid = "u4@calendula", + summary = "Holiday", + start = start, + end = end, + isAllDay = true, + zoneId = "UTC", + ) + val l = lines(listOf(event)) + assertThat(l).contains("DTSTART;VALUE=DATE:20260618") + assertThat(l).contains("DTEND;VALUE=DATE:20260619") + } + + @Test + fun `reminders become VALARM blocks with before-start triggers`() { + val event = IcsEvent( + uid = "u5@calendula", + summary = "Meeting", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 14, 0), + isAllDay = false, + zoneId = "UTC", + reminderMinutes = listOf(15, 0, 15), // duplicate is dropped + ) + val out = writer.writeCalendar(listOf(event), stamp) + val l = out.split(ICS_CRLF) + assertThat(l.count { it == "BEGIN:VALARM" }).isEqualTo(2) + assertThat(l).contains("TRIGGER:-PT15M") + assertThat(l).contains("TRIGGER:PT0M") + assertThat(l).contains("ACTION:DISPLAY") + } + + @Test + fun `text fields and the calendar name are escaped`() { + val event = IcsEvent( + uid = "u6@calendula", + summary = "Lunch; with, notes", + start = instantUtc(2026, 6, 18, 13, 0), + end = instantUtc(2026, 6, 18, 14, 0), + isAllDay = false, + zoneId = "UTC", + location = "Cafe\\Bar", + availability = Availability.Free, + status = EventStatus.Tentative, + calendarName = "Work, Personal", + ) + val l = lines(listOf(event)) + assertThat(l).contains("SUMMARY:Lunch\\; with\\, notes") + assertThat(l).contains("LOCATION:Cafe\\\\Bar") + assertThat(l).contains("STATUS:TENTATIVE") + assertThat(l).contains("TRANSP:TRANSPARENT") + assertThat(l).contains("X-CALENDULA-CALENDAR:Work\\, Personal") + } + + @Test + fun `existing uid is kept and a missing one is synthesised stably`() { + assertThat(deriveIcsUid("real-uid@host", 7, 1000)).isEqualTo("real-uid@host") + assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo("7-1000@calendula") + assertThat(deriveIcsUid(" ", 7, 1000)).isEqualTo("7-1000@calendula") + // Stable across calls — a re-export of the same row yields the same UID. + assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000)) + } +} diff --git a/docs/superpowers/plans/2026-06-18-05-ics-export.md b/docs/superpowers/plans/2026-06-18-05-ics-export.md new file mode 100644 index 0000000..b00b922 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-05-ics-export.md @@ -0,0 +1,150 @@ +# Calendula - Plan 05: ICS Export (v2.7, Branch 1 von 2) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Schreib-Hälfte des `.ics`-Themas. Calendula serialisiert eigene +Events nach RFC 5545 — als Einzel-Event (Share-Sheet) und als +Ganz-Kalender-Backup (SAF-Datei). Damit existiert für gerätelokale Kalender +(`ACCOUNT_TYPE_LOCAL`) zum ersten Mal ein Backup; ohne Sync ist ein verlorenes +Gerät sonst Totalverlust. Diese Branch baut **nur Export**; Import +(Parser, Restore, Open-into-form) folgt in Branch 2 (`feat/ics-import`), +beide landen zusammen in **einem** Release v2.7.0 — es gibt also keine +Zwischenversion, die UIDs schreibt, ohne sie je zu lesen. +`./gradlew lint test assembleDebug` bleibt grün; Release erst nach +On-Device-Review (gemeinsam mit Branch 2). + +**Architecture:** Neue reine-Kotlin-Engine `domain/ics/` — kein +`CalendarContract`, keine Android-Deps, voll JVM-testbar. Kern ist +`IcsWriter` (nimmt eine Liste eigener Event-Modelle + Kalender-Metadaten, +gibt einen `VCALENDAR`-String zurück). Keine ICS-Library: wir bleiben auf +`kotlinx-datetime` (kein `java.time`-Desugaring) und hand-rollen wie schon +bei RRULE in `Recurrence.kt`. Das Schreiben in eine SAF-Datei / das +Share-Intent liegt in einer dünnen Android-Schicht +(`data/ics/IcsExporter` o. ä.), die Provider-Reads → Domain-Modelle → +`IcsWriter` → `OutputStream` verdrahtet. + +**Recherche-Befunde (Codebase, 2026-06-18):** + +1. **Keine ICS-Library, kein `java.time`-Desugaring** — Stack ist + `kotlinx-datetime` + `kotlin.time.Instant`. RRULE wird bereits in + `domain/Recurrence.kt` von Hand geparst/gerendert (inkl. der sorgfältigen + `UNTIL`/DST-Korrektur). `IcsWriter` reiht sich in genau diese Kultur ein und + nutzt `SimpleRecurrence.toRRule()` direkt. +2. **Kein UID-Handling.** `Events.UID_2445` wird heute nirgends gelesen oder + geschrieben. Für idempotenten Restore in Branch 2 muss Import auf UID + matchen — ohne UID verdoppelt ein erneuter Import alles. Darum schreibt + **diese** Branch bereits UID bei jedem Insert (Vorarbeit; ohne Import noch + unsichtbar, zahlt sich erst in Branch 2 aus — beide im selben Release). +3. **Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt.** Lesen/Anzeige: + `EVENT_TIMEZONE` landet in `EventDetail.eventTimezone`, das Detail zeigt ein + Fremdzonen-Label nur bei Abweichung vom Gerät (`foreignTimeZoneLabel`). + Schreiben (`EventWriteMapper.toWriteTimes`): all-day → UTC-Mitternachten, + `EVENT_TIMEZONE="UTC"`; getimt → `EVENT_TIMEZONE = zone.id`, und alle Caller + übergeben `currentSystemDefault()` (kein Zonen-Feld im Formular). Jedes selbst + erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte + Events behalten ihre Originalzone. + +**Leitentscheidungen:** + +1. **Zeitzonen-Regel beim Schreiben (fallbasiert):** + - **All-day** → `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND` exklusiv + (Tag-danach). Keine Zone — trivial korrekt. + - **Getimt, nicht wiederkehrend** → UTC-Instant `…T…Z`. Ein Instant ist ein + Instant; die Anzeige rechnet beim Import wieder in die Gerätezone. Verlustfrei. + - **Getimt, wiederkehrend** → `DTSTART;TZID=:`. + Eine Serie muss an der **Wandzeit** verankert sein, sonst driftet ein + „wöchentlich 9 Uhr" über die nächste DST-Grenze um eine Stunde. Die Zone + liegt bereits in `EVENT_TIMEZONE` vor; die lokale Wandzeit ist eine + `kotlinx-datetime`-Konversion (Instant → LocalDateTime in der Zone). + - **`VTIMEZONE`-Blöcke werden bewusst NICHT emittiert.** Beim eigenen + Round-Trip löst Branch-2-Import `TZID` gegen die OS-tz-Datenbank auf + (`kotlinx-datetime`/`java.time` kennen jede IANA-Id). Technisch nicht + RFC-konform; einziger Preis sind strenge Fremd-Parser ohne tz-DB — als + bekannte Lücke dokumentiert (Skip-and-report-Territorium des Imports), + kein Blocker. Voll-`VTIMEZONE` ist „später, falls nötig". +2. **UID bei jedem Insert.** `insertEvent` schreibt fortan `Events.UID_2445` + (z. B. `@calendula`). Bestehende Events ohne UID exportieren + wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus + `event-id + DTSTART` (`-@calendula`), damit derselbe Bestand + über mehrere Backups dieselbe UID behält und Branch-2-Restore nicht + verdoppelt. Bestehende Rows werden **nicht** rückwirkend gestempelt + (kein Migrations-Sweep über fremde Kalender). +3. **Manueller Export, kein Background.** Backup via + `ACTION_CREATE_DOCUMENT` (SAF, MIME `text/calendar`, Default-Name + `calendula-backup-.ics`); Einzel-Event-Share via `ACTION_SEND` mit + einem `FileProvider`-Cache-File (`text/calendar`). Kein WorkManager, kein + geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos; + Auto-Backup bleibt explizit Roadmap-`later`). +4. **Backup-Layout: eine kombinierte `VCALENDAR`-Datei** über alle + gerätelokalen (beschreibbaren) Kalender. Pro Event ein `VEVENT`; die + Kalender-Zugehörigkeit reist als `X-WR-CALNAME` / `CATEGORIES` o. ä. mit, + damit Branch-2-Restore wieder auffächern oder per Ziel-Picker einsortieren + kann. Eine Datei ist einfacher zu teilen/abzulegen als n Dateien. + *Offen, vor dem Backup-Task zu fixieren:* exaktes Property fürs + Kalender-Mapping (`X-WR-CALNAME` pro `VCALENDAR` erlaubt nur einen Namen; + für mehrere Kalender in einer Datei brauchen wir ein Pro-`VEVENT`-Property + wie `X-CALENDULA-CALENDAR` oder `CATEGORIES`). +5. **Feldumfang = was Calendula modelliert.** `IcsWriter` serialisiert genau + die gelesenen Felder: `SUMMARY`, `DTSTART`/`DTEND` (Regel #1), + `LOCATION`, `DESCRIPTION`, `RRULE` (über `toRRule`), `VALARM` aus den + Remindern (DISPLAY, `TRIGGER` = `-PTM`), `STATUS` + (CONFIRMED/TENTATIVE/CANCELLED), `TRANSP` (Free→TRANSPARENT/Busy→OPAQUE), + `UID`, `DTSTAMP`. Felder ohne sauberes Modell (Attendees, RECURRENCE-ID- + Ausnahmen) bleiben **vorerst weg** — Export erzeugt nichts, was Import in + Branch 2 nicht auch wieder lesen kann. +6. **Korrekte RFC-5545-Mechanik:** Zeilen-Folding bei >75 Oktett (CRLF + + Space-Fortsetzung), Text-Escaping (`\` `;` `,` `\n`), CRLF-Zeilenenden, + `PRODID`/`VERSION:2.0`-Header. Eine reine, einzeln getestete Hilfsschicht + (`IcsLine`/`fold`/`escapeText`), nicht ad hoc im Writer verstreut. + +--- + +## Tasks + +**Domain-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):** +- [x] `IcsText`: `escapeText`, `foldLine` (75-Oktett, CRLF+Space) + Test + (`IcsTextTest`). Wert-Helfer (Instant→`…T…Z`, Wandzeit→`TZID`, + LocalDate→`VALUE=DATE`) leben als private Helfer in `IcsWriter`. +- [x] `IcsEvent`-Eingabemodell (reine Kotlin: summary, start/end als Instant + + isAllDay + zoneId, recurrenceRule?, location, description, + reminderMinutes, status, availability, uid, calendarName) — entkoppelt + vom Provider-Modell +- [x] `IcsWriter.writeCalendar(events, dtStamp)` → String: Header, pro Event + `VEVENT` nach Entscheidung #5, Zeitzonen-Regel #1, `VALARM`; JVM-Test + `IcsWriterTest` (all-day, getimt, wiederkehrend+TZID, unbekannte Zone, + Reminder, Escaping) +- [x] UID-Ableitung `deriveIcsUid` (`uid ?: "-@calendula"`) + + Stabilitätstest + +**Provider → Domain (`data/calendar/IcsExportMapper.kt`):** +- [x] Mapper Provider-Row → `IcsEvent` (`ColumnReader.toIcsEvent`) inkl. + DURATION→DTEND-Rekonstruktion (`parseRfc2445DurationMillis`), + `EventExportProjection`; Datasource-Methode `exportableEvents()` + + Repository `exportEvents()`; Test `IcsExportMapperTest` +- [x] `insertEvent` schreibt `Events.UID_2445` (`UUID@calendula`) bei jedem + Create + +**Android-Export-Schicht:** +- [x] `data/ics/IcsExporter`: `writeDocument(uri)` (SAF) + `stageShareFile` + (FileProvider-Cache) als UTF-8 +- [x] Einzel-Event-Share: Share-Action im Event-Detail → `IcsWriter` für ein + Event (one-off) → Cache-File über `FileProvider` → `ACTION_SEND` +- [x] Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars → + `ACTION_CREATE_DOCUMENT` → in den URI streamen; Ergebnis-Snackbar + (Plural „Exported N events") +- [x] `FileProvider` + `file_paths.xml` im Manifest (Cache-Dir für Shares) +- [x] Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/ + Fehler + Plural, dateierter Default-Name + +**Abschluss:** +- [ ] `./gradlew lint test assembleDebug` grün ← **nächster Schritt (Test)** +- [x] CHANGELOG (`[Unreleased]`) ergänzt +- [ ] On-Device-Review; **kein** Tag/Release vor Review und vor Merge von + Branch 2 (`feat/ics-import`) + +**Offene Detail-Calls (vor Review klären, nicht-blockierend):** +- Kalender→Event-Mapping nutzt das per-`VEVENT`-Property `X-CALENDULA-CALENDAR` + (statt `X-WR-CALNAME`), damit eine kombinierte Datei mehrere Kalender trägt. +- Backup = **eine** kombinierte `VCALENDAR`-Datei über alle lokalen Kalender. +- EXDATE / `RECURRENCE-ID`-Ausnahmen werden beim Export ausgelassen + (`ORIGINAL_ID IS NULL`) — dokumentierter v1-Grenzfall, Import lässt sie auch aus.