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:
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user