From aed32f20625a6206628b577d743d96552f81e91a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 14:40:05 +0200 Subject: 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 XML, into /-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 --- .../kotlin/org/meshbay/client/phonesync/Flavor.kt | 8 +++ .../org/meshbay/client/phonesync/SmsSource.kt | 72 ++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt create mode 100644 packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt (limited to 'packages/meshbay-android/app/src/full/kotlin/org') 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, `-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() + 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() + + 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 } + } + } + } +} -- cgit v1.2.3