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