aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 14:40:05 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 14:40:05 +0200
commitaed32f20625a6206628b577d743d96552f81e91a (patch)
treeaf2eaf3336804f2e4af37445326e52a19b2af169 /packages
parent2f2a9a6542d2194e50ffba6f382e4fdffd481838 (diff)
downloadmeshbay-aed32f20625a6206628b577d743d96552f81e91a.tar.gz
feat(android): back text messages up, in a build Play does not get
A Messages section sends the SMS added since the last copy, as restorable <smses> XML, into <folder>/<account>-messages/YYYY. A play flavor has neither READ_SMS nor the code that reads messages; full is the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-android/README.md14
-rw-r--r--packages/meshbay-android/app/build.gradle.kts13
-rw-r--r--packages/meshbay-android/app/src/full/AndroidManifest.xml6
-rw-r--r--packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt8
-rw-r--r--packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt72
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js19
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt10
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt3
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt5
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt69
-rw-r--r--packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js4
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py9
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py23
-rw-r--r--packages/meshbay-hub/tests/test_phone_sync.py19
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py6
30 files changed, 389 insertions, 26 deletions
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md
index adb7e66..02297c9 100644
--- a/packages/meshbay-android/README.md
+++ b/packages/meshbay-android/README.md
@@ -30,11 +30,17 @@ holds the same service and the same visibility, for as long as it plays
```bash
# needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties)
-./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
-./gradlew testDebugUnitTest # JVM unit tests
-./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk
+./gradlew assembleDebug # app/build/outputs/apk/{full,play}/debug/
+./gradlew testFullDebugUnitTest # JVM unit tests
+./gradlew assembleRelease # app/build/outputs/apk/full/release/app-full-release.apk
+ # app/build/outputs/apk/play/release/app-play-release.apk
```
+Two builds of the same application, same id and key: `full` is the one
+distributed directly; `play` is for the Play Store, whose policy gives
+READ_SMS to the default SMS application only, so it has no text messages
+backup (no permission, no code: `src/full`, `src/play`).
+
A release is signed with the release key, which never enters the repository.
`assembleRelease` reads it from `~/.gradle/gradle.properties`, and stops if
any of these is missing rather than signing with the debug key:
@@ -46,7 +52,7 @@ meshbayReleaseKeyAlias=meshbay
meshbayReleaseKeyPassword=...
```
-`apksigner verify --print-certs app-release.apk` prints the certificate's
+`apksigner verify --print-certs app-full-release.apk` prints the certificate's
SHA-256 fingerprint, the one the download page publishes (§8.2). A release
does not install over a debug build, or the reverse: the keys differ.
diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts
index 7f7ba3c..cd5e1a5 100644
--- a/packages/meshbay-android/app/build.gradle.kts
+++ b/packages/meshbay-android/app/build.gradle.kts
@@ -32,6 +32,16 @@ android {
keyPassword = providers.gradleProperty("meshbayReleaseKeyPassword").get()
}
}
+ // Two builds of the same application, the same id and key. `full` is the
+ // one distributed directly; `play` is for the Play Store, whose policy
+ // gives READ_SMS to the default SMS application only, so it has no
+ // messages backup: not the permission, and not the code that reads them
+ // (src/full, src/play: Flavor.kt).
+ flavorDimensions += "store"
+ productFlavors {
+ create("full") { dimension = "store"; isDefault = true }
+ create("play") { dimension = "store" }
+ }
buildTypes {
getByName("release") {
isMinifyEnabled = false
@@ -100,7 +110,8 @@ val syncUi = tasks.register<SyncUi>("syncUi") {
// Checked when a release is built, not when the project is configured, so a
// debug build and the unit tests need no key.
tasks.configureEach {
- if (name == "preReleaseBuild") doFirst {
+ // One per build: preFullReleaseBuild, prePlayReleaseBuild.
+ if (name.startsWith("pre") && name.endsWith("ReleaseBuild")) doFirst {
val missing = releaseSigning.filter { !providers.gradleProperty(it).isPresent }
if (missing.isNotEmpty()) throw GradleException(
"no release key: set ${missing.joinToString()} in ~/.gradle/gradle.properties")
diff --git a/packages/meshbay-android/app/src/full/AndroidManifest.xml b/packages/meshbay-android/app/src/full/AndroidManifest.xml
new file mode 100644
index 0000000..552902a
--- /dev/null
+++ b/packages/meshbay-android/app/src/full/AndroidManifest.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <!-- Messages backup, `full` build only: asked for when the person turns it
+ on, never at start. The `play` build has no such permission. -->
+ <uses-permission android:name="android.permission.READ_SMS" />
+</manifest>
diff --git a/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt
new file mode 100644
index 0000000..20882a5
--- /dev/null
+++ b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt
@@ -0,0 +1,8 @@
+package org.meshbay.client.phonesync
+
+import android.content.Context
+
+/** What this build backs up beyond photos and contacts: the `full` build reads messages. */
+object Flavor {
+ fun messageSource(context: Context): DocSource? = SmsSource(context)
+}
diff --git a/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt
new file mode 100644
index 0000000..c24ef1f
--- /dev/null
+++ b/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt
@@ -0,0 +1,72 @@
+package org.meshbay.client.phonesync
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.ContactsContract
+import android.provider.Telephony
+import java.io.File
+import java.util.TimeZone
+
+/**
+ * The phone's text messages, from the platform's SMS provider (not MMS).
+ *
+ * Each run writes the messages added since the last file the node took:
+ * `marker` is the highest provider id it held, and ids only grow, so a
+ * message restored onto the phone later counts as new and is sent too. The
+ * first file holds the whole history. Files go under the year they were
+ * written in, `<account>-messages/YYYY/`.
+ *
+ * Only in the `full` build: Play's policy keeps READ_SMS for the default SMS
+ * application, so the `play` build has neither the permission nor this.
+ */
+class SmsSource(private val context: Context) : DocSource {
+ override val permissions = arrayOf(Manifest.permission.READ_SMS)
+ override val suffix = "messages"
+
+ override fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? {
+ val after = marker?.toLongOrNull() ?: 0L
+ val names = Names(context)
+ val out = ArrayList<Sms>()
+ val cols = arrayOf(
+ Telephony.Sms._ID, Telephony.Sms.ADDRESS, Telephony.Sms.DATE, Telephony.Sms.DATE_SENT,
+ Telephony.Sms.TYPE, Telephony.Sms.BODY, Telephony.Sms.READ, Telephony.Sms.STATUS,
+ Telephony.Sms.LOCKED, Telephony.Sms.PROTOCOL, Telephony.Sms.SUBJECT, Telephony.Sms.SERVICE_CENTER,
+ )
+ context.contentResolver.query(Telephony.Sms.CONTENT_URI, cols, "${Telephony.Sms._ID} > ?",
+ arrayOf(after.toString()), "${Telephony.Sms.DATE} ASC")?.use { c ->
+ fun str(i: Int) = if (c.isNull(i)) null else c.getString(i)
+ fun int(i: Int, d: Int) = if (c.isNull(i)) d else c.getInt(i)
+ while (c.moveToNext()) {
+ val address = str(1) ?: ""
+ out += Sms(c.getLong(0), address, c.getLong(2), if (c.isNull(3)) 0 else c.getLong(3),
+ int(4, 1), str(5) ?: "", int(6, 1), int(7, -1), int(8, 0), int(9, 0),
+ str(10), str(11), names.of(address))
+ }
+ }
+ if (out.isEmpty()) return null
+ val file = File(dir, "sms.xml")
+ file.bufferedWriter(Charsets.UTF_8).use { SmsXml.write(out, it, zone) }
+ return Export(file, DocPlan.nameFor("sms", "xml", now, zone), "application/xml",
+ out.maxOf { it.id }.toString(), out.size, DocPlan.yearOf(now, zone))
+ }
+
+ /** Who a number is, when the person also let the application read contacts; once per number. */
+ private class Names(private val context: Context) {
+ private val allowed = context.checkSelfPermission(Manifest.permission.READ_CONTACTS) ==
+ PackageManager.PERMISSION_GRANTED
+ private val known = HashMap<String, String?>()
+
+ fun of(address: String): String? {
+ if (!allowed || address.isBlank()) return null
+ return known.getOrPut(address) {
+ try {
+ val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address))
+ context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
+ null, null, null)?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
+ } catch (e: Exception) { null }
+ }
+ }
+ }
+}
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 b64d7f2..fb613a3 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
@@ -15,10 +15,11 @@
* that refuses: `platform.js` decides what to show from whether an object
* exists (`platform.node.available`, `platform.folder.available`, …).
*
- * `HUB_BASE`, `BINARY` and `CAST` are prepended by the shell when it injects
- * this file: the interface asks for the hub while its modules load, before
+ * `HUB_BASE`, `BINARY`, `CAST` and `MESSAGES` are prepended by the shell when
+ * it injects this file: the interface asks for the hub while its modules load, before
* anything can await; BINARY says whether the WebView carries ArrayBuffer
- * messages; CAST whether this device can cast at all.
+ * messages; CAST whether this device can cast at all; MESSAGES whether this
+ * build backs text messages up (not the Play build).
*/
(function () {
'use strict';
@@ -233,6 +234,18 @@
failed: (code, text) => call('contactsync:failed', code, text),
},
+ // Messages backup (§9.13), the same way as contacts: the messages added
+ // since the last file the node took. Only in a build that may read them.
+ ...(MESSAGES ? { messageSync: {
+ status: () => call('messagesync:status'),
+ permit: () => call('messagesync:permit'),
+ configure: (settings) => call('messagesync:configure', settings || null),
+ plan: () => call('messagesync:plan'),
+ sent: (token, dir, name) => call('messagesync:sent', token, dir, name),
+ completed: () => call('messagesync:completed'),
+ failed: (code, text) => call('messagesync: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 3fa1146..0545ead 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
@@ -39,6 +39,7 @@ 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.phonesync.Flavor
import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.SaveSinks
import org.meshbay.client.shell.Pickers
@@ -108,7 +109,11 @@ class MainActivity : Activity() {
DocChannels("contactsync", this, getSharedPreferences("contactsync", Context.MODE_PRIVATE), destinations,
java.io.File(cacheDir, "contactsync"), ContactSource(this),
notifyId = 10, permissionRequest = 4209),
- )
+ ) + listOfNotNull(Flavor.messageSource(this)?.let { source ->
+ DocChannels("messagesync", this, getSharedPreferences("messagesync", Context.MODE_PRIVATE), destinations,
+ java.io.File(cacheDir, "messagesync"), source,
+ notifyId = 11, permissionRequest = 4210)
+ })
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() } },
@@ -247,7 +252,8 @@ class MainActivity : Activity() {
val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() }
val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)
val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n" +
- "const CAST = ${cast.control.available()};\n"
+ "const CAST = ${cast.control.available()};\n" +
+ "const MESSAGES = ${docs.any { it.handles("messagesync:") }};\n"
shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN))
}
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 6877912..2efe558 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
@@ -124,7 +124,8 @@ class DocChannels(
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("token", token).put("name", export.name)
+ .put("dir", DocPlan.dirFor(c, source.suffix) + (if (export.subdir.isEmpty()) "" else "/${export.subdir}"))
.put("size", export.file.length()).put("count", export.count))
}
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 b1563da..1a2a359 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
@@ -15,6 +15,8 @@ data class Export(
val marker: String,
/** Contacts or messages in it, for the page to say. */
val count: Int,
+ /** A folder under the kind's own, such as the year, or empty. */
+ val subdir: String = "",
)
/**
@@ -46,6 +48,9 @@ object DocPlan {
* `<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 yearOf(now: Long, zone: TimeZone): String =
+ SimpleDateFormat("yyyy", Locale.ROOT).apply { timeZone = zone }.format(Date(now))
+
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/phonesync/SmsXml.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt
new file mode 100644
index 0000000..624e1f1
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt
@@ -0,0 +1,69 @@
+package org.meshbay.client.phonesync
+
+import java.io.Writer
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/** One text message, as the platform's SMS provider stores it. */
+data class Sms(
+ val id: Long,
+ val address: String,
+ /** Milliseconds, received or sent. */
+ val date: Long,
+ val dateSent: Long,
+ /** 1 received, 2 sent, 3 draft, 4 outbox, 5 failed, 6 queued. */
+ val type: Int,
+ val body: String,
+ val read: Int,
+ val status: Int,
+ val locked: Int,
+ val protocol: Int,
+ val subject: String?,
+ val serviceCenter: String?,
+ val contactName: String?,
+)
+
+/**
+ * Messages written as the `<smses>` XML that SMS backup applications read and
+ * restore, so a copy on the node goes back onto a phone with tools that exist.
+ * Pure, so the JVM tests hold the format and its escaping.
+ */
+object SmsXml {
+ fun write(messages: List<Sms>, out: Writer, zone: TimeZone) {
+ val readable = SimpleDateFormat("d MMM yyyy HH:mm:ss", Locale.ROOT).apply { timeZone = zone }
+ out.write("<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>\n")
+ out.write("<smses count=\"${messages.size}\">\n")
+ for (m in messages) {
+ out.write(" <sms protocol=\"${m.protocol}\" address=${q(m.address)} date=\"${m.date}\"" +
+ " type=\"${m.type}\" subject=${q(m.subject ?: "null")} body=${q(m.body)}" +
+ " toa=\"null\" sc_toa=\"null\" service_center=${q(m.serviceCenter ?: "null")}" +
+ " read=\"${m.read}\" status=\"${m.status}\" locked=\"${m.locked}\" date_sent=\"${m.dateSent}\"" +
+ " readable_date=${q(readable.format(Date(m.date)))}" +
+ " contact_name=${q(m.contactName ?: "(Unknown)")} />\n")
+ }
+ out.write("</smses>\n")
+ }
+
+ /**
+ * A quoted attribute value. Line breaks are kept as character references,
+ * which an attribute would otherwise fold into spaces; characters XML 1.0
+ * cannot carry at all, even escaped, are left out.
+ */
+ fun q(text: String): String {
+ val b = StringBuilder(text.length + 2).append('"')
+ for (c in text) when {
+ c == '&' -> b.append("&amp;")
+ c == '<' -> b.append("&lt;")
+ c == '>' -> b.append("&gt;")
+ c == '"' -> b.append("&quot;")
+ c == '\n' -> b.append("&#10;")
+ c == '\r' -> b.append("&#13;")
+ c == '\t' -> b.append("&#9;")
+ c < ' ' || c == '￾' || c == '￿' -> {}
+ else -> b.append(c)
+ }
+ return b.append('"').toString()
+ }
+}
diff --git a/packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt b/packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt
new file mode 100644
index 0000000..76dde1f
--- /dev/null
+++ b/packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt
@@ -0,0 +1,11 @@
+package org.meshbay.client.phonesync
+
+import android.content.Context
+
+/**
+ * What this build backs up beyond photos and contacts: nothing, in the Play
+ * build. Play's policy keeps READ_SMS for the default SMS application.
+ */
+object Flavor {
+ fun messageSource(context: Context): DocSource? = null
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt
new file mode 100644
index 0000000..9945ec1
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt
@@ -0,0 +1,66 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.meshbay.client.phonesync.DocPlan
+import org.meshbay.client.phonesync.Sms
+import org.meshbay.client.phonesync.SmsXml
+import org.w3c.dom.Element
+import java.io.StringWriter
+import java.util.TimeZone
+import javax.xml.parsers.DocumentBuilderFactory
+
+class SmsXmlTest {
+ private val utc = TimeZone.getTimeZone("UTC")
+ // 2026-10-09 12:00:00 UTC
+ private val oct9 = 1791547200000L
+
+ private fun sms(id: Long, body: String, address: String = "+33600000000", name: String? = null) =
+ Sms(id, address, oct9, oct9 - 5000, 1, body, 1, -1, 0, 0, null, null, name)
+
+ private fun parse(xml: String): List<Element> {
+ val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xml.byteInputStream())
+ assertEquals("smses", doc.documentElement.tagName)
+ val list = doc.getElementsByTagName("sms")
+ return (0 until list.length).map { list.item(it) as Element }
+ }
+
+ private fun write(vararg m: Sms) = StringWriter().also { SmsXml.write(m.toList(), it, utc) }.toString()
+
+ @Test fun `what a restore reads is there, with the count`() {
+ val xml = write(sms(1, "hello"), sms(2, "again", name = "Ada"))
+ assertTrue(xml.contains("<smses count=\"2\">"))
+ val rows = parse(xml)
+ assertEquals("hello", rows[0].getAttribute("body"))
+ assertEquals("+33600000000", rows[0].getAttribute("address"))
+ assertEquals(oct9.toString(), rows[0].getAttribute("date"))
+ assertEquals("1", rows[0].getAttribute("type"))
+ assertEquals("(Unknown)", rows[0].getAttribute("contact_name"))
+ assertEquals("Ada", rows[1].getAttribute("contact_name"))
+ assertEquals("9 Oct 2026 12:00:00", rows[0].getAttribute("readable_date"))
+ assertEquals("null", rows[0].getAttribute("subject"))
+ }
+
+ @Test fun `markup, quotes, line breaks and emoji come back as they were`() {
+ val body = "a < b & \"c\" > 'd'\nsecond line\r\n\ttab 🎉 é"
+ assertEquals(body, parse(write(sms(1, body)))[0].getAttribute("body"))
+ }
+
+ @Test fun `characters XML cannot carry are left out rather than breaking the file`() {
+ val rows = parse(write(sms(1, "a\u0000b\u0007c￿d")))
+ assertEquals("abcd", rows[0].getAttribute("body"))
+ }
+
+ @Test fun `an empty body or address is still a well-formed row`() {
+ val rows = parse(write(sms(1, "", address = "")))
+ assertEquals("", rows[0].getAttribute("body"))
+ assertFalse(rows[0].hasAttribute("missing"))
+ }
+
+ @Test fun `a file goes under the year it was written in`() {
+ assertEquals("2026", DocPlan.yearOf(oct9, utc))
+ assertEquals("sms-2026-10-09-1200.xml", DocPlan.nameFor("sms", "xml", oct9, utc))
+ }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 12b48a7..41cc5f2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -944,9 +944,11 @@ function App() {
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
- // Contacts backup (§9.13), the same way.
+ // Contacts and messages backup (§9.13), the same way.
useEffect(() => {
- if (!syncAccount || !platform.contactSync.available) return undefined;
+ if (!syncAccount || !(platform.contactSync.available || platform.messageSync.available)) {
+ return undefined;
+ }
let stop = null;
let gone = false;
import('./phone-sync.js').then((m) => {
@@ -962,7 +964,7 @@ function App() {
},
});
stop = m.stopPhoneSync;
- }).catch((e) => console.warn('[MeshBay] contacts backup did not load:', e.message));
+ }).catch((e) => console.warn('[MeshBay] contacts and messages backup did not load:', e.message));
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index d83d0c0..086f3a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1340,6 +1340,9 @@ export default {
'contactsync.heading': "Kontakte-Sicherung",
'contactsync.intro': "Einmal täglich, wenn sie sich geändert haben, eine Kopie der Kontakte dieses Telefons senden. Jede Kopie ist eine .vcf-Datei, die jedes Telefon importieren kann.",
'contactsync.permission_needed': "MeshBay braucht Zugriff auf die Kontakte dieses Telefons.",
+ 'messagesync.heading': "SMS-Sicherung",
+ 'messagesync.intro': "Die SMS dieses Telefons einmal täglich senden: Die erste Kopie enthält alle, jede weitere nur die neuen. Die Dateien sind im XML-Format, das SMS-Sicherungs-Apps wiederherstellen können. MMS sind nicht enthalten.",
+ 'messagesync.permission_needed': "MeshBay braucht Zugriff auf die SMS dieses Telefons.",
'phonesync.state_sending': "Wird gesendet…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Gesendete Kopien: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index aa25258..66cd23b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1321,6 +1321,9 @@ export default {
'contactsync.heading': "Contacts backup",
'contactsync.intro': "Send a copy of this phone's contacts once a day when they have changed. Each copy is a .vcf file any phone can import.",
'contactsync.permission_needed': "MeshBay needs access to this phone's contacts.",
+ 'messagesync.heading': "Messages backup",
+ 'messagesync.intro': "Send the text messages on this phone once a day: the first copy holds them all, each next one only the new ones. The files are in the XML format SMS backup applications restore. Multimedia messages are not included.",
+ 'messagesync.permission_needed': "MeshBay needs access to this phone's text messages.",
'phonesync.state_sending': "Sending…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copies sent: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 8a483e4..c53e3e4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1334,6 +1334,9 @@ export default {
'contactsync.heading': "Copia de seguridad de contactos",
'contactsync.intro': "Enviar una copia de los contactos de este teléfono una vez al día si han cambiado. Cada copia es un archivo .vcf que cualquier teléfono puede importar.",
'contactsync.permission_needed': "MeshBay necesita acceso a los contactos de este teléfono.",
+ 'messagesync.heading': "Copia de seguridad de SMS",
+ 'messagesync.intro': "Enviar los SMS de este teléfono una vez al día: la primera copia los contiene todos y cada una de las siguientes solo los nuevos. Los archivos están en el formato XML que restauran las aplicaciones de copia de seguridad de SMS. Los MMS no se incluyen.",
+ 'messagesync.permission_needed': "MeshBay necesita acceso a los SMS de este teléfono.",
'phonesync.state_sending': "Enviando…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copias enviadas: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 7f15f05..52b40b0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1349,6 +1349,9 @@ export default {
'contactsync.heading': "Sauvegarde des contacts",
'contactsync.intro': "Envoyer une copie des contacts de ce téléphone une fois par jour s'ils ont changé. Chaque copie est un fichier .vcf que tout téléphone sait importer.",
'contactsync.permission_needed': "MeshBay a besoin d'accéder aux contacts de ce téléphone.",
+ 'messagesync.heading': "Sauvegarde des SMS",
+ 'messagesync.intro': "Envoyer les SMS de ce téléphone une fois par jour : la première copie les contient tous, les suivantes seulement les nouveaux. Les fichiers sont au format XML que les applis de sauvegarde SMS savent restaurer. Les MMS ne sont pas inclus.",
+ 'messagesync.permission_needed': "MeshBay a besoin d'accéder aux SMS de ce téléphone.",
'phonesync.state_sending': "Envoi…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copies envoyées : {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index d18c373..9387ddd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1348,6 +1348,9 @@ export default {
'contactsync.heading': "Backup dei contatti",
'contactsync.intro': "Invia una copia dei contatti di questo telefono una volta al giorno se sono cambiati. Ogni copia è un file .vcf che qualsiasi telefono può importare.",
'contactsync.permission_needed': "MeshBay ha bisogno di accedere ai contatti di questo telefono.",
+ 'messagesync.heading': "Backup degli SMS",
+ 'messagesync.intro': "Invia gli SMS di questo telefono una volta al giorno: la prima copia li contiene tutti, le successive solo quelli nuovi. I file sono nel formato XML che le app di backup degli SMS sanno ripristinare. Gli MMS non sono inclusi.",
+ 'messagesync.permission_needed': "MeshBay ha bisogno di accedere agli SMS di questo telefono.",
'phonesync.state_sending': "Invio…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copie inviate: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 1ee0a81..29296d1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1332,6 +1332,9 @@ export default {
'contactsync.heading': "連絡先のバックアップ",
'contactsync.intro': "このスマートフォンの連絡先のコピーを、変更があった日に1日1回送ります。各コピーはどのスマートフォンでも読み込める .vcf ファイルです。",
'contactsync.permission_needed': "MeshBay にこのスマートフォンの連絡先へのアクセスを許可してください。",
+ 'messagesync.heading': "SMS のバックアップ",
+ 'messagesync.intro': "このスマートフォンの SMS を1日1回送ります。最初のコピーにはすべてが含まれ、以降は新しいものだけが含まれます。ファイルは SMS バックアップアプリで復元できる XML 形式です。MMS は含まれません。",
+ 'messagesync.permission_needed': "MeshBay にこのスマートフォンの SMS へのアクセスを許可してください。",
'phonesync.state_sending': "送信中…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "送信したコピー: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index ec7be69..ada6813 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1350,6 +1350,9 @@ export default {
'contactsync.heading': "Back-up van contacten",
'contactsync.intro': "Stuur eenmaal per dag, als ze veranderd zijn, een kopie van de contacten op deze telefoon. Elke kopie is een .vcf-bestand dat elke telefoon kan importeren.",
'contactsync.permission_needed': "MeshBay heeft toegang nodig tot de contacten op deze telefoon.",
+ 'messagesync.heading': "Back-up van sms'jes",
+ 'messagesync.intro': "Stuur de sms'jes op deze telefoon eenmaal per dag: de eerste kopie bevat ze allemaal, elke volgende alleen de nieuwe. De bestanden zijn in het XML-formaat dat sms-back-upapps kunnen terugzetten. Mms'jes zijn niet inbegrepen.",
+ 'messagesync.permission_needed': "MeshBay heeft toegang nodig tot de sms'jes op deze telefoon.",
'phonesync.state_sending': "Bezig met verzenden…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Verzonden kopieën: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 96da817..3e712bb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1376,6 +1376,9 @@ export default {
'contactsync.heading': "Kopia kontaktów",
'contactsync.intro': "Wysyłaj kopię kontaktów z tego telefonu raz dziennie, jeśli się zmieniły. Każda kopia to plik .vcf, który zaimportuje każdy telefon.",
'contactsync.permission_needed': "MeshBay potrzebuje dostępu do kontaktów w tym telefonie.",
+ 'messagesync.heading': "Kopia SMS-ów",
+ 'messagesync.intro': "Wysyłaj SMS-y z tego telefonu raz dziennie: pierwsza kopia zawiera wszystkie, każda kolejna tylko nowe. Pliki są w formacie XML, który przywracają aplikacje do kopii SMS-ów. MMS-y nie są uwzględniane.",
+ 'messagesync.permission_needed': "MeshBay potrzebuje dostępu do SMS-ów w tym telefonie.",
'phonesync.state_sending': "Wysyłanie…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Wysłane kopie: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index cec9604..8fe39e5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1335,6 +1335,9 @@ export default {
'contactsync.heading': "Backup de contatos",
'contactsync.intro': "Enviar uma cópia dos contatos deste celular uma vez por dia se tiverem mudado. Cada cópia é um arquivo .vcf que qualquer celular consegue importar.",
'contactsync.permission_needed': "O MeshBay precisa de acesso aos contatos deste celular.",
+ 'messagesync.heading': "Backup de SMS",
+ 'messagesync.intro': "Enviar os SMS deste celular uma vez por dia: a primeira cópia contém todos, cada uma das seguintes só os novos. Os arquivos estão no formato XML que os apps de backup de SMS conseguem restaurar. MMS não são incluídos.",
+ 'messagesync.permission_needed': "O MeshBay precisa de acesso aos SMS deste celular.",
'phonesync.state_sending': "Enviando…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Cópias enviadas: {n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 212bc5d..e294fe5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1321,6 +1321,9 @@ export default {
'contactsync.heading': "联系人备份",
'contactsync.intro': "在联系人有变动的日子里,每天一次发送本机联系人的副本。每份副本都是任何手机都能导入的 .vcf 文件。",
'contactsync.permission_needed': "MeshBay 需要访问本机的联系人。",
+ 'messagesync.heading': "短信备份",
+ 'messagesync.intro': "每天一次发送本机的短信:第一份副本包含全部短信,之后每份只包含新短信。文件采用短信备份应用可以恢复的 XML 格式。不包括彩信。",
+ 'messagesync.permission_needed': "MeshBay 需要访问本机的短信。",
'phonesync.state_sending': "正在发送…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "已发送副本:{n}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
index cec8b43..0602e54 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
@@ -1,7 +1,7 @@
-// The contacts section of the Android Sync page, on the Android application
-// only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other client has
-// it. Where the copies go is the page's destination section; this one only
-// turns the backup on or off.
+// The contacts and messages sections of the Android Sync page, on the Android
+// application only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other
+// client has it. Where the copies go is the page's destination section; these
+// only turn each backup on or off.
import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
import { t } from './i18n.js';
@@ -13,6 +13,10 @@ const TEXT = {
intro: 'contactsync.intro',
permission: 'contactsync.permission_needed',
},
+ messages: {
+ intro: 'messagesync.intro',
+ permission: 'messagesync.permission_needed',
+ },
};
function stateLine(st, status) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
index b9db9f7..2c34af0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
@@ -1,6 +1,7 @@
-// Contacts backup: a copy of this phone's address book sent to
-// `<folder>/<account>-contacts` of the destination every backup shares
-// (sync-destination.js), once a day when it changed (docs/MESHBAY_DESIGN.md §9.13).
+// Contacts and messages backup: a copy of this phone's address book, and the
+// text messages added since the last copy, sent to `<folder>/<account>-contacts`
+// and `-messages` of the destination every backup shares (sync-destination.js),
+// once a day when there is something new (docs/MESHBAY_DESIGN.md §9.13).
//
// The same split as photos (photo-sync.js): the phone writes what is new into
// one file and remembers what the node took; this module decides when a run
@@ -29,6 +30,7 @@ const RETRY_MS = 15 * 60 * 1000;
*/
export const KINDS = {
contacts: { sync: platform.contactSync, heading: 'contactsync.heading', suffix: 'contacts' },
+ messages: { sync: platform.messageSync, heading: 'messagesync.heading', suffix: 'messages' },
};
// Refusals that will hold at the next run too: said once (a notification,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 148b2eb..4b507dc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -267,7 +267,7 @@ export const phoneSync = {
};
/**
- * Backing up one kind of this phone's data (the contacts) to the destination
+ * Backing up one kind of this phone's data (contacts, messages) to the destination
* every kind shares (docs/MESHBAY_DESIGN.md §9.13). Android only, like
* `photoSync`. See phone-sync.js.
*/
@@ -292,6 +292,8 @@ function docSync(name) {
}
export const contactSync = docSync('contactSync');
+/** Absent from the Play build, whose policy keeps text messages from it. */
+export const messageSync = docSync('messageSync');
/** Pick a directory to add as a group root. Returns { path, name } or null. */
export const rootPicker = {
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
index c1c4599..3ae73c1 100644
--- a/packages/meshbay-hub/tests/harness/photo_sync_probe.py
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -97,6 +97,15 @@ window.meshbay = {
permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }),
sent: async () => true, completed: async () => ({}), failed: async () => true,
},
+ messageSync: {
+ status: async () => ({
+ permission: 'granted', on: MODE === 'configured', destination: dest,
+ dir: dest && '__FOLDER__/bob-messages', sent: 2, lastCompleted: Date.now(),
+ lastSent: dest && '__FOLDER__/bob-messages/2026/sms-2026-10-10-0900.xml', failure: null,
+ }),
+ permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ },
};
</script>
<main class="main"><div id=root></div></main>
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 426b560..7b2e79f 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -147,7 +147,8 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
# A phone has a push distributor to talk to and a camera roll and an
# address book to back up, and a desktop has none of them; those families are the ones the desktop lacks
# rather than the ones it shares.
- phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:"))}
+ phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:",
+ "messagesync:"))}
assert any(c.startswith("push:") for c in phone_only), "the push channels are gone"
assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone"
assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
@@ -255,13 +256,29 @@ def test_a_release_is_signed_with_the_release_key_or_not_built():
assert 'signingConfigs.getByName("debug")' not in build
assert 'signingConfigs.findByName("release")' in build
assert 'providers.gradleProperty("meshbayReleaseStorePassword")' in build
- assert '"preReleaseBuild"' in build and "throw GradleException" in build
+ assert 'name.endsWith("ReleaseBuild")' in build and "throw GradleException" in build
assert not re.search(r'storePassword = "', build)
+def test_the_play_build_cannot_read_text_messages():
+ """Play's policy keeps READ_SMS for the default SMS application: the Play
+ build has neither the permission nor the code that reads messages."""
+ main = _read(APP / "src" / "main" / "AndroidManifest.xml")
+ assert "READ_SMS" not in main
+ assert "READ_SMS" in _read(APP / "src" / "full" / "AndroidManifest.xml")
+ assert not (APP / "src" / "play" / "AndroidManifest.xml").exists() or \
+ "READ_SMS" not in _read(APP / "src" / "play" / "AndroidManifest.xml")
+ play = list((APP / "src" / "play").rglob("*.kt"))
+ assert play and all("Telephony" not in _read(f) and "SmsSource" not in _read(f) for f in play)
+ assert not list((APP / "src" / "main").rglob("SmsSource.kt"))
+
+
@pytest.mark.skipif(not os.environ.get("ANDROID_HOME") or shutil.which("java") is None,
reason="no Android SDK in the environment")
def test_the_jvm_unit_tests_pass():
- result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testDebugUnitTest"],
+ # The tests are shared; the Play build is compiled too, so a flavor file
+ # missing from it fails here rather than at release time.
+ result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testFullDebugUnitTest",
+ "compilePlayDebugKotlin"],
cwd=ANDROID, capture_output=True, text=True, timeout=900)
assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-3000:]
diff --git a/packages/meshbay-hub/tests/test_phone_sync.py b/packages/meshbay-hub/tests/test_phone_sync.py
index 1a35614..0798ef0 100644
--- a/packages/meshbay-hub/tests/test_phone_sync.py
+++ b/packages/meshbay-hub/tests/test_phone_sync.py
@@ -99,6 +99,13 @@ win.meshbay = { contactSync: {
return fake.status;
},
failed: async (code, text) => { fake.log.push(['failed', code, text]); return true; },
+}, messageSync: {
+ status: async () => ({ ...fake.status, on: !!fake.messagesOn, dir: 'Backups/bob-messages' }),
+ plan: async () => ({ item: { token: 'm1', name: 'sms-2026-10-10-0900.xml',
+ dir: 'Backups/bob-messages/2026', size: 4, count: 300 } }),
+ sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; },
+ completed: async () => { fake.log.push(['completed']); return fake.status; },
+ failed: async (code, text) => { fake.log.push(['failed', code, text]); return true; },
} };
"""
@@ -177,6 +184,18 @@ await m.runDocSync('contacts', { force: true });
assert len(_of(log, "upload")) == 1
+def test_messages_go_under_the_year_in_their_own_folder(tmp_path):
+ log = _run("""
+fake.messagesOn = true;
+m.startPhoneSync(ctx);
+await m.runDocSync('contacts');
+await m.runDocSync('messages');
+""", tmp_path)
+ assert _of(log, "mkdir")[-2:] == [["Backups/bob-messages"], ["Backups/bob-messages/2026"]]
+ assert ["upload", "Backups/bob-messages/2026", "sms-2026-10-10-0900.xml", "tr-4"] in log
+ assert ["sent", "m1", "Backups/bob-messages/2026", "sms-2026-10-10-0900.xml"] in log
+
+
# ── alone in the group, at every run ────────────────────────────────────────
def test_the_group_is_asked_about_at_every_run(tmp_path):
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
index bcd26c8..b101310 100644
--- a/packages/meshbay-hub/tests/test_photo_sync_ui.py
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -57,7 +57,7 @@ def test_limited_access_is_said_rather_than_failing_quietly(probe):
def test_where_backups_go_lists_each_kinds_folder(probe):
text = probe["dest360"]["text"]
- assert "Media/bob-photos" in text and "Media/bob-contacts" in text
+ assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "messages"))
def test_photos_the_photos_tab_will_not_show_are_said(probe):
@@ -66,3 +66,7 @@ def test_photos_the_photos_tab_will_not_show_are_said(probe):
def test_the_contacts_backup_says_where_its_copies_go(probe):
assert "bob-contacts/contacts-2026-10-10-0900.vcf" in probe["configured360"]["text"]
+
+
+def test_the_messages_backup_says_where_its_copies_go(probe):
+ assert "bob-messages/2026/sms-2026-10-10-0900.xml" in probe["configured360"]["text"]