aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-android')
-rw-r--r--packages/meshbay-android/README.md22
-rw-r--r--packages/meshbay-android/app/build.gradle.kts13
-rw-r--r--packages/meshbay-android/app/src/full/AndroidManifest.xml6
-rw-r--r--packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt8
-rw-r--r--packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt72
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml18
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js108
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt121
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt14
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt297
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt79
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveLedger.kt69
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt112
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveSource.kt55
-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/ContactSource.kt51
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Destination.kt79
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt188
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt56
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Ics.kt176
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ManifestLog.kt56
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt24
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt69
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt40
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt394
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt166
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt142
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt6
-rw-r--r--packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt31
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt81
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DriveTest.kt90
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt117
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ManifestLogTest.kt52
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt202
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt66
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt57
39 files changed, 3402 insertions, 17 deletions
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md
index 69977ec..02297c9 100644
--- a/packages/meshbay-android/README.md
+++ b/packages/meshbay-android/README.md
@@ -30,11 +30,17 @@ holds the same service and the same visibility, for as long as it plays
```bash
# needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties)
-./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
-./gradlew testDebugUnitTest # JVM unit tests
-./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk
+./gradlew assembleDebug # app/build/outputs/apk/{full,play}/debug/
+./gradlew testFullDebugUnitTest # JVM unit tests
+./gradlew assembleRelease # app/build/outputs/apk/full/release/app-full-release.apk
+ # app/build/outputs/apk/play/release/app-play-release.apk
```
+Two builds of the same application, same id and key: `full` is the one
+distributed directly; `play` is for the Play Store, whose policy gives
+READ_SMS to the default SMS application only, so it has no text messages
+backup (no permission, no code: `src/full`, `src/play`).
+
A release is signed with the release key, which never enters the repository.
`assembleRelease` reads it from `~/.gradle/gradle.properties`, and stops if
any of these is missing rather than signing with the debug key:
@@ -46,7 +52,7 @@ meshbayReleaseKeyAlias=meshbay
meshbayReleaseKeyPassword=...
```
-`apksigner verify --print-certs app-release.apk` prints the certificate's
+`apksigner verify --print-certs app-full-release.apk` prints the certificate's
SHA-256 fingerprint, the one the download page publishes (§8.2). A release
does not install over a debug build, or the reverse: the keys differ.
@@ -62,6 +68,14 @@ connector could decrypt, and the fetch slows to a four-hour net. The page turns
it on in Settings and registers the phone with the hub; muting and "disable
all" are decided on the hub, which then creates nothing (§11.3).
+Photo backup (`photos/`, §9.12): MediaStore is listed natively, a ledger of
+what was sent is kept per account, group and folder, and each photo's bytes are
+handed to the page at `/photosync/<token>` on the packaged origin — never a
+URI. The page decides when a run is due and sends; a `dataSync` foreground
+service (`BackupService`) keeps it going with the screen off. No
+`ACCESS_MEDIA_LOCATION`, so the platform redacts a photo's location from what
+is read.
+
Not built yet: phone-specific behaviour (back button, network handover,
keeping a download alive with the screen off), updates through a store.
diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts
index 7f7ba3c..cd5e1a5 100644
--- a/packages/meshbay-android/app/build.gradle.kts
+++ b/packages/meshbay-android/app/build.gradle.kts
@@ -32,6 +32,16 @@ android {
keyPassword = providers.gradleProperty("meshbayReleaseKeyPassword").get()
}
}
+ // Two builds of the same application, the same id and key. `full` is the
+ // one distributed directly; `play` is for the Play Store, whose policy
+ // gives READ_SMS to the default SMS application only, so it has no
+ // messages backup: not the permission, and not the code that reads them
+ // (src/full, src/play: Flavor.kt).
+ flavorDimensions += "store"
+ productFlavors {
+ create("full") { dimension = "store"; isDefault = true }
+ create("play") { dimension = "store" }
+ }
buildTypes {
getByName("release") {
isMinifyEnabled = false
@@ -100,7 +110,8 @@ val syncUi = tasks.register<SyncUi>("syncUi") {
// Checked when a release is built, not when the project is configured, so a
// debug build and the unit tests need no key.
tasks.configureEach {
- if (name == "preReleaseBuild") doFirst {
+ // One per build: preFullReleaseBuild, prePlayReleaseBuild.
+ if (name.startsWith("pre") && name.endsWith("ReleaseBuild")) doFirst {
val missing = releaseSigning.filter { !providers.gradleProperty(it).isPresent }
if (missing.isNotEmpty()) throw GradleException(
"no release key: set ${missing.joinToString()} in ~/.gradle/gradle.properties")
diff --git a/packages/meshbay-android/app/src/full/AndroidManifest.xml b/packages/meshbay-android/app/src/full/AndroidManifest.xml
new file mode 100644
index 0000000..552902a
--- /dev/null
+++ b/packages/meshbay-android/app/src/full/AndroidManifest.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <!-- Messages backup, `full` build only: asked for when the person turns it
+ on, never at start. The `play` build has no such permission. -->
+ <uses-permission android:name="android.permission.READ_SMS" />
+</manifest>
diff --git a/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt
new file mode 100644
index 0000000..20882a5
--- /dev/null
+++ b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt
@@ -0,0 +1,8 @@
+package org.meshbay.client.phonesync
+
+import android.content.Context
+
+/** What this build backs up beyond photos and contacts: the `full` build reads messages. */
+object Flavor {
+ fun messageSource(context: Context): DocSource? = SmsSource(context)
+}
diff --git a/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt
new file mode 100644
index 0000000..c24ef1f
--- /dev/null
+++ b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt
@@ -0,0 +1,72 @@
+package org.meshbay.client.phonesync
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.ContactsContract
+import android.provider.Telephony
+import java.io.File
+import java.util.TimeZone
+
+/**
+ * The phone's text messages, from the platform's SMS provider (not MMS).
+ *
+ * Each run writes the messages added since the last file the node took:
+ * `marker` is the highest provider id it held, and ids only grow, so a
+ * message restored onto the phone later counts as new and is sent too. The
+ * first file holds the whole history. Files go under the year they were
+ * written in, `<account>-messages/YYYY/`.
+ *
+ * Only in the `full` build: Play's policy keeps READ_SMS for the default SMS
+ * application, so the `play` build has neither the permission nor this.
+ */
+class SmsSource(private val context: Context) : DocSource {
+ override val permissions = arrayOf(Manifest.permission.READ_SMS)
+ override val suffix = "messages"
+
+ override fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? {
+ val after = marker?.toLongOrNull() ?: 0L
+ val names = Names(context)
+ val out = ArrayList<Sms>()
+ val cols = arrayOf(
+ Telephony.Sms._ID, Telephony.Sms.ADDRESS, Telephony.Sms.DATE, Telephony.Sms.DATE_SENT,
+ Telephony.Sms.TYPE, Telephony.Sms.BODY, Telephony.Sms.READ, Telephony.Sms.STATUS,
+ Telephony.Sms.LOCKED, Telephony.Sms.PROTOCOL, Telephony.Sms.SUBJECT, Telephony.Sms.SERVICE_CENTER,
+ )
+ context.contentResolver.query(Telephony.Sms.CONTENT_URI, cols, "${Telephony.Sms._ID} > ?",
+ arrayOf(after.toString()), "${Telephony.Sms.DATE} ASC")?.use { c ->
+ fun str(i: Int) = if (c.isNull(i)) null else c.getString(i)
+ fun int(i: Int, d: Int) = if (c.isNull(i)) d else c.getInt(i)
+ while (c.moveToNext()) {
+ val address = str(1) ?: ""
+ out += Sms(c.getLong(0), address, c.getLong(2), if (c.isNull(3)) 0 else c.getLong(3),
+ int(4, 1), str(5) ?: "", int(6, 1), int(7, -1), int(8, 0), int(9, 0),
+ str(10), str(11), names.of(address))
+ }
+ }
+ if (out.isEmpty()) return null
+ val file = File(dir, "sms.xml")
+ file.bufferedWriter(Charsets.UTF_8).use { SmsXml.write(out, it, zone) }
+ return Export(file, DocPlan.nameFor("sms", "xml", now, zone), "application/xml",
+ out.maxOf { it.id }.toString(), out.size, DocPlan.yearOf(now, zone))
+ }
+
+ /** Who a number is, when the person also let the application read contacts; once per number. */
+ private class Names(private val context: Context) {
+ private val allowed = context.checkSelfPermission(Manifest.permission.READ_CONTACTS) ==
+ PackageManager.PERMISSION_GRANTED
+ private val known = HashMap<String, String?>()
+
+ fun of(address: String): String? {
+ if (!allowed || address.isBlank()) return null
+ return known.getOrPut(address) {
+ try {
+ val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address))
+ context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
+ null, null, null)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
+ } catch (e: Exception) { null }
+ }
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml
index 0c234aa..6b97b45 100644
--- a/packages/meshbay-android/app/src/main/AndroidManifest.xml
+++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml
@@ -13,6 +13,20 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- The periodic fetch survives a reboot (JobInfo.setPersisted). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+ <!-- Photo backup: the photos, asked for when the person turns it on. Not
+ ACCESS_MEDIA_LOCATION — without it the platform redacts a photo's
+ location from the bytes this application reads, so a camera roll sent
+ to a group does not say where its owner lives. -->
+ <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+ <!-- Videos of the same albums, when the person ticks them in: asked then. -->
+ <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
+ <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
+ <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
@@ -41,6 +55,10 @@
android:name=".cast.CastService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
+ <service
+ android:name=".photos.BackupService"
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
<!-- Not exported: the connector's own receiver takes the distributor's
broadcasts and hands them here inside the application. -->
<service
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 82e556d..22fbe3d 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
@@ -15,10 +15,12 @@
* that refuses: `platform.js` decides what to show from whether an object
* exists (`platform.node.available`, `platform.folder.available`, …).
*
- * `HUB_BASE`, `BINARY` and `CAST` are prepended by the shell when it injects
- * this file: the interface asks for the hub while its modules load, before
- * anything can await; BINARY says whether the WebView carries ArrayBuffer
- * messages; CAST whether this device can cast at all.
+ * `HUB_BASE`, `BINARY`, `CAST`, `BACKUP` and `MESSAGES` are prepended by the
+ * shell when it injects this file: the interface asks for the hub while its
+ * modules load, before anything can await; BINARY says whether the WebView carries ArrayBuffer
+ * messages; CAST whether this device can cast at all; BACKUP whether this is
+ * the personal profile, the only one backed up; MESSAGES whether this build
+ * backs text messages up (not the Play build).
*/
(function () {
'use strict';
@@ -197,6 +199,104 @@
call('push:remember', subscription, account, secret, since),
},
+ // Backups (§9.12, §9.13), of the personal profile only: a copy of the
+ // application inside a work profile has none of these (Profiles.kt).
+ ...(BACKUP ? {
+ // Photo backup (§9.12): the phone lists its photos, keeps what was sent,
+ // and hands each photo's bytes over at /photosync/<token> on this origin.
+ // The page decides when and does the sending. Phone-only, like `push`.
+ photoSync: {
+ status: () => call('photosync:status'),
+ permit: (withVideos) => call('photosync:permit', withVideos === true),
+ albums: (withVideos) => call('photosync:albums', withVideos === true),
+ configure: (settings) => call('photosync:configure', settings || null),
+ estimate: (settings) => call('photosync:estimate', settings),
+ plan: () => call('photosync:plan'),
+ sent: (token, dir, name) => call('photosync:sent', token, dir, name),
+ manifest: () => call('photosync:manifest'),
+ manifestSent: (token) => call('photosync:manifest-sent', token),
+ completed: () => call('photosync:completed'),
+ failed: (code, text) => call('photosync:failed', code, text),
+ keepAlive: (on, text) => call('photosync:keep-alive', on === true, text || ''),
+ },
+
+ // Where every backup goes (§9.12): one folder of one group the account
+ // owns and is alone in. Each kind goes into `<account>-<kind>` under it.
+ phoneSync: {
+ destination: () => call('phonesync:destination'),
+ setDestination: (d) => call('phonesync:set-destination', d || null),
+ },
+
+ // Contacts backup (§9.13): the phone writes the address book into one
+ // file, served at /phonesync/<token> on this origin, when it changed since
+ // the last one sent. The page decides when and does the sending.
+ contactSync: {
+ status: () => call('contactsync:status'),
+ permit: () => call('contactsync:permit'),
+ configure: (settings) => call('contactsync:configure', settings || null),
+ plan: () => call('contactsync:plan'),
+ sent: (token, dir, name) => call('contactsync:sent', token, dir, name),
+ 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),
+ },
+
+ // Files backup (§9.13): folders the person chose through the system's
+ // picker, sent like photos, a range at a time from /drivesync/<token>.
+ driveSync: {
+ status: () => call('drivesync:status'),
+ addFolder: () => call('drivesync:add-folder'),
+ removeFolder: (id) => call('drivesync:remove-folder', id),
+ configure: (settings) => call('drivesync:configure', settings || null),
+ plan: () => call('drivesync:plan'),
+ sent: (token, dir, name) => call('drivesync:sent', token, dir, name),
+ manifest: () => call('drivesync:manifest'),
+ manifestSent: (token) => call('drivesync:manifest-sent', token),
+ completed: () => call('drivesync:completed'),
+ failed: (code, text) => call('drivesync:failed', code, text),
+ keepAlive: (on, text) => call('drivesync:keep-alive', on === true, text || ''),
+ },
+
+ // WhatsApp's own folder (§9.13), the same way: its encrypted chat
+ // backups for a restore, and its media if the person ticks them.
+ whatsappSync: {
+ status: () => call('wasync:status'),
+ chooseFolder: () => call('wasync:add-folder'),
+ forgetFolder: (id) => call('wasync:remove-folder', id),
+ configure: (settings) => call('wasync:configure', settings || null),
+ plan: () => call('wasync:plan'),
+ sent: (token, dir, name) => call('wasync:sent', token, dir, name),
+ manifest: () => call('wasync:manifest'),
+ manifestSent: (token) => call('wasync:manifest-sent', token),
+ completed: () => call('wasync:completed'),
+ failed: (code, text) => call('wasync:failed', code, text),
+ keepAlive: (on, text) => call('wasync:keep-alive', on === true, text || ''),
+ },
+ } : {}),
+
+ // Messages backup (§9.13), the same way as contacts: the messages added
+ // since the last file the node took. Only in a build that may read them.
+ ...(MESSAGES ? { messageSync: {
+ status: () => call('messagesync:status'),
+ permit: () => call('messagesync:permit'),
+ configure: (settings) => call('messagesync:configure', settings || null),
+ plan: () => call('messagesync:plan'),
+ sent: (token, dir, name) => call('messagesync:sent', token, dir, name),
+ completed: () => call('messagesync:completed'),
+ failed: (code, text) => call('messagesync:failed', code, text),
+ } } : {}),
+
// Where downloads go, chosen once. A display name comes back, never a URI.
folder: {
choose: () => call('folder:choose'),
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 dad8462..5cf06fc 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
@@ -35,6 +35,16 @@ import org.meshbay.client.keys.SecretStore
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.drive.DriveChannels
+import org.meshbay.client.drive.DriveKind
+import org.meshbay.client.phonesync.CalendarSource
+import org.meshbay.client.phonesync.ContactSource
+import org.meshbay.client.phonesync.DestinationChannels
+import org.meshbay.client.phonesync.DocChannels
+import org.meshbay.client.phonesync.Flavor
+import org.meshbay.client.phonesync.Profiles
+import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.SaveSinks
import org.meshbay.client.shell.Pickers
import org.meshbay.client.shell.ShellWebView
@@ -56,6 +66,12 @@ class MainActivity : Activity() {
private lateinit var cast: CastChannels
private lateinit var hub: HubClient
private lateinit var channels: Channels
+ private lateinit var photos: PhotoChannels
+ private var docs: List<DocChannels> = emptyList()
+ private var drives: List<DriveChannels> = emptyList()
+ /** Backups exist in the personal profile only (Profiles.kt). */
+ private var backups = false
+ private var network: android.net.ConnectivityManager.NetworkCallback? = null
private val pickers = Pickers(this)
private val text = NativeText { code ->
try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null }
@@ -63,6 +79,9 @@ class MainActivity : Activity() {
private var shim: ScriptHandler? = null
private var casting = false
private var playing = false
+ /** Which backups hold the keep-alive now ("photos", "drive"): one stopping leaves the other's. */
+ private val syncers = HashSet<String>()
+ private val syncing get() = syncers.isNotEmpty()
private var fullscreen: View? = null
private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null
private var pendingLink: String? = null
@@ -91,19 +110,45 @@ class MainActivity : Activity() {
Thread { saves.cleanUpAfterAKilledProcess() }.start()
cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting = on; keepAlive() } },
tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } })
+ backups = Profiles.isPersonal(this)
+ val destinations = DestinationChannels(getSharedPreferences(DestinationChannels.PREFS, Context.MODE_PRIVATE))
+ photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE), destinations,
+ java.io.File(filesDir, "photosync"),
+ onKeepAlive = { on, line -> runOnUiThread { backup("photos", on, line) } })
+ drives = if (!backups) emptyList() else listOf(DriveKind.FILES, DriveKind.WHATSAPP).map { kind ->
+ DriveChannels(kind, this, getSharedPreferences(kind.prefix, Context.MODE_PRIVATE),
+ java.io.File(filesDir, kind.prefix), destinations, pickers,
+ photosBackedUp = { photos.backedUpPaths() }, unmetered = { photos.unmetered() },
+ onKeepAlive = { on, line -> runOnUiThread { backup(kind.suffix, on, line) } })
+ }
+ docs = if (!backups) emptyList() else listOf(
+ 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,
+ notifyId = 11, permissionRequest = 4210)
+ })
channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves,
cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } },
push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)),
channelNames = { mapOf(
Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale),
- Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }))
+ Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }),
+ photos = photos.takeIf { backups }, destinations = destinations.takeIf { backups },
+ docs = docs, drives = drives)
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
cast.control.warmUp()
installShim()
// Opened from a notification: to what it was about, once the page is up.
pendingLink = Notifier.linkOf(intent)
web.loadUrl(UiAssets.START)
+ watchNetwork()
}
override fun onNewIntent(intent: Intent) {
@@ -141,6 +186,19 @@ class MainActivity : Activity() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url
+ if (url.host == UiAssets.HOST && url.path?.startsWith(PhotoChannels.PATH) == true) {
+ val range = request.requestHeaders.entries.firstOrNull { it.key.equals("Range", true) }?.value
+ return photos.takeIf { backups }?.serve(url.path ?: "", range) ?: refused()
+ }
+ val driveKind = drives.firstOrNull { url.path?.startsWith(it.path) == true }
+ if (url.host == UiAssets.HOST && driveKind != null) {
+ val range = request.requestHeaders.entries.firstOrNull { it.key.equals("Range", true) }?.value
+ return driveKind.serve(url.path ?: "", range) ?: refused()
+ }
+ if (url.host == UiAssets.HOST && url.path?.startsWith(DocChannels.PATH) == true) {
+ val path = url.path ?: ""
+ return docs.firstNotNullOfOrNull { it.serve(path) } ?: refused()
+ }
if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused()
// reCAPTCHA (sign-up) and nothing else goes to the network from
// the page; the policy says the same, this is the second wall.
@@ -220,7 +278,9 @@ class MainActivity : Activity() {
val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() }
val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)
val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n" +
- "const CAST = ${cast.control.available()};\n"
+ "const CAST = ${cast.control.available()};\n" +
+ "const BACKUP = $backups;\n" +
+ "const MESSAGES = ${docs.any { it.handles("messagesync:") }};\n"
shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN))
}
@@ -258,11 +318,12 @@ class MainActivity : Activity() {
/**
* A cast, or music playing here, keeps the process, the Wi-Fi and the page
* alive with the screen off (spike S-2a, scenario F): the foreground service
- * holds the first two, the WebView reported visible holds the third.
+ * holds the first two, the WebView reported visible holds the third. A photo
+ * backup holds the page here too; its service is its own (`backup`).
*/
private fun keepAlive() {
val on = casting || playing
- web.keepVisible = on
+ web.keepVisible = on || syncing
val service = Intent(this, CastService::class.java)
if (!on) { stopService(service); return }
service.putExtra(CastService.EXTRA_TEXT,
@@ -272,6 +333,56 @@ class MainActivity : Activity() {
try { startForegroundService(service) } catch (e: IllegalStateException) { Log.w(Bridge.TAG, "keep-alive refused: $e") }
}
+ /**
+ * A photo or files backup running: its own foreground service, and the
+ * page kept visible like a cast. Started once; afterwards only its line
+ * changes, which needs no start (refused from the background on Android
+ * 12+). Held while any backup holds it.
+ */
+ private fun backup(who: String, on: Boolean, line: String) {
+ val service = Intent(this, BackupService::class.java)
+ val was = syncing
+ if (on) syncers += who else syncers -= who
+ if (on && was) { BackupService.update(this, line); return }
+ if (syncing == was) return
+ if (on) {
+ try { startForegroundService(service.putExtra(BackupService.EXTRA_TEXT, line)) }
+ catch (e: IllegalStateException) { Log.w(Bridge.TAG, "backup keep-alive refused: $e") }
+ } else stopService(service)
+ keepAlive()
+ }
+
+ /**
+ * Tells the page when the network becomes unmetered or stops being: a
+ * backup waiting for Wi-Fi starts, one running on it stops. An event on the
+ * window, carrying the one boolean and nothing about the network.
+ */
+ private fun watchNetwork() {
+ val cm = getSystemService(android.net.ConnectivityManager::class.java)
+ var last: Boolean? = null
+ val callback = object : android.net.ConnectivityManager.NetworkCallback() {
+ override fun onCapabilitiesChanged(n: android.net.Network, caps: android.net.NetworkCapabilities) = tell()
+ override fun onLost(n: android.net.Network) = tell()
+ private fun tell() {
+ val now = photos.unmetered()
+ if (now == last) return
+ last = now
+ runOnUiThread {
+ if (::web.isInitialized) web.evaluateJavascript(
+ "window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: $now } }));", null)
+ }
+ }
+ }
+ try { cm.registerDefaultNetworkCallback(callback); network = callback }
+ catch (e: Exception) { Log.w(Bridge.TAG, "no network callback: $e") }
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
+ if (::photos.isInitialized && photos.deliverPermission(requestCode)) return
+ if (docs.any { it.deliverPermission(requestCode) }) return
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ }
+
private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false }
private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream())
@@ -335,6 +446,8 @@ class MainActivity : Activity() {
override fun onDestroy() {
if (::cast.isInitialized && cast.relay.active) cast.relay.stop()
if (casting || playing) { casting = false; playing = false; keepAlive() }
+ if (syncing) { syncers.clear(); stopService(Intent(this, BackupService::class.java)); keepAlive() }
+ network?.let { try { getSystemService(android.net.ConnectivityManager::class.java).unregisterNetworkCallback(it) } catch (e: Exception) {} }
if (::web.isInitialized) { root.removeView(web); web.destroy() }
super.onDestroy()
}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
index 5f202ba..e3a81df 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
@@ -4,6 +4,10 @@ import org.json.JSONArray
import org.meshbay.client.cast.CastChannels
import org.meshbay.client.hub.HubClient
import org.meshbay.client.notify.PushChannels
+import org.meshbay.client.drive.DriveChannels
+import org.meshbay.client.phonesync.DestinationChannels
+import org.meshbay.client.phonesync.DocChannels
+import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.BinaryFrame
import org.meshbay.client.save.SaveSinks
import java.net.Inet4Address
@@ -28,6 +32,10 @@ class Channels(
private val cast: CastChannels? = null,
private val onPlayback: (Boolean) -> Unit = {},
private val push: PushChannels? = null,
+ private val photos: PhotoChannels? = null,
+ private val destinations: DestinationChannels? = null,
+ private val docs: List<DocChannels> = emptyList(),
+ private val drives: List<DriveChannels> = emptyList(),
) {
@Volatile var locale = "en"
private set
@@ -56,7 +64,11 @@ class Channels(
keys != null && keys.handles(channel) -> keys.call(channel, args)
cast != null && cast.handles(channel) -> cast.call(channel, args)
push != null && push.handles(channel) -> push.call(channel, args)
- else -> throw Refused("Refused: no such channel")
+ photos != null && photos.handles(channel) -> photos.call(channel, args)
+ destinations != null && destinations.handles(channel) -> destinations.call(channel, args)
+ drives.any { it.handles(channel) } -> drives.first { it.handles(channel) }.call(channel, args)
+ else -> docs.firstOrNull { it.handles(channel) }?.call(channel, args)
+ ?: throw Refused("Refused: no such channel")
}
}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt
new file mode 100644
index 0000000..c6fb476
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt
@@ -0,0 +1,297 @@
+package org.meshbay.client.drive
+
+import android.app.Activity
+import android.content.Intent
+import android.content.SharedPreferences
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.webkit.WebResourceResponse
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.phonesync.Destination
+import org.meshbay.client.phonesync.DestinationChannels
+import org.meshbay.client.phonesync.ManifestLog
+import org.meshbay.client.phonesync.DocPlan
+import org.meshbay.client.photos.PhotoChannels
+import org.meshbay.client.shell.Pickers
+import java.io.File
+import java.security.SecureRandom
+import java.util.TimeZone
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Files backup (docs/MESHBAY_DESIGN.md §9.13): the folders the person chose,
+ * sent into `<folder>/<account>-drive/<chosen folder>/…` of the destination
+ * every kind shares, on the photo backup's terms: the page decides when and
+ * sends, this side lists, keeps the ledger and hands bytes over by token
+ * (`/drivesync/<token>`, a range at a time).
+ *
+ * Media folders cannot be chosen, and files the photo backup sends are left
+ * out (DrivePlan): nothing is stored twice.
+ */
+class DriveChannels(
+ private val kind: DriveKind,
+ private val activity: Activity,
+ private val prefs: SharedPreferences,
+ private val dir: File,
+ private val destinations: DestinationChannels,
+ private val pickers: Pickers,
+ private val photosBackedUp: () -> Set<String>,
+ private val unmetered: () -> Boolean,
+ private val onKeepAlive: (Boolean, String) -> Unit,
+) {
+ private val source = DriveSource(activity)
+ private val random = SecureRandom()
+ private val tokens = ConcurrentHashMap<String, Issued>()
+
+ private class Issued(val pending: DrivePending, val key: String)
+
+ init {
+ destinations.onChange {
+ tokens.clear()
+ prefs.edit().remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ }
+ }
+
+ /** `/drivesync/`, `/wasync/`: where this kind's files are served on the packaged origin. */
+ val path: String get() = kind.path
+
+ fun handles(channel: String) = channel.startsWith("${kind.prefix}:")
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("${kind.prefix}:")) {
+ "status" -> status()
+ "add-folder" -> { addFolder(); status() }
+ "remove-folder" -> { removeFolder(args.optString(0, "")); status() }
+ "configure" -> { configure(args.optJSONObject(0)); status() }
+ "plan" -> plan()
+ "sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true }
+ "manifest" -> manifest()
+ "manifest-sent" -> { manifestSent(args.optString(0, "")); true }
+ "completed" -> { completed(); status() }
+ "failed" -> failed(args.optString(0, ""), args.optString(1, ""))
+ "keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true }
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ // ── state ────────────────────────────────────────────────────────────────
+
+ private data class Folder(val uri: String, val name: String)
+
+ private fun folders(): List<Folder> = try {
+ val a = JSONArray(prefs.getString(FOLDERS, "[]"))
+ (0 until a.length()).map { a.getJSONObject(it).let { o -> Folder(o.getString("uri"), o.getString("name")) } }
+ } catch (e: Exception) { emptyList() }
+
+ private fun saveFolders(list: List<Folder>) {
+ prefs.edit().putString(FOLDERS, JSONArray(list.map { JSONObject().put("uri", it.uri).put("name", it.name) })
+ .toString()).apply()
+ }
+
+ /** Two chosen folders of one name go into two folders on the node: `Notes`, `Notes-2`. */
+ private fun topNames(list: List<Folder>): Map<String, String> {
+ if (!kind.keepTop) return list.associate { it.uri to "" }
+ val used = HashMap<String, Int>()
+ return list.associate { f ->
+ val n = (used[f.name.lowercase()] ?: 0) + 1
+ used[f.name.lowercase()] = n
+ f.uri to (if (n == 1) f.name else "${f.name}-$n")
+ }
+ }
+
+ private fun base(d: Destination) = DocPlan.dirFor(d, kind.suffix)
+
+ private fun options(): JSONObject = try { JSONObject(prefs.getString(OPTIONS, "{}")) } catch (e: Exception) { JSONObject() }
+
+ private fun keyOf(d: Destination) = PhotoChannels.hex(PhotoChannels.sha256Of(d.ledgerKey.toByteArray())).take(32)
+
+ private fun ledger(d: Destination) = DriveLedger(File(dir, keyOf(d) + ".jsonl"))
+
+ private fun manifestLog(d: Destination) = ManifestLog(File(dir, keyOf(d) + ".manifest"))
+
+ @Volatile private var manifestToken: Pair<String, File>? = null
+
+ fun status(): JSONObject {
+ val d = destinations.get()
+ return JSONObject()
+ .put("on", prefs.getBoolean(ON, false))
+ .put("options", options())
+ .put("unmetered", unmetered())
+ .put("destination", d?.toJson() ?: JSONObject.NULL)
+ .put("dir", d?.let { base(it) } ?: JSONObject.NULL)
+ .put("folders", JSONArray(folders().map { JSONObject().put("id", it.uri).put("name", it.name) }))
+ .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL)
+ .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL)
+ .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL)
+ .put("sent", if (d != null) ledger(d).size else 0)
+ .put("now", System.currentTimeMillis())
+ }
+
+ /** The system's folder picker; a media folder or a whole volume is refused, with the reason. */
+ private fun addFolder() {
+ val ask = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
+ kind.initialDocId?.let {
+ ask.putExtra(DocumentsContract.EXTRA_INITIAL_URI,
+ DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", it))
+ }
+ val result = pickers.run(ask) ?: return
+ val tree = result.data ?: return
+ kind.refusal(DocumentsContract.getTreeDocumentId(tree))?.let { throw Refused("Refused: $it") }
+ activity.contentResolver.takePersistableUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ var list = folders()
+ if (list.any { it.uri == tree.toString() }) return
+ // One folder only: the one chosen now replaces the last.
+ if (kind.single) { list.forEach { removeFolder(it.uri) }; list = emptyList() }
+ val fallback = DrivePlan.pathOf(DocumentsContract.getTreeDocumentId(tree)).substringAfterLast('/')
+ saveFolders(list + Folder(tree.toString(), source.nameOf(tree) ?: fallback.ifEmpty { "folder" }))
+ }
+
+ /** Forgotten here, and its grant given back; what was sent from it stays on the node. */
+ private fun removeFolder(uri: String) {
+ val list = folders()
+ if (list.none { it.uri == uri }) return
+ saveFolders(list.filter { it.uri != uri })
+ try {
+ activity.contentResolver.releasePersistableUriPermission(Uri.parse(uri), Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ } catch (e: Exception) { /* already gone */ }
+ }
+
+ /** `{…options}` turns it on and keeps them (`media` for WhatsApp); null turns it off. */
+ private fun configure(o: JSONObject?) {
+ tokens.clear()
+ if (o == null) {
+ prefs.edit().remove(ON).remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ return
+ }
+ if (destinations.get() == null) throw Refused("Refused: no destination")
+ val options = JSONObject().put("media", o.optBoolean("media", false))
+ prefs.edit().putBoolean(ON, true).putString(OPTIONS, options.toString()).apply()
+ }
+
+ private fun completed() {
+ prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ }
+
+ private fun failed(code: String, text: String): Boolean {
+ val c = code.take(64)
+ prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply()
+ if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false
+ prefs.edit().putString(NOTIFIED, c).apply()
+ PhotoChannels.notice(activity, kind.notifyId, text.take(300))
+ return true
+ }
+
+ // ── the files ────────────────────────────────────────────────────────────
+
+ private fun plan(): JSONObject {
+ val d = destinations.get()?.takeIf { prefs.getBoolean(ON, false) } ?: throw Refused("Refused: files backup is off")
+ val list = folders()
+ val ledger = ledger(d)
+ // A folder whose grant is gone (removed on the phone, revoked) is
+ // skipped this run rather than failing the others.
+ val options = options()
+ val files = list.flatMap { f -> try { source.files(Uri.parse(f.uri)) } catch (e: Exception) { emptyList() } }
+ .filter { kind.include(it, options) }
+ restoreSet = kind.restoreSet?.let { inSet -> files.filter(inSet) }
+ val items = DrivePlan.plan(files, base(d), topNames(list), ledger::get, photosBackedUp(),
+ TimeZone.getDefault()) { f, sent -> hashOf(f)?.let { it == sent.sha256 } ?: true }
+ tokens.clear()
+ val out = JSONArray()
+ for (p in items) {
+ val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) })
+ tokens[token] = Issued(p, d.ledgerKey)
+ out.put(JSONObject().put("token", token).put("name", p.name).put("dir", p.dir)
+ .put("size", p.file.size).put("edited", p.edited))
+ }
+ return JSONObject().put("items", out).put("manifest", manifestLog(d).waiting())
+ }
+
+ /** The node took it (or already had it): into the ledger, hashed from the phone now. */
+ private fun sent(token: String, dir: String, name: String) {
+ val issued = tokens[token] ?: throw Refused("Refused: unknown file")
+ val d = destinations.get()?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed")
+ val f = issued.pending.file
+ val sha = hashOf(f) ?: throw Refused("Refused: the file is gone")
+ val now = System.currentTimeMillis()
+ ledger(d).record(DriveLedger.Entry(f.docId, f.modified, f.size, sha, dir.take(1024), name.take(256), now))
+ manifestLog(d).append(JSONObject()
+ .put("kind", "file")
+ .put("node", "${dir.take(1024)}/${name.take(256)}")
+ .put("source", DrivePlan.pathOf(f.docId)).put("name", f.name)
+ .put("folder", folders().firstOrNull { it.uri == f.tree }?.name ?: "")
+ .put("modified", f.modified).put("size", f.size).put("sha256", sha).put("mime", f.mime)
+ .put("edited", issued.pending.edited).put("sentAt", now))
+ if (kind.restoreSet?.invoke(f) == true) prefs.edit().putBoolean(SET_CHANGED, true).apply()
+ tokens.remove(token)
+ }
+
+ /** The files a restore needs together, as the last plan found them. */
+ @Volatile private var restoreSet: List<DriveFile>? = null
+
+ /**
+ * Once something in the restore set was sent: one manifest line naming, for
+ * each file of the set as it is now, the copy on the node that a restore
+ * takes. Without it, a restore could pair this week's full copy with last
+ * week's increments, which WhatsApp would not open.
+ */
+ private fun noteRestoreSet(d: Destination) {
+ val set = restoreSet ?: return
+ if (!prefs.getBoolean(SET_CHANGED, false)) return
+ val ledger = ledger(d)
+ val files = JSONArray()
+ for (f in set) {
+ val e = ledger[f.docId] ?: return // not all on the node yet: the next run says it
+ if (e.size != f.size || e.modified != f.modified) return
+ files.put(JSONObject().put("source", DrivePlan.pathOf(f.docId)).put("node", "${e.dir}/${e.name}")
+ .put("size", e.size).put("sha256", e.sha256))
+ }
+ manifestLog(d).append(JSONObject().put("kind", "restore-set").put("app", kind.suffix)
+ .put("at", System.currentTimeMillis()).put("files", files))
+ prefs.edit().remove(SET_CHANGED).apply()
+ }
+
+ /** The manifest of what was sent and not yet described on the node (ManifestLog), or `item: null`. */
+ private fun manifest(): JSONObject {
+ val d = destinations.get()?.takeIf { prefs.getBoolean(ON, false) } ?: throw Refused("Refused: files backup is off")
+ noteRestoreSet(d)
+ val file = manifestLog(d).issue() ?: return JSONObject().put("item", JSONObject.NULL)
+ val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) })
+ manifestToken = token to file
+ return JSONObject().put("item", JSONObject().put("token", token)
+ .put("name", ManifestLog.nameFor(System.currentTimeMillis(), TimeZone.getDefault()))
+ .put("dir", base(d) + "/" + ManifestLog.DIR).put("size", file.length()))
+ }
+
+ private fun manifestSent(token: String) {
+ if (manifestToken?.first != token) throw Refused("Refused: unknown manifest")
+ val d = destinations.get() ?: throw Refused("Refused: the backup changed")
+ manifestLog(d).confirm()
+ manifestToken = null
+ }
+
+ /** A range of an issued file, for `/drivesync/<token>` on the packaged origin. */
+ fun serve(path: String, range: String?): WebResourceResponse? {
+ manifestToken?.takeIf { it.first == path.removePrefix(kind.path) }?.let { (_, file) ->
+ return PhotoChannels.serveRange(file.inputStream(), file.length(), "application/x-ndjson", range)
+ }
+ val issued = tokens[path.removePrefix(kind.path)] ?: return null
+ val f = issued.pending.file
+ val raw = try { source.open(f) } catch (e: Exception) { null } ?: return null
+ return PhotoChannels.serveRange(raw, f.size, f.mime.ifEmpty { "application/octet-stream" }, range)
+ }
+
+ private fun hashOf(f: DriveFile): String? = try {
+ source.open(f)?.use { PhotoChannels.hex(PhotoChannels.digestOf(it)) }
+ } catch (e: Exception) { null }
+
+ companion object {
+ private const val OPTIONS = "options"
+ private const val SET_CHANGED = "restore_set_changed"
+ private const val FOLDERS = "folders"
+ private const val ON = "on"
+ private const val LAST = "last_completed"
+ private const val FAILURE = "failure"
+ private const val FAILURE_AT = "failure_at"
+ private const val NOTIFIED = "notified"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt
new file mode 100644
index 0000000..69a658a
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt
@@ -0,0 +1,79 @@
+package org.meshbay.client.drive
+
+import org.json.JSONObject
+import java.util.Locale
+
+/**
+ * A backup of folders chosen through the system's picker: the files backup,
+ * or WhatsApp's own folder (docs/MESHBAY_DESIGN.md §9.13). One class runs both
+ * (DriveChannels); this is what differs.
+ */
+data class DriveKind(
+ /** Names the channels (`drivesync:status`) and the preferences. */
+ val prefix: String,
+ /** The folder under the destination: `<account>-<suffix>`. */
+ val suffix: String,
+ val notifyId: Int,
+ /** Why a chosen folder is refused, from its tree document id, or null. */
+ val refusal: (String) -> String?,
+ /** One folder only: choosing again replaces it. */
+ val single: Boolean,
+ /** The chosen folder's own name as the first level on the node. */
+ val keepTop: Boolean,
+ /** Whether a file found there is sent, given the options the person set. */
+ val include: (DriveFile, JSONObject) -> Boolean,
+ /** The files a restore needs together, listed in the manifest as one set; null when none. */
+ val restoreSet: ((DriveFile) -> Boolean)?,
+ /** Where the picker opens. */
+ val initialDocId: String?,
+) {
+ val path: String get() = "/$prefix/"
+
+ companion object {
+ val FILES = DriveKind(
+ prefix = "drivesync", suffix = "drive", notifyId = 13,
+ refusal = DrivePlan::refusal, single = false, keepTop = true,
+ include = { _, _ -> true }, restoreSet = null, initialDocId = null,
+ )
+
+ val WHATSAPP = DriveKind(
+ prefix = "wasync", suffix = "whatsapp", notifyId = 14,
+ refusal = WhatsApp::refusal, single = true, keepTop = false,
+ include = { f, options -> WhatsApp.include(f, options.optBoolean("media", false)) },
+ restoreSet = WhatsApp::inRestoreSet,
+ initialDocId = "primary:" + WhatsApp.FOLDER,
+ )
+ }
+}
+
+/**
+ * WhatsApp's folder, `Android/media/com.whatsapp/WhatsApp` (or the business
+ * application's): what a restore onto a new phone needs, and the media.
+ *
+ * `Databases/` holds the chats, encrypted by WhatsApp with a key only it (or
+ * the person's end-to-end backup password) opens: a full copy written weekly,
+ * `msgstore.db.crypt14`, and an increment a night,
+ * `msgstore-increment-N.db.crypt14`. When a new week starts WhatsApp renames
+ * the last week's set with its date; those dated copies are what this backup
+ * already kept as earlier versions, so they are not sent again. `Backups/`
+ * holds the rest of a restore (contacts, settings, stickers).
+ */
+object WhatsApp {
+ const val FOLDER = "Android/media/com.whatsapp/WhatsApp"
+ private val FOLDERS = setOf(FOLDER, "Android/media/com.whatsapp.w4b/WhatsApp Business")
+ private val DATED = Regex("^msgstore(-increment-\\d+)?-\\d{4}-\\d{2}-\\d{2}\\..*", RegexOption.IGNORE_CASE)
+
+ fun refusal(treeDocId: String): String? =
+ if (DrivePlan.pathOf(treeDocId) in FOLDERS) null else "not_whatsapp"
+
+ private fun top(f: DriveFile) = f.sub.substringBefore('/').lowercase(Locale.ROOT)
+
+ fun include(f: DriveFile, media: Boolean): Boolean = when (top(f)) {
+ "databases" -> !DATED.matches(f.name)
+ "backups" -> true
+ "media" -> media
+ else -> false
+ }
+
+ fun inRestoreSet(f: DriveFile): Boolean = include(f, false)
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveLedger.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveLedger.kt
new file mode 100644
index 0000000..044edc7
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveLedger.kt
@@ -0,0 +1,69 @@
+package org.meshbay.client.drive
+
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * What this phone has sent of its chosen folders, by document id: the memory
+ * of what was sent, never a mirror (PhotoLedger, the same idea for photos). A
+ * file deleted on the phone stays on the node; one deleted on the node is not
+ * sent again. One line of JSON per send, the last line for an id winning,
+ * compacted on load once dead lines outnumber live ones.
+ */
+class DriveLedger(private val file: File) {
+
+ data class Entry(
+ val docId: String,
+ val modified: Long,
+ val size: Long,
+ /** SHA-256 of the bytes sent, hex: a touched file is sent again only if this changed. */
+ val sha256: String,
+ val dir: String,
+ val name: String,
+ val sentAt: Long,
+ )
+
+ private val entries = HashMap<String, Entry>()
+ private var lines = 0
+
+ init { load() }
+
+ val size: Int get() = entries.size
+
+ operator fun get(docId: String): Entry? = entries[docId]
+
+ fun record(entry: Entry) {
+ entries[entry.docId] = entry
+ file.parentFile?.mkdirs()
+ file.appendText(encode(entry) + "\n")
+ lines += 1
+ }
+
+ private fun load() {
+ if (!file.exists()) return
+ file.forEachLine { line ->
+ if (line.isBlank()) return@forEachLine
+ lines += 1
+ decode(line)?.let { entries[it.docId] = it }
+ }
+ if (lines > 2 * entries.size + COMPACT_SLACK) {
+ val tmp = File(file.path + ".tmp")
+ tmp.writeText(entries.values.joinToString("") { encode(it) + "\n" })
+ if (tmp.renameTo(file)) lines = entries.size else tmp.delete()
+ }
+ }
+
+ companion object {
+ private const val COMPACT_SLACK = 64
+
+ fun encode(e: Entry): String = JSONObject()
+ .put("id", e.docId).put("m", e.modified).put("s", e.size).put("h", e.sha256)
+ .put("d", e.dir).put("n", e.name).put("t", e.sentAt).toString()
+
+ fun decode(line: String): Entry? = try {
+ val o = JSONObject(line)
+ Entry(o.getString("id"), o.getLong("m"), o.getLong("s"), o.getString("h"),
+ o.getString("d"), o.getString("n"), o.getLong("t"))
+ } catch (e: Exception) { null }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt
new file mode 100644
index 0000000..eb373ba
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt
@@ -0,0 +1,112 @@
+package org.meshbay.client.drive
+
+import org.meshbay.client.photos.PhotoPlan
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/** One file found in a chosen folder, through the Storage Access Framework. */
+data class DriveFile(
+ /** The folder it was found under, as granted (a tree URI). */
+ val tree: String,
+ /** Its document id, stable for the life of the file (`primary:Documents/a.pdf`). */
+ val docId: String,
+ /** Its folder inside the chosen one, `/`-separated, empty at the top. */
+ val sub: String,
+ val name: String,
+ val size: Long,
+ /** Milliseconds. */
+ val modified: Long,
+ val mime: String,
+)
+
+/** A file to send: a new one, or a new version of one already sent. */
+data class DrivePending(val file: DriveFile, val edited: Boolean, val dir: String, val name: String)
+
+/**
+ * The rules of the files backup (docs/MESHBAY_DESIGN.md §9.13), pure, so the
+ * JVM tests hold them: which folders may be chosen, what a run sends, and the
+ * names the node will accept.
+ */
+object DrivePlan {
+ /**
+ * The phone's media folders: what lives there is the photo backup's, and
+ * taking it here too would store every photo twice on the node.
+ */
+ private val MEDIA = setOf("dcim", "pictures", "movies")
+
+ /** `primary:Documents/Scans` → `Documents/Scans`; the part after the volume. */
+ fun pathOf(docId: String): String = docId.substringAfter(':', "").trim('/')
+
+ /**
+ * Why a folder cannot be chosen, or null when it can: the whole of a
+ * volume (it holds the media folders), or a media folder or anything in one.
+ */
+ fun refusal(treeDocId: String): String? {
+ val path = pathOf(treeDocId)
+ if (path.isEmpty()) return "whole_storage"
+ if (path.split('/').first().lowercase(Locale.ROOT) in MEDIA) return "media_folder"
+ return null
+ }
+
+ /**
+ * A run's files: new ones, and new versions of ones sent (beside them,
+ * never in their place). `skip` holds the files the photo backup already
+ * sends, as lowercase `relative/path/name`. Smallest first: a run cut short
+ * has saved the most files.
+ */
+ fun plan(
+ files: List<DriveFile>, base: String, folderNames: Map<String, String>,
+ sent: (String) -> DriveLedger.Entry?, skip: Set<String>, zone: TimeZone,
+ sameBytes: (DriveFile, DriveLedger.Entry) -> Boolean,
+ ): List<DrivePending> {
+ val out = ArrayList<DrivePending>()
+ for (f in files) {
+ if (f.name.startsWith('.')) continue
+ if (skip.contains(pathOf(f.docId).lowercase(Locale.ROOT))) continue
+ val top = folderNames[f.tree] ?: continue
+ val dir = dirFor(base, top, f.sub)
+ val was = sent(f.docId)
+ if (was == null) {
+ out += DrivePending(f, false, dir, safeName(f.name))
+ } else if (was.modified != f.modified || was.size != f.size) {
+ if (was.size == f.size && sameBytes(f, was)) continue
+ out += DrivePending(f, true, dir, editedName(f, zone))
+ }
+ }
+ return out.sortedBy { it.file.size }
+ }
+
+ /** `<base>/<chosen folder>/<sub>`, each level a name the node accepts; no chosen-folder level when `top` is empty. */
+ fun dirFor(base: String, top: String, sub: String): String =
+ (listOf(base) + listOf(top).filter { it.isNotEmpty() }.map(::safeName) +
+ sub.split('/').filter { it.isNotEmpty() }.map(::safeName)).joinToString("/")
+
+ /**
+ * The name as is when the node accepts it (PhotoPlan.UPLOAD_NAME, the
+ * node's own rule); otherwise its refused characters become `_`, and a
+ * name that cannot start as the node wants is given a `file-` prefix.
+ */
+ fun safeName(name: String): String {
+ if (PhotoPlan.UPLOAD_NAME.matches(name)) return name
+ var s = name.map { c -> if (c.isLetterOrDigit() || c in " _.-()[]'’,&+#@") c else '_' }.joinToString("")
+ .trimEnd(' ', '.')
+ if (s.isEmpty() || !s.first().isLetterOrDigit()) s = "file-$s"
+ s = s.take(MAX_NAME).trimEnd(' ', '.')
+ return if (PhotoPlan.UPLOAD_NAME.matches(s)) s else "file"
+ }
+
+ /** A new version lands beside the one sent, its date in its name. */
+ fun editedName(f: DriveFile, zone: TimeZone): String {
+ val base = safeName(f.name)
+ val dot = base.lastIndexOf('.')
+ val stem = if (dot > 0) base.substring(0, dot) else base
+ val ext = if (dot > 0) base.substring(dot) else ""
+ val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone }.format(Date(f.modified))
+ val suffix = "-modified-$stamp$ext"
+ return stem.take(MAX_NAME - suffix.length) + suffix
+ }
+
+ private const val MAX_NAME = 128
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveSource.kt
new file mode 100644
index 0000000..69077fa
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveSource.kt
@@ -0,0 +1,55 @@
+package org.meshbay.client.drive
+
+import android.content.Context
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Document
+import java.io.InputStream
+
+/**
+ * The files of the folders the person chose, read through the Storage Access
+ * Framework: each folder was granted through the system's own picker, which
+ * is all the access this needs (no storage permission, which Play keeps for a
+ * few kinds of application).
+ */
+class DriveSource(private val context: Context) {
+
+ /** Every file under `tree`, its sub-folders included; hidden ones (`.x`) and their contents skipped. */
+ fun files(tree: Uri): List<DriveFile> {
+ val out = ArrayList<DriveFile>()
+ val root = DocumentsContract.getTreeDocumentId(tree)
+ val pending = ArrayDeque(listOf(root to ""))
+ val cols = arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED)
+ while (pending.isNotEmpty()) {
+ val (parent, sub) = pending.removeFirst()
+ val children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, parent)
+ context.contentResolver.query(children, cols, null, null, null)?.use { c ->
+ while (c.moveToNext()) {
+ val id = c.getString(0) ?: continue
+ val name = c.getString(1) ?: continue
+ if (name.startsWith('.')) continue
+ val mime = c.getString(2) ?: ""
+ if (mime == Document.MIME_TYPE_DIR) {
+ pending.addLast(id to (if (sub.isEmpty()) name else "$sub/$name"))
+ } else {
+ out += DriveFile(tree.toString(), id, sub, name,
+ if (c.isNull(3)) 0 else c.getLong(3),
+ if (c.isNull(4)) 0 else c.getLong(4), mime)
+ }
+ }
+ }
+ }
+ return out
+ }
+
+ fun open(f: DriveFile): InputStream? = context.contentResolver.openInputStream(
+ DocumentsContract.buildDocumentUriUsingTree(Uri.parse(f.tree), f.docId))
+
+ /** The chosen folder's own name, as the picker showed it. */
+ fun nameOf(tree: Uri): String? = try {
+ val doc = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree))
+ context.contentResolver.query(doc, arrayOf(Document.COLUMN_DISPLAY_NAME), null, null, null)
+ ?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
+ } catch (e: Exception) { null }
+}
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/ContactSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt
new file mode 100644
index 0000000..0612e46
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt
@@ -0,0 +1,51 @@
+package org.meshbay.client.phonesync
+
+import android.Manifest
+import android.content.Context
+import android.net.Uri
+import android.provider.ContactsContract
+import org.meshbay.client.photos.PhotoChannels
+import java.io.File
+import java.security.MessageDigest
+import java.util.TimeZone
+
+/**
+ * The phone's contacts, as the platform itself exports them: one vCard per
+ * contact from ContactsContract, the format any phone imports back.
+ *
+ * Every run writes the whole address book and sends it only when it differs
+ * from the last one sent (`marker` is its SHA-256): a file is a complete
+ * copy as of its date, so restoring is importing the newest one.
+ */
+class ContactSource(private val context: Context) : DocSource {
+ override val permissions = arrayOf(Manifest.permission.READ_CONTACTS)
+ override val suffix = "contacts"
+
+ override fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? {
+ val keys = ArrayList<String>()
+ context.contentResolver.query(
+ ContactsContract.Contacts.CONTENT_URI, arrayOf(ContactsContract.Contacts.LOOKUP_KEY),
+ null, null, ContactsContract.Contacts._ID,
+ )?.use { c -> while (c.moveToNext()) c.getString(0)?.let { keys += it } }
+ if (keys.isEmpty()) return null
+
+ val file = File(dir, "contacts.vcf")
+ val digest = MessageDigest.getInstance("SHA-256")
+ var count = 0
+ file.outputStream().buffered().use { out ->
+ for (key in keys) {
+ val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, Uri.encode(key))
+ // A contact removed between the list and its card is simply not in this copy.
+ val bytes = try {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ } catch (e: Exception) { null } ?: continue
+ out.write(bytes)
+ digest.update(bytes)
+ count += 1
+ }
+ }
+ val sha = PhotoChannels.hex(digest.digest())
+ if (count == 0 || sha == marker) { file.delete(); return null }
+ return Export(file, DocPlan.nameFor("contacts", "vcf", now, zone), "text/vcard", sha, count)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Destination.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Destination.kt
new file mode 100644
index 0000000..e140fd0
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Destination.kt
@@ -0,0 +1,79 @@
+package org.meshbay.client.phonesync
+
+import android.content.SharedPreferences
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+
+/**
+ * Where everything this phone backs up goes: one folder of one group the
+ * account owns and is the only member of (docs/MESHBAY_DESIGN.md §9.12).
+ * Each kind of data goes into its own folder under it, `<account>-<kind>`.
+ */
+data class Destination(
+ val account: String,
+ val groupId: String,
+ val groupName: String,
+ /** A folder among the group's own, as a virtual path (`Backups`). */
+ val folder: String,
+) {
+ /** What was sent belongs to this, so a different group or folder starts again from nothing. */
+ val ledgerKey: String get() = "$account\n$groupId\n$folder"
+
+ fun toJson(): JSONObject = JSONObject()
+ .put("account", account).put("groupId", groupId).put("groupName", groupName).put("folder", folder)
+
+ companion object {
+ /** From the page, so checked like any input: names it chose, never a path on this phone. */
+ fun fromJson(o: JSONObject): Destination {
+ val account = o.optString("account", "").take(64)
+ val groupId = o.optString("groupId", "").take(64)
+ val folder = o.optString("folder", "").trim().trim('/').take(1024)
+ require(account.isNotEmpty() && groupId.isNotEmpty() && folder.isNotEmpty()) { "incomplete" }
+ require(DocPlan.SAFE_ACCOUNT.matches(account)) { "bad account" }
+ require(folder.split('/').none { it.isEmpty() || it == "." || it == ".." }) { "bad folder" }
+ return Destination(account, groupId, o.optString("groupName", "").take(256), folder)
+ }
+
+ fun parse(text: String?): Destination? = try {
+ val o = JSONObject(text ?: return null)
+ Destination(o.getString("account"), o.getString("groupId"), o.optString("groupName"), o.getString("folder"))
+ } catch (e: Exception) { null }
+ }
+}
+
+/**
+ * The destination, kept once for every kind. Changing it is a new backup for
+ * each of them: what they remember having sent belongs to the old place, so
+ * each is told and starts again from nothing.
+ */
+class DestinationChannels(private val prefs: SharedPreferences) {
+ private val listeners = mutableListOf<() -> Unit>()
+
+ fun onChange(listener: () -> Unit) { listeners += listener }
+
+ fun get(): Destination? = Destination.parse(prefs.getString(KEY, null))
+
+ fun handles(channel: String) = channel.startsWith("phonesync:")
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel) {
+ "phonesync:destination" -> get()?.toJson() ?: JSONObject.NULL
+ "phonesync:set-destination" -> { set(args.optJSONObject(0)); get()?.toJson() ?: JSONObject.NULL }
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ private fun set(o: JSONObject?) {
+ val previous = get()
+ val next = o?.let {
+ try { Destination.fromJson(it) } catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") }
+ }
+ if (next == null) prefs.edit().remove(KEY).apply()
+ else prefs.edit().putString(KEY, next.toJson().toString()).apply()
+ if (previous?.ledgerKey != next?.ledgerKey) listeners.forEach { it() }
+ }
+
+ companion object {
+ const val PREFS = "phonesync"
+ private const val KEY = "destination"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt
new file mode 100644
index 0000000..2efe558
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt
@@ -0,0 +1,188 @@
+package org.meshbay.client.phonesync
+
+import android.app.Activity
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.webkit.WebResourceResponse
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.photos.PhotoChannels
+import java.io.File
+import java.io.FileInputStream
+import java.security.SecureRandom
+import java.util.TimeZone
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Contacts or messages backup (docs/MESHBAY_DESIGN.md §9.13): what the page
+ * needs from the phone for one kind of data, and nothing it could use to read
+ * anything else.
+ *
+ * The same split as photos (PhotoChannels): the page decides when and does the
+ * sending; this side writes what is new into one file and hands it over by an
+ * opaque token at `/phonesync/<token>`, valid until the next plan. What the
+ * next file starts from (`marker`) moves only when the node has taken this one.
+ *
+ * Where the file goes is the destination every kind shares (Destination.kt);
+ * what is kept here is only whether this kind is on, and what was sent.
+ * `prefix` names the channels (`contactsync:status`, …).
+ */
+class DocChannels(
+ private val prefix: String,
+ private val activity: Activity,
+ private val prefs: SharedPreferences,
+ private val destinations: DestinationChannels,
+ private val dir: File,
+ private val source: DocSource,
+ private val notifyId: Int,
+ private val permissionRequest: Int,
+) {
+ private val random = SecureRandom()
+ @Volatile private var issued: Issued? = null
+ @Volatile private var permission: CountDownLatch? = null
+
+ private class Issued(val token: String, val export: Export, val key: String)
+
+ init {
+ // Somewhere new starts from nothing: the first file there holds everything.
+ destinations.onChange {
+ issued = null
+ prefs.edit().remove(MARKER).remove(SENT).remove(LAST_SENT).remove(LAST)
+ .remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ }
+ }
+
+ fun handles(channel: String) = channel.startsWith("$prefix:")
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("$prefix:")) {
+ "status" -> status()
+ "permit" -> { permit(); status() }
+ "configure" -> { configure(args.optJSONObject(0)); status() }
+ "plan" -> { requirePermission(); plan() }
+ "sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true }
+ "completed" -> { completed(); status() }
+ "failed" -> failed(args.optString(0, ""), args.optString(1, ""))
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ // ── state ────────────────────────────────────────────────────────────────
+
+ /** On, and somewhere to go: the destination, or null. */
+ private fun active(): Destination? = if (prefs.getBoolean(ON, false)) destinations.get() else null
+
+ fun status(): JSONObject {
+ val d = destinations.get()
+ return JSONObject()
+ .put("permission", if (permitted()) "granted" else "denied")
+ .put("on", prefs.getBoolean(ON, false))
+ .put("destination", d?.toJson() ?: JSONObject.NULL)
+ .put("dir", d?.let { DocPlan.dirFor(it, source.suffix) } ?: JSONObject.NULL)
+ .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL)
+ .put("lastSent", prefs.getString(LAST_SENT, null) ?: JSONObject.NULL)
+ .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL)
+ .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL)
+ .put("sent", prefs.getInt(SENT, 0))
+ .put("now", System.currentTimeMillis())
+ }
+
+ /** `{}` turns it on, null off; turned off, it forgets what it sent. */
+ private fun configure(o: JSONObject?) {
+ issued = null
+ if (o == null) {
+ prefs.edit().clear().apply()
+ return
+ }
+ if (destinations.get() == null) throw Refused("Refused: no destination")
+ prefs.edit().putBoolean(ON, true).apply()
+ }
+
+ private fun completed() {
+ prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ }
+
+ /** A run stopped for a reason that will hold tomorrow too; said once. True when this call said it. */
+ private fun failed(code: String, text: String): Boolean {
+ val c = code.take(64)
+ prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply()
+ if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false
+ prefs.edit().putString(NOTIFIED, c).apply()
+ PhotoChannels.notice(activity, notifyId, text.take(300))
+ return true
+ }
+
+ // ── the file ─────────────────────────────────────────────────────────────
+
+ private fun plan(): JSONObject {
+ val c = active() ?: throw Refused("Refused: this backup is off")
+ issued = null
+ dir.listFiles()?.forEach { it.delete() }
+ dir.mkdirs()
+ val export = source.export(prefs.getString(MARKER, null), dir, System.currentTimeMillis(), TimeZone.getDefault())
+ ?: return JSONObject().put("item", JSONObject.NULL)
+ val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) })
+ issued = Issued(token, export, c.ledgerKey)
+ return JSONObject().put("item", JSONObject()
+ .put("token", token).put("name", export.name)
+ .put("dir", DocPlan.dirFor(c, source.suffix) + (if (export.subdir.isEmpty()) "" else "/${export.subdir}"))
+ .put("size", export.file.length()).put("count", export.count))
+ }
+
+ /** The node took it: what the next file starts from moves, never before. */
+ private fun sent(token: String, dir: String, name: String) {
+ val i = issued?.takeIf { it.token == token } ?: throw Refused("Refused: unknown file")
+ if (active()?.ledgerKey != i.key) throw Refused("Refused: the backup changed")
+ prefs.edit().putString(MARKER, i.export.marker).putInt(SENT, prefs.getInt(SENT, 0) + 1)
+ .putString(LAST_SENT, "${dir.take(1024)}/${name.take(256)}").apply()
+ issued = null
+ i.export.file.delete()
+ }
+
+ /** The bytes of the issued file, for `/phonesync/<token>` on the packaged origin. */
+ fun serve(path: String): WebResourceResponse? {
+ val i = issued?.takeIf { it.token == path.removePrefix(PATH) } ?: return null
+ val stream = try { FileInputStream(i.export.file) } catch (e: Exception) { return null }
+ val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff")
+ return WebResourceResponse(i.export.mime, null, 200, "OK", headers, stream)
+ }
+
+ // ── permission ───────────────────────────────────────────────────────────
+
+ private fun permitted() = source.permissions.all {
+ activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun requirePermission() {
+ if (!permitted()) throw Refused("Refused: no access")
+ }
+
+ /** Asks, and waits for the answer: the page goes on from what was decided. */
+ private fun permit() {
+ if (permitted()) return
+ val latch = CountDownLatch(1)
+ permission = latch
+ activity.runOnUiThread { activity.requestPermissions(source.permissions, permissionRequest) }
+ latch.await(5, TimeUnit.MINUTES)
+ permission = null
+ }
+
+ /** From Activity.onRequestPermissionsResult; true when the request was ours. */
+ fun deliverPermission(requestCode: Int): Boolean {
+ if (requestCode != permissionRequest) return false
+ permission?.countDown()
+ return true
+ }
+
+ companion object {
+ const val PATH = "/phonesync/"
+ private const val ON = "on"
+ private const val MARKER = "marker"
+ private const val SENT = "sent"
+ private const val LAST_SENT = "last_sent"
+ private const val LAST = "last_completed"
+ private const val FAILURE = "failure"
+ private const val FAILURE_AT = "failure_at"
+ private const val NOTIFIED = "notified"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt
new file mode 100644
index 0000000..1a2a359
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt
@@ -0,0 +1,56 @@
+package org.meshbay.client.phonesync
+
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/** One file a run sends, and what is recorded once the node has it. */
+data class Export(
+ val file: File,
+ val name: String,
+ val mime: String,
+ /** Recorded only when the node took the file: what the next export starts from. */
+ val marker: String,
+ /** Contacts or messages in it, for the page to say. */
+ val count: Int,
+ /** A folder under the kind's own, such as the year, or empty. */
+ val subdir: String = "",
+)
+
+/**
+ * One kind of the phone's data (the contacts, the messages) written out as
+ * a file of what is new since `marker`, the value the last file sent left.
+ */
+interface DocSource {
+ /** The runtime permissions it reads with. */
+ val permissions: Array<String>
+ /** Where its files go, under the chosen folder: `<account>-<suffix>`. */
+ val suffix: String
+ /** A file of what is new since `marker` in `dir`, or null when nothing is. */
+ fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export?
+}
+
+/** The names a run gives what it sends. Pure, so the JVM tests hold them. */
+object DocPlan {
+ /** A run is due once a day, counted from the last one that finished. */
+ const val DAY_MS = 24L * 3600 * 1000
+
+ /** A username as the hub allows it; it names a folder on the node. */
+ val SAFE_ACCOUNT = Regex("^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
+
+ /** `<folder>/<account>-<suffix>`: everything of one kind, from one account, in one place. */
+ fun dirFor(destination: Destination, suffix: String): String =
+ "${destination.folder}/${destination.account}-$suffix"
+
+ /**
+ * `<stem>-YYYY-MM-DD-HHmm.<ext>`, in the phone's time: a run never replaces
+ * an earlier file, and the name says when it was taken.
+ */
+ fun yearOf(now: Long, zone: TimeZone): String =
+ SimpleDateFormat("yyyy", Locale.ROOT).apply { timeZone = zone }.format(Date(now))
+
+ fun nameFor(stem: String, ext: String, now: Long, zone: TimeZone): String =
+ "$stem-" + SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.ROOT).apply { timeZone = zone }.format(Date(now)) + ".$ext"
+}
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/main/kotlin/org/meshbay/client/phonesync/ManifestLog.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ManifestLog.kt
new file mode 100644
index 0000000..0d7142c
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ManifestLog.kt
@@ -0,0 +1,56 @@
+package org.meshbay.client.phonesync
+
+import org.json.JSONObject
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * What a backup sent, as the phone knew it, for a restore or a merge later
+ * (docs/MESHBAY_DESIGN.md §9.12): one JSON line per file the node took, with
+ * where it came from on the phone, its album or folder, its dates, size and
+ * SHA-256. The node has the files; only the phone knew these.
+ *
+ * Lines wait here until a manifest carrying them is on the node: `issue()`
+ * gathers them into the file to send, `confirm()` forgets them once the node
+ * has it. A manifest that did not arrive is sent again, with whatever was
+ * added since, at the next run: nothing is lost to an interrupted one.
+ */
+class ManifestLog(private val pending: File) {
+ private val sending = File(pending.path + ".sending")
+
+ fun append(line: JSONObject) {
+ pending.parentFile?.mkdirs()
+ pending.appendText(line.toString() + "\n")
+ }
+
+ /** True when there are lines no manifest on the node holds yet. */
+ fun waiting(): Boolean = (sending.exists() && sending.length() > 0) || (pending.exists() && pending.length() > 0)
+
+ /** The file to send now, holding every waiting line, or null. */
+ fun issue(): File? {
+ if (pending.exists() && pending.length() > 0) {
+ if (sending.exists()) { sending.appendText(pending.readText()); pending.delete() }
+ else if (!pending.renameTo(sending)) return null
+ }
+ return sending.takeIf { it.exists() && it.length() > 0 }
+ }
+
+ /** The node has it: those lines are done. */
+ fun confirm() { sending.delete() }
+
+ /** Everything forgotten: the destination changed, or the backup was turned off. */
+ fun clear() { pending.delete(); sending.delete() }
+
+ companion object {
+ /** The folder manifests go into, under a kind's own folder. */
+ const val DIR = "meshbay-manifest"
+
+ /** `manifest-2026-10-10-143205.jsonl`, in the phone's time: never one name twice. */
+ fun nameFor(now: Long, zone: TimeZone): String =
+ "manifest-" + SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.ROOT).apply { timeZone = zone }.format(Date(now)) +
+ ".jsonl"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt
new file mode 100644
index 0000000..660cfaa
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt
@@ -0,0 +1,24 @@
+package org.meshbay.client.phonesync
+
+import android.content.Context
+import android.os.Build
+import android.os.UserManager
+
+/**
+ * Backups are of the personal profile only, never of a work profile: a copy
+ * of the application installed inside a work profile offers none of them.
+ *
+ * In the personal profile, every source reads that profile alone: MediaStore,
+ * ContactsContract, the SMS and calendar providers answer for the profile
+ * they are asked from, and none of the cross-profile (`ENTERPRISE_*`) URIs
+ * is used (`test_android_shell.py` checks for them).
+ */
+object Profiles {
+ fun isPersonal(context: Context): Boolean {
+ val users = context.getSystemService(UserManager::class.java)
+ // Before Android 11 an application cannot ask whether its own profile
+ // is managed; a work profile is never the system user, so that is
+ // the test there (a secondary user on a shared tablet loses backups).
+ return if (Build.VERSION.SDK_INT >= 30) !users.isManagedProfile else users.isSystemUser
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt
new file mode 100644
index 0000000..624e1f1
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt
@@ -0,0 +1,69 @@
+package org.meshbay.client.phonesync
+
+import java.io.Writer
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/** One text message, as the platform's SMS provider stores it. */
+data class Sms(
+ val id: Long,
+ val address: String,
+ /** Milliseconds, received or sent. */
+ val date: Long,
+ val dateSent: Long,
+ /** 1 received, 2 sent, 3 draft, 4 outbox, 5 failed, 6 queued. */
+ val type: Int,
+ val body: String,
+ val read: Int,
+ val status: Int,
+ val locked: Int,
+ val protocol: Int,
+ val subject: String?,
+ val serviceCenter: String?,
+ val contactName: String?,
+)
+
+/**
+ * Messages written as the `<smses>` XML that SMS backup applications read and
+ * restore, so a copy on the node goes back onto a phone with tools that exist.
+ * Pure, so the JVM tests hold the format and its escaping.
+ */
+object SmsXml {
+ fun write(messages: List<Sms>, out: Writer, zone: TimeZone) {
+ val readable = SimpleDateFormat("d MMM yyyy HH:mm:ss", Locale.ROOT).apply { timeZone = zone }
+ out.write("<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>\n")
+ out.write("<smses count=\"${messages.size}\">\n")
+ for (m in messages) {
+ out.write(" <sms protocol=\"${m.protocol}\" address=${q(m.address)} date=\"${m.date}\"" +
+ " type=\"${m.type}\" subject=${q(m.subject ?: "null")} body=${q(m.body)}" +
+ " toa=\"null\" sc_toa=\"null\" service_center=${q(m.serviceCenter ?: "null")}" +
+ " read=\"${m.read}\" status=\"${m.status}\" locked=\"${m.locked}\" date_sent=\"${m.dateSent}\"" +
+ " readable_date=${q(readable.format(Date(m.date)))}" +
+ " contact_name=${q(m.contactName ?: "(Unknown)")} />\n")
+ }
+ out.write("</smses>\n")
+ }
+
+ /**
+ * A quoted attribute value. Line breaks are kept as character references,
+ * which an attribute would otherwise fold into spaces; characters XML 1.0
+ * cannot carry at all, even escaped, are left out.
+ */
+ fun q(text: String): String {
+ val b = StringBuilder(text.length + 2).append('"')
+ for (c in text) when {
+ c == '&' -> b.append("&amp;")
+ c == '<' -> b.append("&lt;")
+ c == '>' -> b.append("&gt;")
+ c == '"' -> b.append("&quot;")
+ c == '\n' -> b.append("&#10;")
+ c == '\r' -> b.append("&#13;")
+ c == '\t' -> b.append("&#9;")
+ c < ' ' || c == '￾' || c == '￿' -> {}
+ else -> b.append(c)
+ }
+ return b.append('"').toString()
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt
new file mode 100644
index 0000000..b40d006
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt
@@ -0,0 +1,91 @@
+package org.meshbay.client.photos
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import org.meshbay.client.R
+
+/**
+ * Keeps a photo backup going with the screen off — the same pair as a cast
+ * (CastService): this service holds the process, the CPU and the Wi-Fi, and
+ * the shell keeps the WebView reported visible, because the sending happens in
+ * the page and Chromium freezes a hidden page after 60 s.
+ *
+ * Its own service, of type dataSync, rather than a second reason on the cast
+ * service: music can play during a backup, and each stops on its own.
+ *
+ * Started only from the page while the application is in front — a foreground
+ * service cannot be started from the background on Android 12+ — and its
+ * progress line is updated through the notification, which needs no start.
+ */
+class BackupService : Service() {
+ private var wake: PowerManager.WakeLock? = null
+ private var wifi: WifiManager.WifiLock? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val n = notification(this, intent?.getStringExtra(EXTRA_TEXT) ?: "")
+ if (Build.VERSION.SDK_INT >= 29) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ else startForeground(ID, n)
+ if (wake == null) {
+ wake = getSystemService(PowerManager::class.java)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "meshbay:backup").apply { acquire(MAX_HOLD_MS) }
+ @Suppress("DEPRECATION")
+ val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF
+ wifi = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager)
+ .createWifiLock(mode, "meshbay:backup").apply { acquire() }
+ }
+ return START_NOT_STICKY
+ }
+
+ /**
+ * Android 15 gives dataSync six hours a day and then calls this; not
+ * stopping here is a crash. The run carries on with the screen on, or at
+ * the next opening, which is where an interrupted run goes anyway.
+ */
+ override fun onTimeout(startId: Int, fgsType: Int) {
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ wake?.let { if (it.isHeld) it.release() }
+ wifi?.let { if (it.isHeld) it.release() }
+ wake = null; wifi = null
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val ID = 8
+ const val EXTRA_TEXT = "text"
+ private const val MAX_HOLD_MS = 6L * 3600 * 1000
+
+ fun notification(context: Context, text: String): Notification {
+ PhotoChannels.ensureChannel(context)
+ val open = PendingIntent.getActivity(context, ID,
+ context.packageManager.getLaunchIntentForPackage(context.packageName), PendingIntent.FLAG_IMMUTABLE)
+ return Notification.Builder(context, PhotoChannels.CHANNEL)
+ .setContentTitle("MeshBay")
+ .setContentText(text)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentIntent(open)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build()
+ }
+
+ /** The progress line of a running backup; nothing when none runs. */
+ fun update(context: Context, text: String) {
+ val nm = context.getSystemService(NotificationManager::class.java)
+ if (nm.activeNotifications.any { it.id == ID }) nm.notify(ID, notification(context, text))
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt
new file mode 100644
index 0000000..001bea2
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt
@@ -0,0 +1,40 @@
+package org.meshbay.client.photos
+
+import java.io.FilterInputStream
+import java.io.InputStream
+
+/**
+ * One `Range: bytes=a-b` of a file the page fetches a piece at a time, so a
+ * video of gigabytes never sits whole in the page's memory: the upload reads
+ * one chunk, sends it, and asks for the next.
+ */
+object ByteRange {
+ /** The first and last byte asked for, inside `size`; null for no range or one this does not serve. */
+ fun parse(header: String?, size: Long): LongRange? {
+ val m = Regex("^bytes=(\\d+)-(\\d*)$").matchEntire(header?.trim() ?: return null) ?: return null
+ val first = m.groupValues[1].toLongOrNull() ?: return null
+ val last = m.groupValues[2].takeIf { it.isNotEmpty() }?.toLongOrNull() ?: (size - 1)
+ if (first >= size || last < first) return null
+ return first..minOf(last, size - 1)
+ }
+
+ /** `stream` from `range.first`, ending after `range.last`. */
+ fun slice(stream: InputStream, range: LongRange): InputStream {
+ var toSkip = range.first
+ while (toSkip > 0) {
+ val n = stream.skip(toSkip)
+ if (n <= 0) { if (stream.read() < 0) break; toSkip -= 1 } else toSkip -= n
+ }
+ return object : FilterInputStream(stream) {
+ private var left = range.last - range.first + 1
+ override fun read(): Int {
+ if (left <= 0) return -1
+ return super.read().also { if (it >= 0) left -= 1 }
+ }
+ override fun read(b: ByteArray, off: Int, len: Int): Int {
+ if (left <= 0) return -1
+ return super.read(b, off, minOf(len.toLong(), left).toInt()).also { if (it > 0) left -= it }
+ }
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt
new file mode 100644
index 0000000..2e7c519
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt
@@ -0,0 +1,394 @@
+package org.meshbay.client.photos
+
+import android.Manifest
+import android.app.Activity
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Build
+import android.webkit.WebResourceResponse
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.MainActivity
+import org.meshbay.client.R
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.notify.Notifier
+import org.meshbay.client.phonesync.Destination
+import org.meshbay.client.phonesync.DestinationChannels
+import org.meshbay.client.phonesync.ManifestLog
+import java.io.File
+import java.io.FilterInputStream
+import java.io.InputStream
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Photo backup (docs/MESHBAY_DESIGN.md §9.12): what the page needs from the
+ * phone, and nothing it could use to read anything else.
+ *
+ * The page decides when a run is due and does the sending, because the
+ * transport and the group key are there. This side lists the photos, keeps the
+ * ledger, and hands over bytes — by an opaque token the page fetches from the
+ * packaged origin (`/photosync/<token>`), valid for the run that issued it and
+ * for nothing but the photo it names. The page never sees a `content://` URI.
+ *
+ * Phone-only: the desktop preload has no counterpart, so `platform.photoSync`
+ * is absent there.
+ */
+class PhotoChannels(
+ private val activity: Activity,
+ private val prefs: SharedPreferences,
+ private val destinations: DestinationChannels,
+ private val dir: File,
+ private val onKeepAlive: (Boolean, String) -> Unit,
+) {
+ private val source = PhotoSource(activity)
+ private val random = SecureRandom()
+ private val tokens = ConcurrentHashMap<String, Issued>()
+ @Volatile private var permission: CountDownLatch? = null
+
+ private class Issued(val pending: Pending, val key: String) {
+ @Volatile var sha256: String? = null
+ }
+
+ init {
+ // Somewhere new is a new backup: due at once, from now when only new
+ // photos were asked for, and its ledger is the new place's own.
+ destinations.onChange {
+ tokens.clear()
+ val edit = prefs.edit().remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED)
+ config()?.let { edit.putString(CONFIG, it.copy(since = System.currentTimeMillis()).toJson().toString()) }
+ edit.apply()
+ }
+ }
+
+ fun handles(channel: String) = channel.startsWith("photosync:")
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel) {
+ "photosync:status" -> status()
+ "photosync:permit" -> { permit(args.optBoolean(0, false)); status() }
+ "photosync:albums" -> { requirePermission(); albums(args.optBoolean(0, false)) }
+ "photosync:configure" -> { configure(args.optJSONObject(0)); status() }
+ "photosync:estimate" -> { requirePermission(); estimate(args.optJSONObject(0) ?: throw Refused("Refused: no settings")) }
+ "photosync:plan" -> { requirePermission(); plan() }
+ "photosync:sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true }
+ "photosync:manifest" -> manifest()
+ "photosync:manifest-sent" -> { manifestSent(args.optString(0, "")); true }
+ "photosync:completed" -> { completed(); status() }
+ "photosync:failed" -> failed(args.optString(0, ""), args.optString(1, ""))
+ "photosync:keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true }
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ // ── state ────────────────────────────────────────────────────────────────
+
+ private fun config(): SyncConfig? = SyncConfig.parse(prefs.getString(CONFIG, null))
+
+ /** Where photos go, and which: both, or null when either is missing. */
+ private fun active(): Pair<Destination, SyncConfig>? {
+ val c = config() ?: return null
+ return (destinations.get() ?: return null) to c
+ }
+
+ private fun keyOf(d: Destination) = hex(sha256Of(d.ledgerKey.toByteArray())).take(32)
+
+ private fun ledger(d: Destination) = PhotoLedger(File(dir, keyOf(d) + ".jsonl"))
+
+ private fun manifestLog(d: Destination) = ManifestLog(File(dir, keyOf(d) + ".manifest"))
+
+ @Volatile private var manifestToken: Pair<String, File>? = null
+
+ fun status(): JSONObject {
+ val c = config()
+ val d = destinations.get()
+ return JSONObject()
+ .put("permission", permissionState())
+ .put("videoPermission", permissionState(video = true))
+ .put("unmetered", unmetered())
+ .put("config", c?.toJson() ?: JSONObject.NULL)
+ .put("destination", d?.toJson() ?: JSONObject.NULL)
+ .put("dir", d?.let { PhotoPlan.baseFor(it) } ?: JSONObject.NULL)
+ .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL)
+ .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL)
+ .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL)
+ .put("sent", if (c != null && d != null) ledger(d).size else 0)
+ .put("now", System.currentTimeMillis())
+ }
+
+ private fun configure(o: JSONObject?) {
+ val previous = config()
+ if (o == null) {
+ prefs.edit().remove(CONFIG).remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ tokens.clear()
+ return
+ }
+ if (destinations.get() == null) throw Refused("Refused: no destination")
+ val next = SyncConfig.fromJson(o, System.currentTimeMillis(), previous)
+ val edit = prefs.edit().putString(CONFIG, next.toJson().toString())
+ // A different scope is a different backup: due at once, and whatever
+ // the last one was refused for is not this one's problem.
+ if (previous == null || previous.since != next.since) {
+ edit.remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED)
+ }
+ edit.apply()
+ tokens.clear()
+ }
+
+ private fun completed() {
+ prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ }
+
+ /**
+ * A run stopped for a reason that will hold tomorrow too — the folder is no
+ * longer writable, the disk is full, the person left the group. Said once,
+ * in a notification, rather than every day; true when this call said it.
+ */
+ private fun failed(code: String, text: String): Boolean {
+ val c = code.take(64)
+ prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply()
+ if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false
+ prefs.edit().putString(NOTIFIED, c).apply()
+ notice(activity, NOTIFY_ID, text.take(300))
+ return true
+ }
+
+ // ── the phone's photos ───────────────────────────────────────────────────
+
+ private fun albums(withVideos: Boolean): JSONArray = JSONArray().apply {
+ for (a in source.albums(withVideos)) put(JSONObject().put("id", a.id).put("name", a.name)
+ .put("count", a.count).put("bytes", a.bytes).put("camera", a.camera)
+ .put("videos", a.videos).put("videoBytes", a.videoBytes))
+ }
+
+ /** What a backup set up this way would send first: the count and size the confirmation states. */
+ private fun estimate(o: JSONObject): JSONObject {
+ val d = destinations.get() ?: throw Refused("Refused: no destination")
+ val c = SyncConfig.fromJson(o, System.currentTimeMillis(), config())
+ val ledger = ledger(d)
+ val items = PhotoPlan.plan(source.photos(c.albums, c.includeVideos), c, PhotoPlan.baseFor(d), ledger::get) { _, _ -> true }
+ val videos = items.filter { it.photo.video }
+ return JSONObject().put("count", items.size).put("bytes", items.sumOf { it.photo.size })
+ .put("videos", videos.size).put("videoBytes", videos.sumOf { it.photo.size })
+ }
+
+ private fun plan(): JSONObject {
+ val (d, c) = active() ?: throw Refused("Refused: photo backup is off")
+ val ledger = ledger(d)
+ val items = PhotoPlan.plan(source.photos(c.albums, c.includeVideos), c, PhotoPlan.baseFor(d), ledger::get) { p, sent ->
+ hashOf(p)?.let { it == sent.sha256 } ?: true
+ }
+ // A new plan replaces the last one: tokens are for one run, never kept.
+ tokens.clear()
+ val out = JSONArray()
+ for (p in items) {
+ val token = hex(ByteArray(16).also { random.nextBytes(it) })
+ tokens[token] = Issued(p, d.ledgerKey)
+ out.put(JSONObject().put("token", token).put("name", p.name).put("dir", p.dir)
+ .put("size", p.photo.size).put("edited", p.edited).put("taken", PhotoPlan.whenTaken(p.photo))
+ .put("video", p.photo.video)
+ // After a reinstall the ledger is empty, and the page looks in the
+ // folder for what is already there — an edit under its own name too.
+ .put("alsoKnownAs", PhotoPlan.editedName(p.photo, java.util.TimeZone.getDefault())))
+ }
+ return JSONObject().put("items", out).put("manifest", manifestLog(d).waiting())
+ }
+
+ /** The node took it (or already had it): into the ledger, under the name its ack gave. */
+ private fun sent(token: String, dir: String, name: String) {
+ val issued = tokens[token] ?: throw Refused("Refused: unknown photo")
+ val d = active()?.first?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed")
+ val p = issued.pending.photo
+ val sha = issued.sha256 ?: hashOf(p) ?: throw Refused("Refused: the photo is gone")
+ val now = System.currentTimeMillis()
+ ledger(d).record(PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha,
+ dir.take(1024), name.take(256), now))
+ manifestLog(d).append(JSONObject()
+ .put("kind", if (p.video) "video" else "photo")
+ .put("node", "${dir.take(1024)}/${name.take(256)}")
+ .put("source", p.relPath).put("album", p.album)
+ .put("taken", PhotoPlan.whenTaken(p)).put("modified", p.modified * 1000)
+ .put("size", p.size).put("sha256", sha).put("mime", p.mime)
+ .put("edited", issued.pending.edited).put("sentAt", now))
+ tokens.remove(token)
+ }
+
+ /**
+ * The manifest of what was sent and not yet described on the node, for
+ * `<base>/meshbay-manifest/` (ManifestLog), or `item: null`.
+ */
+ private fun manifest(): JSONObject {
+ val (d, _) = active() ?: throw Refused("Refused: photo backup is off")
+ val file = manifestLog(d).issue() ?: return JSONObject().put("item", JSONObject.NULL)
+ val token = hex(ByteArray(16).also { random.nextBytes(it) })
+ manifestToken = token to file
+ return JSONObject().put("item", JSONObject().put("token", token)
+ .put("name", ManifestLog.nameFor(System.currentTimeMillis(), java.util.TimeZone.getDefault()))
+ .put("dir", PhotoPlan.baseFor(d) + "/" + ManifestLog.DIR).put("size", file.length()))
+ }
+
+ private fun manifestSent(token: String) {
+ if (manifestToken?.first != token) throw Refused("Refused: unknown manifest")
+ val d = destinations.get() ?: throw Refused("Refused: the backup changed")
+ manifestLog(d).confirm()
+ manifestToken = null
+ }
+
+ /**
+ * The bytes of an issued photo or video, for `/photosync/<token>` on the
+ * packaged origin. Asked a range at a time (ByteRange), as the upload
+ * reads them, so a video never sits whole in the page; the ledger's hash
+ * is then taken when the node has it (`sent`). Asked whole, it is hashed
+ * as it goes out.
+ */
+ fun serve(path: String, range: String? = null): WebResourceResponse? {
+ manifestToken?.takeIf { it.first == path.removePrefix(PATH) }?.let { (_, file) ->
+ return serveRange(file.inputStream(), file.length(), "application/x-ndjson", range)
+ }
+ val issued = tokens[path.removePrefix(PATH)] ?: return null
+ val raw = try { source.open(issued.pending.photo) } catch (e: Exception) { null } ?: return null
+ val mime = issued.pending.photo.mime.ifEmpty { "application/octet-stream" }
+ if (range != null) return serveRange(raw, issued.pending.photo.size, mime, range)
+ val digest = MessageDigest.getInstance("SHA-256")
+ val stream = object : FilterInputStream(raw) {
+ private var done = false
+ override fun read(): Int = super.read().also { if (it < 0) finish() else digest.update(it.toByte()) }
+ override fun read(b: ByteArray, off: Int, len: Int): Int =
+ super.read(b, off, len).also { if (it < 0) finish() else digest.update(b, off, it) }
+ private fun finish() { if (!done) { done = true; issued.sha256 = hex(digest.digest()) } }
+ }
+ val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff")
+ return WebResourceResponse(mime, null, 200, "OK", headers, stream)
+ }
+
+ private fun hashOf(p: Photo): String? = try {
+ source.open(p)?.use { s -> hex(digestOf(s)) }
+ } catch (e: Exception) { null }
+
+ /** What the photo backup sends, for the files backup to leave out; empty while it is off. */
+ fun backedUpPaths(): Set<String> {
+ val c = config() ?: return emptySet()
+ if (permissionState() == "denied") return emptySet()
+ return try { source.relativePaths(c.albums, c.includeVideos) } catch (e: Exception) { emptySet() }
+ }
+
+ // ── permission and network ───────────────────────────────────────────────
+
+ /** For the photos, or with `video` for the videos, which Android 13+ asks about apart. */
+ private fun permissionState(video: Boolean = false): String {
+ fun has(p: String) = activity.checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED
+ val media = if (video) Manifest.permission.READ_MEDIA_VIDEO else Manifest.permission.READ_MEDIA_IMAGES
+ return when {
+ Build.VERSION.SDK_INT >= 33 && has(media) -> "granted"
+ Build.VERSION.SDK_INT >= 34 && has(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) -> "partial"
+ Build.VERSION.SDK_INT < 33 && has(Manifest.permission.READ_EXTERNAL_STORAGE) -> "granted"
+ else -> "denied"
+ }
+ }
+
+ private fun requirePermission() {
+ if (permissionState() == "denied") throw Refused("Refused: no access to photos")
+ }
+
+ /** Asks, and waits for the answer: the page goes on from what was decided. */
+ private fun permit(withVideos: Boolean) {
+ val media = listOf(Manifest.permission.READ_MEDIA_IMAGES) +
+ (if (withVideos) listOf(Manifest.permission.READ_MEDIA_VIDEO) else emptyList())
+ val wanted = when {
+ Build.VERSION.SDK_INT >= 34 -> (media + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED).toTypedArray()
+ Build.VERSION.SDK_INT >= 33 -> media.toTypedArray()
+ else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ val latch = CountDownLatch(1)
+ permission = latch
+ activity.runOnUiThread { activity.requestPermissions(wanted, PERMISSION_REQUEST) }
+ latch.await(5, TimeUnit.MINUTES)
+ permission = null
+ }
+
+ /** From Activity.onRequestPermissionsResult; true when the request was ours. */
+ fun deliverPermission(requestCode: Int): Boolean {
+ if (requestCode != PERMISSION_REQUEST) return false
+ permission?.countDown()
+ return true
+ }
+
+ /**
+ * Not "on Wi-Fi": a phone joined to another phone's hotspot is on Wi-Fi and
+ * spending that phone's mobile data, and Android reports it as metered.
+ */
+ fun unmetered(): Boolean {
+ val cm = activity.getSystemService(ConnectivityManager::class.java)
+ val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
+ return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) ||
+ (Build.VERSION.SDK_INT >= 30 &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED))
+ }
+
+ companion object {
+ const val PATH = "/photosync/"
+ const val CHANNEL = "backup"
+ private const val NOTIFY_ID = 9
+ private const val PERMISSION_REQUEST = 4208
+ const val PREFS = "photosync"
+ private const val CONFIG = "config"
+ private const val LAST = "last_completed"
+ private const val FAILURE = "failure"
+ private const val FAILURE_AT = "failure_at"
+ private const val NOTIFIED = "notified"
+
+ /** Created, or renamed from "Photo backup" now that contacts and messages use it too. */
+ fun ensureChannel(context: Context) {
+ context.getSystemService(NotificationManager::class.java)
+ .createNotificationChannel(NotificationChannel(CHANNEL, "Backup", NotificationManager.IMPORTANCE_LOW))
+ }
+
+ /** A backup that stopped, said once; a tap opens the Android Sync page. */
+ fun notice(context: Context, id: Int, text: String) {
+ val nm = context.getSystemService(NotificationManager::class.java)
+ ensureChannel(context)
+ val open = Intent(context, MainActivity::class.java).setAction(Intent.ACTION_VIEW)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .putExtra(Notifier.EXTRA_LINK, "#/android-sync")
+ val pending = PendingIntent.getActivity(context, id, open,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ nm.notify(id, Notification.Builder(context, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notify).setContentTitle("MeshBay").setContentText(text)
+ .setStyle(Notification.BigTextStyle().bigText(text))
+ .setContentIntent(pending).setAutoCancel(true).build())
+ }
+
+ /** `range` of `stream`, `size` bytes long, as a 206 (or a 416 for a range that cannot be served). */
+ fun serveRange(stream: InputStream, size: Long, mime: String, range: String?): WebResourceResponse {
+ val r = ByteRange.parse(range, size) ?: run {
+ stream.close()
+ return WebResourceResponse("text/plain", null, 416, "Range Not Satisfiable",
+ mapOf("Content-Range" to "bytes */$size"), "".byteInputStream())
+ }
+ val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff",
+ "Content-Range" to "bytes ${r.first}-${r.last}/$size",
+ "Content-Length" to (r.last - r.first + 1).toString())
+ return WebResourceResponse(mime, null, 206, "Partial Content", headers, ByteRange.slice(stream, r))
+ }
+
+ fun digestOf(s: InputStream): ByteArray {
+ val d = MessageDigest.getInstance("SHA-256")
+ val buf = ByteArray(64 * 1024)
+ while (true) { val n = s.read(buf); if (n < 0) break; d.update(buf, 0, n) }
+ return d.digest()
+ }
+
+ fun sha256Of(b: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(b)
+
+ fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt
new file mode 100644
index 0000000..f3aa6e5
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt
@@ -0,0 +1,91 @@
+package org.meshbay.client.photos
+
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * What this phone has sent to one folder of one group — the memory of what was
+ * sent, never a mirror of the phone (docs/MESHBAY_DESIGN.md §9.12).
+ *
+ * A run sends what is not here, and nothing compares in the other direction: a
+ * photo deleted on the phone simply stops being listed, and one deleted on the
+ * node stays here and is not sent again — deleting it there was a decision.
+ *
+ * One line of JSON per send, appended; the last line for a media id wins. A
+ * whole-file rewrite per photo would be megabytes written per photo on a roll
+ * of twenty thousand. Compacted on load once the dead lines outnumber the live
+ * ones. Plain files and org.json, so the JVM tests run it as it runs here.
+ */
+class PhotoLedger(private val file: File) {
+
+ data class Entry(
+ val mediaId: Long,
+ /** MediaStore DATE_MODIFIED, seconds — what tells an edit from the photo sent. */
+ val modified: Long,
+ val size: Long,
+ /** SHA-256 of the bytes sent, hex: an edit is sent only if this changed. */
+ val sha256: String,
+ /** Where the node put it: the folder and the name its ack gave. */
+ val dir: String,
+ val name: String,
+ val sentAt: Long,
+ )
+
+ private val entries = HashMap<Long, Entry>()
+ private var lines = 0
+
+ init { load() }
+
+ val size: Int get() = entries.size
+
+ operator fun get(mediaId: Long): Entry? = entries[mediaId]
+
+ fun all(): Collection<Entry> = entries.values
+
+ fun record(entry: Entry) {
+ entries[entry.mediaId] = entry
+ file.parentFile?.mkdirs()
+ file.appendText(encode(entry) + "\n")
+ lines += 1
+ }
+
+ /** Everything forgotten — the group or the folder changed, or backup was turned off. */
+ fun clear() {
+ entries.clear()
+ lines = 0
+ file.delete()
+ }
+
+ private fun load() {
+ if (!file.exists()) return
+ file.forEachLine { line ->
+ if (line.isBlank()) return@forEachLine
+ lines += 1
+ // A line cut short by a process killed mid-write is the only kind
+ // that fails to parse; what it was recording is sent again, once.
+ decode(line)?.let { entries[it.mediaId] = it }
+ }
+ if (lines > 2 * entries.size + COMPACT_SLACK) compact()
+ }
+
+ private fun compact() {
+ val tmp = File(file.path + ".tmp")
+ tmp.writeText(entries.values.joinToString("") { encode(it) + "\n" })
+ if (!tmp.renameTo(file)) { tmp.delete(); return }
+ lines = entries.size
+ }
+
+ companion object {
+ private const val COMPACT_SLACK = 64
+
+ fun encode(e: Entry): String = JSONObject()
+ .put("id", e.mediaId).put("m", e.modified).put("s", e.size).put("h", e.sha256)
+ .put("d", e.dir).put("n", e.name).put("t", e.sentAt).toString()
+
+ fun decode(line: String): Entry? = try {
+ val o = JSONObject(line)
+ Entry(o.getLong("id"), o.getLong("m"), o.getLong("s"), o.getString("h"),
+ o.getString("d"), o.getString("n"), o.getLong("t"))
+ } catch (e: Exception) { null }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt
new file mode 100644
index 0000000..2cb85e6
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt
@@ -0,0 +1,166 @@
+package org.meshbay.client.photos
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.phonesync.DocPlan
+import org.meshbay.client.phonesync.Destination
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/** One image in MediaStore, as much of it as a plan needs. */
+data class Photo(
+ val mediaId: Long,
+ val displayName: String,
+ val size: Long,
+ /** Milliseconds; 0 when the camera wrote none. */
+ val taken: Long,
+ /** MediaStore DATE_ADDED and DATE_MODIFIED, seconds. */
+ val added: Long,
+ val modified: Long,
+ val bucketId: String,
+ val mime: String,
+ /** From MediaStore's video collection rather than its images. */
+ val video: Boolean = false,
+ /** Where it is on the phone, `DCIM/Camera/PXL_….jpg`, and its album's name, for the manifest. */
+ val relPath: String = "",
+ val album: String = "",
+)
+
+/**
+ * Which of this phone's photos are backed up, as the person set it up. Where
+ * they go is the destination every kind shares (phonesync/Destination.kt).
+ */
+data class SyncConfig(
+ val albums: List<String>,
+ /** The photos already on the phone too: the default, as backup applications do. */
+ val includeExisting: Boolean,
+ /** When it was set up or moved, ms: with `includeExisting` off, only photos added since count. */
+ val since: Long,
+ /** The videos of the same albums too, beside the photos. Off by default: they are large. */
+ val includeVideos: Boolean = false,
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("albums", JSONArray(albums)).put("includeExisting", includeExisting).put("since", since)
+ .put("includeVideos", includeVideos)
+
+ companion object {
+ /** From the page, so checked like any input. */
+ fun fromJson(o: JSONObject, now: Long, previous: SyncConfig? = null): SyncConfig {
+ val albums = o.optJSONArray("albums") ?: JSONArray()
+ val includeExisting = o.optBoolean("includeExisting", true)
+ // A change of scope starts again from that moment; a change of
+ // album list alone does not move it.
+ val same = previous != null && previous.includeExisting == includeExisting
+ return SyncConfig(
+ (0 until albums.length()).map { albums.optString(it, "").take(64) }.filter { it.isNotEmpty() }.distinct(),
+ includeExisting,
+ if (same) previous!!.since else now,
+ o.optBoolean("includeVideos", false),
+ )
+ }
+
+ fun parse(text: String?): SyncConfig? = try {
+ val o = JSONObject(text ?: return null)
+ val albums = o.getJSONArray("albums")
+ SyncConfig((0 until albums.length()).map { albums.getString(it) },
+ o.optBoolean("includeExisting", true), o.getLong("since"),
+ o.optBoolean("includeVideos", false))
+ } catch (e: Exception) { null }
+ }
+}
+
+/** A photo to send: a new one, or a new version of one already sent. */
+data class Pending(val photo: Photo, val edited: Boolean, val dir: String, val name: String)
+
+/**
+ * What a run sends, decided from the phone's photos and the ledger alone.
+ *
+ * Pure, so the rules are tested on the JVM; reading bytes for an edit's hash is
+ * the caller's (`sameBytes`).
+ */
+object PhotoPlan {
+ /** A run is due once a day, counted from the last one that finished. */
+ const val DAY_MS = 24L * 3600 * 1000
+
+ fun due(lastCompleted: Long?, now: Long): Boolean =
+ lastCompleted == null || now - lastCompleted >= DAY_MS || now < lastCompleted
+
+ /**
+ * `sameBytes(photo, entry)` is asked only of a photo whose MediaStore
+ * modification date moved since it was sent: true when its bytes are still
+ * those the ledger hashed (a favourite flag, a rescan), in which case
+ * nothing is sent.
+ */
+ fun plan(
+ photos: List<Photo>, config: SyncConfig, base: String, ledger: (Long) -> PhotoLedger.Entry?,
+ zone: TimeZone = TimeZone.getDefault(),
+ sameBytes: (Photo, PhotoLedger.Entry) -> Boolean,
+ ): List<Pending> {
+ val albums = config.albums.toSet()
+ val out = ArrayList<Pending>()
+ for (p in photos) {
+ if (p.bucketId !in albums) continue
+ if (!(if (p.video) config.includeVideos && p.mime.startsWith("video/") else p.mime.startsWith("image/"))) continue
+ val sent = ledger(p.mediaId)
+ if (sent == null) {
+ if (!config.includeExisting && p.added * 1000 < config.since) continue
+ out += Pending(p, false, dirFor(base, p, zone), nameFor(p))
+ } else if (sent.modified != p.modified || sent.size != p.size) {
+ if (sent.size == p.size && sameBytes(p, sent)) continue
+ out += Pending(p, true, dirFor(base, p, zone), editedName(p, zone))
+ }
+ }
+ // Newest first: the photos most likely to exist nowhere else are safe earliest.
+ return out.sortedByDescending { whenTaken(it.photo) }
+ }
+
+ fun whenTaken(p: Photo): Long = if (p.taken > 0) p.taken else p.added * 1000
+
+ /** Where every photo goes: `<folder>/<account>-photos`. */
+ fun baseFor(destination: Destination): String = DocPlan.dirFor(destination, SUFFIX)
+
+ const val SUFFIX = "photos"
+
+ /** `<base>/YYYY/YYYY-MM`, from when it was taken: an album is a directory (§9.9). */
+ fun dirFor(base: String, p: Photo, zone: TimeZone): String {
+ val fmt = SimpleDateFormat("yyyy/yyyy-MM", Locale.ROOT).apply { timeZone = zone }
+ return "$base/${fmt.format(Date(whenTaken(p)))}"
+ }
+
+ fun nameFor(p: Photo): String =
+ p.displayName.takeIf { UPLOAD_NAME.matches(it) }
+ ?: "${if (p.video) "video" else "photo"}-${p.mediaId}.${extension(p)}"
+
+ /**
+ * An edit lands beside the original under a name that says what it is; left
+ * to the node it would be `IMG_…(1).jpg`, which says nothing.
+ */
+ fun editedName(p: Photo, zone: TimeZone): String {
+ val base = nameFor(p)
+ val dot = base.lastIndexOf('.')
+ val stem = if (dot > 0) base.substring(0, dot) else base
+ val ext = if (dot > 0) base.substring(dot) else ""
+ val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone }
+ .format(Date(p.modified * 1000))
+ val suffix = "-edited-$stamp$ext"
+ return stem.take(MAX_NAME - suffix.length) + suffix
+ }
+
+ private fun extension(p: Photo): String =
+ p.displayName.substringAfterLast('.', "").lowercase(Locale.ROOT).takeIf { it.matches(Regex("^[a-z0-9]{1,5}$")) }
+ ?: when (p.mime) { "image/png" -> "png"; "image/heic" -> "heic"; "image/heif" -> "heif"
+ "image/webp" -> "webp"; "image/gif" -> "gif"
+ "video/mp4" -> "mp4"; "video/quicktime" -> "mov"; "video/3gpp" -> "3gp"
+ "video/webm" -> "webm"; "video/x-matroska" -> "mkv"
+ else -> if (p.video) "mp4" else "jpg" }
+
+ private const val MAX_NAME = 128
+
+ /**
+ * The node's SAFE_UPLOAD_NAME (roots.py), as files-app.js copies it. A name it
+ * refuses would fail the upload; this one is renamed before it is sent.
+ */
+ val UPLOAD_NAME = Regex("^[\\p{L}\\p{N}][\\p{L}\\p{N}_ .\\-()\\[\\]'’,&+#@]{0,127}(?<![ .])$")
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt
new file mode 100644
index 0000000..b3b2834
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt
@@ -0,0 +1,142 @@
+package org.meshbay.client.photos
+
+import android.content.ContentUris
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import java.io.InputStream
+
+/**
+ * The phone's photos and videos, through MediaStore and nothing else.
+ *
+ * Opened through MediaStore by an application without ACCESS_MEDIA_LOCATION,
+ * a file's location is **redacted by the platform** (Android 10+): the EXIF of
+ * a photo, and the location boxes of an MP4 or MOV video (MediaProvider's
+ * IsoInterface). A whole camera roll going to several people does not say
+ * where its owner lives, and nobody had to do anything for that. The manifest
+ * does not ask for it.
+ *
+ * An album is a MediaStore bucket, one directory on the phone; photos and
+ * videos in it share its id.
+ */
+class PhotoSource(private val context: Context) {
+
+ data class Album(
+ val id: String, val name: String, val count: Int, val bytes: Long, val camera: Boolean,
+ val videos: Int = 0, val videoBytes: Long = 0,
+ )
+
+ private val images: Uri =
+ if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ private val videos: Uri =
+ if (Build.VERSION.SDK_INT >= 29) MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ else MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+
+ /** Every album with photos in it, and with videos too when `withVideos`. */
+ fun albums(withVideos: Boolean): List<Album> {
+ val by = LinkedHashMap<String, Album>()
+ query(images, false, null, null) { p, name, camera ->
+ val a = by[p.bucketId]
+ by[p.bucketId] = if (a == null) Album(p.bucketId, name, 1, p.size, camera)
+ else a.copy(count = a.count + 1, bytes = a.bytes + p.size, camera = a.camera || camera)
+ }
+ if (withVideos) query(videos, true, null, null) { p, name, camera ->
+ val a = by[p.bucketId] ?: Album(p.bucketId, name, 0, 0, camera)
+ by[p.bucketId] = a.copy(videos = a.videos + 1, videoBytes = a.videoBytes + p.size,
+ camera = a.camera || camera)
+ }
+ return by.values.sortedWith(compareByDescending<Album> { it.camera }.thenByDescending { it.count + it.videos })
+ }
+
+ fun photos(albums: List<String>, withVideos: Boolean): List<Photo> {
+ if (albums.isEmpty()) return emptyList()
+ val out = ArrayList<Photo>()
+ val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})"
+ query(images, false, where, albums.toTypedArray()) { p, _, _ -> out += p }
+ if (withVideos) query(videos, true, where, albums.toTypedArray()) { p, _, _ -> out += p }
+ return out
+ }
+
+ /**
+ * The files the photo backup sends, as lowercase `relative/path/name`
+ * from the top of their volume: what the files backup leaves out, so
+ * nothing is stored twice.
+ */
+ fun relativePaths(albums: List<String>, withVideos: Boolean): Set<String> {
+ if (albums.isEmpty()) return emptySet()
+ val out = HashSet<String>()
+ val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})"
+ @Suppress("DEPRECATION")
+ val cols = if (Build.VERSION.SDK_INT >= 29)
+ arrayOf(MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.DISPLAY_NAME)
+ else arrayOf(MediaStore.MediaColumns.DATA)
+ for (collection in listOfNotNull(images, videos.takeIf { withVideos })) {
+ context.contentResolver.query(collection, cols, where, albums.toTypedArray(), null)?.use { c ->
+ while (c.moveToNext()) {
+ val path = if (Build.VERSION.SDK_INT >= 29)
+ (c.getString(0) ?: continue).trim('/') + "/" + (c.getString(1) ?: continue)
+ else volumeRelative(c.getString(0) ?: continue)
+ out += path.lowercase(java.util.Locale.ROOT)
+ }
+ }
+ }
+ return out
+ }
+
+ fun open(p: Photo): InputStream? =
+ context.contentResolver.openInputStream(ContentUris.withAppendedId(if (p.video) videos else images, p.mediaId))
+
+ private fun query(collection: Uri, video: Boolean, where: String?, args: Array<String>?,
+ each: (Photo, String, Boolean) -> Unit) {
+ // The same columns exist for images and videos (MediaColumns), and on
+ // Android 8 and 9 under the same names in each collection.
+ val cols = mutableListOf(
+ MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.SIZE,
+ MediaStore.Images.Media.DATE_TAKEN, MediaStore.MediaColumns.DATE_ADDED,
+ MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.Images.Media.BUCKET_ID,
+ MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE,
+ )
+ @Suppress("DEPRECATION")
+ val location = if (Build.VERSION.SDK_INT >= 29) MediaStore.MediaColumns.RELATIVE_PATH else MediaStore.MediaColumns.DATA
+ cols += location
+ // A photo or video still being written by the camera is not one yet.
+ val pending = if (Build.VERSION.SDK_INT >= 29) "${MediaStore.MediaColumns.IS_PENDING} = 0" else null
+ val selection = listOfNotNull(pending, where).joinToString(" AND ").ifEmpty { null }
+ context.contentResolver.query(collection, cols.toTypedArray(), selection, args, null)?.use { c ->
+ val id = c.getColumnIndexOrThrow(cols[0]); val name = c.getColumnIndexOrThrow(cols[1])
+ val size = c.getColumnIndexOrThrow(cols[2]); val taken = c.getColumnIndexOrThrow(cols[3])
+ val added = c.getColumnIndexOrThrow(cols[4]); val modified = c.getColumnIndexOrThrow(cols[5])
+ val bucket = c.getColumnIndexOrThrow(cols[6]); val bucketName = c.getColumnIndexOrThrow(cols[7])
+ val mime = c.getColumnIndexOrThrow(cols[8]); val where2 = c.getColumnIndexOrThrow(cols[9])
+ while (c.moveToNext()) {
+ val bucketId = c.getString(bucket) ?: continue
+ val path = (c.getString(where2) ?: "").replace('\\', '/')
+ val display = c.getString(name) ?: ""
+ val album = c.getString(bucketName) ?: ""
+ // Where it was on the phone, from the top of its volume: what a
+ // restore puts it back to (the manifest, §9.12).
+ val relPath = if (Build.VERSION.SDK_INT >= 29) path.trim('/') + "/" + display else volumeRelative(path)
+ val photo = Photo(
+ c.getLong(id), display, c.getLong(size),
+ if (c.isNull(taken)) 0 else c.getLong(taken), c.getLong(added), c.getLong(modified),
+ bucketId, c.getString(mime) ?: "", video, relPath, album,
+ )
+ each(photo, album, isCamera(path))
+ }
+ }
+ }
+
+ companion object {
+ /** `/storage/emulated/0/Documents/a.jpg` or `/storage/1234-ABCD/x/a.jpg` → the part after the volume. */
+ fun volumeRelative(absolute: String): String {
+ val m = Regex("^/storage/(?:emulated/\\d+|[^/]+)/(.*)$").matchEntire(absolute)
+ return (m?.groupValues?.get(1) ?: absolute).trim('/')
+ }
+
+ /** `DCIM/Camera/` (RELATIVE_PATH) or `…/DCIM/Camera/x.jpg` (DATA, before Android 10). */
+ fun isCamera(path: String): Boolean =
+ path.startsWith("DCIM/Camera") || path.contains("/DCIM/Camera/")
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt
index 731da69..f3eb84e 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt
@@ -8,9 +8,9 @@ import android.webkit.WebView
* While `keepVisible` is set, the WebView is told its window stayed visible
* when the screen turns off. Chromium then never marks the page hidden, and
* its freeze of hidden pages — exactly 60 s after hiding, measured (spike
- * S-2a C) — never starts. Set only while a cast runs or music plays: a page that is never
- * hidden is never throttled, which is the battery cost the freeze exists to
- * avoid.
+ * S-2a C) — never starts. Set only while a cast runs, music plays or photos
+ * are being backed up: a page that is never hidden is never throttled, which
+ * is the battery cost the freeze exists to avoid.
*/
class ShellWebView(context: Context) : WebView(context) {
var keepVisible = false
diff --git a/packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt b/packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt
new file mode 100644
index 0000000..76dde1f
--- /dev/null
+++ b/packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt
@@ -0,0 +1,11 @@
+package org.meshbay.client.phonesync
+
+import android.content.Context
+
+/**
+ * What this build backs up beyond photos and contacts: nothing, in the Play
+ * build. Play's policy keeps READ_SMS for the default SMS application.
+ */
+object Flavor {
+ fun messageSource(context: Context): DocSource? = null
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt
new file mode 100644
index 0000000..29d8528
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt
@@ -0,0 +1,31 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.meshbay.client.photos.ByteRange
+
+class ByteRangeTest {
+ @Test fun `a range is read as the upload asks for it`() {
+ assertEquals(0L..1023L, ByteRange.parse("bytes=0-1023", 5000))
+ assertEquals(4096L..4999L, ByteRange.parse("bytes=4096-", 5000))
+ assertEquals("the last chunk may ask past the end", 4096L..4999L, ByteRange.parse("bytes=4096-8191", 5000))
+ }
+
+ @Test fun `what cannot be served is refused`() {
+ assertNull(ByteRange.parse(null, 10))
+ assertNull(ByteRange.parse("bytes=10-20", 10))
+ assertNull(ByteRange.parse("bytes=5-2", 10))
+ assertNull(ByteRange.parse("bytes=0-1,4-5", 10))
+ assertNull(ByteRange.parse("bytes=-5", 10))
+ assertNull(ByteRange.parse("items=0-1", 10))
+ }
+
+ @Test fun `a slice holds exactly those bytes`() {
+ val data = ByteArray(100_000) { (it % 251).toByte() }
+ val got = ByteRange.slice(data.inputStream(), 70_000L..99_999L).readBytes()
+ assertArrayEquals(data.copyOfRange(70_000, 100_000), got)
+ assertArrayEquals(data.copyOfRange(10, 12), ByteRange.slice(data.inputStream(), 10L..11L).readBytes())
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt
new file mode 100644
index 0000000..f53110b
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt
@@ -0,0 +1,81 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.meshbay.client.phonesync.Destination
+import org.meshbay.client.phonesync.DestinationChannels
+import org.meshbay.client.phonesync.DocPlan
+import org.json.JSONArray
+import org.meshbay.client.bridge.Refused
+import java.util.TimeZone
+
+class DocSyncTest {
+ private val utc = TimeZone.getTimeZone("UTC")
+ // 2026-10-09 12:00:00 UTC
+ private val oct9 = 1791547200000L
+
+ private fun settings(account: String = "bob", folder: String = "Backups") = JSONObject()
+ .put("account", account).put("groupId", "g1").put("groupName", "Mine").put("folder", folder)
+
+ @Test fun `each kind goes into a folder of its own named for the account`() {
+ val c = Destination.fromJson(settings(folder = "/Backups/Phone/"))
+ assertEquals("Backups/Phone/bob-contacts", DocPlan.dirFor(c, "contacts"))
+ assertEquals("Backups/Phone/bob-messages", DocPlan.dirFor(c, "messages"))
+ }
+
+ @Test fun `a file is named for when it was taken, in the phone's time`() {
+ assertEquals("contacts-2026-10-09-1200.vcf", DocPlan.nameFor("contacts", "vcf", oct9, utc))
+ assertEquals("contacts-2026-10-09-1400.vcf",
+ DocPlan.nameFor("contacts", "vcf", oct9, TimeZone.getTimeZone("Europe/Paris")))
+ }
+
+ @Test fun `a folder that climbs out or an account that is not a name is refused`() {
+ for (bad in listOf("../etc", "Backups/../x", "Backups//x", "./x", "")) {
+ assertThrows(bad, IllegalArgumentException::class.java) { Destination.fromJson(settings(folder = bad)) }
+ }
+ for (bad in listOf("../bob", "bob/x", "", "-bob")) {
+ assertThrows(bad, IllegalArgumentException::class.java) { Destination.fromJson(settings(account = bad)) }
+ }
+ }
+
+ @Test fun `settings survive being stored`() {
+ val c = Destination.fromJson(settings())
+ assertEquals(c, Destination.parse(c.toJson().toString()))
+ assertNull(Destination.parse("{not json"))
+ }
+
+ @Test fun `another group or folder is another backup`() {
+ val a = Destination.fromJson(settings())
+ assertNotEquals(a.ledgerKey, Destination.fromJson(settings(folder = "Other")).ledgerKey)
+ assertNotEquals(a.ledgerKey, Destination.fromJson(settings().put("groupId", "g2")).ledgerKey)
+ assertEquals(a.ledgerKey, Destination.fromJson(settings().put("groupName", "Renamed")).ledgerKey)
+ }
+
+ @Test fun `every kind is told when the destination moves, and only then`() {
+ val store = DestinationChannels(FakePrefs())
+ var told = 0
+ store.onChange { told++ }
+ store.call("phonesync:set-destination", JSONArray().put(settings()))
+ assertEquals(1, told)
+ store.call("phonesync:set-destination", JSONArray().put(settings().put("groupName", "Renamed")))
+ assertEquals("a new name for the same place is not a new backup", 1, told)
+ store.call("phonesync:set-destination", JSONArray().put(settings(folder = "Other")))
+ assertEquals(2, told)
+ store.call("phonesync:set-destination", JSONArray())
+ assertEquals(3, told)
+ assertNull(store.get())
+ }
+
+ @Test fun `a destination the page got wrong is refused and the old one kept`() {
+ val store = DestinationChannels(FakePrefs())
+ store.call("phonesync:set-destination", JSONArray().put(settings()))
+ assertThrows(Refused::class.java) {
+ store.call("phonesync:set-destination", JSONArray().put(settings(folder = "../x")))
+ }
+ assertEquals("Backups", store.get()!!.folder)
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DriveTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DriveTest.kt
new file mode 100644
index 0000000..e661bd1
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DriveTest.kt
@@ -0,0 +1,90 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.meshbay.client.drive.DriveFile
+import org.meshbay.client.drive.DriveLedger
+import org.meshbay.client.drive.DrivePlan
+import org.meshbay.client.photos.PhotoPlan
+import org.meshbay.client.photos.PhotoSource
+import java.util.TimeZone
+
+class DriveTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private val utc = TimeZone.getTimeZone("UTC")
+ private val tree = "content://tree/primary%3ADocuments"
+ private val names = mapOf(tree to "Documents")
+
+ private fun file(id: String, sub: String = "", size: Long = 10, modified: Long = 1000) =
+ DriveFile(tree, "primary:Documents/" + (if (sub.isEmpty()) "" else "$sub/") + id, sub, id, size, modified,
+ "application/pdf")
+
+ private fun plan(files: List<DriveFile>, sent: Map<String, DriveLedger.Entry> = emptyMap(),
+ skip: Set<String> = emptySet(), same: Boolean = false) =
+ DrivePlan.plan(files, "Backups/bob-drive", names, { sent[it] }, skip, utc) { _, _ -> same }
+
+ private fun entry(f: DriveFile) = DriveLedger.Entry(f.docId, f.modified, f.size, "h", "d", f.name, 1)
+
+ @Test fun `media folders and a whole volume cannot be chosen`() {
+ assertEquals("whole_storage", DrivePlan.refusal("primary:"))
+ assertEquals("whole_storage", DrivePlan.refusal("1234-ABCD:"))
+ assertEquals("media_folder", DrivePlan.refusal("primary:DCIM"))
+ assertEquals("media_folder", DrivePlan.refusal("primary:DCIM/Camera"))
+ assertEquals("media_folder", DrivePlan.refusal("primary:Pictures/Screenshots"))
+ assertEquals("media_folder", DrivePlan.refusal("1234-ABCD:movies"))
+ assertNull(DrivePlan.refusal("primary:Documents"))
+ assertNull(DrivePlan.refusal("primary:Download/Invoices"))
+ assertNull(DrivePlan.refusal("primary:Documents/Pictures"))
+ }
+
+ @Test fun `files go under the chosen folder's name, sub-folders kept`() {
+ val out = plan(listOf(file("a.pdf"), file("b.pdf", sub = "Tax/2026")))
+ assertEquals(setOf("Backups/bob-drive/Documents", "Backups/bob-drive/Documents/Tax/2026"),
+ out.map { it.dir }.toSet())
+ }
+
+ @Test fun `smallest first, hidden files and photo-backed files left out`() {
+ val out = plan(listOf(file("big.pdf", size = 900), file("small.pdf", size = 5), file(".secret"),
+ file("scan.jpg", sub = "Scans")),
+ skip = setOf("documents/scans/scan.jpg"))
+ assertEquals(listOf("small.pdf", "big.pdf"), out.map { it.name })
+ }
+
+ @Test fun `a file already sent is not sent again, a changed one goes beside it`() {
+ val f = file("a.pdf")
+ assertTrue(plan(listOf(f), sent = mapOf(f.docId to entry(f))).isEmpty())
+ val changed = f.copy(size = 20, modified = 86_400_000)
+ val out = plan(listOf(changed), sent = mapOf(f.docId to entry(f)))
+ assertEquals("a-modified-19700102-000000.pdf", out.single().name)
+ assertTrue(out.single().edited)
+ val touched = f.copy(modified = 5000)
+ assertTrue(plan(listOf(touched), sent = mapOf(f.docId to entry(f)), same = true).isEmpty())
+ }
+
+ @Test fun `names the node would refuse are mended, not dropped`() {
+ assertEquals("report.pdf", DrivePlan.safeName("report.pdf"))
+ assertEquals("a_b_c.txt", DrivePlan.safeName("a:b?c.txt"))
+ assertEquals("file-.env", DrivePlan.safeName(".env"))
+ assertEquals("file-_draft", DrivePlan.safeName("_draft"))
+ assertTrue(PhotoPlan.UPLOAD_NAME.matches(DrivePlan.safeName("x".repeat(300) + ".pdf")))
+ assertTrue(PhotoPlan.UPLOAD_NAME.matches(DrivePlan.safeName("end. ")))
+ }
+
+ @Test fun `the ledger remembers across a restart`() {
+ val path = tmp.newFile("d.jsonl")
+ DriveLedger(path).apply { record(entry(file("a.pdf"))); record(entry(file("a.pdf")).copy(sha256 = "z")) }
+ val again = DriveLedger(path)
+ assertEquals(1, again.size)
+ assertEquals("z", again["primary:Documents/a.pdf"]!!.sha256)
+ }
+
+ @Test fun `a path from before Android 10 is read from the top of its volume`() {
+ assertEquals("Documents/Scans/a.jpg", PhotoSource.volumeRelative("/storage/emulated/0/Documents/Scans/a.jpg"))
+ assertEquals("x/a.jpg", PhotoSource.volumeRelative("/storage/1234-ABCD/x/a.jpg"))
+ }
+}
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-android/app/src/test/kotlin/org/meshbay/client/ManifestLogTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ManifestLogTest.kt
new file mode 100644
index 0000000..bb3a67f
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ManifestLogTest.kt
@@ -0,0 +1,52 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.meshbay.client.phonesync.ManifestLog
+import java.io.File
+import java.util.TimeZone
+
+class ManifestLogTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private fun line(n: Int) = JSONObject().put("node", "d/$n.jpg").put("source", "DCIM/Camera/$n.jpg")
+
+ private fun nodes(f: File) = f.readLines().filter { it.isNotBlank() }.map { JSONObject(it).getString("node") }
+
+ @Test fun `nothing waiting means nothing to send`() {
+ val log = ManifestLog(File(tmp.root, "m"))
+ assertFalse(log.waiting())
+ assertNull(log.issue())
+ }
+
+ @Test fun `what was sent is described once the node has the manifest, and only then forgotten`() {
+ val log = ManifestLog(File(tmp.root, "m"))
+ log.append(line(1)); log.append(line(2))
+ assertTrue(log.waiting())
+ assertEquals(listOf("d/1.jpg", "d/2.jpg"), nodes(log.issue()!!))
+ log.confirm()
+ assertFalse(log.waiting())
+ assertNull(log.issue())
+ }
+
+ @Test fun `a manifest that did not arrive goes again, with what came since`() {
+ val log = ManifestLog(File(tmp.root, "m"))
+ log.append(line(1))
+ log.issue() // sent, never confirmed: the run was cut short
+ log.append(line(2))
+ val again = ManifestLog(File(tmp.root, "m")) // a restart in between
+ assertTrue(again.waiting())
+ assertEquals(listOf("d/1.jpg", "d/2.jpg"), nodes(again.issue()!!))
+ }
+
+ @Test fun `manifests are named for when they were written`() {
+ assertEquals("manifest-2026-10-09-120000.jsonl",
+ ManifestLog.nameFor(1791547200000L, TimeZone.getTimeZone("UTC")))
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt
new file mode 100644
index 0000000..a87bffb
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt
@@ -0,0 +1,202 @@
+package org.meshbay.client
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.meshbay.client.phonesync.Destination
+import org.meshbay.client.photos.Photo
+import org.meshbay.client.photos.PhotoLedger
+import org.meshbay.client.photos.PhotoPlan
+import org.meshbay.client.photos.SyncConfig
+import java.util.TimeZone
+
+class PhotoSyncTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private val utc = TimeZone.getTimeZone("UTC")
+ // 2026-10-09 12:00:00 UTC
+ private val oct9 = 1791547200000L
+
+ private fun photo(id: Long, name: String = "IMG_$id.jpg", size: Long = 100, taken: Long = oct9,
+ added: Long = oct9 / 1000, modified: Long = oct9 / 1000, bucket: String = "cam",
+ mime: String = "image/jpeg", video: Boolean = false) =
+ Photo(id, name, size, taken, added, modified, bucket, mime, video)
+
+ private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List<String> = listOf("cam"),
+ videos: Boolean = false) =
+ SyncConfig(albums, includeExisting, since, videos)
+
+ private val base = PhotoPlan.baseFor(Destination("bob", "g1", "Mine", "Backups"))
+
+ private fun entry(p: Photo, sha: String = "h", name: String = p.displayName) =
+ PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, "d", name, 1)
+
+ private fun plan(photos: List<Photo>, c: SyncConfig = config(), ledger: Map<Long, PhotoLedger.Entry> = emptyMap(),
+ same: Boolean = false) =
+ PhotoPlan.plan(photos, c, base, { ledger[it] }, utc) { _, _ -> same }
+
+ // ── what is sent ──────────────────────────────────────────────────────────
+
+ @Test fun `everything already on the phone is sent, newest first, under its year and month`() {
+ val out = plan(listOf(photo(1, taken = oct9 - 40L * 86400000), photo(2), photo(3, taken = oct9 - 86400000)))
+ assertEquals(listOf(2L, 3L, 1L), out.map { it.photo.mediaId })
+ assertEquals("Backups/bob-photos/2026/2026-10", out[0].dir)
+ assertEquals("Backups/bob-photos/2026/2026-08", out[2].dir)
+ assertEquals("IMG_2.jpg", out[0].name)
+ }
+
+ @Test fun `from now on leaves out what was on the phone before`() {
+ val since = oct9 + 1000
+ val out = plan(listOf(photo(1), photo(2, added = (oct9 + 5000) / 1000)), config(includeExisting = false, since = since))
+ assertEquals(listOf(2L), out.map { it.photo.mediaId })
+ }
+
+ @Test fun `only the chosen albums, and only images`() {
+ val out = plan(listOf(photo(1), photo(2, bucket = "screens"), photo(3, mime = "video/mp4")))
+ assertEquals(listOf(1L), out.map { it.photo.mediaId })
+ }
+
+ @Test fun `videos of the same albums go beside the photos, only when asked for`() {
+ val clip = photo(5, name = "PXL_20261009_120000.mp4", mime = "video/mp4", video = true)
+ assertTrue(plan(listOf(clip)).isEmpty())
+ val out = plan(listOf(photo(1), clip), config(videos = true))
+ assertEquals(setOf(1L, 5L), out.map { it.photo.mediaId }.toSet())
+ assertEquals("Backups/bob-photos/2026/2026-10", out.first { it.photo.video }.dir)
+ assertTrue("a video file is not taken for a photo", plan(listOf(photo(6, mime = "video/mp4")), config(videos = true)).isEmpty())
+ assertEquals("video-9.mp4", PhotoPlan.nameFor(photo(9, name = "_clip.mp4", mime = "video/mp4", video = true)))
+ assertEquals("video-9.mov", PhotoPlan.nameFor(photo(9, name = "", mime = "video/quicktime", video = true)))
+ }
+
+ @Test fun `ticking videos in keeps the starting point`() {
+ val o = JSONObject().put("albums", JSONArray(listOf("cam"))).put("includeExisting", false)
+ val first = SyncConfig.fromJson(o, 100)
+ val withVideos = SyncConfig.fromJson(o.put("includeVideos", true), 200, first)
+ assertEquals(100, withVideos.since)
+ assertTrue(withVideos.includeVideos)
+ assertEquals(withVideos, SyncConfig.parse(withVideos.toJson().toString()))
+ assertFalse(SyncConfig.parse("""{"albums":[],"since":1}""")!!.includeVideos)
+ }
+
+ @Test fun `a photo already sent is not sent again`() {
+ val p = photo(1)
+ assertTrue(plan(listOf(p), ledger = mapOf(1L to entry(p))).isEmpty())
+ }
+
+ @Test fun `a photo deleted on the phone is simply not listed, and nothing is asked of the node`() {
+ // The plan has only additions in it: there is no other kind of item.
+ val sent = photo(1)
+ assertTrue(plan(emptyList(), ledger = mapOf(1L to entry(sent))).isEmpty())
+ }
+
+ // ── edits ────────────────────────────────────────────────────────────────
+
+ @Test fun `an edit is sent beside the original under a name that says so`() {
+ val before = photo(1)
+ val after = before.copy(size = 120, modified = before.modified + 3600)
+ val out = plan(listOf(after), ledger = mapOf(1L to entry(before)))
+ assertEquals(1, out.size)
+ assertTrue(out[0].edited)
+ assertEquals("IMG_1-edited-20261009-130000.jpg", out[0].name)
+ assertEquals("Backups/bob-photos/2026/2026-10", out[0].dir)
+ }
+
+ @Test fun `a touch that left the bytes alone sends nothing`() {
+ val before = photo(1)
+ val touched = before.copy(modified = before.modified + 60)
+ assertTrue(plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = true).isEmpty())
+ assertEquals(1, plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = false).size)
+ }
+
+ @Test fun `the bytes are only read when the date moved and the size did not`() {
+ val before = photo(1)
+ var asked = 0
+ PhotoPlan.plan(listOf(before), config(), base, { entry(before) }, utc) { _, _ -> asked++; true }
+ PhotoPlan.plan(listOf(before.copy(size = 7, modified = 9)), config(), base, { entry(before) }, utc) { _, _ -> asked++; true }
+ assertEquals(0, asked)
+ }
+
+ // ── names ────────────────────────────────────────────────────────────────
+
+ @Test fun `a name the node would refuse is replaced before it is sent`() {
+ assertEquals("IMG_1.jpg", PhotoPlan.nameFor(photo(1)))
+ assertEquals("photo-7.jpg", PhotoPlan.nameFor(photo(7, name = ".hidden.jpg")))
+ assertEquals("photo-8.png", PhotoPlan.nameFor(photo(8, name = "_x.png")))
+ assertEquals("photo-9.jpg", PhotoPlan.nameFor(photo(9, name = "")))
+ assertTrue(PhotoPlan.UPLOAD_NAME.matches("PXL_20261009_120000123.jpg"))
+ assertFalse(PhotoPlan.UPLOAD_NAME.matches("a.jpg."))
+ }
+
+ @Test fun `an edited name stays within the node's length`() {
+ val long = photo(1, name = "A".repeat(124) + ".jpg", modified = 1)
+ val name = PhotoPlan.editedName(long, utc)
+ assertTrue(name.length <= 128)
+ assertTrue(PhotoPlan.UPLOAD_NAME.matches(name))
+ }
+
+ // ── when ─────────────────────────────────────────────────────────────────
+
+ @Test fun `once a day, counted from the last run that finished`() {
+ assertTrue(PhotoPlan.due(null, oct9))
+ assertFalse(PhotoPlan.due(oct9 - 3600_000, oct9))
+ assertTrue(PhotoPlan.due(oct9 - PhotoPlan.DAY_MS, oct9))
+ assertTrue("a clock moved back must not stop backups for good", PhotoPlan.due(oct9 + 3600_000, oct9))
+ }
+
+ // ── settings from the page ───────────────────────────────────────────────
+
+ @Test fun `photos go into the account's own folder under the destination`() {
+ assertEquals("Backups/bob-photos", base)
+ }
+
+ @Test fun `changing the albums keeps the starting point, changing the scope moves it`() {
+ val o = JSONObject().put("albums", JSONArray(listOf("cam"))).put("includeExisting", false)
+ val first = SyncConfig.fromJson(o, 100)
+ assertEquals(100, SyncConfig.fromJson(o.put("albums", JSONArray(listOf("cam", "x"))), 200, first).since)
+ assertEquals(300, SyncConfig.fromJson(o.put("includeExisting", true), 300, first).since)
+ }
+
+ @Test fun `settings survive being stored`() {
+ val c = config(albums = listOf("a", "b"))
+ assertEquals(c, SyncConfig.parse(c.toJson().toString()))
+ assertNull(SyncConfig.parse("{}"))
+ }
+
+ // ── the ledger ───────────────────────────────────────────────────────────
+
+ @Test fun `the ledger remembers across a restart, last line wins`() {
+ val f = tmp.newFile("l.jsonl")
+ PhotoLedger(f).apply {
+ record(entry(photo(1), sha = "a"))
+ record(entry(photo(2), sha = "b"))
+ record(entry(photo(1), sha = "c"))
+ }
+ val again = PhotoLedger(f)
+ assertEquals(2, again.size)
+ assertEquals("c", again[1]!!.sha256)
+ }
+
+ @Test fun `a line cut short by a killed process costs that one photo, not the ledger`() {
+ val f = tmp.newFile("l.jsonl")
+ PhotoLedger(f).record(entry(photo(1)))
+ f.appendText("{\"id\":2,\"m\":")
+ val again = PhotoLedger(f)
+ assertEquals(1, again.size)
+ assertNull(again[2])
+ }
+
+ @Test fun `a ledger rewritten many times is compacted on load`() {
+ val f = tmp.newFile("l.jsonl")
+ val l = PhotoLedger(f)
+ repeat(300) { l.record(entry(photo(1), sha = "s$it")) }
+ val again = PhotoLedger(f)
+ assertEquals(1, again.size)
+ assertEquals("s299", again[1]!!.sha256)
+ assertEquals(1, f.readLines().count { it.isNotBlank() })
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt
new file mode 100644
index 0000000..9945ec1
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt
@@ -0,0 +1,66 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.phonesync.DocPlan
+import org.meshbay.client.phonesync.Sms
+import org.meshbay.client.phonesync.SmsXml
+import org.w3c.dom.Element
+import java.io.StringWriter
+import java.util.TimeZone
+import javax.xml.parsers.DocumentBuilderFactory
+
+class SmsXmlTest {
+ private val utc = TimeZone.getTimeZone("UTC")
+ // 2026-10-09 12:00:00 UTC
+ private val oct9 = 1791547200000L
+
+ private fun sms(id: Long, body: String, address: String = "+33600000000", name: String? = null) =
+ Sms(id, address, oct9, oct9 - 5000, 1, body, 1, -1, 0, 0, null, null, name)
+
+ private fun parse(xml: String): List<Element> {
+ val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xml.byteInputStream())
+ assertEquals("smses", doc.documentElement.tagName)
+ val list = doc.getElementsByTagName("sms")
+ return (0 until list.length).map { list.item(it) as Element }
+ }
+
+ private fun write(vararg m: Sms) = StringWriter().also { SmsXml.write(m.toList(), it, utc) }.toString()
+
+ @Test fun `what a restore reads is there, with the count`() {
+ val xml = write(sms(1, "hello"), sms(2, "again", name = "Ada"))
+ assertTrue(xml.contains("<smses count=\"2\">"))
+ val rows = parse(xml)
+ assertEquals("hello", rows[0].getAttribute("body"))
+ assertEquals("+33600000000", rows[0].getAttribute("address"))
+ assertEquals(oct9.toString(), rows[0].getAttribute("date"))
+ assertEquals("1", rows[0].getAttribute("type"))
+ assertEquals("(Unknown)", rows[0].getAttribute("contact_name"))
+ assertEquals("Ada", rows[1].getAttribute("contact_name"))
+ assertEquals("9 Oct 2026 12:00:00", rows[0].getAttribute("readable_date"))
+ assertEquals("null", rows[0].getAttribute("subject"))
+ }
+
+ @Test fun `markup, quotes, line breaks and emoji come back as they were`() {
+ val body = "a < b & \"c\" > 'd'\nsecond line\r\n\ttab 🎉 é"
+ assertEquals(body, parse(write(sms(1, body)))[0].getAttribute("body"))
+ }
+
+ @Test fun `characters XML cannot carry are left out rather than breaking the file`() {
+ val rows = parse(write(sms(1, "a\u0000b\u0007c￿d")))
+ assertEquals("abcd", rows[0].getAttribute("body"))
+ }
+
+ @Test fun `an empty body or address is still a well-formed row`() {
+ val rows = parse(write(sms(1, "", address = "")))
+ assertEquals("", rows[0].getAttribute("body"))
+ assertFalse(rows[0].hasAttribute("missing"))
+ }
+
+ @Test fun `a file goes under the year it was written in`() {
+ assertEquals("2026", DocPlan.yearOf(oct9, utc))
+ assertEquals("sms-2026-10-09-1200.xml", DocPlan.nameFor("sms", "xml", oct9, utc))
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt
new file mode 100644
index 0000000..f3e76fe
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt
@@ -0,0 +1,57 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.drive.DriveFile
+import org.meshbay.client.drive.DriveKind
+import org.meshbay.client.drive.DrivePlan
+import org.meshbay.client.drive.WhatsApp
+import java.util.TimeZone
+
+class WhatsAppTest {
+ private val tree = "content://tree/wa"
+
+ private fun file(sub: String, name: String, size: Long = 10) =
+ DriveFile(tree, "primary:${WhatsApp.FOLDER}/$sub/$name", sub, name, size, 1000, "application/octet-stream")
+
+ @Test fun `only WhatsApp's own folder can be chosen`() {
+ assertNull(WhatsApp.refusal("primary:Android/media/com.whatsapp/WhatsApp"))
+ assertNull(WhatsApp.refusal("primary:Android/media/com.whatsapp.w4b/WhatsApp Business"))
+ assertEquals("not_whatsapp", WhatsApp.refusal("primary:Android/media/com.whatsapp"))
+ assertEquals("not_whatsapp", WhatsApp.refusal("primary:Documents"))
+ assertEquals("not_whatsapp", WhatsApp.refusal("primary:Android/media/com.whatsapp/WhatsApp/Media"))
+ }
+
+ @Test fun `the chats as they are now, not the dated copies of earlier weeks`() {
+ assertTrue(WhatsApp.include(file("Databases", "msgstore.db.crypt14"), false))
+ assertTrue(WhatsApp.include(file("Databases", "msgstore-increment-1.db.crypt14"), false))
+ assertFalse(WhatsApp.include(file("Databases", "msgstore-2026-10-09.1.db.crypt14"), false))
+ assertFalse(WhatsApp.include(file("Databases", "msgstore-increment-3-2026-10-09.1.db.crypt14"), false))
+ assertTrue(WhatsApp.include(file("Backups", "wa.db.crypt14"), false))
+ assertTrue(WhatsApp.include(file("Backups/Stickers", "s.webp"), false))
+ }
+
+ @Test fun `media only when asked for, and nothing else of the folder`() {
+ val photo = file("Media/WhatsApp Images", "IMG-20261010-WA0001.jpg")
+ assertFalse(WhatsApp.include(photo, false))
+ assertTrue(WhatsApp.include(photo, true))
+ assertFalse(WhatsApp.include(file(".Shared", "x"), true))
+ assertTrue(DriveKind.WHATSAPP.include(photo, JSONObject().put("media", true)))
+ }
+
+ @Test fun `what a restore needs together is the chats and the backups, not the media`() {
+ assertTrue(WhatsApp.inRestoreSet(file("Databases", "msgstore.db.crypt14")))
+ assertTrue(WhatsApp.inRestoreSet(file("Backups", "wa.db.crypt14")))
+ assertFalse(WhatsApp.inRestoreSet(file("Media/WhatsApp Video", "v.mp4")))
+ }
+
+ @Test fun `on the node the folders are WhatsApp's own, with no extra level`() {
+ val out = DrivePlan.plan(listOf(file("Databases", "msgstore.db.crypt14")), "Backups/bob-whatsapp",
+ mapOf(tree to ""), { null }, emptySet(), TimeZone.getTimeZone("UTC")) { _, _ -> false }
+ assertEquals("Backups/bob-whatsapp/Databases", out.single().dir)
+ }
+}