aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 14:21:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 14:21:46 +0200
commit2f2a9a6542d2194e50ffba6f382e4fdffd481838 (patch)
tree5437330f9e8b963f8af0904f3c5d80d1afc6dc3b /packages/meshbay-android
parent914d2f0af848fb80d70d517901f44420a4e47764 (diff)
downloadmeshbay-2f2a9a6542d2194e50ffba6f382e4fdffd481838.tar.gz
feat(android): one backup destination, a group the account owns alone
Chosen once at the top of Android Sync for every kind; photos and contacts go into <folder>/<account>-photos and -contacts. Owner and sole member are checked at set-up and before every run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android')
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js7
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt8
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt3
-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.kt42
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt44
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt57
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt61
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt52
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt32
10 files changed, 238 insertions, 147 deletions
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 8aba36d..b64d7f2 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
@@ -213,6 +213,13 @@
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.
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 335e22d..3fa1146 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
@@ -37,6 +37,7 @@ import org.meshbay.client.notify.PushChannels
import org.meshbay.client.notify.PushState
import org.meshbay.client.photos.BackupService
import org.meshbay.client.phonesync.ContactSource
+import org.meshbay.client.phonesync.DestinationChannels
import org.meshbay.client.phonesync.DocChannels
import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.SaveSinks
@@ -99,11 +100,12 @@ 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() } })
- photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE),
+ 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(on, line) } })
docs = listOf(
- DocChannels("contactsync", this, getSharedPreferences("contactsync", Context.MODE_PRIVATE),
+ DocChannels("contactsync", this, getSharedPreferences("contactsync", Context.MODE_PRIVATE), destinations,
java.io.File(cacheDir, "contactsync"), ContactSource(this),
notifyId = 10, permissionRequest = 4209),
)
@@ -114,7 +116,7 @@ class MainActivity : Activity() {
channelNames = { mapOf(
Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale),
Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }),
- photos = photos, docs = docs)
+ photos = photos, destinations = destinations, docs = docs)
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
cast.control.warmUp()
installShim()
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 f988888..512aa13 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,7 @@ 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.phonesync.DestinationChannels
import org.meshbay.client.phonesync.DocChannels
import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.BinaryFrame
@@ -31,6 +32,7 @@ class Channels(
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(),
) {
@Volatile var locale = "en"
@@ -61,6 +63,7 @@ class Channels(
cast != null && cast.handles(channel) -> cast.call(channel, args)
push != null && push.handles(channel) -> push.call(channel, args)
photos != null && photos.handles(channel) -> photos.call(channel, args)
+ destinations != null && destinations.handles(channel) -> destinations.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/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
index 0b8db3d..6877912 100644
--- 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
@@ -25,12 +25,15 @@ import java.util.concurrent.TimeUnit
* 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.
*
- * `prefix` names the channels (`contactsync:status`, …) and the preferences.
+ * 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,
@@ -42,6 +45,15 @@ class DocChannels(
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:")) {
@@ -57,14 +69,16 @@ class DocChannels(
// ── state ────────────────────────────────────────────────────────────────
- private fun config(): DocConfig? = DocConfig.parse(prefs.getString(CONFIG, null))
+ /** 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 c = config()
+ val d = destinations.get()
return JSONObject()
.put("permission", if (permitted()) "granted" else "denied")
- .put("config", c?.toJson() ?: JSONObject.NULL)
- .put("dir", c?.let { DocPlan.dirFor(it, source.suffix) } ?: JSONObject.NULL)
+ .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)
@@ -73,21 +87,15 @@ class DocChannels(
.put("now", System.currentTimeMillis())
}
+ /** `{}` turns it on, null off; turned off, it forgets what it sent. */
private fun configure(o: JSONObject?) {
- val previous = config()
issued = null
if (o == null) {
prefs.edit().clear().apply()
return
}
- val next = try { DocConfig.fromJson(o) } catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") }
- val edit = prefs.edit().putString(CONFIG, next.toJson().toString())
- // Somewhere new starts from nothing: the first file there holds everything.
- if (previous == null || previous.ledgerKey != next.ledgerKey) {
- edit.remove(MARKER).remove(SENT).remove(LAST_SENT).remove(LAST)
- .remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED)
- }
- edit.apply()
+ if (destinations.get() == null) throw Refused("Refused: no destination")
+ prefs.edit().putBoolean(ON, true).apply()
}
private fun completed() {
@@ -107,7 +115,7 @@ class DocChannels(
// ── the file ─────────────────────────────────────────────────────────────
private fun plan(): JSONObject {
- val c = config() ?: throw Refused("Refused: this backup is off")
+ val c = active() ?: throw Refused("Refused: this backup is off")
issued = null
dir.listFiles()?.forEach { it.delete() }
dir.mkdirs()
@@ -123,7 +131,7 @@ class DocChannels(
/** 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 (config()?.ledgerKey != i.key) throw Refused("Refused: the backup changed")
+ 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
@@ -167,7 +175,7 @@ class DocChannels(
companion object {
const val PATH = "/phonesync/"
- private const val CONFIG = "config"
+ private const val ON = "on"
private const val MARKER = "marker"
private const val SENT = "sent"
private const val LAST_SENT = "last_sent"
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
index e74d19e..b1563da 100644
--- 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
@@ -1,52 +1,11 @@
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
-/**
- * Where one kind of phone data goes, as the person set it up: a writable
- * folder of a group only they belong to (docs/MESHBAY_DESIGN.md §9.13).
- */
-data class DocConfig(
- val account: String,
- val groupId: String,
- val groupName: String,
- val owner: 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("owner", owner).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): DocConfig {
- 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 DocConfig(account, groupId, o.optString("groupName", "").take(256),
- o.optString("owner", "").take(64), folder)
- }
-
- fun parse(text: String?): DocConfig? = try {
- val o = JSONObject(text ?: return null)
- DocConfig(o.getString("account"), o.getString("groupId"), o.optString("groupName"),
- o.optString("owner"), o.getString("folder"))
- } catch (e: Exception) { null }
- }
-}
-
/** One file a run sends, and what is recorded once the node has it. */
data class Export(
val file: File,
@@ -80,7 +39,8 @@ object DocPlan {
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(config: DocConfig, suffix: String): String = "${config.folder}/${config.account}-$suffix"
+ 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
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
index 2e7fb55..3eb0ead 100644
--- 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
@@ -20,6 +20,8 @@ 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 java.io.File
import java.io.FilterInputStream
import java.io.InputStream
@@ -45,6 +47,7 @@ import java.util.concurrent.TimeUnit
class PhotoChannels(
private val activity: Activity,
private val prefs: SharedPreferences,
+ private val destinations: DestinationChannels,
private val dir: File,
private val onKeepAlive: (Boolean, String) -> Unit,
) {
@@ -57,6 +60,17 @@ class PhotoChannels(
@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) {
@@ -77,18 +91,27 @@ class PhotoChannels(
private fun config(): SyncConfig? = SyncConfig.parse(prefs.getString(CONFIG, null))
- private fun ledger(c: SyncConfig) = PhotoLedger(File(dir, hex(sha256Of(c.ledgerKey.toByteArray())).take(32) + ".jsonl"))
+ /** 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 ledger(d: Destination) = PhotoLedger(File(dir, hex(sha256Of(d.ledgerKey.toByteArray())).take(32) + ".jsonl"))
fun status(): JSONObject {
val c = config()
+ val d = destinations.get()
return JSONObject()
.put("permission", permissionState())
.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", c?.let { ledger(it).size } ?: 0)
+ .put("sent", if (c != null && d != null) ledger(d).size else 0)
.put("now", System.currentTimeMillis())
}
@@ -99,12 +122,12 @@ class PhotoChannels(
tokens.clear()
return
}
- val next = try { SyncConfig.fromJson(o, System.currentTimeMillis(), previous) }
- catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") }
+ 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 destination or 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.ledgerKey != next.ledgerKey || previous.since != next.since) {
+ // 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()
@@ -138,17 +161,17 @@ class PhotoChannels(
/** What a backup set up this way would send first: the count and size the confirmation states. */
private fun estimate(o: JSONObject): JSONObject {
- val c = try { SyncConfig.fromJson(o, System.currentTimeMillis(), config()) }
- catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") }
- val ledger = ledger(c)
- val items = PhotoPlan.plan(source.photos(c.albums), c, ledger::get) { _, _ -> true }
+ 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, PhotoPlan.baseFor(d), ledger::get) { _, _ -> true }
return JSONObject().put("count", items.size).put("bytes", items.sumOf { it.photo.size })
}
private fun plan(): JSONObject {
- val c = config() ?: throw Refused("Refused: photo backup is off")
- val ledger = ledger(c)
- val items = PhotoPlan.plan(source.photos(c.albums), c, ledger::get) { p, sent ->
+ val (d, c) = active() ?: throw Refused("Refused: photo backup is off")
+ val ledger = ledger(d)
+ val items = PhotoPlan.plan(source.photos(c.albums), c, PhotoPlan.baseFor(d), ledger::get) { p, sent ->
hashOf(p.mediaId)?.let { it == sent.sha256 } ?: true
}
// A new plan replaces the last one: tokens are for one run, never kept.
@@ -156,7 +179,7 @@ class PhotoChannels(
val out = JSONArray()
for (p in items) {
val token = hex(ByteArray(16).also { random.nextBytes(it) })
- tokens[token] = Issued(p, c.ledgerKey)
+ 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))
// After a reinstall the ledger is empty, and the page looks in the
@@ -169,10 +192,10 @@ class PhotoChannels(
/** 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 c = config()?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed")
+ 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.mediaId) ?: throw Refused("Refused: the photo is gone")
- ledger(c).record(PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha,
+ ledger(d).record(PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha,
dir.take(1024), name.take(256), System.currentTimeMillis()))
tokens.remove(token)
}
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
index 7e7801d..ee0275e 100644
--- 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
@@ -2,6 +2,8 @@ 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
@@ -22,48 +24,28 @@ data class Photo(
)
/**
- * Where this phone's photos go and which of them, as the person set it up.
- * One group per phone (docs/MESHBAY_DESIGN.md §9.12).
+ * 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 account: String,
- val groupId: String,
- val groupName: String,
- val owner: String,
- /** A folder among the group's own, as a virtual path (`Photos/Family`). */
- val folder: String,
val albums: List<String>,
- /** The photos already on the phone too — the default, as backup applications do. */
+ /** The photos already on the phone too: the default, as backup applications do. */
val includeExisting: Boolean,
- /** When it was set up, ms: with `includeExisting` off, only photos added since count. */
+ /** When it was set up or moved, ms: with `includeExisting` off, only photos added since count. */
val since: Long,
) {
- /** The ledger belongs to this, so a different group or folder starts a fresh one. */
- val ledgerKey: String get() = "$account\n$groupId\n$folder"
-
fun toJson(): JSONObject = JSONObject()
- .put("account", account).put("groupId", groupId).put("groupName", groupName)
- .put("owner", owner).put("folder", folder).put("albums", JSONArray(albums))
- .put("includeExisting", includeExisting).put("since", since)
+ .put("albums", JSONArray(albums)).put("includeExisting", includeExisting).put("since", since)
companion object {
- /** From the page, so checked like any input: names it chose, never a path on this phone. */
+ /** From the page, so checked like any input. */
fun fromJson(o: JSONObject, now: Long, previous: SyncConfig? = null): SyncConfig {
- 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(folder.split('/').none { it.isEmpty() || it == "." || it == ".." }) { "bad folder" }
val albums = o.optJSONArray("albums") ?: JSONArray()
val includeExisting = o.optBoolean("includeExisting", true)
- // A change of destination or of scope starts again from that moment;
- // a change of album list alone does not move it.
- val same = previous != null && previous.account == account && previous.groupId == groupId &&
- previous.folder == folder && previous.includeExisting == includeExisting
+ // 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(
- account, groupId,
- o.optString("groupName", "").take(256), o.optString("owner", "").take(64),
- folder,
(0 until albums.length()).map { albums.optString(it, "").take(64) }.filter { it.isNotEmpty() }.distinct(),
includeExisting,
if (same) previous!!.since else now,
@@ -73,9 +55,7 @@ data class SyncConfig(
fun parse(text: String?): SyncConfig? = try {
val o = JSONObject(text ?: return null)
val albums = o.getJSONArray("albums")
- SyncConfig(o.getString("account"), o.getString("groupId"), o.optString("groupName"),
- o.optString("owner"), o.getString("folder"),
- (0 until albums.length()).map { albums.getString(it) },
+ SyncConfig((0 until albums.length()).map { albums.getString(it) },
o.optBoolean("includeExisting", true), o.getLong("since"))
} catch (e: Exception) { null }
}
@@ -104,7 +84,7 @@ object PhotoPlan {
* nothing is sent.
*/
fun plan(
- photos: List<Photo>, config: SyncConfig, ledger: (Long) -> PhotoLedger.Entry?,
+ photos: List<Photo>, config: SyncConfig, base: String, ledger: (Long) -> PhotoLedger.Entry?,
zone: TimeZone = TimeZone.getDefault(),
sameBytes: (Photo, PhotoLedger.Entry) -> Boolean,
): List<Pending> {
@@ -116,10 +96,10 @@ object PhotoPlan {
val sent = ledger(p.mediaId)
if (sent == null) {
if (!config.includeExisting && p.added * 1000 < config.since) continue
- out += Pending(p, false, dirFor(config.folder, p, zone), nameFor(p))
+ 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(config.folder, p, zone), editedName(p, zone))
+ out += Pending(p, true, dirFor(base, p, zone), editedName(p, zone))
}
}
// Newest first: the photos most likely to exist nowhere else are safe earliest.
@@ -128,10 +108,15 @@ object PhotoPlan {
fun whenTaken(p: Photo): Long = if (p.taken > 0) p.taken else p.added * 1000
- /** `<folder>/YYYY/YYYY-MM`, from when it was taken: an album is a directory (§9.9). */
- fun dirFor(folder: String, p: Photo, zone: TimeZone): String {
+ /** 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 "$folder/${fmt.format(Date(whenTaken(p)))}"
+ return "$base/${fmt.format(Date(whenTaken(p)))}"
}
fun nameFor(p: Photo): String =
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
index 276f1fd..f53110b 100644
--- 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
@@ -6,8 +6,11 @@ import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
-import org.meshbay.client.phonesync.DocConfig
+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 {
@@ -16,11 +19,10 @@ class DocSyncTest {
private val oct9 = 1791547200000L
private fun settings(account: String = "bob", folder: String = "Backups") = JSONObject()
- .put("account", account).put("groupId", "g1").put("groupName", "Mine").put("owner", "bob")
- .put("folder", folder)
+ .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 = DocConfig.fromJson(settings(folder = "/Backups/Phone/"))
+ 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"))
}
@@ -33,23 +35,47 @@ class DocSyncTest {
@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) { DocConfig.fromJson(settings(folder = bad)) }
+ assertThrows(bad, IllegalArgumentException::class.java) { Destination.fromJson(settings(folder = bad)) }
}
for (bad in listOf("../bob", "bob/x", "", "-bob")) {
- assertThrows(bad, IllegalArgumentException::class.java) { DocConfig.fromJson(settings(account = bad)) }
+ assertThrows(bad, IllegalArgumentException::class.java) { Destination.fromJson(settings(account = bad)) }
}
}
@Test fun `settings survive being stored`() {
- val c = DocConfig.fromJson(settings())
- assertEquals(c, DocConfig.parse(c.toJson().toString()))
- assertNull(DocConfig.parse("{not json"))
+ 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 = DocConfig.fromJson(settings())
- assertNotEquals(a.ledgerKey, DocConfig.fromJson(settings(folder = "Other")).ledgerKey)
- assertNotEquals(a.ledgerKey, DocConfig.fromJson(settings().put("groupId", "g2")).ledgerKey)
- assertEquals(a.ledgerKey, DocConfig.fromJson(settings().put("groupName", "Renamed")).ledgerKey)
+ 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/PhotoSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt
index 0e2e924..7c0b794 100644
--- 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
@@ -5,11 +5,11 @@ import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
-import org.junit.Assert.assertThrows
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
@@ -29,22 +29,24 @@ class PhotoSyncTest {
Photo(id, name, size, taken, added, modified, bucket, mime)
private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List<String> = listOf("cam")) =
- SyncConfig("bob", "g1", "Family", "alice", "Media/Photos/Bob", albums, includeExisting, since)
+ SyncConfig(albums, includeExisting, since)
+
+ 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, { ledger[it] }, utc) { _, _ -> same }
+ 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("Media/Photos/Bob/2026/2026-10", out[0].dir)
- assertEquals("Media/Photos/Bob/2026/2026-08", out[2].dir)
+ 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)
}
@@ -79,7 +81,7 @@ class PhotoSyncTest {
assertEquals(1, out.size)
assertTrue(out[0].edited)
assertEquals("IMG_1-edited-20261009-130000.jpg", out[0].name)
- assertEquals("Media/Photos/Bob/2026/2026-10", out[0].dir)
+ assertEquals("Backups/bob-photos/2026/2026-10", out[0].dir)
}
@Test fun `a touch that left the bytes alone sends nothing`() {
@@ -92,8 +94,8 @@ class PhotoSyncTest {
@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(), { entry(before) }, utc) { _, _ -> asked++; true }
- PhotoPlan.plan(listOf(before.copy(size = 7, modified = 9)), config(), { entry(before) }, utc) { _, _ -> asked++; true }
+ 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)
}
@@ -126,19 +128,15 @@ class PhotoSyncTest {
// ── settings from the page ───────────────────────────────────────────────
- @Test fun `a folder that is not one of the group's own is refused`() {
- for (bad in listOf("", "../etc", "Media/../x", "Media//x", "/")) {
- val o = JSONObject().put("account", "bob").put("groupId", "g1").put("folder", bad)
- assertThrows(bad, IllegalArgumentException::class.java) { SyncConfig.fromJson(o, 0) }
- }
+ @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 group moves it`() {
- val o = JSONObject().put("account", "bob").put("groupId", "g1").put("folder", "Media/Photos")
- .put("albums", JSONArray(listOf("cam"))).put("includeExisting", false)
+ @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("groupId", "g2"), 300, first).since)
+ assertEquals(300, SyncConfig.fromJson(o.put("includeExisting", true), 300, first).since)
}
@Test fun `settings survive being stored`() {