diff options
Diffstat (limited to 'packages/meshbay-android')
15 files changed, 14 insertions, 301 deletions
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md index 02297c9..d44b58c 100644 --- a/packages/meshbay-android/README.md +++ b/packages/meshbay-android/README.md @@ -30,17 +30,11 @@ 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/{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 +./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 ``` -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: diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts index cd5e1a5..7887d04 100644 --- a/packages/meshbay-android/app/build.gradle.kts +++ b/packages/meshbay-android/app/build.gradle.kts @@ -32,16 +32,6 @@ 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 @@ -110,7 +100,6 @@ 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 { - // 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( diff --git a/packages/meshbay-android/app/src/full/AndroidManifest.xml b/packages/meshbay-android/app/src/full/AndroidManifest.xml deleted file mode 100644 index 552902a..0000000 --- a/packages/meshbay-android/app/src/full/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?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 deleted file mode 100644 index 20882a5..0000000 --- a/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/Flavor.kt +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index c24ef1f..0000000 --- a/packages/meshbay-android/app/src/full/kotlin/org/meshbay/client/phonesync/SmsSource.kt +++ /dev/null @@ -1,72 +0,0 @@ -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 a07399f..d829df3 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,12 +15,11 @@ * that refuses: `platform.js` decides what to show from whether an object * exists (`platform.node.available`, `platform.folder.available`, …). * - * `HUB_BASE`, `BINARY`, `CAST`, `BACKUP` and `MESSAGES` are prepended by the + * `HUB_BASE`, `BINARY`, `CAST` and `BACKUP` 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; BACKUP whether this is - * the personal profile, the only one backed up; MESSAGES whether this build - * backs text messages up (not the Play build). + * the personal profile, the only one backed up. */ (function () { 'use strict'; @@ -238,7 +237,6 @@ sent: (token, dir, name) => call('contactsync:sent', token, dir, name), completed: () => call('contactsync:completed'), failed: (code, text) => call('contactsync:failed', code, text), - openSettings: () => call('contactsync:open-settings'), }, // Calendar backup (§9.13), the same way: one iCalendar file of every @@ -251,7 +249,6 @@ sent: (token, dir, name) => call('calendarsync:sent', token, dir, name), completed: () => call('calendarsync:completed'), failed: (code, text) => call('calendarsync:failed', code, text), - openSettings: () => call('calendarsync:open-settings'), }, // Files backup (§9.13): folders the person chose through the system's @@ -287,19 +284,6 @@ }, } : {}), - // 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), - openSettings: () => call('messagesync:open-settings'), - } } : {}), - // 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 d523da7..5f225e2 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 @@ -42,7 +42,6 @@ import org.meshbay.client.phonesync.CalendarSource 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.phonesync.Profiles import org.meshbay.client.photos.PhotoChannels import org.meshbay.client.save.SaveSinks @@ -128,11 +127,7 @@ class MainActivity : Activity() { DocChannels("calendarsync", this, getSharedPreferences("calendarsync", Context.MODE_PRIVATE), destinations, java.io.File(cacheDir, "calendarsync"), CalendarSource(this), notifyId = 12, permissionRequest = 4211), - ) + 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() } }, @@ -279,8 +274,7 @@ class MainActivity : Activity() { 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 BACKUP = $backups;\n" + - "const MESSAGES = ${docs.any { it.handles("messagesync:") }};\n" + "const BACKUP = $backups;\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 f4da64f..dd74256 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 @@ -16,7 +16,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** - * Contacts or messages backup (docs/MESHBAY_DESIGN.md §9.13): what the page + * Contacts or calendar 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. * @@ -64,7 +64,6 @@ class DocChannels( "sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } "completed" -> { completed(); status() } "failed" -> failed(args.optString(0, ""), args.optString(1, "")) - "open-settings" -> { openSettings(); true } else -> throw Refused("Refused: no such channel") } @@ -126,7 +125,7 @@ class DocChannels( 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) + (if (export.subdir.isEmpty()) "" else "/${export.subdir}")) + .put("dir", DocPlan.dirFor(c, source.suffix)) .put("size", export.file.length()).put("count", export.count)) } @@ -168,19 +167,6 @@ class DocChannels( permission = null } - /** - * The application's page in the system's settings. Android keeps some - * permissions (text messages) behind "restricted settings" for an - * application installed outside a store; that page is where the person - * lifts it (⋮ > Allow restricted settings), and nowhere in here can. - */ - private fun openSettings() { - val intent = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - android.net.Uri.fromParts("package", activity.packageName, null)) - .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - activity.runOnUiThread { activity.startActivity(intent) } - } - /** From Activity.onRequestPermissionsResult; true when the request was ours. */ fun deliverPermission(requestCode: Int): Boolean { if (requestCode != permissionRequest) return false 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 1a2a359..ecf643b 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 @@ -13,14 +13,12 @@ data class Export( 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. */ + /** Contacts or events 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 = "", ) /** - * One kind of the phone's data (the contacts, the messages) written out as + * One kind of the phone's data (the contacts, the calendar) written out as * a file of what is new since `marker`, the value the last file sent left. */ interface DocSource { diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt index 660cfaa..305e705 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt @@ -9,7 +9,7 @@ import android.os.UserManager * of the application installed inside a work profile offers none of them. * * In the personal profile, every source reads that profile alone: MediaStore, - * ContactsContract, the SMS and calendar providers answer for the profile + * ContactsContract and the calendar provider answer for the profile * they are asked from, and none of the cross-profile (`ENTERPRISE_*`) URIs * is used (`test_android_shell.py` checks for them). */ 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 deleted file mode 100644 index 624e1f1..0000000 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/SmsXml.kt +++ /dev/null @@ -1,69 +0,0 @@ -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/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt index c1de859..21734fb 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 @@ -348,7 +348,7 @@ 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. */ + /** Created, or renamed from "Photo backup" now that contacts and the calendar use it too. */ fun ensureChannel(context: Context) { context.getSystemService(NotificationManager::class.java) .createNotificationChannel(NotificationChannel(CHANNEL, "Backup", NotificationManager.IMPORTANCE_LOW)) 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 deleted file mode 100644 index 76dde1f..0000000 --- a/packages/meshbay-android/app/src/play/kotlin/org/meshbay/client/phonesync/Flavor.kt +++ /dev/null @@ -1,11 +0,0 @@ -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/DocSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt index f53110b..6a9e4e6 100644 --- 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 @@ -24,7 +24,7 @@ class DocSyncTest { @Test fun `each kind goes into a folder of its own named for the account`() { val c = Destination.fromJson(settings(folder = "/Backups/Phone/")) assertEquals("Backups/Phone/bob-contacts", DocPlan.dirFor(c, "contacts")) - assertEquals("Backups/Phone/bob-messages", DocPlan.dirFor(c, "messages")) + assertEquals("Backups/Phone/bob-calendar", DocPlan.dirFor(c, "calendar")) } @Test fun `a file is named for when it was taken, in the phone's time`() { 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 deleted file mode 100644 index 9945ec1..0000000 --- a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SmsXmlTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -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)) - } -} |