aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
commita0793dd37030f7213ecb07a018b11e6a8adf9238 (patch)
tree5de1046eae9b3747ac97a81652590fb6af99622b /packages/meshbay-android/app/src
parent044c585f73cfd548e300859e89eaf5dcb40d3d3c (diff)
downloadmeshbay-a0793dd37030f7213ecb07a018b11e6a8adf9238.tar.gz
feat(android): back the phone's contacts up to a group of one's own
A Contacts backup section on the Android Sync page sends a dated .vcf into <folder>/<account>-contacts once a day when the address book changed, only to a group the account is alone in, checked again at every run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android/app/src')
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml2
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js13
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt15
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt5
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt51
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt179
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt36
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt55
9 files changed, 427 insertions, 20 deletions
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml
index 37c18c7..48a69ea 100644
--- a/packages/meshbay-android/app/src/main/AndroidManifest.xml
+++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml
@@ -21,6 +21,8 @@
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
+ <!-- Contacts backup: the address book, asked for when the person turns it on. -->
+ <uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- No backup of any kind: the keys are wrapped by a Keystore key that a
restore cannot bring with it, so a backed-up store is one that silently
diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
index edfdb11..8aba36d 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,19 @@
keepAlive: (on, text) => call('photosync:keep-alive', on === true, text || ''),
},
+ // Contacts backup (§9.13): the phone writes the address book into one
+ // file, served at /phonesync/<token> on this origin, when it changed since
+ // the last one sent. The page decides when and does the sending.
+ contactSync: {
+ status: () => call('contactsync:status'),
+ permit: () => call('contactsync:permit'),
+ configure: (settings) => call('contactsync:configure', settings || null),
+ plan: () => call('contactsync:plan'),
+ sent: (token, dir, name) => call('contactsync:sent', token, dir, name),
+ completed: () => call('contactsync:completed'),
+ failed: (code, text) => call('contactsync:failed', code, text),
+ },
+
// Where downloads go, chosen once. A display name comes back, never a URI.
folder: {
choose: () => call('folder:choose'),
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index 16ea1cd..335e22d 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -36,6 +36,8 @@ import org.meshbay.client.notify.Notifier
import org.meshbay.client.notify.PushChannels
import org.meshbay.client.notify.PushState
import org.meshbay.client.photos.BackupService
+import org.meshbay.client.phonesync.ContactSource
+import org.meshbay.client.phonesync.DocChannels
import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.SaveSinks
import org.meshbay.client.shell.Pickers
@@ -59,6 +61,7 @@ class MainActivity : Activity() {
private lateinit var hub: HubClient
private lateinit var channels: Channels
private lateinit var photos: PhotoChannels
+ private var docs: List<DocChannels> = emptyList()
private var network: android.net.ConnectivityManager.NetworkCallback? = null
private val pickers = Pickers(this)
private val text = NativeText { code ->
@@ -99,6 +102,11 @@ class MainActivity : Activity() {
photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE),
java.io.File(filesDir, "photosync"),
onKeepAlive = { on, line -> runOnUiThread { backup(on, line) } })
+ docs = listOf(
+ DocChannels("contactsync", this, getSharedPreferences("contactsync", Context.MODE_PRIVATE),
+ java.io.File(cacheDir, "contactsync"), ContactSource(this),
+ notifyId = 10, permissionRequest = 4209),
+ )
channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves,
cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } },
@@ -106,7 +114,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)
+ photos = photos, docs = docs)
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
cast.control.warmUp()
installShim()
@@ -154,6 +162,10 @@ class MainActivity : Activity() {
if (url.host == UiAssets.HOST && url.path?.startsWith(PhotoChannels.PATH) == true) {
return photos.serve(url.path ?: "") ?: refused()
}
+ if (url.host == UiAssets.HOST && url.path?.startsWith(DocChannels.PATH) == true) {
+ val path = url.path ?: ""
+ return docs.firstNotNullOfOrNull { it.serve(path) } ?: refused()
+ }
if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused()
// reCAPTCHA (sign-up) and nothing else goes to the network from
// the page; the policy says the same, this is the second wall.
@@ -330,6 +342,7 @@ class MainActivity : Activity() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (::photos.isInitialized && photos.deliverPermission(requestCode)) return
+ if (docs.any { it.deliverPermission(requestCode) }) return
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
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 1775d57..f988888 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.DocChannels
import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.BinaryFrame
import org.meshbay.client.save.SaveSinks
@@ -30,6 +31,7 @@ class Channels(
private val onPlayback: (Boolean) -> Unit = {},
private val push: PushChannels? = null,
private val photos: PhotoChannels? = null,
+ private val docs: List<DocChannels> = emptyList(),
) {
@Volatile var locale = "en"
private set
@@ -59,7 +61,8 @@ 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)
- else -> throw Refused("Refused: no such channel")
+ 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/ContactSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt
new file mode 100644
index 0000000..0612e46
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt
@@ -0,0 +1,51 @@
+package org.meshbay.client.phonesync
+
+import android.Manifest
+import android.content.Context
+import android.net.Uri
+import android.provider.ContactsContract
+import org.meshbay.client.photos.PhotoChannels
+import java.io.File
+import java.security.MessageDigest
+import java.util.TimeZone
+
+/**
+ * The phone's contacts, as the platform itself exports them: one vCard per
+ * contact from ContactsContract, the format any phone imports back.
+ *
+ * Every run writes the whole address book and sends it only when it differs
+ * from the last one sent (`marker` is its SHA-256): a file is a complete
+ * copy as of its date, so restoring is importing the newest one.
+ */
+class ContactSource(private val context: Context) : DocSource {
+ override val permissions = arrayOf(Manifest.permission.READ_CONTACTS)
+ override val suffix = "contacts"
+
+ override fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? {
+ val keys = ArrayList<String>()
+ context.contentResolver.query(
+ ContactsContract.Contacts.CONTENT_URI, arrayOf(ContactsContract.Contacts.LOOKUP_KEY),
+ null, null, ContactsContract.Contacts._ID,
+ )?.use { c -> while (c.moveToNext()) c.getString(0)?.let { keys += it } }
+ if (keys.isEmpty()) return null
+
+ val file = File(dir, "contacts.vcf")
+ val digest = MessageDigest.getInstance("SHA-256")
+ var count = 0
+ file.outputStream().buffered().use { out ->
+ for (key in keys) {
+ val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, Uri.encode(key))
+ // A contact removed between the list and its card is simply not in this copy.
+ val bytes = try {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ } catch (e: Exception) { null } ?: continue
+ out.write(bytes)
+ digest.update(bytes)
+ count += 1
+ }
+ }
+ val sha = PhotoChannels.hex(digest.digest())
+ if (count == 0 || sha == marker) { file.delete(); return null }
+ return Export(file, DocPlan.nameFor("contacts", "vcf", now, zone), "text/vcard", sha, count)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt
new file mode 100644
index 0000000..0b8db3d
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt
@@ -0,0 +1,179 @@
+package org.meshbay.client.phonesync
+
+import android.app.Activity
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.webkit.WebResourceResponse
+import org.json.JSONArray
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.photos.PhotoChannels
+import java.io.File
+import java.io.FileInputStream
+import java.security.SecureRandom
+import java.util.TimeZone
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Contacts or messages backup (docs/MESHBAY_DESIGN.md §9.13): what the page
+ * needs from the phone for one kind of data, and nothing it could use to read
+ * anything else.
+ *
+ * The same split as photos (PhotoChannels): the page decides when and does the
+ * sending; this side writes what is new into one file and hands it over by an
+ * opaque token at `/phonesync/<token>`, valid until the next plan. What the
+ * next file starts from (`marker`) moves only when the node has taken this one.
+ *
+ * `prefix` names the channels (`contactsync:status`, …) and the preferences.
+ */
+class DocChannels(
+ private val prefix: String,
+ private val activity: Activity,
+ private val prefs: SharedPreferences,
+ private val dir: File,
+ private val source: DocSource,
+ private val notifyId: Int,
+ private val permissionRequest: Int,
+) {
+ private val random = SecureRandom()
+ @Volatile private var issued: Issued? = null
+ @Volatile private var permission: CountDownLatch? = null
+
+ private class Issued(val token: String, val export: Export, val key: String)
+
+ fun handles(channel: String) = channel.startsWith("$prefix:")
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("$prefix:")) {
+ "status" -> status()
+ "permit" -> { permit(); status() }
+ "configure" -> { configure(args.optJSONObject(0)); status() }
+ "plan" -> { requirePermission(); plan() }
+ "sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true }
+ "completed" -> { completed(); status() }
+ "failed" -> failed(args.optString(0, ""), args.optString(1, ""))
+ else -> throw Refused("Refused: no such channel")
+ }
+
+ // ── state ────────────────────────────────────────────────────────────────
+
+ private fun config(): DocConfig? = DocConfig.parse(prefs.getString(CONFIG, null))
+
+ fun status(): JSONObject {
+ val c = config()
+ 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("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL)
+ .put("lastSent", prefs.getString(LAST_SENT, null) ?: JSONObject.NULL)
+ .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL)
+ .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL)
+ .put("sent", prefs.getInt(SENT, 0))
+ .put("now", System.currentTimeMillis())
+ }
+
+ 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()
+ }
+
+ private fun completed() {
+ prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
+ }
+
+ /** A run stopped for a reason that will hold tomorrow too; said once. True when this call said it. */
+ private fun failed(code: String, text: String): Boolean {
+ val c = code.take(64)
+ prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply()
+ if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false
+ prefs.edit().putString(NOTIFIED, c).apply()
+ PhotoChannels.notice(activity, notifyId, text.take(300))
+ return true
+ }
+
+ // ── the file ─────────────────────────────────────────────────────────────
+
+ private fun plan(): JSONObject {
+ val c = config() ?: throw Refused("Refused: this backup is off")
+ issued = null
+ dir.listFiles()?.forEach { it.delete() }
+ dir.mkdirs()
+ val export = source.export(prefs.getString(MARKER, null), dir, System.currentTimeMillis(), TimeZone.getDefault())
+ ?: return JSONObject().put("item", JSONObject.NULL)
+ val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) })
+ issued = Issued(token, export, c.ledgerKey)
+ return JSONObject().put("item", JSONObject()
+ .put("token", token).put("name", export.name).put("dir", DocPlan.dirFor(c, source.suffix))
+ .put("size", export.file.length()).put("count", export.count))
+ }
+
+ /** The node took it: what the next file starts from moves, never before. */
+ private fun sent(token: String, dir: String, name: String) {
+ val i = issued?.takeIf { it.token == token } ?: throw Refused("Refused: unknown file")
+ if (config()?.ledgerKey != i.key) throw Refused("Refused: the backup changed")
+ prefs.edit().putString(MARKER, i.export.marker).putInt(SENT, prefs.getInt(SENT, 0) + 1)
+ .putString(LAST_SENT, "${dir.take(1024)}/${name.take(256)}").apply()
+ issued = null
+ i.export.file.delete()
+ }
+
+ /** The bytes of the issued file, for `/phonesync/<token>` on the packaged origin. */
+ fun serve(path: String): WebResourceResponse? {
+ val i = issued?.takeIf { it.token == path.removePrefix(PATH) } ?: return null
+ val stream = try { FileInputStream(i.export.file) } catch (e: Exception) { return null }
+ val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff")
+ return WebResourceResponse(i.export.mime, null, 200, "OK", headers, stream)
+ }
+
+ // ── permission ───────────────────────────────────────────────────────────
+
+ private fun permitted() = source.permissions.all {
+ activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun requirePermission() {
+ if (!permitted()) throw Refused("Refused: no access")
+ }
+
+ /** Asks, and waits for the answer: the page goes on from what was decided. */
+ private fun permit() {
+ if (permitted()) return
+ val latch = CountDownLatch(1)
+ permission = latch
+ activity.runOnUiThread { activity.requestPermissions(source.permissions, permissionRequest) }
+ latch.await(5, TimeUnit.MINUTES)
+ permission = null
+ }
+
+ /** From Activity.onRequestPermissionsResult; true when the request was ours. */
+ fun deliverPermission(requestCode: Int): Boolean {
+ if (requestCode != permissionRequest) return false
+ permission?.countDown()
+ return true
+ }
+
+ companion object {
+ const val PATH = "/phonesync/"
+ private const val CONFIG = "config"
+ private const val MARKER = "marker"
+ private const val SENT = "sent"
+ private const val LAST_SENT = "last_sent"
+ private const val LAST = "last_completed"
+ private const val FAILURE = "failure"
+ private const val FAILURE_AT = "failure_at"
+ private const val NOTIFIED = "notified"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt
new file mode 100644
index 0000000..e74d19e
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt
@@ -0,0 +1,91 @@
+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,
+ val name: String,
+ val mime: String,
+ /** Recorded only when the node took the file: what the next export starts from. */
+ val marker: String,
+ /** Contacts or messages in it, for the page to say. */
+ val count: Int,
+)
+
+/**
+ * One kind of the phone's data (the contacts, the messages) written out as
+ * a file of what is new since `marker`, the value the last file sent left.
+ */
+interface DocSource {
+ /** The runtime permissions it reads with. */
+ val permissions: Array<String>
+ /** Where its files go, under the chosen folder: `<account>-<suffix>`. */
+ val suffix: String
+ /** A file of what is new since `marker` in `dir`, or null when nothing is. */
+ fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export?
+}
+
+/** The names a run gives what it sends. Pure, so the JVM tests hold them. */
+object DocPlan {
+ /** A run is due once a day, counted from the last one that finished. */
+ const val DAY_MS = 24L * 3600 * 1000
+
+ /** A username as the hub allows it; it names a folder on the node. */
+ val SAFE_ACCOUNT = Regex("^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
+
+ /** `<folder>/<account>-<suffix>`: everything of one kind, from one account, in one place. */
+ fun dirFor(config: DocConfig, suffix: String): String = "${config.folder}/${config.account}-$suffix"
+
+ /**
+ * `<stem>-YYYY-MM-DD-HHmm.<ext>`, in the phone's time: a run never replaces
+ * an earlier file, and the name says when it was taken.
+ */
+ fun nameFor(stem: String, ext: String, now: Long, zone: TimeZone): String =
+ "$stem-" + SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.ROOT).apply { timeZone = zone }.format(Date(now)) + ".$ext"
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt
index ee14984..2e7fb55 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
@@ -125,7 +125,7 @@ class PhotoChannels(
prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply()
if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false
prefs.edit().putString(NOTIFIED, c).apply()
- notify(text.take(300))
+ notice(activity, NOTIFY_ID, text.take(300))
return true
}
@@ -251,20 +251,6 @@ class PhotoChannels(
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED))
}
- private fun notify(text: String) {
- val nm = activity.getSystemService(NotificationManager::class.java)
- ensureChannel(activity)
- val open = Intent(activity, MainActivity::class.java).setAction(Intent.ACTION_VIEW)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
- .putExtra(Notifier.EXTRA_LINK, "#/android-sync")
- val pending = PendingIntent.getActivity(activity, NOTIFY_ID, open,
- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
- nm.notify(NOTIFY_ID, Notification.Builder(activity, CHANNEL)
- .setSmallIcon(R.drawable.ic_notify).setContentTitle("MeshBay").setContentText(text)
- .setStyle(Notification.BigTextStyle().bigText(text))
- .setContentIntent(pending).setAutoCancel(true).build())
- }
-
companion object {
const val PATH = "/photosync/"
const val CHANNEL = "backup"
@@ -277,11 +263,25 @@ class PhotoChannels(
private const val FAILURE_AT = "failure_at"
private const val NOTIFIED = "notified"
+ /** Created, or renamed from "Photo backup" now that contacts and messages use it too. */
fun ensureChannel(context: Context) {
+ context.getSystemService(NotificationManager::class.java)
+ .createNotificationChannel(NotificationChannel(CHANNEL, "Backup", NotificationManager.IMPORTANCE_LOW))
+ }
+
+ /** A backup that stopped, said once; a tap opens the Android Sync page. */
+ fun notice(context: Context, id: Int, text: String) {
val nm = context.getSystemService(NotificationManager::class.java)
- if (nm.getNotificationChannel(CHANNEL) == null) {
- nm.createNotificationChannel(NotificationChannel(CHANNEL, "Photo backup", NotificationManager.IMPORTANCE_LOW))
- }
+ ensureChannel(context)
+ val open = Intent(context, MainActivity::class.java).setAction(Intent.ACTION_VIEW)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .putExtra(Notifier.EXTRA_LINK, "#/android-sync")
+ val pending = PendingIntent.getActivity(context, id, open,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ nm.notify(id, Notification.Builder(context, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notify).setContentTitle("MeshBay").setContentText(text)
+ .setStyle(Notification.BigTextStyle().bigText(text))
+ .setContentIntent(pending).setAutoCancel(true).build())
}
fun digestOf(s: InputStream): ByteArray {
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt
new file mode 100644
index 0000000..276f1fd
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt
@@ -0,0 +1,55 @@
+package org.meshbay.client
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.meshbay.client.phonesync.DocConfig
+import org.meshbay.client.phonesync.DocPlan
+import java.util.TimeZone
+
+class DocSyncTest {
+ private val utc = TimeZone.getTimeZone("UTC")
+ // 2026-10-09 12:00:00 UTC
+ private val oct9 = 1791547200000L
+
+ private fun settings(account: String = "bob", folder: String = "Backups") = JSONObject()
+ .put("account", account).put("groupId", "g1").put("groupName", "Mine").put("owner", "bob")
+ .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/"))
+ assertEquals("Backups/Phone/bob-contacts", DocPlan.dirFor(c, "contacts"))
+ assertEquals("Backups/Phone/bob-messages", DocPlan.dirFor(c, "messages"))
+ }
+
+ @Test fun `a file is named for when it was taken, in the phone's time`() {
+ assertEquals("contacts-2026-10-09-1200.vcf", DocPlan.nameFor("contacts", "vcf", oct9, utc))
+ assertEquals("contacts-2026-10-09-1400.vcf",
+ DocPlan.nameFor("contacts", "vcf", oct9, TimeZone.getTimeZone("Europe/Paris")))
+ }
+
+ @Test fun `a folder that climbs out or an account that is not a name is refused`() {
+ for (bad in listOf("../etc", "Backups/../x", "Backups//x", "./x", "")) {
+ assertThrows(bad, IllegalArgumentException::class.java) { DocConfig.fromJson(settings(folder = bad)) }
+ }
+ for (bad in listOf("../bob", "bob/x", "", "-bob")) {
+ assertThrows(bad, IllegalArgumentException::class.java) { DocConfig.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"))
+ }
+
+ @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)
+ }
+}