aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md24
-rw-r--r--docs/USERGUIDE.md13
-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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js3
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py9
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py4
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py6
25 files changed, 495 insertions, 21 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 29b31a2..a30215d 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -3431,10 +3431,10 @@ foreground service (`BackupService`) and the WebView reported visible, like a
cast (§11.3). Android 15 limits that type to six hours a day; the service stops
when told and the run carries on at the next opening.
-### 9.13 Contacts and messages backup (Android)
+### 9.13 Contacts, calendar and messages backup (Android)
-The Android application sends a copy of the phone's **contacts** and its **text
-messages** to a folder of a group, on the photo backup's terms (§9.12): a client
+The Android application sends a copy of the phone's **contacts**, its
+**calendars** and its **text messages** to a folder of a group, on the photo backup's terms (§9.12): a client
feature over the upload path, additive, once a day, the page doing the sending
and the phone handing one file over by token (`/phonesync/<token>`,
`phonesync/` in the Android package, `phone-sync.js` on the page). Each kind is
@@ -3452,6 +3452,18 @@ contact through `ContactsContract.Contacts.CONTENT_VCARD_URI`, the format any
phone imports. It is sent only when its SHA-256 differs from the last copy the
node took; a day with no change sends nothing.
+**Calendar.** `<folder>/<account>-calendar/calendar-2026-10-10-0900.ics`: one
+iCalendar file (RFC 5545, `Ics.kt`) of every calendar the person can write to
+(`CALENDAR_ACCESS_LEVEL` contributor or above); the ones they only subscribe
+to, public holidays or birthdays from contacts, are someone else's and come
+back by subscribing again. Timed events keep their Olson zone (`TZID`), so a
+weekly 9:00 stays 9:00 across summer time; recurrences keep their RRULE,
+RDATE and EXDATE, Android's `P3600S` durations become RFC's `PT3600S`, and a
+changed instance carries its series' UID and a RECURRENCE-ID. Reminders,
+organizer and attendees come along; each event names its calendar in
+`X-MESHBAY-CALENDAR`, which importers ignore. Like contacts it is sent when
+its SHA-256, taken without the DTSTAMP lines, differs from the last copy.
+
**Messages.** `<folder>/<account>-messages/YYYY/sms-2026-10-10-0900.xml`: the
SMS added since the last file the node took (the marker is the highest
provider `_id` sent; ids only grow, so a message restored onto the phone later
@@ -3470,10 +3482,10 @@ answers null in `src/play`). The bridge offers `messageSync` only where the
shell has a message source (`MESSAGES` in its prelude), so the Play build shows
no Messages section rather than one that refuses.
-**Unlike photos**, both run on any network: a contacts file is small, and a
+**Unlike photos**, these run on any network: a contacts or calendar file is small, and a
history of text messages is megabytes, not the gigabytes of a camera roll.
-`READ_CONTACTS` and `READ_SMS` are asked for when the person turns each on,
-never at start.
+`READ_CONTACTS`, `READ_CALENDAR` and `READ_SMS` are asked for when the person
+turns each on, never at start.
## 10. Filesystem portability
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index e0c29da..1587160 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -385,7 +385,7 @@ that matters to you.
### Backing up your phone
On the Android application, **Phone → Android Sync** in the side menu backs up
-your phone's photos, contacts and text messages to a group.
+your phone's photos, contacts, calendar and text messages to a group.
Only your **personal profile** is backed up. If your phone has a work profile,
nothing in it is sent, and MeshBay installed inside the work profile offers no
@@ -434,6 +434,17 @@ backup at all.
- It runs on any connection, since a copy is small. **Back up now** sends one
at once if your contacts changed since the last copy.
+#### Calendar
+
+- **Turn on** asks for access to your calendars, then sends a copy once a day
+ when they have changed.
+- It holds every calendar you can edit: your own, local ones and those of the
+ accounts on the phone. Calendars you only subscribe to (public holidays,
+ birthdays) are left out.
+- Each copy is an `.ics` file with the date in its name
+ (`calendar-2026-10-10-0900.ics`). Any calendar application can import it;
+ repeating events, their exceptions and reminders come along.
+
#### Text messages
- **Turn on** asks for access to your text messages. The first copy holds them
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"))
+ }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 41cc5f2..031e276 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -944,9 +944,10 @@ function App() {
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
- // Contacts and messages backup (§9.13), the same way.
+ // Contacts, calendar and messages backup (§9.13), the same way.
useEffect(() => {
- if (!syncAccount || !(platform.contactSync.available || platform.messageSync.available)) {
+ if (!syncAccount || !(platform.contactSync.available || platform.calendarSync.available
+ || platform.messageSync.available)) {
return undefined;
}
let stop = null;
@@ -964,7 +965,7 @@ function App() {
},
});
stop = m.stopPhoneSync;
- }).catch((e) => console.warn('[MeshBay] contacts and messages backup did not load:', e.message));
+ }).catch((e) => console.warn('[MeshBay] phone backups did not load:', e.message));
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 086f3a9..5f508da 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1340,6 +1340,9 @@ export default {
'contactsync.heading': "Kontakte-Sicherung",
'contactsync.intro': "Einmal täglich, wenn sie sich geändert haben, eine Kopie der Kontakte dieses Telefons senden. Jede Kopie ist eine .vcf-Datei, die jedes Telefon importieren kann.",
'contactsync.permission_needed': "MeshBay braucht Zugriff auf die Kontakte dieses Telefons.",
+ 'calendarsync.heading': "Kalender-Sicherung",
+ 'calendarsync.intro': "Einmal täglich, wenn sie sich geändert haben, eine Kopie der Kalender dieses Telefons senden: die, die du bearbeiten kannst, nicht die nur abonnierten. Jede Kopie ist eine .ics-Datei, die jeder Kalender importieren kann.",
+ 'calendarsync.permission_needed': "MeshBay braucht Zugriff auf die Kalender dieses Telefons.",
'messagesync.heading': "SMS-Sicherung",
'messagesync.intro': "Die SMS dieses Telefons einmal täglich senden: Die erste Kopie enthält alle, jede weitere nur die neuen. Die Dateien sind im XML-Format, das SMS-Sicherungs-Apps wiederherstellen können. MMS sind nicht enthalten.",
'messagesync.permission_needed': "MeshBay braucht Zugriff auf die SMS dieses Telefons.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 66cd23b..6b7d73f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1321,6 +1321,9 @@ export default {
'contactsync.heading': "Contacts backup",
'contactsync.intro': "Send a copy of this phone's contacts once a day when they have changed. Each copy is a .vcf file any phone can import.",
'contactsync.permission_needed': "MeshBay needs access to this phone's contacts.",
+ 'calendarsync.heading': "Calendar backup",
+ 'calendarsync.intro': "Send a copy of this phone's calendars once a day when they have changed: the ones you can edit, not those you only subscribe to. Each copy is an .ics file any calendar can import.",
+ 'calendarsync.permission_needed': "MeshBay needs access to this phone's calendars.",
'messagesync.heading': "Messages backup",
'messagesync.intro': "Send the text messages on this phone once a day: the first copy holds them all, each next one only the new ones. The files are in the XML format SMS backup applications restore. Multimedia messages are not included.",
'messagesync.permission_needed': "MeshBay needs access to this phone's text messages.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index c53e3e4..f2c1cfc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1334,6 +1334,9 @@ export default {
'contactsync.heading': "Copia de seguridad de contactos",
'contactsync.intro': "Enviar una copia de los contactos de este teléfono una vez al día si han cambiado. Cada copia es un archivo .vcf que cualquier teléfono puede importar.",
'contactsync.permission_needed': "MeshBay necesita acceso a los contactos de este teléfono.",
+ 'calendarsync.heading': "Copia de seguridad del calendario",
+ 'calendarsync.intro': "Enviar una copia de los calendarios de este teléfono una vez al día si han cambiado: los que puedes editar, no aquellos a los que solo estás suscrito. Cada copia es un archivo .ics que cualquier calendario puede importar.",
+ 'calendarsync.permission_needed': "MeshBay necesita acceso a los calendarios de este teléfono.",
'messagesync.heading': "Copia de seguridad de SMS",
'messagesync.intro': "Enviar los SMS de este teléfono una vez al día: la primera copia los contiene todos y cada una de las siguientes solo los nuevos. Los archivos están en el formato XML que restauran las aplicaciones de copia de seguridad de SMS. Los MMS no se incluyen.",
'messagesync.permission_needed': "MeshBay necesita acceso a los SMS de este teléfono.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 52b40b0..3b5b881 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1349,6 +1349,9 @@ export default {
'contactsync.heading': "Sauvegarde des contacts",
'contactsync.intro': "Envoyer une copie des contacts de ce téléphone une fois par jour s'ils ont changé. Chaque copie est un fichier .vcf que tout téléphone sait importer.",
'contactsync.permission_needed': "MeshBay a besoin d'accéder aux contacts de ce téléphone.",
+ 'calendarsync.heading': "Sauvegarde de l'agenda",
+ 'calendarsync.intro': "Envoyer une copie des agendas de ce téléphone une fois par jour s'ils ont changé : ceux que vous pouvez modifier, pas ceux auxquels vous êtes seulement abonné. Chaque copie est un fichier .ics que tout agenda sait importer.",
+ 'calendarsync.permission_needed': "MeshBay a besoin d'accéder aux agendas de ce téléphone.",
'messagesync.heading': "Sauvegarde des SMS",
'messagesync.intro': "Envoyer les SMS de ce téléphone une fois par jour : la première copie les contient tous, les suivantes seulement les nouveaux. Les fichiers sont au format XML que les applis de sauvegarde SMS savent restaurer. Les MMS ne sont pas inclus.",
'messagesync.permission_needed': "MeshBay a besoin d'accéder aux SMS de ce téléphone.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 9387ddd..9935edb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1348,6 +1348,9 @@ export default {
'contactsync.heading': "Backup dei contatti",
'contactsync.intro': "Invia una copia dei contatti di questo telefono una volta al giorno se sono cambiati. Ogni copia è un file .vcf che qualsiasi telefono può importare.",
'contactsync.permission_needed': "MeshBay ha bisogno di accedere ai contatti di questo telefono.",
+ 'calendarsync.heading': "Backup del calendario",
+ 'calendarsync.intro': "Invia una copia dei calendari di questo telefono una volta al giorno se sono cambiati: quelli che puoi modificare, non quelli a cui sei solo iscritto. Ogni copia è un file .ics che qualsiasi calendario può importare.",
+ 'calendarsync.permission_needed': "MeshBay ha bisogno di accedere ai calendari di questo telefono.",
'messagesync.heading': "Backup degli SMS",
'messagesync.intro': "Invia gli SMS di questo telefono una volta al giorno: la prima copia li contiene tutti, le successive solo quelli nuovi. I file sono nel formato XML che le app di backup degli SMS sanno ripristinare. Gli MMS non sono inclusi.",
'messagesync.permission_needed': "MeshBay ha bisogno di accedere agli SMS di questo telefono.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 29296d1..2af2f49 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1332,6 +1332,9 @@ export default {
'contactsync.heading': "連絡先のバックアップ",
'contactsync.intro': "このスマートフォンの連絡先のコピーを、変更があった日に1日1回送ります。各コピーはどのスマートフォンでも読み込める .vcf ファイルです。",
'contactsync.permission_needed': "MeshBay にこのスマートフォンの連絡先へのアクセスを許可してください。",
+ 'calendarsync.heading': "カレンダーのバックアップ",
+ 'calendarsync.intro': "このスマートフォンのカレンダーのコピーを、変更があった日に1日1回送ります。対象は編集できるカレンダーで、購読しているだけのものは含みません。各コピーはどのカレンダーでも読み込める .ics ファイルです。",
+ 'calendarsync.permission_needed': "MeshBay にこのスマートフォンのカレンダーへのアクセスを許可してください。",
'messagesync.heading': "SMS のバックアップ",
'messagesync.intro': "このスマートフォンの SMS を1日1回送ります。最初のコピーにはすべてが含まれ、以降は新しいものだけが含まれます。ファイルは SMS バックアップアプリで復元できる XML 形式です。MMS は含まれません。",
'messagesync.permission_needed': "MeshBay にこのスマートフォンの SMS へのアクセスを許可してください。",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index ada6813..9e83c86 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1350,6 +1350,9 @@ export default {
'contactsync.heading': "Back-up van contacten",
'contactsync.intro': "Stuur eenmaal per dag, als ze veranderd zijn, een kopie van de contacten op deze telefoon. Elke kopie is een .vcf-bestand dat elke telefoon kan importeren.",
'contactsync.permission_needed': "MeshBay heeft toegang nodig tot de contacten op deze telefoon.",
+ 'calendarsync.heading': "Back-up van de agenda",
+ 'calendarsync.intro': "Stuur eenmaal per dag, als ze veranderd zijn, een kopie van de agenda's op deze telefoon: de agenda's die je kunt bewerken, niet de agenda's waarop je alleen geabonneerd bent. Elke kopie is een .ics-bestand dat elke agenda kan importeren.",
+ 'calendarsync.permission_needed': "MeshBay heeft toegang nodig tot de agenda's op deze telefoon.",
'messagesync.heading': "Back-up van sms'jes",
'messagesync.intro': "Stuur de sms'jes op deze telefoon eenmaal per dag: de eerste kopie bevat ze allemaal, elke volgende alleen de nieuwe. De bestanden zijn in het XML-formaat dat sms-back-upapps kunnen terugzetten. Mms'jes zijn niet inbegrepen.",
'messagesync.permission_needed': "MeshBay heeft toegang nodig tot de sms'jes op deze telefoon.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 3e712bb..cb0d0b6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1376,6 +1376,9 @@ export default {
'contactsync.heading': "Kopia kontaktów",
'contactsync.intro': "Wysyłaj kopię kontaktów z tego telefonu raz dziennie, jeśli się zmieniły. Każda kopia to plik .vcf, który zaimportuje każdy telefon.",
'contactsync.permission_needed': "MeshBay potrzebuje dostępu do kontaktów w tym telefonie.",
+ 'calendarsync.heading': "Kopia kalendarza",
+ 'calendarsync.intro': "Wysyłaj kopię kalendarzy z tego telefonu raz dziennie, jeśli się zmieniły: tych, które możesz edytować, a nie tylko subskrybowanych. Każda kopia to plik .ics, który zaimportuje każdy kalendarz.",
+ 'calendarsync.permission_needed': "MeshBay potrzebuje dostępu do kalendarzy w tym telefonie.",
'messagesync.heading': "Kopia SMS-ów",
'messagesync.intro': "Wysyłaj SMS-y z tego telefonu raz dziennie: pierwsza kopia zawiera wszystkie, każda kolejna tylko nowe. Pliki są w formacie XML, który przywracają aplikacje do kopii SMS-ów. MMS-y nie są uwzględniane.",
'messagesync.permission_needed': "MeshBay potrzebuje dostępu do SMS-ów w tym telefonie.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 8fe39e5..cb3aa70 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1335,6 +1335,9 @@ export default {
'contactsync.heading': "Backup de contatos",
'contactsync.intro': "Enviar uma cópia dos contatos deste celular uma vez por dia se tiverem mudado. Cada cópia é um arquivo .vcf que qualquer celular consegue importar.",
'contactsync.permission_needed': "O MeshBay precisa de acesso aos contatos deste celular.",
+ 'calendarsync.heading': "Backup da agenda",
+ 'calendarsync.intro': "Enviar uma cópia das agendas deste celular uma vez por dia se tiverem mudado: as que você pode editar, não as que você só assina. Cada cópia é um arquivo .ics que qualquer agenda consegue importar.",
+ 'calendarsync.permission_needed': "O MeshBay precisa de acesso às agendas deste celular.",
'messagesync.heading': "Backup de SMS",
'messagesync.intro': "Enviar os SMS deste celular uma vez por dia: a primeira cópia contém todos, cada uma das seguintes só os novos. Os arquivos estão no formato XML que os apps de backup de SMS conseguem restaurar. MMS não são incluídos.",
'messagesync.permission_needed': "O MeshBay precisa de acesso aos SMS deste celular.",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index e294fe5..bb47b00 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1321,6 +1321,9 @@ export default {
'contactsync.heading': "联系人备份",
'contactsync.intro': "在联系人有变动的日子里,每天一次发送本机联系人的副本。每份副本都是任何手机都能导入的 .vcf 文件。",
'contactsync.permission_needed': "MeshBay 需要访问本机的联系人。",
+ 'calendarsync.heading': "日历备份",
+ 'calendarsync.intro': "在日历有变动的日子里,每天一次发送本机日历的副本:包括你可以编辑的日历,不包括仅订阅的日历。每份副本都是任何日历都能导入的 .ics 文件。",
+ 'calendarsync.permission_needed': "MeshBay 需要访问本机的日历。",
'messagesync.heading': "短信备份",
'messagesync.intro': "每天一次发送本机的短信:第一份副本包含全部短信,之后每份只包含新短信。文件采用短信备份应用可以恢复的 XML 格式。不包括彩信。",
'messagesync.permission_needed': "MeshBay 需要访问本机的短信。",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
index 0602e54..2c1ebc0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
@@ -1,6 +1,6 @@
-// The contacts and messages sections of the Android Sync page, on the Android
-// application only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other
-// client has it. Where the copies go is the page's destination section; these
+// The contacts, calendar and messages sections of the Android Sync page, on
+// the Android application only (docs/MESHBAY_DESIGN.md §9.13). Loaded on
+// demand: no other client has it. Where the copies go is the page's destination section; these
// only turn each backup on or off.
import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
@@ -13,6 +13,10 @@ const TEXT = {
intro: 'contactsync.intro',
permission: 'contactsync.permission_needed',
},
+ calendar: {
+ intro: 'calendarsync.intro',
+ permission: 'calendarsync.permission_needed',
+ },
messages: {
intro: 'messagesync.intro',
permission: 'messagesync.permission_needed',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
index 2c34af0..4e9245a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
@@ -1,7 +1,8 @@
-// Contacts and messages backup: a copy of this phone's address book, and the
-// text messages added since the last copy, sent to `<folder>/<account>-contacts`
-// and `-messages` of the destination every backup shares (sync-destination.js),
-// once a day when there is something new (docs/MESHBAY_DESIGN.md §9.13).
+// Contacts, calendar and messages backup: a copy of this phone's address book
+// and calendars, and the text messages added since the last copy, sent to
+// `<folder>/<account>-contacts`, `-calendar` and `-messages` of the destination
+// every backup shares (sync-destination.js), once a day when there is
+// something new (docs/MESHBAY_DESIGN.md §9.13).
//
// The same split as photos (photo-sync.js): the phone writes what is new into
// one file and remembers what the node took; this module decides when a run
@@ -30,6 +31,7 @@ const RETRY_MS = 15 * 60 * 1000;
*/
export const KINDS = {
contacts: { sync: platform.contactSync, heading: 'contactsync.heading', suffix: 'contacts' },
+ calendar: { sync: platform.calendarSync, heading: 'calendarsync.heading', suffix: 'calendar' },
messages: { sync: platform.messageSync, heading: 'messagesync.heading', suffix: 'messages' },
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 4b507dc..2cfa673 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -267,7 +267,7 @@ export const phoneSync = {
};
/**
- * Backing up one kind of this phone's data (contacts, messages) to the destination
+ * Backing up one kind of this phone's data (contacts, calendar, messages) to the destination
* every kind shares (docs/MESHBAY_DESIGN.md §9.13). Android only, like
* `photoSync`. See phone-sync.js.
*/
@@ -292,6 +292,7 @@ function docSync(name) {
}
export const contactSync = docSync('contactSync');
+export const calendarSync = docSync('calendarSync');
/** Absent from the Play build, whose policy keeps text messages from it. */
export const messageSync = docSync('messageSync');
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
index 3ae73c1..6c1a4d6 100644
--- a/packages/meshbay-hub/tests/harness/photo_sync_probe.py
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -97,6 +97,15 @@ window.meshbay = {
permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }),
sent: async () => true, completed: async () => ({}), failed: async () => true,
},
+ calendarSync: {
+ status: async () => ({
+ permission: 'granted', on: MODE === 'configured', destination: dest,
+ dir: dest && '__FOLDER__/bob-calendar', sent: 5, lastCompleted: Date.now(),
+ lastSent: dest && '__FOLDER__/bob-calendar/calendar-2026-10-10-0900.ics', failure: null,
+ }),
+ permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ },
messageSync: {
status: async () => ({
permission: 'granted', on: MODE === 'configured', destination: dest,
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 2990e9b..b5b0fa0 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -148,7 +148,7 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
# address book to back up, and a desktop has none of them; those families are the ones the desktop lacks
# rather than the ones it shares.
phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:",
- "messagesync:"))}
+ "calendarsync:", "messagesync:"))}
assert any(c.startswith("push:") for c in phone_only), "the push channels are gone"
assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone"
assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
@@ -267,7 +267,7 @@ def test_only_the_personal_profile_is_backed_up():
assert "Profiles.isPersonal(this)" in activity and "const BACKUP = $backups;" in activity
shim = _strip_js_comments(_read(SHIM))
gated = shim.split("...(BACKUP ? {", 1)[1].split("} : {}),", 1)[0]
- for name in ("photoSync:", "phoneSync:", "contactSync:"):
+ for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:"):
assert name in gated, name
for path in [*(SRC / "phonesync").rglob("*.kt"), *(SRC / "photos").rglob("*.kt"),
*(APP / "src" / "full").rglob("*.kt")]:
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
index b101310..cbac80a 100644
--- a/packages/meshbay-hub/tests/test_photo_sync_ui.py
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -57,7 +57,7 @@ def test_limited_access_is_said_rather_than_failing_quietly(probe):
def test_where_backups_go_lists_each_kinds_folder(probe):
text = probe["dest360"]["text"]
- assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "messages"))
+ assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages"))
def test_photos_the_photos_tab_will_not_show_are_said(probe):
@@ -70,3 +70,7 @@ def test_the_contacts_backup_says_where_its_copies_go(probe):
def test_the_messages_backup_says_where_its_copies_go(probe):
assert "bob-messages/2026/sms-2026-10-10-0900.xml" in probe["configured360"]["text"]
+
+
+def test_the_calendar_backup_says_where_its_copies_go(probe):
+ assert "bob-calendar/calendar-2026-10-10-0900.ics" in probe["configured360"]["text"]