feat(ics): export — share single event + back up local calendars as .ics

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 14:27:53 +02:00
parent 64d0a89b28
commit 0b683d374f
25 changed files with 1190 additions and 13 deletions

View File

@@ -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<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = 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<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
writeError?.let { throw it }

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<IcsEvent>): List<String> =
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))
}
}