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.