diff options
Diffstat (limited to 'packages/meshbay-android/app')
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) + } +} |