aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 14:52:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 14:52:57 +0200
commit3bad2b50187f55463588a2d4e74fa9df4beb35b8 (patch)
treeaa213c64c4ee7566dce34365e700434bbdf5fd43 /packages/meshbay-android
parentbe8b7fcff56a1104e7cd974cc0f506d90f1299a8 (diff)
downloadmeshbay-3bad2b50187f55463588a2d4e74fa9df4beb35b8.tar.gz
feat(android): back the calendars up as an iCalendar file
A Calendar section sends every calendar the person can edit, as a dated .ics, into <folder>/<account>-calendar once a day when it changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android')
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml2
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js12
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt4
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/CalendarSource.kt89
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Ics.kt176
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt117
6 files changed, 400 insertions, 0 deletions
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml
index 48a69ea..f52c06f 100644
--- a/packages/meshbay-android/app/src/main/AndroidManifest.xml
+++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml
@@ -23,6 +23,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Contacts backup: the address book, asked for when the person turns it on. -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
+ <!-- Calendar backup: the same, for the calendars. -->
+ <uses-permission android:name="android.permission.READ_CALENDAR" />
<!-- No backup of any kind: the keys are wrapped by a Keystore key that a
restore cannot bring with it, so a backed-up store is one that silently
diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
index fc7231c..9d313bf 100644
--- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
+++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
@@ -237,6 +237,18 @@
completed: () => call('contactsync:completed'),
failed: (code, text) => call('contactsync:failed', code, text),
},
+
+ // Calendar backup (§9.13), the same way: one iCalendar file of every
+ // calendar the person can write to, when it changed.
+ calendarSync: {
+ status: () => call('calendarsync:status'),
+ permit: () => call('calendarsync:permit'),
+ configure: (settings) => call('calendarsync:configure', settings || null),
+ plan: () => call('calendarsync:plan'),
+ sent: (token, dir, name) => call('calendarsync:sent', token, dir, name),
+ completed: () => call('calendarsync:completed'),
+ failed: (code, text) => call('calendarsync:failed', code, text),
+ },
} : {}),
// Messages backup (§9.13), the same way as contacts: the messages added
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index 09cef0e..a0b1f71 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -36,6 +36,7 @@ import org.meshbay.client.notify.Notifier
import org.meshbay.client.notify.PushChannels
import org.meshbay.client.notify.PushState
import org.meshbay.client.photos.BackupService
+import org.meshbay.client.phonesync.CalendarSource
import org.meshbay.client.phonesync.ContactSource
import org.meshbay.client.phonesync.DestinationChannels
import org.meshbay.client.phonesync.DocChannels
@@ -113,6 +114,9 @@ class MainActivity : Activity() {
DocChannels("contactsync", this, getSharedPreferences("contactsync", Context.MODE_PRIVATE), destinations,
java.io.File(cacheDir, "contactsync"), ContactSource(this),
notifyId = 10, permissionRequest = 4209),
+ DocChannels("calendarsync", this, getSharedPreferences("calendarsync", Context.MODE_PRIVATE), destinations,
+ java.io.File(cacheDir, "calendarsync"), CalendarSource(this),
+ notifyId = 12, permissionRequest = 4211),
) + listOfNotNull(Flavor.messageSource(this)?.let { source ->
DocChannels("messagesync", this, getSharedPreferences("messagesync", Context.MODE_PRIVATE), destinations,
java.io.File(cacheDir, "messagesync"), source,
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/CalendarSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/CalendarSource.kt
new file mode 100644
index 0000000..bcef08c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/CalendarSource.kt
@@ -0,0 +1,89 @@
+package org.meshbay.client.phonesync
+
+import android.Manifest
+import android.content.Context
+import android.provider.CalendarContract.Attendees
+import android.provider.CalendarContract.Calendars
+import android.provider.CalendarContract.Events
+import android.provider.CalendarContract.Reminders
+import java.io.File
+import java.security.MessageDigest
+import java.util.TimeZone
+
+/**
+ * The phone's calendars, as one iCalendar file (Ics.kt) every calendar
+ * application imports.
+ *
+ * Every calendar the person can write to is in it: their own, local ones and
+ * those of the accounts on the phone. Calendars they only subscribe to (public
+ * holidays, birthdays from contacts) are left out: they are someone else's and
+ * come back by subscribing again. Like contacts, every run writes the whole of
+ * it and sends it only when it differs from the last copy the node took
+ * (`marker` is its SHA-256, without the DTSTAMP lines that change every run).
+ */
+class CalendarSource(private val context: Context) : DocSource {
+ override val permissions = arrayOf(Manifest.permission.READ_CALENDAR)
+ override val suffix = "calendar"
+
+ override fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? {
+ val resolver = context.contentResolver
+ val calendars = HashMap<Long, String>()
+ resolver.query(Calendars.CONTENT_URI,
+ arrayOf(Calendars._ID, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_ACCESS_LEVEL),
+ "${Calendars.CALENDAR_ACCESS_LEVEL} >= ?", arrayOf(Calendars.CAL_ACCESS_CONTRIBUTOR.toString()),
+ null)?.use { c -> while (c.moveToNext()) calendars[c.getLong(0)] = c.getString(1) ?: "" }
+ if (calendars.isEmpty()) return null
+
+ val reminders = HashMap<Long, MutableList<Int>>()
+ resolver.query(Reminders.CONTENT_URI, arrayOf(Reminders.EVENT_ID, Reminders.MINUTES), null, null, null)
+ ?.use { c -> while (c.moveToNext()) reminders.getOrPut(c.getLong(0)) { ArrayList() } += c.getInt(1) }
+ val attendees = HashMap<Long, MutableList<Attendee>>()
+ val organizers = HashMap<Long, String>()
+ resolver.query(Attendees.CONTENT_URI,
+ arrayOf(Attendees.EVENT_ID, Attendees.ATTENDEE_EMAIL, Attendees.ATTENDEE_NAME,
+ Attendees.ATTENDEE_RELATIONSHIP), null, null, null)?.use { c ->
+ while (c.moveToNext()) {
+ val email = c.getString(1)?.takeIf { it.contains('@') } ?: continue
+ if (c.getInt(3) == Attendees.RELATIONSHIP_ORGANIZER) organizers[c.getLong(0)] = email
+ else attendees.getOrPut(c.getLong(0)) { ArrayList() } += Attendee(email, c.getString(2))
+ }
+ }
+
+ val cols = arrayOf(
+ Events._ID, Events.CALENDAR_ID, Events.UID_2445, Events.TITLE, Events.DESCRIPTION,
+ Events.EVENT_LOCATION, Events.DTSTART, Events.DTEND, Events.DURATION, Events.ALL_DAY,
+ Events.EVENT_TIMEZONE, Events.RRULE, Events.RDATE, Events.EXDATE, Events.ORIGINAL_ID,
+ Events.ORIGINAL_INSTANCE_TIME, Events.ORIGINAL_ALL_DAY, Events.STATUS,
+ )
+ val ids = calendars.keys.joinToString(",")
+ val events = ArrayList<CalEvent>()
+ resolver.query(Events.CONTENT_URI, cols, "${Events.DELETED} = 0 AND ${Events.CALENDAR_ID} IN ($ids)",
+ null, "${Events._ID} ASC")?.use { c ->
+ fun str(i: Int) = if (c.isNull(i)) null else c.getString(i)
+ fun long(i: Int) = if (c.isNull(i)) null else c.getLong(i)
+ while (c.moveToNext()) {
+ val id = c.getLong(0)
+ events += CalEvent(
+ id, calendars[c.getLong(1)] ?: "", str(2), str(3), str(4), str(5),
+ c.getLong(6), long(7), str(8), c.getInt(9) == 1, str(10),
+ str(11), str(12), str(13), long(14), long(15), c.getInt(16) == 1,
+ if (c.isNull(17)) null else c.getInt(17),
+ reminders[id].orEmpty(), organizers[id], attendees[id].orEmpty(),
+ )
+ }
+ }
+ if (events.isEmpty()) return null
+
+ val file = File(dir, "calendar.ics")
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.bufferedWriter(Charsets.UTF_8).use { out ->
+ Ics.write(events, now) { l ->
+ out.write(l); out.write("\r\n")
+ if (!l.startsWith("DTSTAMP:")) digest.update((l + "\n").toByteArray(Charsets.UTF_8))
+ }
+ }
+ val sha = digest.digest().joinToString("") { "%02x".format(it) }
+ if (sha == marker) { file.delete(); return null }
+ return Export(file, DocPlan.nameFor("calendar", "ics", now, zone), "text/calendar", sha, events.size)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Ics.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Ics.kt
new file mode 100644
index 0000000..8f151f0
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Ics.kt
@@ -0,0 +1,176 @@
+package org.meshbay.client.phonesync
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/** One event as the calendar provider stores it, as much of it as a backup needs. */
+data class CalEvent(
+ val id: Long,
+ val calendar: String,
+ /** RFC 5545 UID from the provider, when the event came with one. */
+ val uid: String?,
+ val title: String?,
+ val description: String?,
+ val location: String?,
+ /** Milliseconds; for an all-day event, midnight UTC of its first day. */
+ val start: Long,
+ val end: Long?,
+ /** Android's form, `P3600S` or `P1D`, set instead of `end` on a recurring event. */
+ val duration: String?,
+ val allDay: Boolean,
+ /** Olson id (`Europe/Paris`), or null. */
+ val timeZone: String?,
+ val rrule: String?,
+ val rdate: String?,
+ val exdate: String?,
+ /** On an exception to a recurring event: the event it changes, and the instance. */
+ val originalId: Long?,
+ val originalTime: Long?,
+ val originalAllDay: Boolean,
+ /** 0 tentative, 1 confirmed, 2 cancelled, or null. */
+ val status: Int?,
+ val reminderMinutes: List<Int>,
+ val organizer: String?,
+ val attendees: List<Attendee>,
+)
+
+data class Attendee(val email: String, val name: String?)
+
+/**
+ * Events written as one iCalendar file (RFC 5545), the format every calendar
+ * imports. Pure, so the JVM tests hold it; `line` receives each folded line
+ * without its CRLF, so the caller can write it and leave the DTSTAMP lines out
+ * of the hash that says whether anything changed.
+ */
+object Ics {
+ fun write(events: List<CalEvent>, stamp: Long, line: (String) -> Unit) {
+ val uids = events.associate { it.id to uidOf(it) }
+ line("BEGIN:VCALENDAR")
+ line("VERSION:2.0")
+ line("PRODID:-//MeshBay//Android backup//EN")
+ line("CALSCALE:GREGORIAN")
+ for (e in events) {
+ val out = ArrayList<String>()
+ out += "BEGIN:VEVENT"
+ // An exception carries the UID of the event it changes.
+ out += "UID:" + text(e.originalId?.let { uids[it] } ?: uids.getValue(e.id))
+ out += "DTSTAMP:" + utc(stamp)
+ if (e.originalId != null && e.originalTime != null) {
+ out += "RECURRENCE-ID" + time(e.originalTime, e.originalAllDay, e.timeZone)
+ }
+ out += "DTSTART" + time(e.start, e.allDay, e.timeZone)
+ when {
+ e.end != null && e.end >= e.start -> out += "DTEND" + time(e.end, e.allDay, e.timeZone)
+ e.duration != null -> duration(e.duration)?.let { out += "DURATION:$it" }
+ }
+ e.title?.takeIf { it.isNotEmpty() }?.let { out += "SUMMARY:" + text(it) }
+ e.description?.takeIf { it.isNotEmpty() }?.let { out += "DESCRIPTION:" + text(it) }
+ e.location?.takeIf { it.isNotEmpty() }?.let { out += "LOCATION:" + text(it) }
+ e.rrule?.takeIf { it.isNotBlank() }?.let { out += "RRULE:" + it.trim() }
+ e.rdate?.let { dates("RDATE", it) }?.let { out += it }
+ e.exdate?.let { dates("EXDATE", it) }?.let { out += it }
+ when (e.status) {
+ 0 -> out += "STATUS:TENTATIVE"
+ 1 -> out += "STATUS:CONFIRMED"
+ 2 -> out += "STATUS:CANCELLED"
+ }
+ out += "X-MESHBAY-CALENDAR:" + text(e.calendar)
+ e.organizer?.takeIf { it.isNotBlank() }?.let { out += "ORGANIZER:mailto:" + it.trim() }
+ for (a in e.attendees) {
+ val cn = a.name?.takeIf { it.isNotBlank() }?.let { ";CN=" + param(it) } ?: ""
+ out += "ATTENDEE$cn:mailto:" + a.email.trim()
+ }
+ for (m in e.reminderMinutes.distinct()) {
+ out += "BEGIN:VALARM"
+ out += "ACTION:DISPLAY"
+ out += "DESCRIPTION:" + text(e.title ?: "Reminder")
+ out += "TRIGGER:-PT${maxOf(0, m)}M"
+ out += "END:VALARM"
+ }
+ out += "END:VEVENT"
+ for (l in out) fold(l, line)
+ }
+ line("END:VCALENDAR")
+ }
+
+ private fun uidOf(e: CalEvent): String =
+ e.uid?.takeIf { it.isNotBlank() }?.trim() ?: "android-${e.id}@meshbay"
+
+ /** `;VALUE=DATE:20261010`, `;TZID=Europe/Paris:20261010T090000` or `:20261010T070000Z`. */
+ fun time(ms: Long, allDay: Boolean, zone: String?): String {
+ if (allDay) return ";VALUE=DATE:" + fmt("yyyyMMdd", "UTC", ms)
+ val tz = zone?.takeIf { it.isNotBlank() && it != "UTC" && it != "GMT" && it in zones }
+ return if (tz == null) ":" + utc(ms) else ";TZID=$tz:" + fmt("yyyyMMdd'T'HHmmss", tz, ms)
+ }
+
+ private val zones by lazy { TimeZone.getAvailableIDs().toHashSet() }
+
+ fun utc(ms: Long) = fmt("yyyyMMdd'T'HHmmss'Z'", "UTC", ms)
+
+ private fun fmt(pattern: String, zone: String, ms: Long) =
+ SimpleDateFormat(pattern, Locale.ROOT).apply { timeZone = TimeZone.getTimeZone(zone) }.format(Date(ms))
+
+ /** Android's `P3600S` is not RFC 5545, which wants `PT3600S`; days and weeks are the same. */
+ fun duration(android: String): String? {
+ val m = Regex("^([+-]?)P(?:(\\d+)W)?(?:(\\d+)D)?(?:T?(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$")
+ .matchEntire(android.trim()) ?: return null
+ val (sign, w, d, h, min, s) = m.destructured
+ if (listOf(w, d, h, min, s).all { it.isEmpty() }) return null
+ val time = (if (h.isEmpty()) "" else "${h}H") + (if (min.isEmpty()) "" else "${min}M") +
+ (if (s.isEmpty()) "" else "${s}S")
+ return sign + "P" + (if (w.isEmpty()) "" else "${w}W") + (if (d.isEmpty()) "" else "${d}D") +
+ (if (time.isEmpty()) "" else "T$time")
+ }
+
+ /**
+ * Android keeps RDATE and EXDATE as `20261010T090000Z,20261017T090000Z`,
+ * or with a zone first: `Europe/Paris;20261010T090000,…`.
+ */
+ fun dates(name: String, value: String): String? {
+ val v = value.trim().ifEmpty { return null }
+ val semi = v.indexOf(';')
+ if (semi > 0) return "$name;TZID=${v.substring(0, semi)}:${v.substring(semi + 1)}"
+ return if (v.split(',').all { it.length == 8 }) "$name;VALUE=DATE:$v" else "$name:$v"
+ }
+
+ /** A TEXT value: backslash, semicolon, comma and line breaks escaped. */
+ fun text(s: String): String = buildString {
+ for (c in s) when (c) {
+ '\\' -> append("\\\\")
+ ';' -> append("\\;")
+ ',' -> append("\\,")
+ '\n' -> append("\\n")
+ '\r' -> {}
+ else -> if (c < ' ' && c != '\t') {} else append(c)
+ }
+ }
+
+ /** A parameter value, quoted when it holds what would end it. */
+ private fun param(s: String): String {
+ val clean = s.replace("\"", "'").filter { it >= ' ' }
+ return if (clean.any { it == ';' || it == ':' || it == ',' }) "\"$clean\"" else clean
+ }
+
+ /** Lines of at most 75 octets, continued with a space; never inside a character. */
+ fun fold(l: String, line: (String) -> Unit) {
+ var rest = l
+ var limit = 75
+ while (rest.toByteArray(Charsets.UTF_8).size > limit) {
+ var cut = 0
+ var bytes = 0
+ while (cut < rest.length) {
+ val cp = rest.codePointAt(cut)
+ val n = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8).size
+ if (bytes + n > limit) break
+ bytes += n
+ cut += Character.charCount(cp)
+ }
+ line((if (limit == 74) " " else "") + rest.substring(0, cut))
+ rest = rest.substring(cut)
+ limit = 74
+ }
+ line((if (limit == 74) " " else "") + rest)
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt
new file mode 100644
index 0000000..c8f8fa3
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt
@@ -0,0 +1,117 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.phonesync.Attendee
+import org.meshbay.client.phonesync.CalEvent
+import org.meshbay.client.phonesync.Ics
+
+class IcsTest {
+ // 2026-10-09 12:00:00 UTC
+ private val oct9 = 1791547200000L
+ private val hour = 3600_000L
+
+ private fun event(id: Long, title: String = "Lunch", start: Long = oct9, end: Long? = oct9 + hour,
+ duration: String? = null, allDay: Boolean = false, zone: String? = "Europe/Paris",
+ rrule: String? = null, exdate: String? = null, originalId: Long? = null,
+ originalTime: Long? = null, uid: String? = null, status: Int? = 1,
+ reminders: List<Int> = emptyList(), attendees: List<Attendee> = emptyList(),
+ description: String? = null) =
+ CalEvent(id, "Personal", uid, title, description, null, start, end, duration, allDay, zone,
+ rrule, null, exdate, originalId, originalTime, false, status, reminders, null, attendees)
+
+ private fun lines(vararg e: CalEvent): List<String> {
+ val out = ArrayList<String>()
+ Ics.write(e.toList(), oct9) { out += it }
+ return out
+ }
+
+ /** RFC 5545 unfolding: a line starting with a space continues the one before. */
+ private fun unfold(l: List<String>): List<String> {
+ val out = ArrayList<String>()
+ for (x in l) if (x.startsWith(" ")) out[out.size - 1] = out.last() + x.substring(1) else out += x
+ return out
+ }
+
+ private fun events(l: List<String>): List<List<String>> {
+ val out = ArrayList<List<String>>()
+ var cur: MutableList<String>? = null
+ for (x in unfold(l)) when (x) {
+ "BEGIN:VEVENT" -> cur = ArrayList()
+ "END:VEVENT" -> { out += cur!!; cur = null }
+ else -> cur?.add(x)
+ }
+ return out
+ }
+
+ private fun List<String>.prop(name: String) = firstOrNull { it.startsWith("$name:") || it.startsWith("$name;") }
+
+ @Test fun `a calendar wraps the events, with what an importer requires`() {
+ val l = lines(event(1))
+ assertEquals("BEGIN:VCALENDAR", l.first())
+ assertEquals("END:VCALENDAR", l.last())
+ assertTrue("VERSION:2.0" in l)
+ val e = events(l).single()
+ assertEquals("UID:android-1@meshbay", e.prop("UID"))
+ assertEquals("DTSTAMP:20261009T120000Z", e.prop("DTSTAMP"))
+ }
+
+ @Test fun `a timed event keeps its zone, so a recurrence follows summer time`() {
+ val e = events(lines(event(1)))[0]
+ assertEquals("DTSTART;TZID=Europe/Paris:20261009T140000", e.prop("DTSTART"))
+ assertEquals("DTEND;TZID=Europe/Paris:20261009T150000", e.prop("DTEND"))
+ val utc = events(lines(event(2, zone = null)))[0]
+ assertEquals("DTSTART:20261009T120000Z", utc.prop("DTSTART"))
+ val unknown = events(lines(event(3, zone = "Not/AZone")))[0]
+ assertEquals("DTSTART:20261009T120000Z", unknown.prop("DTSTART"))
+ }
+
+ @Test fun `an all-day event is a date`() {
+ val day = 1791504000000L // 2026-10-09 00:00 UTC
+ val e = events(lines(event(1, start = day, end = day + 24 * hour, allDay = true)))[0]
+ assertEquals("DTSTART;VALUE=DATE:20261009", e.prop("DTSTART"))
+ assertEquals("DTEND;VALUE=DATE:20261010", e.prop("DTEND"))
+ }
+
+ @Test fun `a recurring event keeps its rule, and Android's duration is made valid`() {
+ val e = events(lines(event(1, end = null, duration = "P3600S", rrule = "FREQ=WEEKLY;BYDAY=FR",
+ exdate = "Europe/Paris;20261016T140000")))[0]
+ assertEquals("RRULE:FREQ=WEEKLY;BYDAY=FR", e.prop("RRULE"))
+ assertEquals("DURATION:PT3600S", e.prop("DURATION"))
+ assertEquals("EXDATE;TZID=Europe/Paris:20261016T140000", e.prop("EXDATE"))
+ assertNull(e.prop("DTEND"))
+ assertEquals("P1D", Ics.duration("P1D"))
+ assertEquals("PT1H30M", Ics.duration("PT1H30M"))
+ assertNull(Ics.duration("nonsense"))
+ }
+
+ @Test fun `a changed instance carries the series' UID and says which instance`() {
+ val master = event(1, uid = "abc@example.org", end = null, duration = "P3600S", rrule = "FREQ=DAILY")
+ val moved = event(2, start = oct9 + 26 * hour, end = oct9 + 27 * hour, originalId = 1,
+ originalTime = oct9 + 24 * hour)
+ val e = events(lines(master, moved))
+ assertEquals("UID:abc@example.org", e[0].prop("UID"))
+ assertEquals("UID:abc@example.org", e[1].prop("UID"))
+ assertEquals("RECURRENCE-ID;TZID=Europe/Paris:20261010T140000", e[1].prop("RECURRENCE-ID"))
+ }
+
+ @Test fun `text is escaped and long lines folded without splitting a character`() {
+ val desc = "a;b,c\\d\nsecond line " + "é".repeat(60) + " 🎉".repeat(20)
+ val l = lines(event(1, description = desc))
+ assertTrue(l.all { it.toByteArray(Charsets.UTF_8).size <= 75 })
+ val e = events(l)[0]
+ assertEquals("DESCRIPTION:" + "a\\;b\\,c\\\\d\\nsecond line " + "é".repeat(60) + " 🎉".repeat(20),
+ e.prop("DESCRIPTION"))
+ }
+
+ @Test fun `reminders, attendees and a cancelled status come along`() {
+ val e = events(lines(event(1, status = 2, reminders = listOf(10, 10, 60),
+ attendees = listOf(Attendee("ada@example.org", "Ada, L.")))))[0]
+ assertEquals("STATUS:CANCELLED", e.prop("STATUS"))
+ assertEquals(listOf("TRIGGER:-PT10M", "TRIGGER:-PT60M"), e.filter { it.startsWith("TRIGGER") })
+ assertEquals("ATTENDEE;CN=\"Ada, L.\":mailto:ada@example.org", e.prop("ATTENDEE"))
+ assertEquals("X-MESHBAY-CALENDAR:Personal", e.prop("X-MESHBAY-CALENDAR"))
+ }
+}