diff options
Diffstat (limited to 'packages/meshbay-android')
12 files changed, 285 insertions, 11 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("&") + c == '<' -> b.append("<") + c == '>' -> b.append(">") + c == '"' -> b.append(""") + c == '\n' -> b.append(" ") + c == '\r' -> b.append(" ") + c == '\t' -> b.append("	") + 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\u0007cd"))) + 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)) + } +} |