diff options
Diffstat (limited to 'packages/meshbay-android/app/src/main/kotlin/org/meshbay')
7 files changed, 177 insertions, 117 deletions
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 = |