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..bdffc43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.7.0] — 2026-06-18
+
+### 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.
+- Open or share an `.ics` file into Calendula: a single event opens the create
+ form prefilled for review, while a file with many events (e.g. a backup) opens
+ a bulk import — pick a calendar and import them all. Re-importing a backup
+ won't create duplicates (events are matched by their unique identifier), and
+ anything Calendula can't represent (changed recurring occurrences, guest
+ lists) is reported rather than silently dropped.
+
+### Fixed
+- All-day events that cover a single day (e.g. a birthday) no longer show up on
+ the following day as well — in the day, week and month views or on the event
+ detail screen. The extra day came from interpreting the all-day date range in
+ the device's time zone instead of UTC.
+- Fixed the app crashing immediately on every launch in the optimized release
+ build: release code-shrinking (R8) was stripping a database class the
+ home-screen widget framework needs, so the app died at startup before showing
+ anything. Added the missing keep rule.
+
## [2.6.0] — 2026-06-18
### Added
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 84f19ea..8c14444 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
- versionCode = 20600
- versionName = "2.6.0"
+ versionCode = 20700
+ versionName = "2.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index dccc1da..dce5b77 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -4,3 +4,14 @@
# Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.**
+
+# Room database implementations (pulled in transitively via
+# androidx.glance:glance-appwidget → androidx.work → androidx.room).
+# The widgets rely on Glance, whose WorkManager backend stores state in a Room
+# database. Under R8 full mode (AGP 9 default) the generated *_Impl subclasses
+# of RoomDatabase lose their usable no-arg constructor / are marked abstract,
+# so Room's reflective instantiation throws InstantiationException and the app
+# crashes at startup with "Failed to create an instance of ...WorkDatabase".
+# Keep the generated Room database implementations fully intact.
+-keep class * extends androidx.room.RoomDatabase { *; }
+-dontwarn androidx.room.paging.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c01f068..772da76 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -47,6 +47,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(null)
+ // An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
+ // by CalendarHost's import flow.
+ private var requestedImportUri by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
+ requestedImportUri = intent.importUriOrNull()
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -60,6 +67,8 @@ class MainActivity : AppCompatActivity() {
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
+ requestedImportUri = requestedImportUri,
+ onImportConsumed = { requestedImportUri = null },
)
}
}
@@ -69,6 +78,21 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
+ intent.importUriOrNull()?.let { requestedImportUri = it }
+ }
+
+ /**
+ * The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
+ * or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
+ * app's own `calendula://` deep-links never match.
+ */
+ private fun Intent.importUriOrNull(): Uri? {
+ val uri = when (action) {
+ Intent.ACTION_VIEW -> data
+ Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
+ else -> null
+ } ?: return null
+ return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
index 99aceb2..7aa838e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt
@@ -18,11 +18,15 @@ 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.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
+import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.ZoneId
import java.time.ZoneOffset
+import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -48,6 +52,26 @@ interface CalendarDataSource {
*/
fun eventColorPalette(calendarId: Long): List
+ /**
+ * 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
+
+ /**
+ * The non-empty `Events.UID_2445` values present in [calendarId] — used to
+ * dedup an `.ics` import so re-importing a backup doesn't double events.
+ */
+ fun existingUids(calendarId: Long): Set
+
+ /**
+ * Insert a parsed `.ics` event into [calendarId], preserving its UID (or
+ * minting one when absent); returns the new `Events._ID`. Reminders are
+ * written as the file's raw lead minutes (METHOD_ALERT).
+ */
+ fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
+
/**
* 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 +289,112 @@ 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()
+ }
+
+ override fun existingUids(calendarId: Long): Set = resolver.query(
+ CalendarContract.Events.CONTENT_URI,
+ arrayOf(CalendarContract.Events.UID_2445),
+ "${CalendarContract.Events.CALENDAR_ID} = ? AND " +
+ "${CalendarContract.Events.UID_2445} IS NOT NULL",
+ arrayOf(calendarId.toString()),
+ null,
+ )?.use { c ->
+ buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
+ } ?: emptySet()
+
+ override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
+ val startMillis = event.start.toEpochMillis()
+ val endMillis = event.end.toEpochMillis()
+ val values = ContentValues().apply {
+ put(CalendarContract.Events.CALENDAR_ID, calendarId)
+ // Preserve the file's UID so a re-import dedups against it; mint one
+ // only when the source event carried none.
+ put(
+ CalendarContract.Events.UID_2445,
+ event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
+ )
+ put(CalendarContract.Events.TITLE, event.summary.trim())
+ put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
+ put(CalendarContract.Events.DTSTART, startMillis)
+ if (event.recurrenceRule == null) {
+ put(CalendarContract.Events.DTEND, endMillis)
+ } else {
+ put(CalendarContract.Events.RRULE, event.recurrenceRule)
+ put(
+ CalendarContract.Events.DURATION,
+ importDuration(startMillis, endMillis, event.isAllDay),
+ )
+ }
+ // All-day rows live at UTC midnights (the file already encodes them so);
+ // timed rows keep the event's own zone.
+ put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
+ put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
+ put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
+ event.location?.trim()?.takeIf { it.isNotEmpty() }
+ ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
+ event.description?.trim()?.takeIf { it.isNotEmpty() }
+ ?.let { put(CalendarContract.Events.DESCRIPTION, it) }
+ }
+ val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
+ ?: throw WriteFailedException("import event into calendar id=$calendarId")
+ val eventId = ContentUris.parseId(uri)
+ // Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
+ event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
+ val reminder = ContentValues().apply {
+ put(CalendarContract.Reminders.EVENT_ID, eventId)
+ put(CalendarContract.Reminders.MINUTES, minutes)
+ put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
+ }
+ if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
+ Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
+ }
+ }
+ return eventId
+ }
+
+ /** Provider DURATION for an imported recurring row: whole days / seconds. */
+ private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
+ val span = (endMillis - startMillis).coerceAtLeast(0)
+ return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
+ }
+
+ private fun EventStatus.toProviderStatus(): Int = when (this) {
+ EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
+ EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
+ EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
+ }
+
/** 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 +446,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..4dcaac1 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,9 @@ 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 de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
@@ -28,6 +31,19 @@ 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
+
+ /**
+ * Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
+ * UID already exists in the target are skipped (idempotent restore); the
+ * rest are inserted. See [CalendarDataSource.insertImportedEvent].
+ */
+ suspend fun importEvents(targetCalendarId: Long, events: List): IcsImportSummary
+
/** 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..749c429 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
@@ -8,6 +8,8 @@ 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.IcsImportSummary
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -99,6 +101,28 @@ 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 importEvents(
+ targetCalendarId: Long,
+ events: List,
+ ): IcsImportSummary = withContext(io) {
+ val existing = dataSource.existingUids(targetCalendarId)
+ var imported = 0
+ var skipped = 0
+ for (event in events) {
+ // A known UID means the event is already in this calendar — skip,
+ // keeping a restore idempotent (no overwrite this pass).
+ if (event.uid != null && event.uid in existing) {
+ skipped++
+ } else {
+ dataSource.insertImportedEvent(event, targetCalendarId)
+ imported++
+ }
+ }
+ IcsImportSummary(imported = imported, skippedDuplicate = skipped)
+ }
+
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..817cff3
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt
@@ -0,0 +1,54 @@
+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
+import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
+
+/**
+ * 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,
+ )
+}
+
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/data/ics/IcsImporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsImporter.kt
new file mode 100644
index 0000000..5437e80
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsImporter.kt
@@ -0,0 +1,21 @@
+package de.jeanlucmakiola.calendula.data.ics
+
+import android.content.Context
+import android.net.Uri
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Android IO edge of `.ics` import: reads the text of a received/opened
+ * document [Uri]. Parsing is the pure `domain.ics.IcsParser`; this class only
+ * pulls bytes off the ContentResolver. Returns null on any read failure.
+ */
+@Singleton
+class IcsImporter @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ fun readText(uri: Uri): String? = runCatching {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
+ }.getOrNull()
+}
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/IcsDuration.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsDuration.kt
new file mode 100644
index 0000000..2321021
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsDuration.kt
@@ -0,0 +1,40 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+// 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`, `-PT15M`, `P1W`, …),
+ * sign-aware. Calendula writes `PD` / `PS`, but the parser also
+ * accepts the general RFC 5545 grammar so backup `DURATION` rows and foreign
+ * `VALARM` triggers round-trip. Unparseable input is treated as zero.
+ */
+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/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/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt
new file mode 100644
index 0000000..dc7e7e3
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt
@@ -0,0 +1,259 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+import de.jeanlucmakiola.calendula.domain.Availability
+import de.jeanlucmakiola.calendula.domain.EventStatus
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.toInstant
+import kotlin.time.Instant
+
+/**
+ * A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
+ * but [uid] is nullable (an incoming event may carry none; the insert layer
+ * then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
+ * how the writer encoded them.
+ */
+data class ParsedIcsEvent(
+ val uid: String?,
+ val summary: String,
+ val start: Instant,
+ val end: Instant,
+ val isAllDay: Boolean,
+ val zoneId: String,
+ val recurrenceRule: String? = null,
+ val location: String? = null,
+ val description: String? = null,
+ val reminderMinutes: List = emptyList(),
+ val status: EventStatus = EventStatus.Confirmed,
+ val availability: Availability = Availability.Busy,
+ val calendarName: String? = null,
+)
+
+/** Things the parser dropped rather than failing — surfaced in the import report. */
+enum class IcsParseWarning {
+ /** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
+ ModifiedOccurrenceSkipped,
+
+ /** A `VEVENT` with no parseable `DTSTART`. */
+ EventWithoutStartSkipped,
+
+ /** `ATTENDEE` rows were present; Calendula doesn't import attendees. */
+ AttendeesIgnored,
+
+ /** A `TZID` couldn't be resolved against the device tz database (used local zone). */
+ UnknownTimezone,
+}
+
+data class IcsParseResult(
+ val events: List,
+ val warnings: Set,
+)
+
+/** Outcome of a bulk `.ics` import into one calendar. */
+data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
+
+/**
+ * Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
+ * JVM-testable. Liberal-in/strict-out: unknown properties are ignored, a single
+ * malformed `VEVENT` is skipped (not fatal), and unsupported constructs
+ * (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
+ * rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
+ * resolved against the OS tz database instead ([deviceZone] is the fallback).
+ */
+class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
+
+ fun parse(text: String): IcsParseResult {
+ val lines = unfoldLines(text)
+ val events = mutableListOf()
+ val warnings = mutableSetOf()
+ var calendarName: String? = null
+
+ var i = 0
+ while (i < lines.size) {
+ val line = parseContentLine(lines[i])
+ if (line == null) { i++; continue }
+ when {
+ line.isBegin("VEVENT") -> {
+ val end = indexOfEnd(lines, i + 1, "VEVENT")
+ parseVevent(lines.subList(i + 1, end), calendarName, warnings)
+ ?.let(events::add)
+ i = end + 1
+ }
+ line.isBegin("VTIMEZONE") -> {
+ // Skipped wholesale; TZIDs resolve against the OS tz database.
+ i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
+ }
+ line.name == "X-WR-CALNAME" -> {
+ calendarName = unescapeText(line.value).trim().ifEmpty { null }
+ i++
+ }
+ else -> i++
+ }
+ }
+ return IcsParseResult(events, warnings)
+ }
+
+ private fun parseVevent(
+ body: List,
+ fileCalendarName: String?,
+ warnings: MutableSet,
+ ): ParsedIcsEvent? {
+ var uid: String? = null
+ var summary = ""
+ var dtStart: IcsDateTime? = null
+ var dtEnd: IcsDateTime? = null
+ var duration: String? = null
+ var rrule: String? = null
+ var location: String? = null
+ var description: String? = null
+ var status = EventStatus.Confirmed
+ var availability = Availability.Busy
+ var calendarName = fileCalendarName
+ val reminders = mutableListOf()
+ var skipAsOverride = false
+
+ var i = 0
+ while (i < body.size) {
+ val line = parseContentLine(body[i])
+ if (line == null) { i++; continue }
+ when (line.name) {
+ "BEGIN" -> if (line.value.trim().equals("VALARM", true)) {
+ val end = indexOfEnd(body, i + 1, "VALARM")
+ parseAlarmMinutes(body.subList(i + 1, end))?.let(reminders::add)
+ i = end + 1
+ continue
+ }
+ "UID" -> uid = line.value.trim().ifEmpty { null }
+ "SUMMARY" -> summary = unescapeText(line.value)
+ "DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
+ "DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
+ "DURATION" -> duration = line.value.trim()
+ "RRULE" -> rrule = line.value.trim().ifEmpty { null }
+ "LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
+ "DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
+ "STATUS" -> status = mapIcsStatus(line.value)
+ "TRANSP" -> availability =
+ if (line.value.trim().equals("TRANSPARENT", true)) Availability.Free
+ else Availability.Busy
+ "RECURRENCE-ID" -> skipAsOverride = true
+ "ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
+ "X-CALENDULA-CALENDAR" ->
+ calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
+ }
+ i++
+ }
+
+ if (skipAsOverride) {
+ warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
+ return null
+ }
+ val start = dtStart ?: run {
+ warnings.add(IcsParseWarning.EventWithoutStartSkipped)
+ return null
+ }
+ val end = dtEnd
+ ?: duration?.let {
+ start.copy(
+ instant = Instant.fromEpochMilliseconds(
+ start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
+ ),
+ )
+ }
+ ?: start
+ return ParsedIcsEvent(
+ uid = uid,
+ summary = summary,
+ start = start.instant,
+ end = end.instant,
+ isAllDay = start.isAllDay,
+ zoneId = start.zoneId,
+ recurrenceRule = rrule,
+ location = location,
+ description = description,
+ reminderMinutes = reminders.distinct(),
+ status = status,
+ availability = availability,
+ calendarName = calendarName,
+ )
+ }
+
+ /** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
+ private fun parseAlarmMinutes(body: List): Int? {
+ val trigger = body.asSequence()
+ .mapNotNull { parseContentLine(it) }
+ .firstOrNull { it.name == "TRIGGER" }
+ ?: return null
+ // Absolute (DATE-TIME) triggers can't be expressed as a lead time.
+ if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
+ val millis = parseRfc2445DurationMillis(trigger.value)
+ // Negative = before start (the normal case) → positive lead minutes.
+ return (-millis / 60_000L).toInt().coerceAtLeast(0)
+ }
+
+ private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet): IcsDateTime? {
+ val raw = line.value.trim()
+ val isDate = line.params["VALUE"].equals("DATE", true) ||
+ (raw.length == 8 && !raw.contains('T'))
+ if (isDate) {
+ val date = parseBasicDate(raw) ?: return null
+ return IcsDateTime(date.atStartOfDayIn(TimeZone.UTC), isAllDay = true, zoneId = "UTC")
+ }
+ val isUtc = raw.endsWith("Z")
+ val ldt = parseBasicDateTime(raw.removeSuffix("Z")) ?: return null
+ if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
+
+ val tzid = line.params["TZID"]
+ val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
+ if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
+ val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
+ return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
+ }
+
+ private data class IcsDateTime(val instant: Instant, val isAllDay: Boolean, val zoneId: String)
+
+ private companion object {
+ fun IcsContentLine.isBegin(component: String) =
+ name == "BEGIN" && value.trim().equals(component, true)
+
+ /** Index of the matching `END:` at/after [from], or list end. */
+ fun indexOfEnd(lines: List, from: Int, component: String): Int {
+ var i = from
+ while (i < lines.size) {
+ val line = parseContentLine(lines[i])
+ if (line != null && line.name == "END" &&
+ line.value.trim().equals(component, true)
+ ) {
+ return i
+ }
+ i++
+ }
+ return lines.size
+ }
+
+ fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
+ "TENTATIVE" -> EventStatus.Tentative
+ "CANCELLED" -> EventStatus.Cancelled
+ else -> EventStatus.Confirmed
+ }
+
+ fun parseBasicDate(s: String): LocalDate? = runCatching {
+ LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
+ }.getOrNull()
+
+ fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
+ val date = LocalDate(
+ s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
+ )
+ // Format is YYYYMMDD 'T' HHMMSS; seconds optional.
+ val time = LocalTime(
+ s.substring(9, 11).toInt(),
+ s.substring(11, 13).toInt(),
+ if (s.length >= 15) s.substring(13, 15).toInt() else 0,
+ )
+ LocalDateTime(date, time)
+ }.getOrNull()
+ }
+}
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..6a06216
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsText.kt
@@ -0,0 +1,155 @@
+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()
+}
+
+/**
+ * Reverse of [escapeText]: turn `\n`/`\N` into newlines and unescape `\\`, `\;`,
+ * `\,`. A backslash before any other character is dropped, keeping the
+ * character (lenient — foreign files escape liberally).
+ */
+fun unescapeText(value: String): String = buildString(value.length) {
+ var i = 0
+ while (i < value.length) {
+ val c = value[i]
+ if (c == '\\' && i + 1 < value.length) {
+ when (val next = value[i + 1]) {
+ 'n', 'N' -> append('\n')
+ else -> append(next) // \\, \;, \, and any other escaped char
+ }
+ i += 2
+ } else {
+ append(c)
+ i++
+ }
+ }
+}
+
+/**
+ * Reverse of [foldLine] across a whole document: split into physical lines on
+ * CRLF/LF/CR, then re-join any line that begins with a single space or tab onto
+ * the previous one (RFC 5545 unfolding). Returns the logical content lines.
+ */
+fun unfoldLines(text: String): List {
+ val out = mutableListOf()
+ for (physical in text.split("\r\n", "\n", "\r")) {
+ if (physical.isEmpty()) continue
+ val isContinuation = physical[0] == ' ' || physical[0] == '\t'
+ if (isContinuation && out.isNotEmpty()) {
+ out[out.lastIndex] = out.last() + physical.substring(1)
+ } else {
+ out.add(physical)
+ }
+ }
+ return out
+}
+
+/**
+ * One unfolded content line split into its property name, parameters and value:
+ * `SUMMARY;LANGUAGE=en:Lunch` → name `SUMMARY`, params `{LANGUAGE=en}`, value
+ * `Lunch`. The value is everything after the first colon that isn't inside a
+ * quoted parameter; param keys are upper-cased, quoted param values unquoted.
+ * Returns null for a line with no colon.
+ */
+data class IcsContentLine(val name: String, val params: Map, val value: String)
+
+fun parseContentLine(line: String): IcsContentLine? {
+ var inQuote = false
+ var colon = -1
+ for (i in line.indices) {
+ when (line[i]) {
+ '"' -> inQuote = !inQuote
+ ':' -> if (!inQuote) { colon = i; break }
+ }
+ }
+ if (colon < 0) return null
+ val head = splitUnquoted(line.substring(0, colon), ';')
+ val name = head.firstOrNull()?.trim()?.uppercase().orEmpty()
+ if (name.isEmpty()) return null
+ val params = buildMap {
+ for (part in head.drop(1)) {
+ val eq = part.indexOf('=')
+ if (eq > 0) {
+ put(
+ part.substring(0, eq).trim().uppercase(),
+ part.substring(eq + 1).trim().removeSurrounding("\""),
+ )
+ }
+ }
+ }
+ return IcsContentLine(name, params, line.substring(colon + 1))
+}
+
+/** Split on [delimiter] except where it falls inside a double-quoted run. */
+private fun splitUnquoted(text: String, delimiter: Char): List {
+ val parts = mutableListOf()
+ val current = StringBuilder()
+ var inQuote = false
+ for (c in text) {
+ when {
+ c == '"' -> { inQuote = !inQuote; current.append(c) }
+ c == delimiter && !inQuote -> { parts.add(current.toString()); current.clear() }
+ else -> current.append(c)
+ }
+ }
+ parts.add(current.toString())
+ return parts
+}
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/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt
new file mode 100644
index 0000000..3e8ff04
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt
@@ -0,0 +1,38 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+import de.jeanlucmakiola.calendula.domain.EventForm
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+
+/**
+ * Prefill the create form from a single parsed `.ics` event (the "open one
+ * event" path). [calendarId] is left null so the form preselects the last-used
+ * calendar, exactly like a fresh create — the user confirms the target and
+ * reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
+ * handling (provider all-day times are UTC midnights with an exclusive end).
+ */
+fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
+ val (start, end) = if (isAllDay) {
+ val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
+ val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
+ val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
+ LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
+ } else {
+ this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
+ }
+ return EventForm(
+ calendarId = null,
+ title = summary,
+ isAllDay = isAllDay,
+ start = start,
+ end = end,
+ location = location.orEmpty(),
+ description = description.orEmpty(),
+ reminders = reminderMinutes.distinct().sorted(),
+ availability = availability,
+ rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
index 92b6ffd..941f80f 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
@@ -23,6 +24,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
+import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
@@ -48,6 +50,8 @@ fun CalendarHost(
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
+ requestedImportUri: android.net.Uri? = null,
+ onImportConsumed: () -> Unit = {},
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -121,6 +125,18 @@ fun CalendarHost(
var editKey by rememberSaveable { mutableStateOf(null) }
var heldEditKey by remember { mutableStateOf(null) }
+ // An opened/received .ics file. [ImportScreen] parses it and either opens
+ // the prefilled create form (one event → [importForm]) or its own bulk
+ // picker (many). A plain conditional overlay (no slide) — it's transient.
+ var importUri by remember { mutableStateOf(null) }
+ var importForm by remember { mutableStateOf(null) }
+ LaunchedEffect(requestedImportUri) {
+ if (requestedImportUri != null) {
+ importUri = requestedImportUri
+ onImportConsumed()
+ }
+ }
+
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
LaunchedEffect(widgetNavRequest) {
@@ -254,5 +270,26 @@ fun CalendarHost(
) {
CalendarsScreen(onBack = { showCalendars = false })
}
+
+ // Import flow for an opened/received .ics file. A single event routes
+ // into the create form (prefilled, for review); many open the picker.
+ importUri?.let { uri ->
+ ImportScreen(
+ uri = uri,
+ onClose = { importUri = null },
+ onOpenSingle = { form ->
+ importUri = null
+ importForm = form
+ },
+ )
+ }
+ importForm?.let { form ->
+ EventEditScreen(
+ initialDateIso = null,
+ initialForm = form,
+ onClose = { importForm = null },
+ onSaved = { importForm = null },
+ )
+ }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
index 9300645..aae4d82 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
@@ -27,6 +27,8 @@ fun RootScreen(
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
+ requestedImportUri: android.net.Uri? = null,
+ onImportConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -62,6 +64,8 @@ fun RootScreen(
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
+ requestedImportUri = requestedImportUri,
+ onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
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..e19ca18 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,
@@ -743,14 +776,19 @@ private fun formatWhen(
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) {
- // All-day end is the exclusive next midnight; step back to the last
- // covered day so a one-day event reads as a single date.
- val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
- return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
- allDayLabel to dateFull.format(startLdt.toLocalDate())
+ // All-day events live at UTC midnights with an exclusive end. Resolve
+ // the covered dates in UTC — not the device zone, which would shift the
+ // midnight boundaries off the intended date (east of UTC pushes the
+ // end past the last day; west of UTC pulls the start back) — and step
+ // the end back to the last covered day so a one-day event reads as a
+ // single date.
+ val utc = ZoneId.of("UTC")
+ val startDate = instance.start.toJavaLocalDateTime(utc).toLocalDate()
+ val lastDate = (instance.end - 1.seconds).toJavaLocalDateTime(utc).toLocalDate()
+ return if (startDate == lastDate) {
+ allDayLabel to dateFull.format(startDate)
} else {
- allDayLabel to
- "${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
+ allDayLabel to "${dateMedium.format(startDate)} – ${dateMedium.format(lastDate)}"
}
}
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/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
index 5ea434f..3f9866e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
@@ -98,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
+import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
@@ -156,19 +157,23 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
+ initialForm: EventForm? = null,
viewModel: EventEditViewModel = hiltViewModel(),
) {
- LaunchedEffect(initialDateIso, editKey) {
- if (editKey != null) {
- viewModel.openForEdit(
+ LaunchedEffect(initialDateIso, editKey, initialForm) {
+ when {
+ // Single-event .ics open: the form arrives prefilled for review.
+ initialForm != null -> viewModel.openImported(initialForm)
+ editKey != null -> viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
endMillis = editKey[2],
)
- } else {
- val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
- ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
- viewModel.openNew(date, initialStartMinutes)
+ else -> {
+ val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
+ ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
+ viewModel.openNew(date, initialStartMinutes)
+ }
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
index 8fd8dc6..d30ece4 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
@@ -210,6 +210,21 @@ class EventEditViewModel @Inject constructor(
applyDefaultReminder()
}
+ /**
+ * Seed a fresh event from a parsed `.ics` file (the single-event "open into
+ * the create form" path). [form] already carries the file's fields; its
+ * [EventForm.calendarId] is null so the calendar still resolves to the
+ * last-used/first-writable one, and reminders are frozen as touched so the
+ * settings default never overwrites what the file specified. No-op when a
+ * form is already open, so the prefill survives configuration changes.
+ */
+ fun openImported(form: EventForm) {
+ if (_form.value != null || _editTarget.value != null) return
+ _remindersTouched.value = true
+ _revealed.value = form.populatedFields()
+ _form.value = form
+ }
+
/**
* Prefill a new event's reminders from the settings default — the all-day
* default for all-day events, otherwise the resolved calendar's per-calendar
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt
new file mode 100644
index 0000000..92e152e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt
@@ -0,0 +1,207 @@
+package de.jeanlucmakiola.calendula.ui.imports
+
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventForm
+import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
+import de.jeanlucmakiola.calendula.ui.common.OptionCard
+
+/**
+ * Handles an opened/received `.ics` file. A single event is handed straight to
+ * the prefilled create form via [onOpenSingle]; several events show a target-
+ * calendar picker and import in bulk (dedup by UID), then a result summary.
+ * Empty/failed files show a short message and close.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ImportScreen(
+ uri: Uri,
+ onClose: () -> Unit,
+ onOpenSingle: (EventForm) -> Unit,
+ viewModel: ImportViewModel = hiltViewModel(),
+) {
+ LaunchedEffect(uri) { viewModel.load(uri) }
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ BackHandler(onBack = onClose)
+
+ // A single event isn't shown here — it opens the create form for review.
+ LaunchedEffect(state) {
+ (state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
+ }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.import_title)) },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding)) {
+ when (val s = state) {
+ ImportUiState.Loading,
+ ImportUiState.Importing,
+ is ImportUiState.Single,
+ -> CircularProgressIndicator(Modifier.align(Alignment.Center))
+
+ ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
+ ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
+ is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
+ is ImportUiState.Done -> DoneContent(s, onClose)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
+ // No writable calendar to import into — tell the user honestly.
+ if (state.calendars.isEmpty()) {
+ CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
+ return
+ }
+ var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
+
+ Column(
+ Modifier.fillMaxSize().verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ Text(
+ stringResource(R.string.import_target_header),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ state.calendars.forEach { calendar ->
+ OptionCard(
+ label = calendar.displayName,
+ onClick = { selected = calendar.id },
+ selected = calendar.id == selected,
+ icon = null,
+ )
+ }
+ state.warnings.forEach { WarningText(it) }
+ Button(
+ onClick = { onImport(selected) },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ ) {
+ Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
+ }
+ }
+}
+
+@Composable
+private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
+ Column(
+ Modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ stringResource(R.string.import_done_title),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(top = 24.dp),
+ )
+ Text(
+ pluralStringResource(
+ R.plurals.import_done_imported,
+ state.summary.imported,
+ state.summary.imported,
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ if (state.summary.skippedDuplicate > 0) {
+ Text(
+ pluralStringResource(
+ R.plurals.import_done_skipped,
+ state.summary.skippedDuplicate,
+ state.summary.skippedDuplicate,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
+ Text(stringResource(R.string.import_close))
+ }
+ }
+}
+
+@Composable
+private fun WarningText(warning: IcsParseWarning) {
+ val text = when (warning) {
+ IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
+ IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
+ IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
+ IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+}
+
+@Composable
+private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ Modifier.padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(message, style = MaterialTheme.typography.bodyLarge)
+ if (onClose != null) {
+ Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
new file mode 100644
index 0000000..63debea
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
@@ -0,0 +1,105 @@
+package de.jeanlucmakiola.calendula.ui.imports
+
+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.IcsImporter
+import de.jeanlucmakiola.calendula.domain.CalendarSource
+import de.jeanlucmakiola.calendula.domain.EventForm
+import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
+import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
+import de.jeanlucmakiola.calendula.domain.ics.IcsParser
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
+import de.jeanlucmakiola.calendula.domain.ics.toEventForm
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.TimeZone
+import kotlin.coroutines.cancellation.CancellationException
+import javax.inject.Inject
+
+/** What an opened/received `.ics` resolved to. */
+sealed interface ImportUiState {
+ data object Loading : ImportUiState
+ data object Importing : ImportUiState
+
+ /** The file held no importable event (or couldn't be read/parsed as one). */
+ data object Empty : ImportUiState
+ data object Failed : ImportUiState
+
+ /** Exactly one event → review it in the prefilled create form. */
+ data class Single(val form: EventForm, val warnings: Set) : ImportUiState
+
+ /** Several events → pick a target calendar and bulk-import. */
+ data class Many(
+ val events: List,
+ val warnings: Set,
+ val calendars: List,
+ ) : ImportUiState
+
+ data class Done(val summary: IcsImportSummary) : ImportUiState
+}
+
+/**
+ * Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
+ * one event opens the create form for review, many open the bulk-import picker.
+ * The bulk import dedups by UID in the repository.
+ */
+@HiltViewModel
+class ImportViewModel @Inject constructor(
+ private val repository: CalendarRepository,
+ private val importer: IcsImporter,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) : ViewModel() {
+
+ private val parser = IcsParser()
+ private val _state = MutableStateFlow(ImportUiState.Loading)
+ val state: StateFlow = _state.asStateFlow()
+ private var started = false
+
+ /** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
+ fun load(uri: Uri) {
+ if (started) return
+ started = true
+ viewModelScope.launch {
+ val parsed = withContext(io) {
+ importer.readText(uri)?.let(parser::parse)
+ }
+ _state.value = when {
+ parsed == null -> ImportUiState.Failed
+ parsed.events.isEmpty() -> ImportUiState.Empty
+ parsed.events.size == 1 -> ImportUiState.Single(
+ form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
+ warnings = parsed.warnings,
+ )
+ else -> ImportUiState.Many(
+ events = parsed.events,
+ warnings = parsed.warnings,
+ calendars = repository.calendars().first().filter { it.canModifyContents },
+ )
+ }
+ }
+ }
+
+ /** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
+ fun import(targetCalendarId: Long) {
+ val many = _state.value as? ImportUiState.Many ?: return
+ viewModelScope.launch {
+ _state.value = ImportUiState.Importing
+ _state.value = try {
+ ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ ImportUiState.Failed
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt
index a4418c1..3a73e5d 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt
@@ -181,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange {
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
+ if (isAllDay) {
+ // All-day events live at UTC midnights with an exclusive end. Compare
+ // calendar dates in UTC and step the exclusive end back to the last
+ // covered day (mirroring the detail/edit views), so a one-day event
+ // covers exactly its single date. Slicing the day in the device zone
+ // would push the exclusive end a few hours into the next local day
+ // east of UTC, making the event leak onto day + 1.
+ val startDate = start.toLocalDateTime(TimeZone.UTC).date
+ val endExclusive = end.toLocalDateTime(TimeZone.UTC).date
+ val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY))
+ return day in startDate..lastDay
+ }
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return start < dayEnd && end > dayStart
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 8aeadd5..c10888e 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,41 @@
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.
+
+
+ Termine importieren
+ Zu Kalender hinzufügen
+ In dieser Datei wurden keine Termine gefunden.
+ Datei konnte nicht gelesen werden.
+ Kein beschreibbarer Kalender zum Importieren. Lege zuerst einen lokalen Kalender an.
+ Import abgeschlossen
+ Schließen
+ Einige geänderte Einzeltermine wiederkehrender Termine wurden übersprungen.
+ Ein Termin ohne Startzeit wurde übersprungen.
+ Gästelisten wurden nicht importiert.
+ Eine unbekannte Zeitzone wurde durch die deines Geräts ersetzt.
+
+ - %d Termin in dieser Datei.
+ - %d Termine in dieser Datei.
+
+
+ - %d Termin importieren
+ - %d Termine importieren
+
+
+ - %d Termin importiert.
+ - %d Termine importiert.
+
+
+ - %d bereits in diesem Kalender übersprungen.
+ - %d bereits in diesem Kalender übersprungen.
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f27b914..e4c7ab1 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,43 @@
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.
+
+
+ Import events
+ Add to calendar
+ No events found in this file.
+ Couldn\'t read this file.
+ No writable calendar to import into. Create a local calendar first.
+ Import complete
+ Close
+ Some changed occurrences of recurring events were skipped.
+ An event without a start time was skipped.
+ Guest lists weren\'t imported.
+ An unknown time zone fell back to your device\'s.
+
+ - %d event in this file.
+ - %d events in this file.
+
+
+ - Import %d event
+ - Import %d events
+
+
+ - Imported %d event.
+ - Imported %d events.
+
+
+ - Skipped %d already in this calendar.
+ - Skipped %d already in this calendar.
+
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/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
index 9f4b94f..80c8d28 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt
@@ -445,4 +445,35 @@ class CalendarRepositoryImplTest {
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
assertThat(repo.eventColorPalette(8L)).isEmpty()
}
+
+ @Test
+ fun `importEvents skips events whose UID already exists and inserts the rest`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ val fake = FakeCalendarDataSource().apply {
+ existingUidsResult = setOf("dup@x")
+ }
+ val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
+ val events = listOf(
+ parsedEvent("dup@x"), // already present → skipped
+ parsedEvent("new@x"), // inserted
+ parsedEvent(null), // no UID → always inserted
+ )
+
+ val summary = repo.importEvents(targetCalendarId = 3L, events = events)
+
+ assertThat(summary.imported).isEqualTo(2)
+ assertThat(summary.skippedDuplicate).isEqualTo(1)
+ assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("new@x", null)
+ assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
+ }
+
+ private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
+ uid = uid,
+ summary = "E",
+ start = Instant.fromEpochMilliseconds(1_000_000_000L),
+ end = Instant.fromEpochMilliseconds(1_000_003_600L),
+ isAllDay = false,
+ zoneId = "UTC",
+ )
}
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..f6a447b 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,8 @@ 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 de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
@@ -16,6 +18,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List = { emptyList() }
+ var exportableEventsResult: List = emptyList()
+ /** UIDs the target calendar already holds, for import dedup. */
+ var existingUidsResult: Set = emptySet()
/** Set to make the next write call throw. */
var writeError: Exception? = null
/** Id returned by the next [insertEvent]. */
@@ -49,6 +54,18 @@ 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 existingUids(calendarId: Long): Set = existingUidsResult
+
+ /** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
+ val importedEvents = mutableListOf>()
+
+ override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
+ writeError?.let { throw it }
+ importedEvents += event to calendarId
+ return nextInsertId
+ }
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..552e1d9
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt
@@ -0,0 +1,70 @@
+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 `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/IcsDurationTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsDurationTest.kt
new file mode 100644
index 0000000..9442c3e
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsDurationTest.kt
@@ -0,0 +1,28 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class IcsDurationTest {
+
+ @Test
+ fun `parses the single-unit 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)
+ }
+
+ @Test
+ fun `is sign-aware for before-start VALARM triggers`() {
+ assertThat(parseRfc2445DurationMillis("-PT15M")).isEqualTo(-900_000L)
+ assertThat(parseRfc2445DurationMillis("PT0M")).isEqualTo(0L)
+ }
+
+ @Test
+ fun `unparseable input is zero`() {
+ assertThat(parseRfc2445DurationMillis(null)).isEqualTo(0L)
+ assertThat(parseRfc2445DurationMillis("")).isEqualTo(0L)
+ assertThat(parseRfc2445DurationMillis("nonsense")).isEqualTo(0L)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt
new file mode 100644
index 0000000..abdc710
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt
@@ -0,0 +1,168 @@
+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 IcsParserTest {
+
+ private val parser = IcsParser(deviceZone = TimeZone.of("Europe/Berlin"))
+ private val writer = IcsWriter()
+ private val stamp = LocalDateTime(2026, 6, 18, 9, 30, 0).toInstant(TimeZone.UTC)
+
+ private fun roundTrip(event: IcsEvent): ParsedIcsEvent {
+ val text = writer.writeCalendar(listOf(event), stamp)
+ val result = parser.parse(text)
+ assertThat(result.events).hasSize(1)
+ return result.events.single()
+ }
+
+ 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 `round-trips a timed one-off event`() {
+ val event = IcsEvent(
+ uid = "u1@calendula",
+ summary = "Lunch; with, friends",
+ start = instantUtc(2026, 6, 18, 11, 0),
+ end = instantUtc(2026, 6, 18, 12, 0),
+ isAllDay = false,
+ zoneId = "Europe/Berlin",
+ location = "Café",
+ availability = Availability.Free,
+ status = EventStatus.Tentative,
+ )
+ val parsed = roundTrip(event)
+ assertThat(parsed.uid).isEqualTo("u1@calendula")
+ assertThat(parsed.summary).isEqualTo("Lunch; with, friends")
+ assertThat(parsed.start).isEqualTo(event.start)
+ assertThat(parsed.end).isEqualTo(event.end)
+ assertThat(parsed.isAllDay).isFalse()
+ assertThat(parsed.location).isEqualTo("Café")
+ assertThat(parsed.availability).isEqualTo(Availability.Free)
+ assertThat(parsed.status).isEqualTo(EventStatus.Tentative)
+ }
+
+ @Test
+ fun `round-trips a recurring TZID event to the same instant`() {
+ 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 parsed = roundTrip(event)
+ assertThat(parsed.start).isEqualTo(event.start)
+ assertThat(parsed.end).isEqualTo(event.end)
+ assertThat(parsed.zoneId).isEqualTo("Europe/Berlin")
+ assertThat(parsed.recurrenceRule).isEqualTo("FREQ=WEEKLY")
+ }
+
+ @Test
+ fun `round-trips an all-day event`() {
+ val event = IcsEvent(
+ uid = "u3@calendula",
+ summary = "Holiday",
+ start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
+ end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
+ isAllDay = true,
+ zoneId = "UTC",
+ )
+ val parsed = roundTrip(event)
+ assertThat(parsed.isAllDay).isTrue()
+ assertThat(parsed.start).isEqualTo(event.start)
+ assertThat(parsed.end).isEqualTo(event.end)
+ }
+
+ @Test
+ fun `round-trips reminders as before-start lead minutes`() {
+ val event = IcsEvent(
+ uid = "u4@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),
+ )
+ val parsed = roundTrip(event)
+ assertThat(parsed.reminderMinutes).containsExactly(15, 0)
+ }
+
+ @Test
+ fun `tolerates folded lines and a missing UID`() {
+ val ics = buildString {
+ append("BEGIN:VCALENDAR\r\n")
+ append("VERSION:2.0\r\n")
+ append("BEGIN:VEVENT\r\n")
+ // Folded DESCRIPTION (continuation line begins with a space).
+ append("DESCRIPTION:This is a long descriptio\r\n n that was folded\r\n")
+ append("SUMMARY:No UID here\r\n")
+ append("DTSTART:20260618T090000Z\r\n")
+ append("DTEND:20260618T100000Z\r\n")
+ append("END:VEVENT\r\n")
+ append("END:VCALENDAR\r\n")
+ }
+ val parsed = parser.parse(ics).events.single()
+ assertThat(parsed.uid).isNull()
+ assertThat(parsed.description).isEqualTo("This is a long description that was folded")
+ assertThat(parsed.summary).isEqualTo("No UID here")
+ }
+
+ @Test
+ fun `skips a RECURRENCE-ID override and reports it`() {
+ val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\n" +
+ "RECURRENCE-ID:20260618T090000Z\r\nDTSTART:20260618T090000Z\r\n" +
+ "SUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
+ val result = parser.parse(ics)
+ assertThat(result.events).isEmpty()
+ assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
+ }
+
+ @Test
+ fun `reports ignored attendees but still imports the event`() {
+ val ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:y\r\n" +
+ "DTSTART:20260618T090000Z\r\nSUMMARY:Has guests\r\n" +
+ "ATTENDEE;CN=Bob:mailto:bob@example.com\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
+ val result = parser.parse(ics)
+ assertThat(result.events).hasSize(1)
+ assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
+ }
+
+ @Test
+ fun `parses multiple events and carries the calendar name`() {
+ val events = listOf(
+ IcsEvent("a@c", "One", instantUtc(2026, 6, 18, 9, 0), instantUtc(2026, 6, 18, 10, 0),
+ false, "UTC", calendarName = "Personal"),
+ IcsEvent("b@c", "Two", instantUtc(2026, 6, 19, 9, 0), instantUtc(2026, 6, 19, 10, 0),
+ false, "UTC", calendarName = "Personal"),
+ )
+ val text = writer.writeCalendar(events, stamp)
+ val result = parser.parse(text)
+ assertThat(result.events).hasSize(2)
+ assertThat(result.events.map { it.calendarName }).containsExactly("Personal", "Personal")
+ }
+
+ @Test
+ fun `a malformed event does not sink the rest of the file`() {
+ // First VEVENT has no DTSTART (skipped); second is valid.
+ val ics = "BEGIN:VCALENDAR\r\n" +
+ "BEGIN:VEVENT\r\nUID:bad\r\nSUMMARY:No start\r\nEND:VEVENT\r\n" +
+ "BEGIN:VEVENT\r\nUID:good\r\nDTSTART:20260618T090000Z\r\nSUMMARY:Fine\r\nEND:VEVENT\r\n" +
+ "END:VCALENDAR\r\n"
+ val result = parser.parse(ics)
+ assertThat(result.events.map { it.uid }).containsExactly("good")
+ assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
+ }
+}
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/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt
new file mode 100644
index 0000000..0041ead
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsFormTest.kt
@@ -0,0 +1,54 @@
+package de.jeanlucmakiola.calendula.domain.ics
+
+import com.google.common.truth.Truth.assertThat
+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
+
+class ParsedIcsFormTest {
+
+ private val berlin = TimeZone.of("Europe/Berlin")
+
+ @Test
+ fun `timed event maps to wall-clock form times in the device zone`() {
+ val event = ParsedIcsEvent(
+ uid = "u@x",
+ summary = "Call",
+ start = LocalDateTime(2026, 6, 18, 13, 0, 0).toInstant(TimeZone.UTC),
+ end = LocalDateTime(2026, 6, 18, 14, 0, 0).toInstant(TimeZone.UTC),
+ isAllDay = false,
+ zoneId = "UTC",
+ reminderMinutes = listOf(10, 10, 5),
+ recurrenceRule = "FREQ=WEEKLY",
+ )
+ val form = event.toEventForm(berlin)
+
+ assertThat(form.calendarId).isNull()
+ assertThat(form.title).isEqualTo("Call")
+ // 13:00 UTC == 15:00 Berlin (summer).
+ assertThat(form.start).isEqualTo(LocalDateTime(2026, 6, 18, 15, 0, 0))
+ assertThat(form.end).isEqualTo(LocalDateTime(2026, 6, 18, 16, 0, 0))
+ assertThat(form.reminders).containsExactly(5, 10).inOrder()
+ assertThat(form.rrule).isEqualTo("FREQ=WEEKLY")
+ }
+
+ @Test
+ fun `all-day event shows the last covered day, not the exclusive end`() {
+ val event = ParsedIcsEvent(
+ uid = null,
+ summary = "Trip",
+ start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
+ end = LocalDate(2026, 6, 20).atStartOfDayIn(TimeZone.UTC), // exclusive
+ isAllDay = true,
+ zoneId = "UTC",
+ )
+ val form = event.toEventForm(berlin)
+
+ assertThat(form.isAllDay).isTrue()
+ assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 18))
+ assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 19)) // last covered day
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt
index 43fa21c..32c6c37 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt
@@ -62,13 +62,28 @@ class WeekLayoutTest {
assertThat(ev.coversDay(wed, zone)).isTrue()
assertThat(ev.coversDay(mon, zone)).isFalse()
- val multiDay = event(at(mon, 22), at(wed, 2), allDay = true)
+ // All-day: UTC midnights, end exclusive. Mon..Tue covers Mon and Tue
+ // but not Wed (the Wed-midnight end is exclusive).
+ val multiDay = event(at(mon, 0), at(wed, 0), allDay = true)
assertThat(multiDay.coversDay(mon, zone)).isTrue()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue()
- assertThat(multiDay.coversDay(wed, zone)).isTrue()
+ assertThat(multiDay.coversDay(wed, zone)).isFalse()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse()
}
+ @Test
+ fun `single-day all-day event does not leak into the next day east of UTC`() {
+ // A birthday on Wed: the provider stores UTC midnights with an exclusive
+ // end (Thu 00:00 UTC). In a zone east of UTC the device-local day must
+ // still resolve to Wed only — never Thu. Regression for the all-day
+ // event appearing on two days in the views.
+ val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in June
+ val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
+ assertThat(ev.coversDay(wed, berlin)).isTrue()
+ assertThat(ev.coversDay(wed.plusDays(1), berlin)).isFalse()
+ assertThat(ev.coversDay(wed.plusDays(-1), berlin)).isFalse()
+ }
+
@Test
fun `single timed event gets one lane`() {
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
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.
diff --git a/docs/superpowers/plans/2026-06-18-06-ics-import.md b/docs/superpowers/plans/2026-06-18-06-ics-import.md
new file mode 100644
index 0000000..082740e
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-18-06-ics-import.md
@@ -0,0 +1,122 @@
+# Calendula - Plan 06: ICS Import (v2.7, Branch 2 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 Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
+Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
+RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
+das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
+vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
+Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
+Kalender. Beide Branches landen in **einem** Release v2.7.0.
+`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
+On-Device-Review.
+
+**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter` —
+kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
+`IcsParseResult` (`events: List` + `warnings: List`).
+`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
+`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
+`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
+Repository-Methode `importEvents(targetCalendarId, events)` → `ImportSummary`
+(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
+Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
+Event-Anzahl.
+
+**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
+`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
+Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
+den Rest der Datei durch.
+
+**Leitentscheidungen:**
+
+1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
+ Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
+ TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
+ Schicht (`IcsLineParser`), nicht ad hoc im Walker.
+2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
+ - `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
+ Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
+ - `…T…Z` → UTC-Instant.
+ - `…T…` mit `TZID=` → lokale Wandzeit in der Zone, aufgelöst gegen die
+ **OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID` →
+ Gerätezone als Fallback (+ Warnung).
+ - Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
+ Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
+ seine `TZID` nicht in der OS-DB ist).
+3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
+ Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null` →
+ Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
+ eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
+ Datei → freundlicher „nichts gefunden"-Hinweis.
+4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
+ `Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
+ existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
+ skip-not-update — kein Überschreiben, das hält den Restore idempotent und
+ verlustfrei. Events ohne UID bekommen beim Insert eine frische
+ (`UUID@calendula`, wie `insertEvent`).
+5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
+ (+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
+ (`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
+ Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
+ Notification-Deep-Link).
+6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
+ `PT0…`) → Lead-Minuten; `STATUS`/`TRANSP` → `EventStatus`/`Availability`.
+ `DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
+ Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
+ vorhanden).
+
+**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
+- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
+- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
+ Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `PS`.
+- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
+ nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
+- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
+ **vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
+
+---
+
+## Tasks
+
+**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
+- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
+- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
+ Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
+ gequotete Params)
+- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
+ (`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
+- [x] `IcsParser.parse(text)` → `IcsParseResult(events, warnings)`: VCALENDAR/
+ VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
+ Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
+ gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
+ + Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
+
+**Datenschicht (`data/calendar/` + `data/ics/`):**
+- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
+- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
+ `insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
+- [x] Repository `importEvents(targetCalendarId, events)` → `ImportSummary`
+ (imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
+ Fake-Datasource
+- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
+
+**Intent + Routing:**
+- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
+ Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
+- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
+ >1 → Bulk-Import-Screen; 0 → Hinweis
+
+**UI:**
+- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
+ Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
+- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
+ Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
+- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
+ (importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
+ leere-Datei-Hinweis
+
+**Abschluss:**
+- [x] `./gradlew lint test assembleDebug` grün
+- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
+ Branches gemerged sind und On-Device-Review durch ist