aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org
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/meshbay-android/app/src/main/kotlin/org
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/meshbay-android/app/src/main/kotlin/org')
-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
4 files changed, 84 insertions, 3 deletions
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index 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()
+ }
+}