diff options
Diffstat (limited to 'packages/meshbay-android/app/src/main')
5 files changed, 283 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) + } +} |