aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/test
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/app/src/test
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/app/src/test')
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt117
1 files changed, 117 insertions, 0 deletions
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"))
+ }
+}