diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:21:46 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:21:46 +0200 |
| commit | 2f2a9a6542d2194e50ffba6f382e4fdffd481838 (patch) | |
| tree | 5437330f9e8b963f8af0904f3c5d80d1afc6dc3b /packages/meshbay-android | |
| parent | 914d2f0af848fb80d70d517901f44420a4e47764 (diff) | |
| download | meshbay-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')
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`() { |