diff options
Diffstat (limited to 'packages/meshbay-android')
39 files changed, 3402 insertions, 17 deletions
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md index 69977ec..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. @@ -62,6 +68,14 @@ connector could decrypt, and the fetch slows to a four-hour net. The page turns it on in Settings and registers the phone with the hub; muting and "disable all" are decided on the hub, which then creates nothing (§11.3). +Photo backup (`photos/`, §9.12): MediaStore is listed natively, a ledger of +what was sent is kept per account, group and folder, and each photo's bytes are +handed to the page at `/photosync/<token>` on the packaged origin — never a +URI. The page decides when a run is due and sends; a `dataSync` foreground +service (`BackupService`) keeps it going with the screen off. No +`ACCESS_MEDIA_LOCATION`, so the platform redacts a photo's location from what +is read. + Not built yet: phone-specific behaviour (back button, network handover, keeping a download alive with the screen off), updates through a store. 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/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index 0c234aa..6b97b45 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -13,6 +13,20 @@ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- The periodic fetch survives a reboot (JobInfo.setPersisted). --> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> + <!-- Photo backup: the photos, asked for when the person turns it on. Not + ACCESS_MEDIA_LOCATION — without it the platform redacts a photo's + location from the bytes this application reads, so a camera roll sent + to a group does not say where its owner lives. --> + <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> + <!-- Videos of the same albums, when the person ticks them in: asked then. --> + <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> + <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" /> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> + <!-- Contacts backup: the address book, asked for when the person turns it on. --> + <uses-permission android:name="android.permission.READ_CONTACTS" /> + <!-- Calendar backup: the same, for the calendars. --> + <uses-permission android:name="android.permission.READ_CALENDAR" /> <!-- No backup of any kind: the keys are wrapped by a Keystore key that a restore cannot bring with it, so a backed-up store is one that silently @@ -41,6 +55,10 @@ android:name=".cast.CastService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> + <service + android:name=".photos.BackupService" + android:exported="false" + android:foregroundServiceType="dataSync" /> <!-- Not exported: the connector's own receiver takes the distributor's broadcasts and hands them here inside the application. --> <service 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 82e556d..22fbe3d 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,12 @@ * 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 - * anything can await; BINARY says whether the WebView carries ArrayBuffer - * messages; CAST whether this device can cast at all. + * `HUB_BASE`, `BINARY`, `CAST`, `BACKUP` 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; BACKUP whether this is + * the personal profile, the only one backed up; MESSAGES whether this build + * backs text messages up (not the Play build). */ (function () { 'use strict'; @@ -197,6 +199,104 @@ call('push:remember', subscription, account, secret, since), }, + // Backups (§9.12, §9.13), of the personal profile only: a copy of the + // application inside a work profile has none of these (Profiles.kt). + ...(BACKUP ? { + // Photo backup (§9.12): the phone lists its photos, keeps what was sent, + // and hands each photo's bytes over at /photosync/<token> on this origin. + // The page decides when and does the sending. Phone-only, like `push`. + photoSync: { + status: () => call('photosync:status'), + permit: (withVideos) => call('photosync:permit', withVideos === true), + albums: (withVideos) => call('photosync:albums', withVideos === true), + configure: (settings) => call('photosync:configure', settings || null), + estimate: (settings) => call('photosync:estimate', settings), + plan: () => call('photosync:plan'), + sent: (token, dir, name) => call('photosync:sent', token, dir, name), + manifest: () => call('photosync:manifest'), + manifestSent: (token) => call('photosync:manifest-sent', token), + completed: () => call('photosync:completed'), + failed: (code, text) => call('photosync:failed', code, text), + keepAlive: (on, text) => call('photosync:keep-alive', on === true, text || ''), + }, + + // Where every backup goes (§9.12): one folder of one group the account + // owns and is alone in. Each kind goes into `<account>-<kind>` under it. + phoneSync: { + destination: () => call('phonesync:destination'), + setDestination: (d) => call('phonesync:set-destination', d || null), + }, + + // Contacts backup (§9.13): the phone writes the address book into one + // file, served at /phonesync/<token> on this origin, when it changed since + // the last one sent. The page decides when and does the sending. + contactSync: { + status: () => call('contactsync:status'), + permit: () => call('contactsync:permit'), + configure: (settings) => call('contactsync:configure', settings || null), + plan: () => call('contactsync:plan'), + sent: (token, dir, name) => call('contactsync:sent', token, dir, name), + completed: () => call('contactsync:completed'), + failed: (code, text) => call('contactsync:failed', code, text), + }, + + // Calendar backup (§9.13), the same way: one iCalendar file of every + // calendar the person can write to, when it changed. + calendarSync: { + status: () => call('calendarsync:status'), + permit: () => call('calendarsync:permit'), + configure: (settings) => call('calendarsync:configure', settings || null), + plan: () => call('calendarsync:plan'), + sent: (token, dir, name) => call('calendarsync:sent', token, dir, name), + completed: () => call('calendarsync:completed'), + failed: (code, text) => call('calendarsync:failed', code, text), + }, + + // Files backup (§9.13): folders the person chose through the system's + // picker, sent like photos, a range at a time from /drivesync/<token>. + driveSync: { + status: () => call('drivesync:status'), + addFolder: () => call('drivesync:add-folder'), + removeFolder: (id) => call('drivesync:remove-folder', id), + configure: (settings) => call('drivesync:configure', settings || null), + plan: () => call('drivesync:plan'), + sent: (token, dir, name) => call('drivesync:sent', token, dir, name), + manifest: () => call('drivesync:manifest'), + manifestSent: (token) => call('drivesync:manifest-sent', token), + completed: () => call('drivesync:completed'), + failed: (code, text) => call('drivesync:failed', code, text), + keepAlive: (on, text) => call('drivesync:keep-alive', on === true, text || ''), + }, + + // WhatsApp's own folder (§9.13), the same way: its encrypted chat + // backups for a restore, and its media if the person ticks them. + whatsappSync: { + status: () => call('wasync:status'), + chooseFolder: () => call('wasync:add-folder'), + forgetFolder: (id) => call('wasync:remove-folder', id), + configure: (settings) => call('wasync:configure', settings || null), + plan: () => call('wasync:plan'), + sent: (token, dir, name) => call('wasync:sent', token, dir, name), + manifest: () => call('wasync:manifest'), + manifestSent: (token) => call('wasync:manifest-sent', token), + completed: () => call('wasync:completed'), + failed: (code, text) => call('wasync:failed', code, text), + keepAlive: (on, text) => call('wasync:keep-alive', on === true, 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 dad8462..5cf06fc 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 @@ -35,6 +35,16 @@ import org.meshbay.client.keys.SecretStore import org.meshbay.client.notify.Notifier import org.meshbay.client.notify.PushChannels import org.meshbay.client.notify.PushState +import org.meshbay.client.photos.BackupService +import org.meshbay.client.drive.DriveChannels +import org.meshbay.client.drive.DriveKind +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 import org.meshbay.client.shell.Pickers import org.meshbay.client.shell.ShellWebView @@ -56,6 +66,12 @@ class MainActivity : Activity() { private lateinit var cast: CastChannels private lateinit var hub: HubClient private lateinit var channels: Channels + private lateinit var photos: PhotoChannels + private var docs: List<DocChannels> = emptyList() + private var drives: List<DriveChannels> = emptyList() + /** Backups exist in the personal profile only (Profiles.kt). */ + private var backups = false + private var network: android.net.ConnectivityManager.NetworkCallback? = null private val pickers = Pickers(this) private val text = NativeText { code -> try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null } @@ -63,6 +79,9 @@ class MainActivity : Activity() { private var shim: ScriptHandler? = null private var casting = false private var playing = false + /** Which backups hold the keep-alive now ("photos", "drive"): one stopping leaves the other's. */ + private val syncers = HashSet<String>() + private val syncing get() = syncers.isNotEmpty() private var fullscreen: View? = null private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null private var pendingLink: String? = null @@ -91,19 +110,45 @@ class MainActivity : Activity() { Thread { saves.cleanUpAfterAKilledProcess() }.start() cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting = on; keepAlive() } }, tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) + backups = Profiles.isPersonal(this) + val destinations = DestinationChannels(getSharedPreferences(DestinationChannels.PREFS, Context.MODE_PRIVATE)) + photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE), destinations, + java.io.File(filesDir, "photosync"), + onKeepAlive = { on, line -> runOnUiThread { backup("photos", on, line) } }) + drives = if (!backups) emptyList() else listOf(DriveKind.FILES, DriveKind.WHATSAPP).map { kind -> + DriveChannels(kind, this, getSharedPreferences(kind.prefix, Context.MODE_PRIVATE), + java.io.File(filesDir, kind.prefix), destinations, pickers, + photosBackedUp = { photos.backedUpPaths() }, unmetered = { photos.unmetered() }, + onKeepAlive = { on, line -> runOnUiThread { backup(kind.suffix, on, line) } }) + } + docs = if (!backups) emptyList() else listOf( + DocChannels("contactsync", this, getSharedPreferences("contactsync", Context.MODE_PRIVATE), destinations, + java.io.File(cacheDir, "contactsync"), ContactSource(this), + notifyId = 10, permissionRequest = 4209), + 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() } }, push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)), channelNames = { mapOf( Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale), - Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) })) + Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }), + photos = photos.takeIf { backups }, destinations = destinations.takeIf { backups }, + docs = docs, drives = drives) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() // Opened from a notification: to what it was about, once the page is up. pendingLink = Notifier.linkOf(intent) web.loadUrl(UiAssets.START) + watchNetwork() } override fun onNewIntent(intent: Intent) { @@ -141,6 +186,19 @@ class MainActivity : Activity() { override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { val url = request.url + if (url.host == UiAssets.HOST && url.path?.startsWith(PhotoChannels.PATH) == true) { + val range = request.requestHeaders.entries.firstOrNull { it.key.equals("Range", true) }?.value + return photos.takeIf { backups }?.serve(url.path ?: "", range) ?: refused() + } + val driveKind = drives.firstOrNull { url.path?.startsWith(it.path) == true } + if (url.host == UiAssets.HOST && driveKind != null) { + val range = request.requestHeaders.entries.firstOrNull { it.key.equals("Range", true) }?.value + return driveKind.serve(url.path ?: "", range) ?: refused() + } + if (url.host == UiAssets.HOST && url.path?.startsWith(DocChannels.PATH) == true) { + val path = url.path ?: "" + return docs.firstNotNullOfOrNull { it.serve(path) } ?: refused() + } if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() // reCAPTCHA (sign-up) and nothing else goes to the network from // the page; the policy says the same, this is the second wall. @@ -220,7 +278,9 @@ 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 BACKUP = $backups;\n" + + "const MESSAGES = ${docs.any { it.handles("messagesync:") }};\n" shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN)) } @@ -258,11 +318,12 @@ class MainActivity : Activity() { /** * A cast, or music playing here, keeps the process, the Wi-Fi and the page * alive with the screen off (spike S-2a, scenario F): the foreground service - * holds the first two, the WebView reported visible holds the third. + * holds the first two, the WebView reported visible holds the third. A photo + * backup holds the page here too; its service is its own (`backup`). */ private fun keepAlive() { val on = casting || playing - web.keepVisible = on + web.keepVisible = on || syncing val service = Intent(this, CastService::class.java) if (!on) { stopService(service); return } service.putExtra(CastService.EXTRA_TEXT, @@ -272,6 +333,56 @@ class MainActivity : Activity() { try { startForegroundService(service) } catch (e: IllegalStateException) { Log.w(Bridge.TAG, "keep-alive refused: $e") } } + /** + * A photo or files backup running: its own foreground service, and the + * page kept visible like a cast. Started once; afterwards only its line + * changes, which needs no start (refused from the background on Android + * 12+). Held while any backup holds it. + */ + private fun backup(who: String, on: Boolean, line: String) { + val service = Intent(this, BackupService::class.java) + val was = syncing + if (on) syncers += who else syncers -= who + if (on && was) { BackupService.update(this, line); return } + if (syncing == was) return + if (on) { + try { startForegroundService(service.putExtra(BackupService.EXTRA_TEXT, line)) } + catch (e: IllegalStateException) { Log.w(Bridge.TAG, "backup keep-alive refused: $e") } + } else stopService(service) + keepAlive() + } + + /** + * Tells the page when the network becomes unmetered or stops being: a + * backup waiting for Wi-Fi starts, one running on it stops. An event on the + * window, carrying the one boolean and nothing about the network. + */ + private fun watchNetwork() { + val cm = getSystemService(android.net.ConnectivityManager::class.java) + var last: Boolean? = null + val callback = object : android.net.ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(n: android.net.Network, caps: android.net.NetworkCapabilities) = tell() + override fun onLost(n: android.net.Network) = tell() + private fun tell() { + val now = photos.unmetered() + if (now == last) return + last = now + runOnUiThread { + if (::web.isInitialized) web.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: $now } }));", null) + } + } + } + try { cm.registerDefaultNetworkCallback(callback); network = callback } + catch (e: Exception) { Log.w(Bridge.TAG, "no network callback: $e") } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { + if (::photos.isInitialized && photos.deliverPermission(requestCode)) return + if (docs.any { it.deliverPermission(requestCode) }) return + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false } private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream()) @@ -335,6 +446,8 @@ class MainActivity : Activity() { override fun onDestroy() { if (::cast.isInitialized && cast.relay.active) cast.relay.stop() if (casting || playing) { casting = false; playing = false; keepAlive() } + if (syncing) { syncers.clear(); stopService(Intent(this, BackupService::class.java)); keepAlive() } + network?.let { try { getSystemService(android.net.ConnectivityManager::class.java).unregisterNetworkCallback(it) } catch (e: Exception) {} } if (::web.isInitialized) { root.removeView(web); web.destroy() } super.onDestroy() } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index 5f202ba..e3a81df 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -4,6 +4,10 @@ import org.json.JSONArray import org.meshbay.client.cast.CastChannels import org.meshbay.client.hub.HubClient import org.meshbay.client.notify.PushChannels +import org.meshbay.client.drive.DriveChannels +import org.meshbay.client.phonesync.DestinationChannels +import org.meshbay.client.phonesync.DocChannels +import org.meshbay.client.photos.PhotoChannels import org.meshbay.client.save.BinaryFrame import org.meshbay.client.save.SaveSinks import java.net.Inet4Address @@ -28,6 +32,10 @@ class Channels( private val cast: CastChannels? = null, private val onPlayback: (Boolean) -> Unit = {}, private val push: PushChannels? = null, + private val photos: PhotoChannels? = null, + private val destinations: DestinationChannels? = null, + private val docs: List<DocChannels> = emptyList(), + private val drives: List<DriveChannels> = emptyList(), ) { @Volatile var locale = "en" private set @@ -56,7 +64,11 @@ class Channels( keys != null && keys.handles(channel) -> keys.call(channel, args) cast != null && cast.handles(channel) -> cast.call(channel, args) push != null && push.handles(channel) -> push.call(channel, args) - else -> throw Refused("Refused: no such channel") + photos != null && photos.handles(channel) -> photos.call(channel, args) + destinations != null && destinations.handles(channel) -> destinations.call(channel, args) + drives.any { it.handles(channel) } -> drives.first { it.handles(channel) }.call(channel, args) + else -> docs.firstOrNull { it.handles(channel) }?.call(channel, args) + ?: throw Refused("Refused: no such channel") } } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt new file mode 100644 index 0000000..c6fb476 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt @@ -0,0 +1,297 @@ +package org.meshbay.client.drive + +import android.app.Activity +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.provider.DocumentsContract +import android.webkit.WebResourceResponse +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.bridge.Refused +import org.meshbay.client.phonesync.Destination +import org.meshbay.client.phonesync.DestinationChannels +import org.meshbay.client.phonesync.ManifestLog +import org.meshbay.client.phonesync.DocPlan +import org.meshbay.client.photos.PhotoChannels +import org.meshbay.client.shell.Pickers +import java.io.File +import java.security.SecureRandom +import java.util.TimeZone +import java.util.concurrent.ConcurrentHashMap + +/** + * Files backup (docs/MESHBAY_DESIGN.md §9.13): the folders the person chose, + * sent into `<folder>/<account>-drive/<chosen folder>/…` of the destination + * every kind shares, on the photo backup's terms: the page decides when and + * sends, this side lists, keeps the ledger and hands bytes over by token + * (`/drivesync/<token>`, a range at a time). + * + * Media folders cannot be chosen, and files the photo backup sends are left + * out (DrivePlan): nothing is stored twice. + */ +class DriveChannels( + private val kind: DriveKind, + private val activity: Activity, + private val prefs: SharedPreferences, + private val dir: File, + private val destinations: DestinationChannels, + private val pickers: Pickers, + private val photosBackedUp: () -> Set<String>, + private val unmetered: () -> Boolean, + private val onKeepAlive: (Boolean, String) -> Unit, +) { + private val source = DriveSource(activity) + private val random = SecureRandom() + private val tokens = ConcurrentHashMap<String, Issued>() + + private class Issued(val pending: DrivePending, val key: String) + + init { + destinations.onChange { + tokens.clear() + prefs.edit().remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + } + } + + /** `/drivesync/`, `/wasync/`: where this kind's files are served on the packaged origin. */ + val path: String get() = kind.path + + fun handles(channel: String) = channel.startsWith("${kind.prefix}:") + + fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("${kind.prefix}:")) { + "status" -> status() + "add-folder" -> { addFolder(); status() } + "remove-folder" -> { removeFolder(args.optString(0, "")); status() } + "configure" -> { configure(args.optJSONObject(0)); status() } + "plan" -> plan() + "sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } + "manifest" -> manifest() + "manifest-sent" -> { manifestSent(args.optString(0, "")); true } + "completed" -> { completed(); status() } + "failed" -> failed(args.optString(0, ""), args.optString(1, "")) + "keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true } + else -> throw Refused("Refused: no such channel") + } + + // ── state ──────────────────────────────────────────────────────────────── + + private data class Folder(val uri: String, val name: String) + + private fun folders(): List<Folder> = try { + val a = JSONArray(prefs.getString(FOLDERS, "[]")) + (0 until a.length()).map { a.getJSONObject(it).let { o -> Folder(o.getString("uri"), o.getString("name")) } } + } catch (e: Exception) { emptyList() } + + private fun saveFolders(list: List<Folder>) { + prefs.edit().putString(FOLDERS, JSONArray(list.map { JSONObject().put("uri", it.uri).put("name", it.name) }) + .toString()).apply() + } + + /** Two chosen folders of one name go into two folders on the node: `Notes`, `Notes-2`. */ + private fun topNames(list: List<Folder>): Map<String, String> { + if (!kind.keepTop) return list.associate { it.uri to "" } + val used = HashMap<String, Int>() + return list.associate { f -> + val n = (used[f.name.lowercase()] ?: 0) + 1 + used[f.name.lowercase()] = n + f.uri to (if (n == 1) f.name else "${f.name}-$n") + } + } + + private fun base(d: Destination) = DocPlan.dirFor(d, kind.suffix) + + private fun options(): JSONObject = try { JSONObject(prefs.getString(OPTIONS, "{}")) } catch (e: Exception) { JSONObject() } + + private fun keyOf(d: Destination) = PhotoChannels.hex(PhotoChannels.sha256Of(d.ledgerKey.toByteArray())).take(32) + + private fun ledger(d: Destination) = DriveLedger(File(dir, keyOf(d) + ".jsonl")) + + private fun manifestLog(d: Destination) = ManifestLog(File(dir, keyOf(d) + ".manifest")) + + @Volatile private var manifestToken: Pair<String, File>? = null + + fun status(): JSONObject { + val d = destinations.get() + return JSONObject() + .put("on", prefs.getBoolean(ON, false)) + .put("options", options()) + .put("unmetered", unmetered()) + .put("destination", d?.toJson() ?: JSONObject.NULL) + .put("dir", d?.let { base(it) } ?: JSONObject.NULL) + .put("folders", JSONArray(folders().map { JSONObject().put("id", it.uri).put("name", it.name) })) + .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL) + .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL) + .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL) + .put("sent", if (d != null) ledger(d).size else 0) + .put("now", System.currentTimeMillis()) + } + + /** The system's folder picker; a media folder or a whole volume is refused, with the reason. */ + private fun addFolder() { + val ask = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + kind.initialDocId?.let { + ask.putExtra(DocumentsContract.EXTRA_INITIAL_URI, + DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", it)) + } + val result = pickers.run(ask) ?: return + val tree = result.data ?: return + kind.refusal(DocumentsContract.getTreeDocumentId(tree))?.let { throw Refused("Refused: $it") } + activity.contentResolver.takePersistableUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION) + var list = folders() + if (list.any { it.uri == tree.toString() }) return + // One folder only: the one chosen now replaces the last. + if (kind.single) { list.forEach { removeFolder(it.uri) }; list = emptyList() } + val fallback = DrivePlan.pathOf(DocumentsContract.getTreeDocumentId(tree)).substringAfterLast('/') + saveFolders(list + Folder(tree.toString(), source.nameOf(tree) ?: fallback.ifEmpty { "folder" })) + } + + /** Forgotten here, and its grant given back; what was sent from it stays on the node. */ + private fun removeFolder(uri: String) { + val list = folders() + if (list.none { it.uri == uri }) return + saveFolders(list.filter { it.uri != uri }) + try { + activity.contentResolver.releasePersistableUriPermission(Uri.parse(uri), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: Exception) { /* already gone */ } + } + + /** `{…options}` turns it on and keeps them (`media` for WhatsApp); null turns it off. */ + private fun configure(o: JSONObject?) { + tokens.clear() + if (o == null) { + prefs.edit().remove(ON).remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + return + } + if (destinations.get() == null) throw Refused("Refused: no destination") + val options = JSONObject().put("media", o.optBoolean("media", false)) + prefs.edit().putBoolean(ON, true).putString(OPTIONS, options.toString()).apply() + } + + private fun completed() { + prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + } + + private fun failed(code: String, text: String): Boolean { + val c = code.take(64) + prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply() + if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false + prefs.edit().putString(NOTIFIED, c).apply() + PhotoChannels.notice(activity, kind.notifyId, text.take(300)) + return true + } + + // ── the files ──────────────────────────────────────────────────────────── + + private fun plan(): JSONObject { + val d = destinations.get()?.takeIf { prefs.getBoolean(ON, false) } ?: throw Refused("Refused: files backup is off") + val list = folders() + val ledger = ledger(d) + // A folder whose grant is gone (removed on the phone, revoked) is + // skipped this run rather than failing the others. + val options = options() + val files = list.flatMap { f -> try { source.files(Uri.parse(f.uri)) } catch (e: Exception) { emptyList() } } + .filter { kind.include(it, options) } + restoreSet = kind.restoreSet?.let { inSet -> files.filter(inSet) } + val items = DrivePlan.plan(files, base(d), topNames(list), ledger::get, photosBackedUp(), + TimeZone.getDefault()) { f, sent -> hashOf(f)?.let { it == sent.sha256 } ?: true } + tokens.clear() + val out = JSONArray() + for (p in items) { + val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) }) + tokens[token] = Issued(p, d.ledgerKey) + out.put(JSONObject().put("token", token).put("name", p.name).put("dir", p.dir) + .put("size", p.file.size).put("edited", p.edited)) + } + return JSONObject().put("items", out).put("manifest", manifestLog(d).waiting()) + } + + /** The node took it (or already had it): into the ledger, hashed from the phone now. */ + private fun sent(token: String, dir: String, name: String) { + val issued = tokens[token] ?: throw Refused("Refused: unknown file") + val d = destinations.get()?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed") + val f = issued.pending.file + val sha = hashOf(f) ?: throw Refused("Refused: the file is gone") + val now = System.currentTimeMillis() + ledger(d).record(DriveLedger.Entry(f.docId, f.modified, f.size, sha, dir.take(1024), name.take(256), now)) + manifestLog(d).append(JSONObject() + .put("kind", "file") + .put("node", "${dir.take(1024)}/${name.take(256)}") + .put("source", DrivePlan.pathOf(f.docId)).put("name", f.name) + .put("folder", folders().firstOrNull { it.uri == f.tree }?.name ?: "") + .put("modified", f.modified).put("size", f.size).put("sha256", sha).put("mime", f.mime) + .put("edited", issued.pending.edited).put("sentAt", now)) + if (kind.restoreSet?.invoke(f) == true) prefs.edit().putBoolean(SET_CHANGED, true).apply() + tokens.remove(token) + } + + /** The files a restore needs together, as the last plan found them. */ + @Volatile private var restoreSet: List<DriveFile>? = null + + /** + * Once something in the restore set was sent: one manifest line naming, for + * each file of the set as it is now, the copy on the node that a restore + * takes. Without it, a restore could pair this week's full copy with last + * week's increments, which WhatsApp would not open. + */ + private fun noteRestoreSet(d: Destination) { + val set = restoreSet ?: return + if (!prefs.getBoolean(SET_CHANGED, false)) return + val ledger = ledger(d) + val files = JSONArray() + for (f in set) { + val e = ledger[f.docId] ?: return // not all on the node yet: the next run says it + if (e.size != f.size || e.modified != f.modified) return + files.put(JSONObject().put("source", DrivePlan.pathOf(f.docId)).put("node", "${e.dir}/${e.name}") + .put("size", e.size).put("sha256", e.sha256)) + } + manifestLog(d).append(JSONObject().put("kind", "restore-set").put("app", kind.suffix) + .put("at", System.currentTimeMillis()).put("files", files)) + prefs.edit().remove(SET_CHANGED).apply() + } + + /** The manifest of what was sent and not yet described on the node (ManifestLog), or `item: null`. */ + private fun manifest(): JSONObject { + val d = destinations.get()?.takeIf { prefs.getBoolean(ON, false) } ?: throw Refused("Refused: files backup is off") + noteRestoreSet(d) + val file = manifestLog(d).issue() ?: return JSONObject().put("item", JSONObject.NULL) + val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) }) + manifestToken = token to file + return JSONObject().put("item", JSONObject().put("token", token) + .put("name", ManifestLog.nameFor(System.currentTimeMillis(), TimeZone.getDefault())) + .put("dir", base(d) + "/" + ManifestLog.DIR).put("size", file.length())) + } + + private fun manifestSent(token: String) { + if (manifestToken?.first != token) throw Refused("Refused: unknown manifest") + val d = destinations.get() ?: throw Refused("Refused: the backup changed") + manifestLog(d).confirm() + manifestToken = null + } + + /** A range of an issued file, for `/drivesync/<token>` on the packaged origin. */ + fun serve(path: String, range: String?): WebResourceResponse? { + manifestToken?.takeIf { it.first == path.removePrefix(kind.path) }?.let { (_, file) -> + return PhotoChannels.serveRange(file.inputStream(), file.length(), "application/x-ndjson", range) + } + val issued = tokens[path.removePrefix(kind.path)] ?: return null + val f = issued.pending.file + val raw = try { source.open(f) } catch (e: Exception) { null } ?: return null + return PhotoChannels.serveRange(raw, f.size, f.mime.ifEmpty { "application/octet-stream" }, range) + } + + private fun hashOf(f: DriveFile): String? = try { + source.open(f)?.use { PhotoChannels.hex(PhotoChannels.digestOf(it)) } + } catch (e: Exception) { null } + + companion object { + private const val OPTIONS = "options" + private const val SET_CHANGED = "restore_set_changed" + private const val FOLDERS = "folders" + private const val ON = "on" + private const val LAST = "last_completed" + private const val FAILURE = "failure" + private const val FAILURE_AT = "failure_at" + private const val NOTIFIED = "notified" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt new file mode 100644 index 0000000..69a658a --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt @@ -0,0 +1,79 @@ +package org.meshbay.client.drive + +import org.json.JSONObject +import java.util.Locale + +/** + * A backup of folders chosen through the system's picker: the files backup, + * or WhatsApp's own folder (docs/MESHBAY_DESIGN.md §9.13). One class runs both + * (DriveChannels); this is what differs. + */ +data class DriveKind( + /** Names the channels (`drivesync:status`) and the preferences. */ + val prefix: String, + /** The folder under the destination: `<account>-<suffix>`. */ + val suffix: String, + val notifyId: Int, + /** Why a chosen folder is refused, from its tree document id, or null. */ + val refusal: (String) -> String?, + /** One folder only: choosing again replaces it. */ + val single: Boolean, + /** The chosen folder's own name as the first level on the node. */ + val keepTop: Boolean, + /** Whether a file found there is sent, given the options the person set. */ + val include: (DriveFile, JSONObject) -> Boolean, + /** The files a restore needs together, listed in the manifest as one set; null when none. */ + val restoreSet: ((DriveFile) -> Boolean)?, + /** Where the picker opens. */ + val initialDocId: String?, +) { + val path: String get() = "/$prefix/" + + companion object { + val FILES = DriveKind( + prefix = "drivesync", suffix = "drive", notifyId = 13, + refusal = DrivePlan::refusal, single = false, keepTop = true, + include = { _, _ -> true }, restoreSet = null, initialDocId = null, + ) + + val WHATSAPP = DriveKind( + prefix = "wasync", suffix = "whatsapp", notifyId = 14, + refusal = WhatsApp::refusal, single = true, keepTop = false, + include = { f, options -> WhatsApp.include(f, options.optBoolean("media", false)) }, + restoreSet = WhatsApp::inRestoreSet, + initialDocId = "primary:" + WhatsApp.FOLDER, + ) + } +} + +/** + * WhatsApp's folder, `Android/media/com.whatsapp/WhatsApp` (or the business + * application's): what a restore onto a new phone needs, and the media. + * + * `Databases/` holds the chats, encrypted by WhatsApp with a key only it (or + * the person's end-to-end backup password) opens: a full copy written weekly, + * `msgstore.db.crypt14`, and an increment a night, + * `msgstore-increment-N.db.crypt14`. When a new week starts WhatsApp renames + * the last week's set with its date; those dated copies are what this backup + * already kept as earlier versions, so they are not sent again. `Backups/` + * holds the rest of a restore (contacts, settings, stickers). + */ +object WhatsApp { + const val FOLDER = "Android/media/com.whatsapp/WhatsApp" + private val FOLDERS = setOf(FOLDER, "Android/media/com.whatsapp.w4b/WhatsApp Business") + private val DATED = Regex("^msgstore(-increment-\\d+)?-\\d{4}-\\d{2}-\\d{2}\\..*", RegexOption.IGNORE_CASE) + + fun refusal(treeDocId: String): String? = + if (DrivePlan.pathOf(treeDocId) in FOLDERS) null else "not_whatsapp" + + private fun top(f: DriveFile) = f.sub.substringBefore('/').lowercase(Locale.ROOT) + + fun include(f: DriveFile, media: Boolean): Boolean = when (top(f)) { + "databases" -> !DATED.matches(f.name) + "backups" -> true + "media" -> media + else -> false + } + + fun inRestoreSet(f: DriveFile): Boolean = include(f, false) +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveLedger.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveLedger.kt new file mode 100644 index 0000000..044edc7 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveLedger.kt @@ -0,0 +1,69 @@ +package org.meshbay.client.drive + +import org.json.JSONObject +import java.io.File + +/** + * What this phone has sent of its chosen folders, by document id: the memory + * of what was sent, never a mirror (PhotoLedger, the same idea for photos). A + * file deleted on the phone stays on the node; one deleted on the node is not + * sent again. One line of JSON per send, the last line for an id winning, + * compacted on load once dead lines outnumber live ones. + */ +class DriveLedger(private val file: File) { + + data class Entry( + val docId: String, + val modified: Long, + val size: Long, + /** SHA-256 of the bytes sent, hex: a touched file is sent again only if this changed. */ + val sha256: String, + val dir: String, + val name: String, + val sentAt: Long, + ) + + private val entries = HashMap<String, Entry>() + private var lines = 0 + + init { load() } + + val size: Int get() = entries.size + + operator fun get(docId: String): Entry? = entries[docId] + + fun record(entry: Entry) { + entries[entry.docId] = entry + file.parentFile?.mkdirs() + file.appendText(encode(entry) + "\n") + lines += 1 + } + + private fun load() { + if (!file.exists()) return + file.forEachLine { line -> + if (line.isBlank()) return@forEachLine + lines += 1 + decode(line)?.let { entries[it.docId] = it } + } + if (lines > 2 * entries.size + COMPACT_SLACK) { + val tmp = File(file.path + ".tmp") + tmp.writeText(entries.values.joinToString("") { encode(it) + "\n" }) + if (tmp.renameTo(file)) lines = entries.size else tmp.delete() + } + } + + companion object { + private const val COMPACT_SLACK = 64 + + fun encode(e: Entry): String = JSONObject() + .put("id", e.docId).put("m", e.modified).put("s", e.size).put("h", e.sha256) + .put("d", e.dir).put("n", e.name).put("t", e.sentAt).toString() + + fun decode(line: String): Entry? = try { + val o = JSONObject(line) + Entry(o.getString("id"), o.getLong("m"), o.getLong("s"), o.getString("h"), + o.getString("d"), o.getString("n"), o.getLong("t")) + } catch (e: Exception) { null } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt new file mode 100644 index 0000000..eb373ba --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt @@ -0,0 +1,112 @@ +package org.meshbay.client.drive + +import org.meshbay.client.photos.PhotoPlan +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** One file found in a chosen folder, through the Storage Access Framework. */ +data class DriveFile( + /** The folder it was found under, as granted (a tree URI). */ + val tree: String, + /** Its document id, stable for the life of the file (`primary:Documents/a.pdf`). */ + val docId: String, + /** Its folder inside the chosen one, `/`-separated, empty at the top. */ + val sub: String, + val name: String, + val size: Long, + /** Milliseconds. */ + val modified: Long, + val mime: String, +) + +/** A file to send: a new one, or a new version of one already sent. */ +data class DrivePending(val file: DriveFile, val edited: Boolean, val dir: String, val name: String) + +/** + * The rules of the files backup (docs/MESHBAY_DESIGN.md §9.13), pure, so the + * JVM tests hold them: which folders may be chosen, what a run sends, and the + * names the node will accept. + */ +object DrivePlan { + /** + * The phone's media folders: what lives there is the photo backup's, and + * taking it here too would store every photo twice on the node. + */ + private val MEDIA = setOf("dcim", "pictures", "movies") + + /** `primary:Documents/Scans` → `Documents/Scans`; the part after the volume. */ + fun pathOf(docId: String): String = docId.substringAfter(':', "").trim('/') + + /** + * Why a folder cannot be chosen, or null when it can: the whole of a + * volume (it holds the media folders), or a media folder or anything in one. + */ + fun refusal(treeDocId: String): String? { + val path = pathOf(treeDocId) + if (path.isEmpty()) return "whole_storage" + if (path.split('/').first().lowercase(Locale.ROOT) in MEDIA) return "media_folder" + return null + } + + /** + * A run's files: new ones, and new versions of ones sent (beside them, + * never in their place). `skip` holds the files the photo backup already + * sends, as lowercase `relative/path/name`. Smallest first: a run cut short + * has saved the most files. + */ + fun plan( + files: List<DriveFile>, base: String, folderNames: Map<String, String>, + sent: (String) -> DriveLedger.Entry?, skip: Set<String>, zone: TimeZone, + sameBytes: (DriveFile, DriveLedger.Entry) -> Boolean, + ): List<DrivePending> { + val out = ArrayList<DrivePending>() + for (f in files) { + if (f.name.startsWith('.')) continue + if (skip.contains(pathOf(f.docId).lowercase(Locale.ROOT))) continue + val top = folderNames[f.tree] ?: continue + val dir = dirFor(base, top, f.sub) + val was = sent(f.docId) + if (was == null) { + out += DrivePending(f, false, dir, safeName(f.name)) + } else if (was.modified != f.modified || was.size != f.size) { + if (was.size == f.size && sameBytes(f, was)) continue + out += DrivePending(f, true, dir, editedName(f, zone)) + } + } + return out.sortedBy { it.file.size } + } + + /** `<base>/<chosen folder>/<sub>`, each level a name the node accepts; no chosen-folder level when `top` is empty. */ + fun dirFor(base: String, top: String, sub: String): String = + (listOf(base) + listOf(top).filter { it.isNotEmpty() }.map(::safeName) + + sub.split('/').filter { it.isNotEmpty() }.map(::safeName)).joinToString("/") + + /** + * The name as is when the node accepts it (PhotoPlan.UPLOAD_NAME, the + * node's own rule); otherwise its refused characters become `_`, and a + * name that cannot start as the node wants is given a `file-` prefix. + */ + fun safeName(name: String): String { + if (PhotoPlan.UPLOAD_NAME.matches(name)) return name + var s = name.map { c -> if (c.isLetterOrDigit() || c in " _.-()[]'’,&+#@") c else '_' }.joinToString("") + .trimEnd(' ', '.') + if (s.isEmpty() || !s.first().isLetterOrDigit()) s = "file-$s" + s = s.take(MAX_NAME).trimEnd(' ', '.') + return if (PhotoPlan.UPLOAD_NAME.matches(s)) s else "file" + } + + /** A new version lands beside the one sent, its date in its name. */ + fun editedName(f: DriveFile, zone: TimeZone): String { + val base = safeName(f.name) + val dot = base.lastIndexOf('.') + val stem = if (dot > 0) base.substring(0, dot) else base + val ext = if (dot > 0) base.substring(dot) else "" + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone }.format(Date(f.modified)) + val suffix = "-modified-$stamp$ext" + return stem.take(MAX_NAME - suffix.length) + suffix + } + + private const val MAX_NAME = 128 +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveSource.kt new file mode 100644 index 0000000..69077fa --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveSource.kt @@ -0,0 +1,55 @@ +package org.meshbay.client.drive + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import java.io.InputStream + +/** + * The files of the folders the person chose, read through the Storage Access + * Framework: each folder was granted through the system's own picker, which + * is all the access this needs (no storage permission, which Play keeps for a + * few kinds of application). + */ +class DriveSource(private val context: Context) { + + /** Every file under `tree`, its sub-folders included; hidden ones (`.x`) and their contents skipped. */ + fun files(tree: Uri): List<DriveFile> { + val out = ArrayList<DriveFile>() + val root = DocumentsContract.getTreeDocumentId(tree) + val pending = ArrayDeque(listOf(root to "")) + val cols = arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED) + while (pending.isNotEmpty()) { + val (parent, sub) = pending.removeFirst() + val children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, parent) + context.contentResolver.query(children, cols, null, null, null)?.use { c -> + while (c.moveToNext()) { + val id = c.getString(0) ?: continue + val name = c.getString(1) ?: continue + if (name.startsWith('.')) continue + val mime = c.getString(2) ?: "" + if (mime == Document.MIME_TYPE_DIR) { + pending.addLast(id to (if (sub.isEmpty()) name else "$sub/$name")) + } else { + out += DriveFile(tree.toString(), id, sub, name, + if (c.isNull(3)) 0 else c.getLong(3), + if (c.isNull(4)) 0 else c.getLong(4), mime) + } + } + } + } + return out + } + + fun open(f: DriveFile): InputStream? = context.contentResolver.openInputStream( + DocumentsContract.buildDocumentUriUsingTree(Uri.parse(f.tree), f.docId)) + + /** The chosen folder's own name, as the picker showed it. */ + fun nameOf(tree: Uri): String? = try { + val doc = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + context.contentResolver.query(doc, arrayOf(Document.COLUMN_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/kotlin/org/meshbay/client/phonesync/CalendarSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/CalendarSource.kt new file mode 100644 index 0000000..bcef08c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/CalendarSource.kt @@ -0,0 +1,89 @@ +package org.meshbay.client.phonesync + +import android.Manifest +import android.content.Context +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.Reminders +import java.io.File +import java.security.MessageDigest +import java.util.TimeZone + +/** + * The phone's calendars, as one iCalendar file (Ics.kt) every calendar + * application imports. + * + * Every calendar the person can write to is in it: their own, local ones and + * those of the accounts on the phone. Calendars they only subscribe to (public + * holidays, birthdays from contacts) are left out: they are someone else's and + * come back by subscribing again. Like contacts, every run writes the whole of + * it and sends it only when it differs from the last copy the node took + * (`marker` is its SHA-256, without the DTSTAMP lines that change every run). + */ +class CalendarSource(private val context: Context) : DocSource { + override val permissions = arrayOf(Manifest.permission.READ_CALENDAR) + override val suffix = "calendar" + + override fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? { + val resolver = context.contentResolver + val calendars = HashMap<Long, String>() + resolver.query(Calendars.CONTENT_URI, + arrayOf(Calendars._ID, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_ACCESS_LEVEL), + "${Calendars.CALENDAR_ACCESS_LEVEL} >= ?", arrayOf(Calendars.CAL_ACCESS_CONTRIBUTOR.toString()), + null)?.use { c -> while (c.moveToNext()) calendars[c.getLong(0)] = c.getString(1) ?: "" } + if (calendars.isEmpty()) return null + + val reminders = HashMap<Long, MutableList<Int>>() + resolver.query(Reminders.CONTENT_URI, arrayOf(Reminders.EVENT_ID, Reminders.MINUTES), null, null, null) + ?.use { c -> while (c.moveToNext()) reminders.getOrPut(c.getLong(0)) { ArrayList() } += c.getInt(1) } + val attendees = HashMap<Long, MutableList<Attendee>>() + val organizers = HashMap<Long, String>() + resolver.query(Attendees.CONTENT_URI, + arrayOf(Attendees.EVENT_ID, Attendees.ATTENDEE_EMAIL, Attendees.ATTENDEE_NAME, + Attendees.ATTENDEE_RELATIONSHIP), null, null, null)?.use { c -> + while (c.moveToNext()) { + val email = c.getString(1)?.takeIf { it.contains('@') } ?: continue + if (c.getInt(3) == Attendees.RELATIONSHIP_ORGANIZER) organizers[c.getLong(0)] = email + else attendees.getOrPut(c.getLong(0)) { ArrayList() } += Attendee(email, c.getString(2)) + } + } + + val cols = arrayOf( + Events._ID, Events.CALENDAR_ID, Events.UID_2445, Events.TITLE, Events.DESCRIPTION, + Events.EVENT_LOCATION, Events.DTSTART, Events.DTEND, Events.DURATION, Events.ALL_DAY, + Events.EVENT_TIMEZONE, Events.RRULE, Events.RDATE, Events.EXDATE, Events.ORIGINAL_ID, + Events.ORIGINAL_INSTANCE_TIME, Events.ORIGINAL_ALL_DAY, Events.STATUS, + ) + val ids = calendars.keys.joinToString(",") + val events = ArrayList<CalEvent>() + resolver.query(Events.CONTENT_URI, cols, "${Events.DELETED} = 0 AND ${Events.CALENDAR_ID} IN ($ids)", + null, "${Events._ID} ASC")?.use { c -> + fun str(i: Int) = if (c.isNull(i)) null else c.getString(i) + fun long(i: Int) = if (c.isNull(i)) null else c.getLong(i) + while (c.moveToNext()) { + val id = c.getLong(0) + events += CalEvent( + id, calendars[c.getLong(1)] ?: "", str(2), str(3), str(4), str(5), + c.getLong(6), long(7), str(8), c.getInt(9) == 1, str(10), + str(11), str(12), str(13), long(14), long(15), c.getInt(16) == 1, + if (c.isNull(17)) null else c.getInt(17), + reminders[id].orEmpty(), organizers[id], attendees[id].orEmpty(), + ) + } + } + if (events.isEmpty()) return null + + val file = File(dir, "calendar.ics") + val digest = MessageDigest.getInstance("SHA-256") + file.bufferedWriter(Charsets.UTF_8).use { out -> + Ics.write(events, now) { l -> + out.write(l); out.write("\r\n") + if (!l.startsWith("DTSTAMP:")) digest.update((l + "\n").toByteArray(Charsets.UTF_8)) + } + } + val sha = digest.digest().joinToString("") { "%02x".format(it) } + if (sha == marker) { file.delete(); return null } + return Export(file, DocPlan.nameFor("calendar", "ics", now, zone), "text/calendar", sha, events.size) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt new file mode 100644 index 0000000..0612e46 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ContactSource.kt @@ -0,0 +1,51 @@ +package org.meshbay.client.phonesync + +import android.Manifest +import android.content.Context +import android.net.Uri +import android.provider.ContactsContract +import org.meshbay.client.photos.PhotoChannels +import java.io.File +import java.security.MessageDigest +import java.util.TimeZone + +/** + * The phone's contacts, as the platform itself exports them: one vCard per + * contact from ContactsContract, the format any phone imports back. + * + * Every run writes the whole address book and sends it only when it differs + * from the last one sent (`marker` is its SHA-256): a file is a complete + * copy as of its date, so restoring is importing the newest one. + */ +class ContactSource(private val context: Context) : DocSource { + override val permissions = arrayOf(Manifest.permission.READ_CONTACTS) + override val suffix = "contacts" + + override fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? { + val keys = ArrayList<String>() + context.contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, arrayOf(ContactsContract.Contacts.LOOKUP_KEY), + null, null, ContactsContract.Contacts._ID, + )?.use { c -> while (c.moveToNext()) c.getString(0)?.let { keys += it } } + if (keys.isEmpty()) return null + + val file = File(dir, "contacts.vcf") + val digest = MessageDigest.getInstance("SHA-256") + var count = 0 + file.outputStream().buffered().use { out -> + for (key in keys) { + val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, Uri.encode(key)) + // A contact removed between the list and its card is simply not in this copy. + val bytes = try { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } catch (e: Exception) { null } ?: continue + out.write(bytes) + digest.update(bytes) + count += 1 + } + } + val sha = PhotoChannels.hex(digest.digest()) + if (count == 0 || sha == marker) { file.delete(); return null } + return Export(file, DocPlan.nameFor("contacts", "vcf", now, zone), "text/vcard", sha, count) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Destination.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Destination.kt new file mode 100644 index 0000000..e140fd0 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Destination.kt @@ -0,0 +1,79 @@ +package org.meshbay.client.phonesync + +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.bridge.Refused + +/** + * Where everything this phone backs up goes: one folder of one group the + * account owns and is the only member of (docs/MESHBAY_DESIGN.md §9.12). + * Each kind of data goes into its own folder under it, `<account>-<kind>`. + */ +data class Destination( + val account: String, + val groupId: String, + val groupName: String, + /** A folder among the group's own, as a virtual path (`Backups`). */ + val folder: String, +) { + /** What was sent belongs to this, so a different group or folder starts again from nothing. */ + val ledgerKey: String get() = "$account\n$groupId\n$folder" + + fun toJson(): JSONObject = JSONObject() + .put("account", account).put("groupId", groupId).put("groupName", groupName).put("folder", folder) + + companion object { + /** From the page, so checked like any input: names it chose, never a path on this phone. */ + fun fromJson(o: JSONObject): Destination { + val account = o.optString("account", "").take(64) + val groupId = o.optString("groupId", "").take(64) + val folder = o.optString("folder", "").trim().trim('/').take(1024) + require(account.isNotEmpty() && groupId.isNotEmpty() && folder.isNotEmpty()) { "incomplete" } + require(DocPlan.SAFE_ACCOUNT.matches(account)) { "bad account" } + require(folder.split('/').none { it.isEmpty() || it == "." || it == ".." }) { "bad folder" } + return Destination(account, groupId, o.optString("groupName", "").take(256), folder) + } + + fun parse(text: String?): Destination? = try { + val o = JSONObject(text ?: return null) + Destination(o.getString("account"), o.getString("groupId"), o.optString("groupName"), o.getString("folder")) + } catch (e: Exception) { null } + } +} + +/** + * The destination, kept once for every kind. Changing it is a new backup for + * each of them: what they remember having sent belongs to the old place, so + * each is told and starts again from nothing. + */ +class DestinationChannels(private val prefs: SharedPreferences) { + private val listeners = mutableListOf<() -> Unit>() + + fun onChange(listener: () -> Unit) { listeners += listener } + + fun get(): Destination? = Destination.parse(prefs.getString(KEY, null)) + + fun handles(channel: String) = channel.startsWith("phonesync:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "phonesync:destination" -> get()?.toJson() ?: JSONObject.NULL + "phonesync:set-destination" -> { set(args.optJSONObject(0)); get()?.toJson() ?: JSONObject.NULL } + else -> throw Refused("Refused: no such channel") + } + + private fun set(o: JSONObject?) { + val previous = get() + val next = o?.let { + try { Destination.fromJson(it) } catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") } + } + if (next == null) prefs.edit().remove(KEY).apply() + else prefs.edit().putString(KEY, next.toJson().toString()).apply() + if (previous?.ledgerKey != next?.ledgerKey) listeners.forEach { it() } + } + + companion object { + const val PREFS = "phonesync" + private const val KEY = "destination" + } +} 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 new file mode 100644 index 0000000..2efe558 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocChannels.kt @@ -0,0 +1,188 @@ +package org.meshbay.client.phonesync + +import android.app.Activity +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.webkit.WebResourceResponse +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.bridge.Refused +import org.meshbay.client.photos.PhotoChannels +import java.io.File +import java.io.FileInputStream +import java.security.SecureRandom +import java.util.TimeZone +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Contacts or messages 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. + * + * The same split as photos (PhotoChannels): the page decides when and does the + * sending; this side writes what is new into one file and hands it over by an + * opaque token at `/phonesync/<token>`, valid until the next plan. What the + * next file starts from (`marker`) moves only when the node has taken this one. + * + * Where the file goes is the destination every kind shares (Destination.kt); + * what is kept here is only whether this kind is on, and what was sent. + * `prefix` names the channels (`contactsync:status`, …). + */ +class DocChannels( + private val prefix: String, + private val activity: Activity, + private val prefs: SharedPreferences, + private val destinations: DestinationChannels, + private val dir: File, + private val source: DocSource, + private val notifyId: Int, + private val permissionRequest: Int, +) { + private val random = SecureRandom() + @Volatile private var issued: Issued? = null + @Volatile private var permission: CountDownLatch? = null + + private class Issued(val token: String, val export: Export, val key: String) + + init { + // Somewhere new starts from nothing: the first file there holds everything. + destinations.onChange { + issued = null + prefs.edit().remove(MARKER).remove(SENT).remove(LAST_SENT).remove(LAST) + .remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + } + } + + fun handles(channel: String) = channel.startsWith("$prefix:") + + fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("$prefix:")) { + "status" -> status() + "permit" -> { permit(); status() } + "configure" -> { configure(args.optJSONObject(0)); status() } + "plan" -> { requirePermission(); plan() } + "sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } + "completed" -> { completed(); status() } + "failed" -> failed(args.optString(0, ""), args.optString(1, "")) + else -> throw Refused("Refused: no such channel") + } + + // ── state ──────────────────────────────────────────────────────────────── + + /** On, and somewhere to go: the destination, or null. */ + private fun active(): Destination? = if (prefs.getBoolean(ON, false)) destinations.get() else null + + fun status(): JSONObject { + val d = destinations.get() + return JSONObject() + .put("permission", if (permitted()) "granted" else "denied") + .put("on", prefs.getBoolean(ON, false)) + .put("destination", d?.toJson() ?: JSONObject.NULL) + .put("dir", d?.let { DocPlan.dirFor(it, source.suffix) } ?: JSONObject.NULL) + .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL) + .put("lastSent", prefs.getString(LAST_SENT, null) ?: JSONObject.NULL) + .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL) + .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL) + .put("sent", prefs.getInt(SENT, 0)) + .put("now", System.currentTimeMillis()) + } + + /** `{}` turns it on, null off; turned off, it forgets what it sent. */ + private fun configure(o: JSONObject?) { + issued = null + if (o == null) { + prefs.edit().clear().apply() + return + } + if (destinations.get() == null) throw Refused("Refused: no destination") + prefs.edit().putBoolean(ON, true).apply() + } + + private fun completed() { + prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + } + + /** A run stopped for a reason that will hold tomorrow too; said once. True when this call said it. */ + private fun failed(code: String, text: String): Boolean { + val c = code.take(64) + prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply() + if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false + prefs.edit().putString(NOTIFIED, c).apply() + PhotoChannels.notice(activity, notifyId, text.take(300)) + return true + } + + // ── the file ───────────────────────────────────────────────────────────── + + private fun plan(): JSONObject { + val c = active() ?: throw Refused("Refused: this backup is off") + issued = null + dir.listFiles()?.forEach { it.delete() } + dir.mkdirs() + val export = source.export(prefs.getString(MARKER, null), dir, System.currentTimeMillis(), TimeZone.getDefault()) + ?: return JSONObject().put("item", JSONObject.NULL) + 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) + (if (export.subdir.isEmpty()) "" else "/${export.subdir}")) + .put("size", export.file.length()).put("count", export.count)) + } + + /** The node took it: what the next file starts from moves, never before. */ + private fun sent(token: String, dir: String, name: String) { + val i = issued?.takeIf { it.token == token } ?: throw Refused("Refused: unknown file") + if (active()?.ledgerKey != i.key) throw Refused("Refused: the backup changed") + prefs.edit().putString(MARKER, i.export.marker).putInt(SENT, prefs.getInt(SENT, 0) + 1) + .putString(LAST_SENT, "${dir.take(1024)}/${name.take(256)}").apply() + issued = null + i.export.file.delete() + } + + /** The bytes of the issued file, for `/phonesync/<token>` on the packaged origin. */ + fun serve(path: String): WebResourceResponse? { + val i = issued?.takeIf { it.token == path.removePrefix(PATH) } ?: return null + val stream = try { FileInputStream(i.export.file) } catch (e: Exception) { return null } + val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff") + return WebResourceResponse(i.export.mime, null, 200, "OK", headers, stream) + } + + // ── permission ─────────────────────────────────────────────────────────── + + private fun permitted() = source.permissions.all { + activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + private fun requirePermission() { + if (!permitted()) throw Refused("Refused: no access") + } + + /** Asks, and waits for the answer: the page goes on from what was decided. */ + private fun permit() { + if (permitted()) return + val latch = CountDownLatch(1) + permission = latch + activity.runOnUiThread { activity.requestPermissions(source.permissions, permissionRequest) } + latch.await(5, TimeUnit.MINUTES) + permission = null + } + + /** From Activity.onRequestPermissionsResult; true when the request was ours. */ + fun deliverPermission(requestCode: Int): Boolean { + if (requestCode != permissionRequest) return false + permission?.countDown() + return true + } + + companion object { + const val PATH = "/phonesync/" + private const val ON = "on" + private const val MARKER = "marker" + private const val SENT = "sent" + private const val LAST_SENT = "last_sent" + private const val LAST = "last_completed" + private const val FAILURE = "failure" + private const val FAILURE_AT = "failure_at" + private const val NOTIFIED = "notified" + } +} 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 new file mode 100644 index 0000000..1a2a359 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/DocPlan.kt @@ -0,0 +1,56 @@ +package org.meshbay.client.phonesync + +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** One file a run sends, and what is recorded once the node has it. */ +data class Export( + val file: File, + val name: String, + 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. */ + 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 + * a file of what is new since `marker`, the value the last file sent left. + */ +interface DocSource { + /** The runtime permissions it reads with. */ + val permissions: Array<String> + /** Where its files go, under the chosen folder: `<account>-<suffix>`. */ + val suffix: String + /** A file of what is new since `marker` in `dir`, or null when nothing is. */ + fun export(marker: String?, dir: File, now: Long, zone: TimeZone): Export? +} + +/** The names a run gives what it sends. Pure, so the JVM tests hold them. */ +object DocPlan { + /** A run is due once a day, counted from the last one that finished. */ + const val DAY_MS = 24L * 3600 * 1000 + + /** A username as the hub allows it; it names a folder on the node. */ + val SAFE_ACCOUNT = Regex("^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") + + /** `<folder>/<account>-<suffix>`: everything of one kind, from one account, in one place. */ + fun dirFor(destination: Destination, suffix: String): String = + "${destination.folder}/${destination.account}-$suffix" + + /** + * `<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/Ics.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Ics.kt new file mode 100644 index 0000000..8f151f0 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Ics.kt @@ -0,0 +1,176 @@ +package org.meshbay.client.phonesync + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** One event as the calendar provider stores it, as much of it as a backup needs. */ +data class CalEvent( + val id: Long, + val calendar: String, + /** RFC 5545 UID from the provider, when the event came with one. */ + val uid: String?, + val title: String?, + val description: String?, + val location: String?, + /** Milliseconds; for an all-day event, midnight UTC of its first day. */ + val start: Long, + val end: Long?, + /** Android's form, `P3600S` or `P1D`, set instead of `end` on a recurring event. */ + val duration: String?, + val allDay: Boolean, + /** Olson id (`Europe/Paris`), or null. */ + val timeZone: String?, + val rrule: String?, + val rdate: String?, + val exdate: String?, + /** On an exception to a recurring event: the event it changes, and the instance. */ + val originalId: Long?, + val originalTime: Long?, + val originalAllDay: Boolean, + /** 0 tentative, 1 confirmed, 2 cancelled, or null. */ + val status: Int?, + val reminderMinutes: List<Int>, + val organizer: String?, + val attendees: List<Attendee>, +) + +data class Attendee(val email: String, val name: String?) + +/** + * Events written as one iCalendar file (RFC 5545), the format every calendar + * imports. Pure, so the JVM tests hold it; `line` receives each folded line + * without its CRLF, so the caller can write it and leave the DTSTAMP lines out + * of the hash that says whether anything changed. + */ +object Ics { + fun write(events: List<CalEvent>, stamp: Long, line: (String) -> Unit) { + val uids = events.associate { it.id to uidOf(it) } + line("BEGIN:VCALENDAR") + line("VERSION:2.0") + line("PRODID:-//MeshBay//Android backup//EN") + line("CALSCALE:GREGORIAN") + for (e in events) { + val out = ArrayList<String>() + out += "BEGIN:VEVENT" + // An exception carries the UID of the event it changes. + out += "UID:" + text(e.originalId?.let { uids[it] } ?: uids.getValue(e.id)) + out += "DTSTAMP:" + utc(stamp) + if (e.originalId != null && e.originalTime != null) { + out += "RECURRENCE-ID" + time(e.originalTime, e.originalAllDay, e.timeZone) + } + out += "DTSTART" + time(e.start, e.allDay, e.timeZone) + when { + e.end != null && e.end >= e.start -> out += "DTEND" + time(e.end, e.allDay, e.timeZone) + e.duration != null -> duration(e.duration)?.let { out += "DURATION:$it" } + } + e.title?.takeIf { it.isNotEmpty() }?.let { out += "SUMMARY:" + text(it) } + e.description?.takeIf { it.isNotEmpty() }?.let { out += "DESCRIPTION:" + text(it) } + e.location?.takeIf { it.isNotEmpty() }?.let { out += "LOCATION:" + text(it) } + e.rrule?.takeIf { it.isNotBlank() }?.let { out += "RRULE:" + it.trim() } + e.rdate?.let { dates("RDATE", it) }?.let { out += it } + e.exdate?.let { dates("EXDATE", it) }?.let { out += it } + when (e.status) { + 0 -> out += "STATUS:TENTATIVE" + 1 -> out += "STATUS:CONFIRMED" + 2 -> out += "STATUS:CANCELLED" + } + out += "X-MESHBAY-CALENDAR:" + text(e.calendar) + e.organizer?.takeIf { it.isNotBlank() }?.let { out += "ORGANIZER:mailto:" + it.trim() } + for (a in e.attendees) { + val cn = a.name?.takeIf { it.isNotBlank() }?.let { ";CN=" + param(it) } ?: "" + out += "ATTENDEE$cn:mailto:" + a.email.trim() + } + for (m in e.reminderMinutes.distinct()) { + out += "BEGIN:VALARM" + out += "ACTION:DISPLAY" + out += "DESCRIPTION:" + text(e.title ?: "Reminder") + out += "TRIGGER:-PT${maxOf(0, m)}M" + out += "END:VALARM" + } + out += "END:VEVENT" + for (l in out) fold(l, line) + } + line("END:VCALENDAR") + } + + private fun uidOf(e: CalEvent): String = + e.uid?.takeIf { it.isNotBlank() }?.trim() ?: "android-${e.id}@meshbay" + + /** `;VALUE=DATE:20261010`, `;TZID=Europe/Paris:20261010T090000` or `:20261010T070000Z`. */ + fun time(ms: Long, allDay: Boolean, zone: String?): String { + if (allDay) return ";VALUE=DATE:" + fmt("yyyyMMdd", "UTC", ms) + val tz = zone?.takeIf { it.isNotBlank() && it != "UTC" && it != "GMT" && it in zones } + return if (tz == null) ":" + utc(ms) else ";TZID=$tz:" + fmt("yyyyMMdd'T'HHmmss", tz, ms) + } + + private val zones by lazy { TimeZone.getAvailableIDs().toHashSet() } + + fun utc(ms: Long) = fmt("yyyyMMdd'T'HHmmss'Z'", "UTC", ms) + + private fun fmt(pattern: String, zone: String, ms: Long) = + SimpleDateFormat(pattern, Locale.ROOT).apply { timeZone = TimeZone.getTimeZone(zone) }.format(Date(ms)) + + /** Android's `P3600S` is not RFC 5545, which wants `PT3600S`; days and weeks are the same. */ + fun duration(android: String): String? { + val m = Regex("^([+-]?)P(?:(\\d+)W)?(?:(\\d+)D)?(?:T?(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$") + .matchEntire(android.trim()) ?: return null + val (sign, w, d, h, min, s) = m.destructured + if (listOf(w, d, h, min, s).all { it.isEmpty() }) return null + val time = (if (h.isEmpty()) "" else "${h}H") + (if (min.isEmpty()) "" else "${min}M") + + (if (s.isEmpty()) "" else "${s}S") + return sign + "P" + (if (w.isEmpty()) "" else "${w}W") + (if (d.isEmpty()) "" else "${d}D") + + (if (time.isEmpty()) "" else "T$time") + } + + /** + * Android keeps RDATE and EXDATE as `20261010T090000Z,20261017T090000Z`, + * or with a zone first: `Europe/Paris;20261010T090000,…`. + */ + fun dates(name: String, value: String): String? { + val v = value.trim().ifEmpty { return null } + val semi = v.indexOf(';') + if (semi > 0) return "$name;TZID=${v.substring(0, semi)}:${v.substring(semi + 1)}" + return if (v.split(',').all { it.length == 8 }) "$name;VALUE=DATE:$v" else "$name:$v" + } + + /** A TEXT value: backslash, semicolon, comma and line breaks escaped. */ + fun text(s: String): String = buildString { + for (c in s) when (c) { + '\\' -> append("\\\\") + ';' -> append("\\;") + ',' -> append("\\,") + '\n' -> append("\\n") + '\r' -> {} + else -> if (c < ' ' && c != '\t') {} else append(c) + } + } + + /** A parameter value, quoted when it holds what would end it. */ + private fun param(s: String): String { + val clean = s.replace("\"", "'").filter { it >= ' ' } + return if (clean.any { it == ';' || it == ':' || it == ',' }) "\"$clean\"" else clean + } + + /** Lines of at most 75 octets, continued with a space; never inside a character. */ + fun fold(l: String, line: (String) -> Unit) { + var rest = l + var limit = 75 + while (rest.toByteArray(Charsets.UTF_8).size > limit) { + var cut = 0 + var bytes = 0 + while (cut < rest.length) { + val cp = rest.codePointAt(cut) + val n = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8).size + if (bytes + n > limit) break + bytes += n + cut += Character.charCount(cp) + } + line((if (limit == 74) " " else "") + rest.substring(0, cut)) + rest = rest.substring(cut) + limit = 74 + } + line((if (limit == 74) " " else "") + rest) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ManifestLog.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ManifestLog.kt new file mode 100644 index 0000000..0d7142c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/ManifestLog.kt @@ -0,0 +1,56 @@ +package org.meshbay.client.phonesync + +import org.json.JSONObject +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * What a backup sent, as the phone knew it, for a restore or a merge later + * (docs/MESHBAY_DESIGN.md §9.12): one JSON line per file the node took, with + * where it came from on the phone, its album or folder, its dates, size and + * SHA-256. The node has the files; only the phone knew these. + * + * Lines wait here until a manifest carrying them is on the node: `issue()` + * gathers them into the file to send, `confirm()` forgets them once the node + * has it. A manifest that did not arrive is sent again, with whatever was + * added since, at the next run: nothing is lost to an interrupted one. + */ +class ManifestLog(private val pending: File) { + private val sending = File(pending.path + ".sending") + + fun append(line: JSONObject) { + pending.parentFile?.mkdirs() + pending.appendText(line.toString() + "\n") + } + + /** True when there are lines no manifest on the node holds yet. */ + fun waiting(): Boolean = (sending.exists() && sending.length() > 0) || (pending.exists() && pending.length() > 0) + + /** The file to send now, holding every waiting line, or null. */ + fun issue(): File? { + if (pending.exists() && pending.length() > 0) { + if (sending.exists()) { sending.appendText(pending.readText()); pending.delete() } + else if (!pending.renameTo(sending)) return null + } + return sending.takeIf { it.exists() && it.length() > 0 } + } + + /** The node has it: those lines are done. */ + fun confirm() { sending.delete() } + + /** Everything forgotten: the destination changed, or the backup was turned off. */ + fun clear() { pending.delete(); sending.delete() } + + companion object { + /** The folder manifests go into, under a kind's own folder. */ + const val DIR = "meshbay-manifest" + + /** `manifest-2026-10-10-143205.jsonl`, in the phone's time: never one name twice. */ + fun nameFor(now: Long, zone: TimeZone): String = + "manifest-" + SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.ROOT).apply { timeZone = zone }.format(Date(now)) + + ".jsonl" + } +} 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 new file mode 100644 index 0000000..660cfaa --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt @@ -0,0 +1,24 @@ +package org.meshbay.client.phonesync + +import android.content.Context +import android.os.Build +import android.os.UserManager + +/** + * Backups are of the personal profile only, never of a work profile: a copy + * 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 + * they are asked from, and none of the cross-profile (`ENTERPRISE_*`) URIs + * is used (`test_android_shell.py` checks for them). + */ +object Profiles { + fun isPersonal(context: Context): Boolean { + val users = context.getSystemService(UserManager::class.java) + // Before Android 11 an application cannot ask whether its own profile + // is managed; a work profile is never the system user, so that is + // the test there (a secondary user on a shared tablet loses backups). + return if (Build.VERSION.SDK_INT >= 30) !users.isManagedProfile else users.isSystemUser + } +} 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/main/kotlin/org/meshbay/client/photos/BackupService.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt new file mode 100644 index 0000000..b40d006 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.photos + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import org.meshbay.client.R + +/** + * Keeps a photo backup going with the screen off — the same pair as a cast + * (CastService): this service holds the process, the CPU and the Wi-Fi, and + * the shell keeps the WebView reported visible, because the sending happens in + * the page and Chromium freezes a hidden page after 60 s. + * + * Its own service, of type dataSync, rather than a second reason on the cast + * service: music can play during a backup, and each stops on its own. + * + * Started only from the page while the application is in front — a foreground + * service cannot be started from the background on Android 12+ — and its + * progress line is updated through the notification, which needs no start. + */ +class BackupService : Service() { + private var wake: PowerManager.WakeLock? = null + private var wifi: WifiManager.WifiLock? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val n = notification(this, intent?.getStringExtra(EXTRA_TEXT) ?: "") + if (Build.VERSION.SDK_INT >= 29) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else startForeground(ID, n) + if (wake == null) { + wake = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "meshbay:backup").apply { acquire(MAX_HOLD_MS) } + @Suppress("DEPRECATION") + val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF + wifi = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager) + .createWifiLock(mode, "meshbay:backup").apply { acquire() } + } + return START_NOT_STICKY + } + + /** + * Android 15 gives dataSync six hours a day and then calls this; not + * stopping here is a crash. The run carries on with the screen on, or at + * the next opening, which is where an interrupted run goes anyway. + */ + override fun onTimeout(startId: Int, fgsType: Int) { + stopSelf() + } + + override fun onDestroy() { + wake?.let { if (it.isHeld) it.release() } + wifi?.let { if (it.isHeld) it.release() } + wake = null; wifi = null + super.onDestroy() + } + + companion object { + private const val ID = 8 + const val EXTRA_TEXT = "text" + private const val MAX_HOLD_MS = 6L * 3600 * 1000 + + fun notification(context: Context, text: String): Notification { + PhotoChannels.ensureChannel(context) + val open = PendingIntent.getActivity(context, ID, + context.packageManager.getLaunchIntentForPackage(context.packageName), PendingIntent.FLAG_IMMUTABLE) + return Notification.Builder(context, PhotoChannels.CHANNEL) + .setContentTitle("MeshBay") + .setContentText(text) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(open) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + /** The progress line of a running backup; nothing when none runs. */ + fun update(context: Context, text: String) { + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.activeNotifications.any { it.id == ID }) nm.notify(ID, notification(context, text)) + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt new file mode 100644 index 0000000..001bea2 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt @@ -0,0 +1,40 @@ +package org.meshbay.client.photos + +import java.io.FilterInputStream +import java.io.InputStream + +/** + * One `Range: bytes=a-b` of a file the page fetches a piece at a time, so a + * video of gigabytes never sits whole in the page's memory: the upload reads + * one chunk, sends it, and asks for the next. + */ +object ByteRange { + /** The first and last byte asked for, inside `size`; null for no range or one this does not serve. */ + fun parse(header: String?, size: Long): LongRange? { + val m = Regex("^bytes=(\\d+)-(\\d*)$").matchEntire(header?.trim() ?: return null) ?: return null + val first = m.groupValues[1].toLongOrNull() ?: return null + val last = m.groupValues[2].takeIf { it.isNotEmpty() }?.toLongOrNull() ?: (size - 1) + if (first >= size || last < first) return null + return first..minOf(last, size - 1) + } + + /** `stream` from `range.first`, ending after `range.last`. */ + fun slice(stream: InputStream, range: LongRange): InputStream { + var toSkip = range.first + while (toSkip > 0) { + val n = stream.skip(toSkip) + if (n <= 0) { if (stream.read() < 0) break; toSkip -= 1 } else toSkip -= n + } + return object : FilterInputStream(stream) { + private var left = range.last - range.first + 1 + override fun read(): Int { + if (left <= 0) return -1 + return super.read().also { if (it >= 0) left -= 1 } + } + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (left <= 0) return -1 + return super.read(b, off, minOf(len.toLong(), left).toInt()).also { if (it > 0) left -= it } + } + } + } +} 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 new file mode 100644 index 0000000..2e7c519 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt @@ -0,0 +1,394 @@ +package org.meshbay.client.photos + +import android.Manifest +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.webkit.WebResourceResponse +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.MainActivity +import org.meshbay.client.R +import org.meshbay.client.bridge.Refused +import org.meshbay.client.notify.Notifier +import org.meshbay.client.phonesync.Destination +import org.meshbay.client.phonesync.DestinationChannels +import org.meshbay.client.phonesync.ManifestLog +import java.io.File +import java.io.FilterInputStream +import java.io.InputStream +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Photo backup (docs/MESHBAY_DESIGN.md §9.12): what the page needs from the + * phone, and nothing it could use to read anything else. + * + * The page decides when a run is due and does the sending, because the + * transport and the group key are there. This side lists the photos, keeps the + * ledger, and hands over bytes — by an opaque token the page fetches from the + * packaged origin (`/photosync/<token>`), valid for the run that issued it and + * for nothing but the photo it names. The page never sees a `content://` URI. + * + * Phone-only: the desktop preload has no counterpart, so `platform.photoSync` + * is absent there. + */ +class PhotoChannels( + private val activity: Activity, + private val prefs: SharedPreferences, + private val destinations: DestinationChannels, + private val dir: File, + private val onKeepAlive: (Boolean, String) -> Unit, +) { + private val source = PhotoSource(activity) + private val random = SecureRandom() + private val tokens = ConcurrentHashMap<String, Issued>() + @Volatile private var permission: CountDownLatch? = null + + private class Issued(val pending: Pending, val key: String) { + @Volatile var sha256: String? = null + } + + init { + // Somewhere new is a new backup: due at once, from now when only new + // photos were asked for, and its ledger is the new place's own. + destinations.onChange { + tokens.clear() + val edit = prefs.edit().remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED) + config()?.let { edit.putString(CONFIG, it.copy(since = System.currentTimeMillis()).toJson().toString()) } + edit.apply() + } + } + + fun handles(channel: String) = channel.startsWith("photosync:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "photosync:status" -> status() + "photosync:permit" -> { permit(args.optBoolean(0, false)); status() } + "photosync:albums" -> { requirePermission(); albums(args.optBoolean(0, false)) } + "photosync:configure" -> { configure(args.optJSONObject(0)); status() } + "photosync:estimate" -> { requirePermission(); estimate(args.optJSONObject(0) ?: throw Refused("Refused: no settings")) } + "photosync:plan" -> { requirePermission(); plan() } + "photosync:sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } + "photosync:manifest" -> manifest() + "photosync:manifest-sent" -> { manifestSent(args.optString(0, "")); true } + "photosync:completed" -> { completed(); status() } + "photosync:failed" -> failed(args.optString(0, ""), args.optString(1, "")) + "photosync:keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true } + else -> throw Refused("Refused: no such channel") + } + + // ── state ──────────────────────────────────────────────────────────────── + + private fun config(): SyncConfig? = SyncConfig.parse(prefs.getString(CONFIG, null)) + + /** Where photos go, and which: both, or null when either is missing. */ + private fun active(): Pair<Destination, SyncConfig>? { + val c = config() ?: return null + return (destinations.get() ?: return null) to c + } + + private fun keyOf(d: Destination) = hex(sha256Of(d.ledgerKey.toByteArray())).take(32) + + private fun ledger(d: Destination) = PhotoLedger(File(dir, keyOf(d) + ".jsonl")) + + private fun manifestLog(d: Destination) = ManifestLog(File(dir, keyOf(d) + ".manifest")) + + @Volatile private var manifestToken: Pair<String, File>? = null + + fun status(): JSONObject { + val c = config() + val d = destinations.get() + return JSONObject() + .put("permission", permissionState()) + .put("videoPermission", permissionState(video = true)) + .put("unmetered", unmetered()) + .put("config", c?.toJson() ?: JSONObject.NULL) + .put("destination", d?.toJson() ?: JSONObject.NULL) + .put("dir", d?.let { PhotoPlan.baseFor(it) } ?: JSONObject.NULL) + .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL) + .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL) + .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL) + .put("sent", if (c != null && d != null) ledger(d).size else 0) + .put("now", System.currentTimeMillis()) + } + + private fun configure(o: JSONObject?) { + val previous = config() + if (o == null) { + prefs.edit().remove(CONFIG).remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + tokens.clear() + return + } + if (destinations.get() == null) throw Refused("Refused: no destination") + val next = SyncConfig.fromJson(o, System.currentTimeMillis(), previous) + val edit = prefs.edit().putString(CONFIG, next.toJson().toString()) + // A different scope is a different backup: due at once, and whatever + // the last one was refused for is not this one's problem. + if (previous == null || previous.since != next.since) { + edit.remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED) + } + edit.apply() + tokens.clear() + } + + private fun completed() { + prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + } + + /** + * A run stopped for a reason that will hold tomorrow too — the folder is no + * longer writable, the disk is full, the person left the group. Said once, + * in a notification, rather than every day; true when this call said it. + */ + private fun failed(code: String, text: String): Boolean { + val c = code.take(64) + prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply() + if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false + prefs.edit().putString(NOTIFIED, c).apply() + notice(activity, NOTIFY_ID, text.take(300)) + return true + } + + // ── the phone's photos ─────────────────────────────────────────────────── + + private fun albums(withVideos: Boolean): JSONArray = JSONArray().apply { + for (a in source.albums(withVideos)) put(JSONObject().put("id", a.id).put("name", a.name) + .put("count", a.count).put("bytes", a.bytes).put("camera", a.camera) + .put("videos", a.videos).put("videoBytes", a.videoBytes)) + } + + /** What a backup set up this way would send first: the count and size the confirmation states. */ + private fun estimate(o: JSONObject): JSONObject { + val d = destinations.get() ?: throw Refused("Refused: no destination") + val c = SyncConfig.fromJson(o, System.currentTimeMillis(), config()) + val ledger = ledger(d) + val items = PhotoPlan.plan(source.photos(c.albums, c.includeVideos), c, PhotoPlan.baseFor(d), ledger::get) { _, _ -> true } + val videos = items.filter { it.photo.video } + return JSONObject().put("count", items.size).put("bytes", items.sumOf { it.photo.size }) + .put("videos", videos.size).put("videoBytes", videos.sumOf { it.photo.size }) + } + + private fun plan(): JSONObject { + val (d, c) = active() ?: throw Refused("Refused: photo backup is off") + val ledger = ledger(d) + val items = PhotoPlan.plan(source.photos(c.albums, c.includeVideos), c, PhotoPlan.baseFor(d), ledger::get) { p, sent -> + hashOf(p)?.let { it == sent.sha256 } ?: true + } + // A new plan replaces the last one: tokens are for one run, never kept. + tokens.clear() + val out = JSONArray() + for (p in items) { + val token = hex(ByteArray(16).also { random.nextBytes(it) }) + tokens[token] = Issued(p, d.ledgerKey) + out.put(JSONObject().put("token", token).put("name", p.name).put("dir", p.dir) + .put("size", p.photo.size).put("edited", p.edited).put("taken", PhotoPlan.whenTaken(p.photo)) + .put("video", p.photo.video) + // After a reinstall the ledger is empty, and the page looks in the + // folder for what is already there — an edit under its own name too. + .put("alsoKnownAs", PhotoPlan.editedName(p.photo, java.util.TimeZone.getDefault()))) + } + return JSONObject().put("items", out).put("manifest", manifestLog(d).waiting()) + } + + /** The node took it (or already had it): into the ledger, under the name its ack gave. */ + private fun sent(token: String, dir: String, name: String) { + val issued = tokens[token] ?: throw Refused("Refused: unknown photo") + val d = active()?.first?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed") + val p = issued.pending.photo + val sha = issued.sha256 ?: hashOf(p) ?: throw Refused("Refused: the photo is gone") + val now = System.currentTimeMillis() + ledger(d).record(PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, + dir.take(1024), name.take(256), now)) + manifestLog(d).append(JSONObject() + .put("kind", if (p.video) "video" else "photo") + .put("node", "${dir.take(1024)}/${name.take(256)}") + .put("source", p.relPath).put("album", p.album) + .put("taken", PhotoPlan.whenTaken(p)).put("modified", p.modified * 1000) + .put("size", p.size).put("sha256", sha).put("mime", p.mime) + .put("edited", issued.pending.edited).put("sentAt", now)) + tokens.remove(token) + } + + /** + * The manifest of what was sent and not yet described on the node, for + * `<base>/meshbay-manifest/` (ManifestLog), or `item: null`. + */ + private fun manifest(): JSONObject { + val (d, _) = active() ?: throw Refused("Refused: photo backup is off") + val file = manifestLog(d).issue() ?: return JSONObject().put("item", JSONObject.NULL) + val token = hex(ByteArray(16).also { random.nextBytes(it) }) + manifestToken = token to file + return JSONObject().put("item", JSONObject().put("token", token) + .put("name", ManifestLog.nameFor(System.currentTimeMillis(), java.util.TimeZone.getDefault())) + .put("dir", PhotoPlan.baseFor(d) + "/" + ManifestLog.DIR).put("size", file.length())) + } + + private fun manifestSent(token: String) { + if (manifestToken?.first != token) throw Refused("Refused: unknown manifest") + val d = destinations.get() ?: throw Refused("Refused: the backup changed") + manifestLog(d).confirm() + manifestToken = null + } + + /** + * The bytes of an issued photo or video, for `/photosync/<token>` on the + * packaged origin. Asked a range at a time (ByteRange), as the upload + * reads them, so a video never sits whole in the page; the ledger's hash + * is then taken when the node has it (`sent`). Asked whole, it is hashed + * as it goes out. + */ + fun serve(path: String, range: String? = null): WebResourceResponse? { + manifestToken?.takeIf { it.first == path.removePrefix(PATH) }?.let { (_, file) -> + return serveRange(file.inputStream(), file.length(), "application/x-ndjson", range) + } + val issued = tokens[path.removePrefix(PATH)] ?: return null + val raw = try { source.open(issued.pending.photo) } catch (e: Exception) { null } ?: return null + val mime = issued.pending.photo.mime.ifEmpty { "application/octet-stream" } + if (range != null) return serveRange(raw, issued.pending.photo.size, mime, range) + val digest = MessageDigest.getInstance("SHA-256") + val stream = object : FilterInputStream(raw) { + private var done = false + override fun read(): Int = super.read().also { if (it < 0) finish() else digest.update(it.toByte()) } + override fun read(b: ByteArray, off: Int, len: Int): Int = + super.read(b, off, len).also { if (it < 0) finish() else digest.update(b, off, it) } + private fun finish() { if (!done) { done = true; issued.sha256 = hex(digest.digest()) } } + } + val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff") + return WebResourceResponse(mime, null, 200, "OK", headers, stream) + } + + private fun hashOf(p: Photo): String? = try { + source.open(p)?.use { s -> hex(digestOf(s)) } + } catch (e: Exception) { null } + + /** What the photo backup sends, for the files backup to leave out; empty while it is off. */ + fun backedUpPaths(): Set<String> { + val c = config() ?: return emptySet() + if (permissionState() == "denied") return emptySet() + return try { source.relativePaths(c.albums, c.includeVideos) } catch (e: Exception) { emptySet() } + } + + // ── permission and network ─────────────────────────────────────────────── + + /** For the photos, or with `video` for the videos, which Android 13+ asks about apart. */ + private fun permissionState(video: Boolean = false): String { + fun has(p: String) = activity.checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED + val media = if (video) Manifest.permission.READ_MEDIA_VIDEO else Manifest.permission.READ_MEDIA_IMAGES + return when { + Build.VERSION.SDK_INT >= 33 && has(media) -> "granted" + Build.VERSION.SDK_INT >= 34 && has(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) -> "partial" + Build.VERSION.SDK_INT < 33 && has(Manifest.permission.READ_EXTERNAL_STORAGE) -> "granted" + else -> "denied" + } + } + + private fun requirePermission() { + if (permissionState() == "denied") throw Refused("Refused: no access to photos") + } + + /** Asks, and waits for the answer: the page goes on from what was decided. */ + private fun permit(withVideos: Boolean) { + val media = listOf(Manifest.permission.READ_MEDIA_IMAGES) + + (if (withVideos) listOf(Manifest.permission.READ_MEDIA_VIDEO) else emptyList()) + val wanted = when { + Build.VERSION.SDK_INT >= 34 -> (media + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED).toTypedArray() + Build.VERSION.SDK_INT >= 33 -> media.toTypedArray() + else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + val latch = CountDownLatch(1) + permission = latch + activity.runOnUiThread { activity.requestPermissions(wanted, PERMISSION_REQUEST) } + latch.await(5, TimeUnit.MINUTES) + permission = null + } + + /** From Activity.onRequestPermissionsResult; true when the request was ours. */ + fun deliverPermission(requestCode: Int): Boolean { + if (requestCode != PERMISSION_REQUEST) return false + permission?.countDown() + return true + } + + /** + * Not "on Wi-Fi": a phone joined to another phone's hotspot is on Wi-Fi and + * spending that phone's mobile data, and Android reports it as metered. + */ + fun unmetered(): Boolean { + val cm = activity.getSystemService(ConnectivityManager::class.java) + val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) || + (Build.VERSION.SDK_INT >= 30 && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED)) + } + + companion object { + const val PATH = "/photosync/" + const val CHANNEL = "backup" + private const val NOTIFY_ID = 9 + private const val PERMISSION_REQUEST = 4208 + const val PREFS = "photosync" + private const val CONFIG = "config" + private const val LAST = "last_completed" + private const val FAILURE = "failure" + 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. */ + fun ensureChannel(context: Context) { + context.getSystemService(NotificationManager::class.java) + .createNotificationChannel(NotificationChannel(CHANNEL, "Backup", NotificationManager.IMPORTANCE_LOW)) + } + + /** A backup that stopped, said once; a tap opens the Android Sync page. */ + fun notice(context: Context, id: Int, text: String) { + val nm = context.getSystemService(NotificationManager::class.java) + ensureChannel(context) + val open = Intent(context, MainActivity::class.java).setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(Notifier.EXTRA_LINK, "#/android-sync") + val pending = PendingIntent.getActivity(context, id, open, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + nm.notify(id, Notification.Builder(context, CHANNEL) + .setSmallIcon(R.drawable.ic_notify).setContentTitle("MeshBay").setContentText(text) + .setStyle(Notification.BigTextStyle().bigText(text)) + .setContentIntent(pending).setAutoCancel(true).build()) + } + + /** `range` of `stream`, `size` bytes long, as a 206 (or a 416 for a range that cannot be served). */ + fun serveRange(stream: InputStream, size: Long, mime: String, range: String?): WebResourceResponse { + val r = ByteRange.parse(range, size) ?: run { + stream.close() + return WebResourceResponse("text/plain", null, 416, "Range Not Satisfiable", + mapOf("Content-Range" to "bytes */$size"), "".byteInputStream()) + } + val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff", + "Content-Range" to "bytes ${r.first}-${r.last}/$size", + "Content-Length" to (r.last - r.first + 1).toString()) + return WebResourceResponse(mime, null, 206, "Partial Content", headers, ByteRange.slice(stream, r)) + } + + fun digestOf(s: InputStream): ByteArray { + val d = MessageDigest.getInstance("SHA-256") + val buf = ByteArray(64 * 1024) + while (true) { val n = s.read(buf); if (n < 0) break; d.update(buf, 0, n) } + return d.digest() + } + + fun sha256Of(b: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(b) + + fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt new file mode 100644 index 0000000..f3aa6e5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.photos + +import org.json.JSONObject +import java.io.File + +/** + * What this phone has sent to one folder of one group — the memory of what was + * sent, never a mirror of the phone (docs/MESHBAY_DESIGN.md §9.12). + * + * A run sends what is not here, and nothing compares in the other direction: a + * photo deleted on the phone simply stops being listed, and one deleted on the + * node stays here and is not sent again — deleting it there was a decision. + * + * One line of JSON per send, appended; the last line for a media id wins. A + * whole-file rewrite per photo would be megabytes written per photo on a roll + * of twenty thousand. Compacted on load once the dead lines outnumber the live + * ones. Plain files and org.json, so the JVM tests run it as it runs here. + */ +class PhotoLedger(private val file: File) { + + data class Entry( + val mediaId: Long, + /** MediaStore DATE_MODIFIED, seconds — what tells an edit from the photo sent. */ + val modified: Long, + val size: Long, + /** SHA-256 of the bytes sent, hex: an edit is sent only if this changed. */ + val sha256: String, + /** Where the node put it: the folder and the name its ack gave. */ + val dir: String, + val name: String, + val sentAt: Long, + ) + + private val entries = HashMap<Long, Entry>() + private var lines = 0 + + init { load() } + + val size: Int get() = entries.size + + operator fun get(mediaId: Long): Entry? = entries[mediaId] + + fun all(): Collection<Entry> = entries.values + + fun record(entry: Entry) { + entries[entry.mediaId] = entry + file.parentFile?.mkdirs() + file.appendText(encode(entry) + "\n") + lines += 1 + } + + /** Everything forgotten — the group or the folder changed, or backup was turned off. */ + fun clear() { + entries.clear() + lines = 0 + file.delete() + } + + private fun load() { + if (!file.exists()) return + file.forEachLine { line -> + if (line.isBlank()) return@forEachLine + lines += 1 + // A line cut short by a process killed mid-write is the only kind + // that fails to parse; what it was recording is sent again, once. + decode(line)?.let { entries[it.mediaId] = it } + } + if (lines > 2 * entries.size + COMPACT_SLACK) compact() + } + + private fun compact() { + val tmp = File(file.path + ".tmp") + tmp.writeText(entries.values.joinToString("") { encode(it) + "\n" }) + if (!tmp.renameTo(file)) { tmp.delete(); return } + lines = entries.size + } + + companion object { + private const val COMPACT_SLACK = 64 + + fun encode(e: Entry): String = JSONObject() + .put("id", e.mediaId).put("m", e.modified).put("s", e.size).put("h", e.sha256) + .put("d", e.dir).put("n", e.name).put("t", e.sentAt).toString() + + fun decode(line: String): Entry? = try { + val o = JSONObject(line) + Entry(o.getLong("id"), o.getLong("m"), o.getLong("s"), o.getString("h"), + o.getString("d"), o.getString("n"), o.getLong("t")) + } catch (e: Exception) { null } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt new file mode 100644 index 0000000..2cb85e6 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt @@ -0,0 +1,166 @@ +package org.meshbay.client.photos + +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.phonesync.DocPlan +import org.meshbay.client.phonesync.Destination +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** One image in MediaStore, as much of it as a plan needs. */ +data class Photo( + val mediaId: Long, + val displayName: String, + val size: Long, + /** Milliseconds; 0 when the camera wrote none. */ + val taken: Long, + /** MediaStore DATE_ADDED and DATE_MODIFIED, seconds. */ + val added: Long, + val modified: Long, + val bucketId: String, + val mime: String, + /** From MediaStore's video collection rather than its images. */ + val video: Boolean = false, + /** Where it is on the phone, `DCIM/Camera/PXL_….jpg`, and its album's name, for the manifest. */ + val relPath: String = "", + val album: String = "", +) + +/** + * Which of this phone's photos are backed up, as the person set it up. Where + * they go is the destination every kind shares (phonesync/Destination.kt). + */ +data class SyncConfig( + val albums: List<String>, + /** The photos already on the phone too: the default, as backup applications do. */ + val includeExisting: Boolean, + /** When it was set up or moved, ms: with `includeExisting` off, only photos added since count. */ + val since: Long, + /** The videos of the same albums too, beside the photos. Off by default: they are large. */ + val includeVideos: Boolean = false, +) { + fun toJson(): JSONObject = JSONObject() + .put("albums", JSONArray(albums)).put("includeExisting", includeExisting).put("since", since) + .put("includeVideos", includeVideos) + + companion object { + /** From the page, so checked like any input. */ + fun fromJson(o: JSONObject, now: Long, previous: SyncConfig? = null): SyncConfig { + val albums = o.optJSONArray("albums") ?: JSONArray() + val includeExisting = o.optBoolean("includeExisting", true) + // A change of scope starts again from that moment; a change of + // album list alone does not move it. + val same = previous != null && previous.includeExisting == includeExisting + return SyncConfig( + (0 until albums.length()).map { albums.optString(it, "").take(64) }.filter { it.isNotEmpty() }.distinct(), + includeExisting, + if (same) previous!!.since else now, + o.optBoolean("includeVideos", false), + ) + } + + fun parse(text: String?): SyncConfig? = try { + val o = JSONObject(text ?: return null) + val albums = o.getJSONArray("albums") + SyncConfig((0 until albums.length()).map { albums.getString(it) }, + o.optBoolean("includeExisting", true), o.getLong("since"), + o.optBoolean("includeVideos", false)) + } catch (e: Exception) { null } + } +} + +/** A photo to send: a new one, or a new version of one already sent. */ +data class Pending(val photo: Photo, val edited: Boolean, val dir: String, val name: String) + +/** + * What a run sends, decided from the phone's photos and the ledger alone. + * + * Pure, so the rules are tested on the JVM; reading bytes for an edit's hash is + * the caller's (`sameBytes`). + */ +object PhotoPlan { + /** A run is due once a day, counted from the last one that finished. */ + const val DAY_MS = 24L * 3600 * 1000 + + fun due(lastCompleted: Long?, now: Long): Boolean = + lastCompleted == null || now - lastCompleted >= DAY_MS || now < lastCompleted + + /** + * `sameBytes(photo, entry)` is asked only of a photo whose MediaStore + * modification date moved since it was sent: true when its bytes are still + * those the ledger hashed (a favourite flag, a rescan), in which case + * nothing is sent. + */ + fun plan( + photos: List<Photo>, config: SyncConfig, base: String, ledger: (Long) -> PhotoLedger.Entry?, + zone: TimeZone = TimeZone.getDefault(), + sameBytes: (Photo, PhotoLedger.Entry) -> Boolean, + ): List<Pending> { + val albums = config.albums.toSet() + val out = ArrayList<Pending>() + for (p in photos) { + if (p.bucketId !in albums) continue + if (!(if (p.video) config.includeVideos && p.mime.startsWith("video/") else p.mime.startsWith("image/"))) continue + val sent = ledger(p.mediaId) + if (sent == null) { + if (!config.includeExisting && p.added * 1000 < config.since) continue + out += Pending(p, false, dirFor(base, p, zone), nameFor(p)) + } else if (sent.modified != p.modified || sent.size != p.size) { + if (sent.size == p.size && sameBytes(p, sent)) continue + out += Pending(p, true, dirFor(base, p, zone), editedName(p, zone)) + } + } + // Newest first: the photos most likely to exist nowhere else are safe earliest. + return out.sortedByDescending { whenTaken(it.photo) } + } + + fun whenTaken(p: Photo): Long = if (p.taken > 0) p.taken else p.added * 1000 + + /** Where every photo goes: `<folder>/<account>-photos`. */ + fun baseFor(destination: Destination): String = DocPlan.dirFor(destination, SUFFIX) + + const val SUFFIX = "photos" + + /** `<base>/YYYY/YYYY-MM`, from when it was taken: an album is a directory (§9.9). */ + fun dirFor(base: String, p: Photo, zone: TimeZone): String { + val fmt = SimpleDateFormat("yyyy/yyyy-MM", Locale.ROOT).apply { timeZone = zone } + return "$base/${fmt.format(Date(whenTaken(p)))}" + } + + fun nameFor(p: Photo): String = + p.displayName.takeIf { UPLOAD_NAME.matches(it) } + ?: "${if (p.video) "video" else "photo"}-${p.mediaId}.${extension(p)}" + + /** + * An edit lands beside the original under a name that says what it is; left + * to the node it would be `IMG_…(1).jpg`, which says nothing. + */ + fun editedName(p: Photo, zone: TimeZone): String { + val base = nameFor(p) + val dot = base.lastIndexOf('.') + val stem = if (dot > 0) base.substring(0, dot) else base + val ext = if (dot > 0) base.substring(dot) else "" + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone } + .format(Date(p.modified * 1000)) + val suffix = "-edited-$stamp$ext" + return stem.take(MAX_NAME - suffix.length) + suffix + } + + private fun extension(p: Photo): String = + p.displayName.substringAfterLast('.', "").lowercase(Locale.ROOT).takeIf { it.matches(Regex("^[a-z0-9]{1,5}$")) } + ?: when (p.mime) { "image/png" -> "png"; "image/heic" -> "heic"; "image/heif" -> "heif" + "image/webp" -> "webp"; "image/gif" -> "gif" + "video/mp4" -> "mp4"; "video/quicktime" -> "mov"; "video/3gpp" -> "3gp" + "video/webm" -> "webm"; "video/x-matroska" -> "mkv" + else -> if (p.video) "mp4" else "jpg" } + + private const val MAX_NAME = 128 + + /** + * The node's SAFE_UPLOAD_NAME (roots.py), as files-app.js copies it. A name it + * refuses would fail the upload; this one is renamed before it is sent. + */ + val UPLOAD_NAME = Regex("^[\\p{L}\\p{N}][\\p{L}\\p{N}_ .\\-()\\[\\]'’,&+#@]{0,127}(?<![ .])$") +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt new file mode 100644 index 0000000..b3b2834 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt @@ -0,0 +1,142 @@ +package org.meshbay.client.photos + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import java.io.InputStream + +/** + * The phone's photos and videos, through MediaStore and nothing else. + * + * Opened through MediaStore by an application without ACCESS_MEDIA_LOCATION, + * a file's location is **redacted by the platform** (Android 10+): the EXIF of + * a photo, and the location boxes of an MP4 or MOV video (MediaProvider's + * IsoInterface). A whole camera roll going to several people does not say + * where its owner lives, and nobody had to do anything for that. The manifest + * does not ask for it. + * + * An album is a MediaStore bucket, one directory on the phone; photos and + * videos in it share its id. + */ +class PhotoSource(private val context: Context) { + + data class Album( + val id: String, val name: String, val count: Int, val bytes: Long, val camera: Boolean, + val videos: Int = 0, val videoBytes: Long = 0, + ) + + private val images: Uri = + if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + private val videos: Uri = + if (Build.VERSION.SDK_INT >= 29) MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Video.Media.EXTERNAL_CONTENT_URI + + /** Every album with photos in it, and with videos too when `withVideos`. */ + fun albums(withVideos: Boolean): List<Album> { + val by = LinkedHashMap<String, Album>() + query(images, false, null, null) { p, name, camera -> + val a = by[p.bucketId] + by[p.bucketId] = if (a == null) Album(p.bucketId, name, 1, p.size, camera) + else a.copy(count = a.count + 1, bytes = a.bytes + p.size, camera = a.camera || camera) + } + if (withVideos) query(videos, true, null, null) { p, name, camera -> + val a = by[p.bucketId] ?: Album(p.bucketId, name, 0, 0, camera) + by[p.bucketId] = a.copy(videos = a.videos + 1, videoBytes = a.videoBytes + p.size, + camera = a.camera || camera) + } + return by.values.sortedWith(compareByDescending<Album> { it.camera }.thenByDescending { it.count + it.videos }) + } + + fun photos(albums: List<String>, withVideos: Boolean): List<Photo> { + if (albums.isEmpty()) return emptyList() + val out = ArrayList<Photo>() + val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})" + query(images, false, where, albums.toTypedArray()) { p, _, _ -> out += p } + if (withVideos) query(videos, true, where, albums.toTypedArray()) { p, _, _ -> out += p } + return out + } + + /** + * The files the photo backup sends, as lowercase `relative/path/name` + * from the top of their volume: what the files backup leaves out, so + * nothing is stored twice. + */ + fun relativePaths(albums: List<String>, withVideos: Boolean): Set<String> { + if (albums.isEmpty()) return emptySet() + val out = HashSet<String>() + val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})" + @Suppress("DEPRECATION") + val cols = if (Build.VERSION.SDK_INT >= 29) + arrayOf(MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.DISPLAY_NAME) + else arrayOf(MediaStore.MediaColumns.DATA) + for (collection in listOfNotNull(images, videos.takeIf { withVideos })) { + context.contentResolver.query(collection, cols, where, albums.toTypedArray(), null)?.use { c -> + while (c.moveToNext()) { + val path = if (Build.VERSION.SDK_INT >= 29) + (c.getString(0) ?: continue).trim('/') + "/" + (c.getString(1) ?: continue) + else volumeRelative(c.getString(0) ?: continue) + out += path.lowercase(java.util.Locale.ROOT) + } + } + } + return out + } + + fun open(p: Photo): InputStream? = + context.contentResolver.openInputStream(ContentUris.withAppendedId(if (p.video) videos else images, p.mediaId)) + + private fun query(collection: Uri, video: Boolean, where: String?, args: Array<String>?, + each: (Photo, String, Boolean) -> Unit) { + // The same columns exist for images and videos (MediaColumns), and on + // Android 8 and 9 under the same names in each collection. + val cols = mutableListOf( + MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.SIZE, + MediaStore.Images.Media.DATE_TAKEN, MediaStore.MediaColumns.DATE_ADDED, + MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, + ) + @Suppress("DEPRECATION") + val location = if (Build.VERSION.SDK_INT >= 29) MediaStore.MediaColumns.RELATIVE_PATH else MediaStore.MediaColumns.DATA + cols += location + // A photo or video still being written by the camera is not one yet. + val pending = if (Build.VERSION.SDK_INT >= 29) "${MediaStore.MediaColumns.IS_PENDING} = 0" else null + val selection = listOfNotNull(pending, where).joinToString(" AND ").ifEmpty { null } + context.contentResolver.query(collection, cols.toTypedArray(), selection, args, null)?.use { c -> + val id = c.getColumnIndexOrThrow(cols[0]); val name = c.getColumnIndexOrThrow(cols[1]) + val size = c.getColumnIndexOrThrow(cols[2]); val taken = c.getColumnIndexOrThrow(cols[3]) + val added = c.getColumnIndexOrThrow(cols[4]); val modified = c.getColumnIndexOrThrow(cols[5]) + val bucket = c.getColumnIndexOrThrow(cols[6]); val bucketName = c.getColumnIndexOrThrow(cols[7]) + val mime = c.getColumnIndexOrThrow(cols[8]); val where2 = c.getColumnIndexOrThrow(cols[9]) + while (c.moveToNext()) { + val bucketId = c.getString(bucket) ?: continue + val path = (c.getString(where2) ?: "").replace('\\', '/') + val display = c.getString(name) ?: "" + val album = c.getString(bucketName) ?: "" + // Where it was on the phone, from the top of its volume: what a + // restore puts it back to (the manifest, §9.12). + val relPath = if (Build.VERSION.SDK_INT >= 29) path.trim('/') + "/" + display else volumeRelative(path) + val photo = Photo( + c.getLong(id), display, c.getLong(size), + if (c.isNull(taken)) 0 else c.getLong(taken), c.getLong(added), c.getLong(modified), + bucketId, c.getString(mime) ?: "", video, relPath, album, + ) + each(photo, album, isCamera(path)) + } + } + } + + companion object { + /** `/storage/emulated/0/Documents/a.jpg` or `/storage/1234-ABCD/x/a.jpg` → the part after the volume. */ + fun volumeRelative(absolute: String): String { + val m = Regex("^/storage/(?:emulated/\\d+|[^/]+)/(.*)$").matchEntire(absolute) + return (m?.groupValues?.get(1) ?: absolute).trim('/') + } + + /** `DCIM/Camera/` (RELATIVE_PATH) or `…/DCIM/Camera/x.jpg` (DATA, before Android 10). */ + fun isCamera(path: String): Boolean = + path.startsWith("DCIM/Camera") || path.contains("/DCIM/Camera/") + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt index 731da69..f3eb84e 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt @@ -8,9 +8,9 @@ import android.webkit.WebView * While `keepVisible` is set, the WebView is told its window stayed visible * when the screen turns off. Chromium then never marks the page hidden, and * its freeze of hidden pages — exactly 60 s after hiding, measured (spike - * S-2a C) — never starts. Set only while a cast runs or music plays: a page that is never - * hidden is never throttled, which is the battery cost the freeze exists to - * avoid. + * S-2a C) — never starts. Set only while a cast runs, music plays or photos + * are being backed up: a page that is never hidden is never throttled, which + * is the battery cost the freeze exists to avoid. */ class ShellWebView(context: Context) : WebView(context) { var keepVisible = false 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/ByteRangeTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt new file mode 100644 index 0000000..29d8528 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt @@ -0,0 +1,31 @@ +package org.meshbay.client + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.meshbay.client.photos.ByteRange + +class ByteRangeTest { + @Test fun `a range is read as the upload asks for it`() { + assertEquals(0L..1023L, ByteRange.parse("bytes=0-1023", 5000)) + assertEquals(4096L..4999L, ByteRange.parse("bytes=4096-", 5000)) + assertEquals("the last chunk may ask past the end", 4096L..4999L, ByteRange.parse("bytes=4096-8191", 5000)) + } + + @Test fun `what cannot be served is refused`() { + assertNull(ByteRange.parse(null, 10)) + assertNull(ByteRange.parse("bytes=10-20", 10)) + assertNull(ByteRange.parse("bytes=5-2", 10)) + assertNull(ByteRange.parse("bytes=0-1,4-5", 10)) + assertNull(ByteRange.parse("bytes=-5", 10)) + assertNull(ByteRange.parse("items=0-1", 10)) + } + + @Test fun `a slice holds exactly those bytes`() { + val data = ByteArray(100_000) { (it % 251).toByte() } + val got = ByteRange.slice(data.inputStream(), 70_000L..99_999L).readBytes() + assertArrayEquals(data.copyOfRange(70_000, 100_000), got) + assertArrayEquals(data.copyOfRange(10, 12), ByteRange.slice(data.inputStream(), 10L..11L).readBytes()) + } +} 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 new file mode 100644 index 0000000..f53110b --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DocSyncTest.kt @@ -0,0 +1,81 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import org.meshbay.client.phonesync.Destination +import org.meshbay.client.phonesync.DestinationChannels +import org.meshbay.client.phonesync.DocPlan +import org.json.JSONArray +import org.meshbay.client.bridge.Refused +import java.util.TimeZone + +class DocSyncTest { + private val utc = TimeZone.getTimeZone("UTC") + // 2026-10-09 12:00:00 UTC + private val oct9 = 1791547200000L + + private fun settings(account: String = "bob", folder: String = "Backups") = JSONObject() + .put("account", account).put("groupId", "g1").put("groupName", "Mine").put("folder", folder) + + @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")) + } + + @Test fun `a file is named for when it was taken, in the phone's time`() { + assertEquals("contacts-2026-10-09-1200.vcf", DocPlan.nameFor("contacts", "vcf", oct9, utc)) + assertEquals("contacts-2026-10-09-1400.vcf", + DocPlan.nameFor("contacts", "vcf", oct9, TimeZone.getTimeZone("Europe/Paris"))) + } + + @Test fun `a folder that climbs out or an account that is not a name is refused`() { + for (bad in listOf("../etc", "Backups/../x", "Backups//x", "./x", "")) { + assertThrows(bad, IllegalArgumentException::class.java) { Destination.fromJson(settings(folder = bad)) } + } + for (bad in listOf("../bob", "bob/x", "", "-bob")) { + assertThrows(bad, IllegalArgumentException::class.java) { Destination.fromJson(settings(account = bad)) } + } + } + + @Test fun `settings survive being stored`() { + val c = Destination.fromJson(settings()) + assertEquals(c, Destination.parse(c.toJson().toString())) + assertNull(Destination.parse("{not json")) + } + + @Test fun `another group or folder is another backup`() { + val a = Destination.fromJson(settings()) + assertNotEquals(a.ledgerKey, Destination.fromJson(settings(folder = "Other")).ledgerKey) + assertNotEquals(a.ledgerKey, Destination.fromJson(settings().put("groupId", "g2")).ledgerKey) + assertEquals(a.ledgerKey, Destination.fromJson(settings().put("groupName", "Renamed")).ledgerKey) + } + + @Test fun `every kind is told when the destination moves, and only then`() { + val store = DestinationChannels(FakePrefs()) + var told = 0 + store.onChange { told++ } + store.call("phonesync:set-destination", JSONArray().put(settings())) + assertEquals(1, told) + store.call("phonesync:set-destination", JSONArray().put(settings().put("groupName", "Renamed"))) + assertEquals("a new name for the same place is not a new backup", 1, told) + store.call("phonesync:set-destination", JSONArray().put(settings(folder = "Other"))) + assertEquals(2, told) + store.call("phonesync:set-destination", JSONArray()) + assertEquals(3, told) + assertNull(store.get()) + } + + @Test fun `a destination the page got wrong is refused and the old one kept`() { + val store = DestinationChannels(FakePrefs()) + store.call("phonesync:set-destination", JSONArray().put(settings())) + assertThrows(Refused::class.java) { + store.call("phonesync:set-destination", JSONArray().put(settings(folder = "../x"))) + } + assertEquals("Backups", store.get()!!.folder) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DriveTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DriveTest.kt new file mode 100644 index 0000000..e661bd1 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/DriveTest.kt @@ -0,0 +1,90 @@ +package org.meshbay.client + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.meshbay.client.drive.DriveFile +import org.meshbay.client.drive.DriveLedger +import org.meshbay.client.drive.DrivePlan +import org.meshbay.client.photos.PhotoPlan +import org.meshbay.client.photos.PhotoSource +import java.util.TimeZone + +class DriveTest { + @get:Rule val tmp = TemporaryFolder() + + private val utc = TimeZone.getTimeZone("UTC") + private val tree = "content://tree/primary%3ADocuments" + private val names = mapOf(tree to "Documents") + + private fun file(id: String, sub: String = "", size: Long = 10, modified: Long = 1000) = + DriveFile(tree, "primary:Documents/" + (if (sub.isEmpty()) "" else "$sub/") + id, sub, id, size, modified, + "application/pdf") + + private fun plan(files: List<DriveFile>, sent: Map<String, DriveLedger.Entry> = emptyMap(), + skip: Set<String> = emptySet(), same: Boolean = false) = + DrivePlan.plan(files, "Backups/bob-drive", names, { sent[it] }, skip, utc) { _, _ -> same } + + private fun entry(f: DriveFile) = DriveLedger.Entry(f.docId, f.modified, f.size, "h", "d", f.name, 1) + + @Test fun `media folders and a whole volume cannot be chosen`() { + assertEquals("whole_storage", DrivePlan.refusal("primary:")) + assertEquals("whole_storage", DrivePlan.refusal("1234-ABCD:")) + assertEquals("media_folder", DrivePlan.refusal("primary:DCIM")) + assertEquals("media_folder", DrivePlan.refusal("primary:DCIM/Camera")) + assertEquals("media_folder", DrivePlan.refusal("primary:Pictures/Screenshots")) + assertEquals("media_folder", DrivePlan.refusal("1234-ABCD:movies")) + assertNull(DrivePlan.refusal("primary:Documents")) + assertNull(DrivePlan.refusal("primary:Download/Invoices")) + assertNull(DrivePlan.refusal("primary:Documents/Pictures")) + } + + @Test fun `files go under the chosen folder's name, sub-folders kept`() { + val out = plan(listOf(file("a.pdf"), file("b.pdf", sub = "Tax/2026"))) + assertEquals(setOf("Backups/bob-drive/Documents", "Backups/bob-drive/Documents/Tax/2026"), + out.map { it.dir }.toSet()) + } + + @Test fun `smallest first, hidden files and photo-backed files left out`() { + val out = plan(listOf(file("big.pdf", size = 900), file("small.pdf", size = 5), file(".secret"), + file("scan.jpg", sub = "Scans")), + skip = setOf("documents/scans/scan.jpg")) + assertEquals(listOf("small.pdf", "big.pdf"), out.map { it.name }) + } + + @Test fun `a file already sent is not sent again, a changed one goes beside it`() { + val f = file("a.pdf") + assertTrue(plan(listOf(f), sent = mapOf(f.docId to entry(f))).isEmpty()) + val changed = f.copy(size = 20, modified = 86_400_000) + val out = plan(listOf(changed), sent = mapOf(f.docId to entry(f))) + assertEquals("a-modified-19700102-000000.pdf", out.single().name) + assertTrue(out.single().edited) + val touched = f.copy(modified = 5000) + assertTrue(plan(listOf(touched), sent = mapOf(f.docId to entry(f)), same = true).isEmpty()) + } + + @Test fun `names the node would refuse are mended, not dropped`() { + assertEquals("report.pdf", DrivePlan.safeName("report.pdf")) + assertEquals("a_b_c.txt", DrivePlan.safeName("a:b?c.txt")) + assertEquals("file-.env", DrivePlan.safeName(".env")) + assertEquals("file-_draft", DrivePlan.safeName("_draft")) + assertTrue(PhotoPlan.UPLOAD_NAME.matches(DrivePlan.safeName("x".repeat(300) + ".pdf"))) + assertTrue(PhotoPlan.UPLOAD_NAME.matches(DrivePlan.safeName("end. "))) + } + + @Test fun `the ledger remembers across a restart`() { + val path = tmp.newFile("d.jsonl") + DriveLedger(path).apply { record(entry(file("a.pdf"))); record(entry(file("a.pdf")).copy(sha256 = "z")) } + val again = DriveLedger(path) + assertEquals(1, again.size) + assertEquals("z", again["primary:Documents/a.pdf"]!!.sha256) + } + + @Test fun `a path from before Android 10 is read from the top of its volume`() { + assertEquals("Documents/Scans/a.jpg", PhotoSource.volumeRelative("/storage/emulated/0/Documents/Scans/a.jpg")) + assertEquals("x/a.jpg", PhotoSource.volumeRelative("/storage/1234-ABCD/x/a.jpg")) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt new file mode 100644 index 0000000..c8f8fa3 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/IcsTest.kt @@ -0,0 +1,117 @@ +package org.meshbay.client + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.phonesync.Attendee +import org.meshbay.client.phonesync.CalEvent +import org.meshbay.client.phonesync.Ics + +class IcsTest { + // 2026-10-09 12:00:00 UTC + private val oct9 = 1791547200000L + private val hour = 3600_000L + + private fun event(id: Long, title: String = "Lunch", start: Long = oct9, end: Long? = oct9 + hour, + duration: String? = null, allDay: Boolean = false, zone: String? = "Europe/Paris", + rrule: String? = null, exdate: String? = null, originalId: Long? = null, + originalTime: Long? = null, uid: String? = null, status: Int? = 1, + reminders: List<Int> = emptyList(), attendees: List<Attendee> = emptyList(), + description: String? = null) = + CalEvent(id, "Personal", uid, title, description, null, start, end, duration, allDay, zone, + rrule, null, exdate, originalId, originalTime, false, status, reminders, null, attendees) + + private fun lines(vararg e: CalEvent): List<String> { + val out = ArrayList<String>() + Ics.write(e.toList(), oct9) { out += it } + return out + } + + /** RFC 5545 unfolding: a line starting with a space continues the one before. */ + private fun unfold(l: List<String>): List<String> { + val out = ArrayList<String>() + for (x in l) if (x.startsWith(" ")) out[out.size - 1] = out.last() + x.substring(1) else out += x + return out + } + + private fun events(l: List<String>): List<List<String>> { + val out = ArrayList<List<String>>() + var cur: MutableList<String>? = null + for (x in unfold(l)) when (x) { + "BEGIN:VEVENT" -> cur = ArrayList() + "END:VEVENT" -> { out += cur!!; cur = null } + else -> cur?.add(x) + } + return out + } + + private fun List<String>.prop(name: String) = firstOrNull { it.startsWith("$name:") || it.startsWith("$name;") } + + @Test fun `a calendar wraps the events, with what an importer requires`() { + val l = lines(event(1)) + assertEquals("BEGIN:VCALENDAR", l.first()) + assertEquals("END:VCALENDAR", l.last()) + assertTrue("VERSION:2.0" in l) + val e = events(l).single() + assertEquals("UID:android-1@meshbay", e.prop("UID")) + assertEquals("DTSTAMP:20261009T120000Z", e.prop("DTSTAMP")) + } + + @Test fun `a timed event keeps its zone, so a recurrence follows summer time`() { + val e = events(lines(event(1)))[0] + assertEquals("DTSTART;TZID=Europe/Paris:20261009T140000", e.prop("DTSTART")) + assertEquals("DTEND;TZID=Europe/Paris:20261009T150000", e.prop("DTEND")) + val utc = events(lines(event(2, zone = null)))[0] + assertEquals("DTSTART:20261009T120000Z", utc.prop("DTSTART")) + val unknown = events(lines(event(3, zone = "Not/AZone")))[0] + assertEquals("DTSTART:20261009T120000Z", unknown.prop("DTSTART")) + } + + @Test fun `an all-day event is a date`() { + val day = 1791504000000L // 2026-10-09 00:00 UTC + val e = events(lines(event(1, start = day, end = day + 24 * hour, allDay = true)))[0] + assertEquals("DTSTART;VALUE=DATE:20261009", e.prop("DTSTART")) + assertEquals("DTEND;VALUE=DATE:20261010", e.prop("DTEND")) + } + + @Test fun `a recurring event keeps its rule, and Android's duration is made valid`() { + val e = events(lines(event(1, end = null, duration = "P3600S", rrule = "FREQ=WEEKLY;BYDAY=FR", + exdate = "Europe/Paris;20261016T140000")))[0] + assertEquals("RRULE:FREQ=WEEKLY;BYDAY=FR", e.prop("RRULE")) + assertEquals("DURATION:PT3600S", e.prop("DURATION")) + assertEquals("EXDATE;TZID=Europe/Paris:20261016T140000", e.prop("EXDATE")) + assertNull(e.prop("DTEND")) + assertEquals("P1D", Ics.duration("P1D")) + assertEquals("PT1H30M", Ics.duration("PT1H30M")) + assertNull(Ics.duration("nonsense")) + } + + @Test fun `a changed instance carries the series' UID and says which instance`() { + val master = event(1, uid = "abc@example.org", end = null, duration = "P3600S", rrule = "FREQ=DAILY") + val moved = event(2, start = oct9 + 26 * hour, end = oct9 + 27 * hour, originalId = 1, + originalTime = oct9 + 24 * hour) + val e = events(lines(master, moved)) + assertEquals("UID:abc@example.org", e[0].prop("UID")) + assertEquals("UID:abc@example.org", e[1].prop("UID")) + assertEquals("RECURRENCE-ID;TZID=Europe/Paris:20261010T140000", e[1].prop("RECURRENCE-ID")) + } + + @Test fun `text is escaped and long lines folded without splitting a character`() { + val desc = "a;b,c\\d\nsecond line " + "é".repeat(60) + " 🎉".repeat(20) + val l = lines(event(1, description = desc)) + assertTrue(l.all { it.toByteArray(Charsets.UTF_8).size <= 75 }) + val e = events(l)[0] + assertEquals("DESCRIPTION:" + "a\\;b\\,c\\\\d\\nsecond line " + "é".repeat(60) + " 🎉".repeat(20), + e.prop("DESCRIPTION")) + } + + @Test fun `reminders, attendees and a cancelled status come along`() { + val e = events(lines(event(1, status = 2, reminders = listOf(10, 10, 60), + attendees = listOf(Attendee("ada@example.org", "Ada, L.")))))[0] + assertEquals("STATUS:CANCELLED", e.prop("STATUS")) + assertEquals(listOf("TRIGGER:-PT10M", "TRIGGER:-PT60M"), e.filter { it.startsWith("TRIGGER") }) + assertEquals("ATTENDEE;CN=\"Ada, L.\":mailto:ada@example.org", e.prop("ATTENDEE")) + assertEquals("X-MESHBAY-CALENDAR:Personal", e.prop("X-MESHBAY-CALENDAR")) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ManifestLogTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ManifestLogTest.kt new file mode 100644 index 0000000..bb3a67f --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ManifestLogTest.kt @@ -0,0 +1,52 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.meshbay.client.phonesync.ManifestLog +import java.io.File +import java.util.TimeZone + +class ManifestLogTest { + @get:Rule val tmp = TemporaryFolder() + + private fun line(n: Int) = JSONObject().put("node", "d/$n.jpg").put("source", "DCIM/Camera/$n.jpg") + + private fun nodes(f: File) = f.readLines().filter { it.isNotBlank() }.map { JSONObject(it).getString("node") } + + @Test fun `nothing waiting means nothing to send`() { + val log = ManifestLog(File(tmp.root, "m")) + assertFalse(log.waiting()) + assertNull(log.issue()) + } + + @Test fun `what was sent is described once the node has the manifest, and only then forgotten`() { + val log = ManifestLog(File(tmp.root, "m")) + log.append(line(1)); log.append(line(2)) + assertTrue(log.waiting()) + assertEquals(listOf("d/1.jpg", "d/2.jpg"), nodes(log.issue()!!)) + log.confirm() + assertFalse(log.waiting()) + assertNull(log.issue()) + } + + @Test fun `a manifest that did not arrive goes again, with what came since`() { + val log = ManifestLog(File(tmp.root, "m")) + log.append(line(1)) + log.issue() // sent, never confirmed: the run was cut short + log.append(line(2)) + val again = ManifestLog(File(tmp.root, "m")) // a restart in between + assertTrue(again.waiting()) + assertEquals(listOf("d/1.jpg", "d/2.jpg"), nodes(again.issue()!!)) + } + + @Test fun `manifests are named for when they were written`() { + assertEquals("manifest-2026-10-09-120000.jsonl", + ManifestLog.nameFor(1791547200000L, TimeZone.getTimeZone("UTC"))) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt new file mode 100644 index 0000000..a87bffb --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt @@ -0,0 +1,202 @@ +package org.meshbay.client + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.meshbay.client.phonesync.Destination +import org.meshbay.client.photos.Photo +import org.meshbay.client.photos.PhotoLedger +import org.meshbay.client.photos.PhotoPlan +import org.meshbay.client.photos.SyncConfig +import java.util.TimeZone + +class PhotoSyncTest { + @get:Rule val tmp = TemporaryFolder() + + private val utc = TimeZone.getTimeZone("UTC") + // 2026-10-09 12:00:00 UTC + private val oct9 = 1791547200000L + + private fun photo(id: Long, name: String = "IMG_$id.jpg", size: Long = 100, taken: Long = oct9, + added: Long = oct9 / 1000, modified: Long = oct9 / 1000, bucket: String = "cam", + mime: String = "image/jpeg", video: Boolean = false) = + Photo(id, name, size, taken, added, modified, bucket, mime, video) + + private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List<String> = listOf("cam"), + videos: Boolean = false) = + SyncConfig(albums, includeExisting, since, videos) + + private val base = PhotoPlan.baseFor(Destination("bob", "g1", "Mine", "Backups")) + + private fun entry(p: Photo, sha: String = "h", name: String = p.displayName) = + PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, "d", name, 1) + + private fun plan(photos: List<Photo>, c: SyncConfig = config(), ledger: Map<Long, PhotoLedger.Entry> = emptyMap(), + same: Boolean = false) = + PhotoPlan.plan(photos, c, base, { ledger[it] }, utc) { _, _ -> same } + + // ── what is sent ────────────────────────────────────────────────────────── + + @Test fun `everything already on the phone is sent, newest first, under its year and month`() { + val out = plan(listOf(photo(1, taken = oct9 - 40L * 86400000), photo(2), photo(3, taken = oct9 - 86400000))) + assertEquals(listOf(2L, 3L, 1L), out.map { it.photo.mediaId }) + assertEquals("Backups/bob-photos/2026/2026-10", out[0].dir) + assertEquals("Backups/bob-photos/2026/2026-08", out[2].dir) + assertEquals("IMG_2.jpg", out[0].name) + } + + @Test fun `from now on leaves out what was on the phone before`() { + val since = oct9 + 1000 + val out = plan(listOf(photo(1), photo(2, added = (oct9 + 5000) / 1000)), config(includeExisting = false, since = since)) + assertEquals(listOf(2L), out.map { it.photo.mediaId }) + } + + @Test fun `only the chosen albums, and only images`() { + val out = plan(listOf(photo(1), photo(2, bucket = "screens"), photo(3, mime = "video/mp4"))) + assertEquals(listOf(1L), out.map { it.photo.mediaId }) + } + + @Test fun `videos of the same albums go beside the photos, only when asked for`() { + val clip = photo(5, name = "PXL_20261009_120000.mp4", mime = "video/mp4", video = true) + assertTrue(plan(listOf(clip)).isEmpty()) + val out = plan(listOf(photo(1), clip), config(videos = true)) + assertEquals(setOf(1L, 5L), out.map { it.photo.mediaId }.toSet()) + assertEquals("Backups/bob-photos/2026/2026-10", out.first { it.photo.video }.dir) + assertTrue("a video file is not taken for a photo", plan(listOf(photo(6, mime = "video/mp4")), config(videos = true)).isEmpty()) + assertEquals("video-9.mp4", PhotoPlan.nameFor(photo(9, name = "_clip.mp4", mime = "video/mp4", video = true))) + assertEquals("video-9.mov", PhotoPlan.nameFor(photo(9, name = "", mime = "video/quicktime", video = true))) + } + + @Test fun `ticking videos in keeps the starting point`() { + val o = JSONObject().put("albums", JSONArray(listOf("cam"))).put("includeExisting", false) + val first = SyncConfig.fromJson(o, 100) + val withVideos = SyncConfig.fromJson(o.put("includeVideos", true), 200, first) + assertEquals(100, withVideos.since) + assertTrue(withVideos.includeVideos) + assertEquals(withVideos, SyncConfig.parse(withVideos.toJson().toString())) + assertFalse(SyncConfig.parse("""{"albums":[],"since":1}""")!!.includeVideos) + } + + @Test fun `a photo already sent is not sent again`() { + val p = photo(1) + assertTrue(plan(listOf(p), ledger = mapOf(1L to entry(p))).isEmpty()) + } + + @Test fun `a photo deleted on the phone is simply not listed, and nothing is asked of the node`() { + // The plan has only additions in it: there is no other kind of item. + val sent = photo(1) + assertTrue(plan(emptyList(), ledger = mapOf(1L to entry(sent))).isEmpty()) + } + + // ── edits ──────────────────────────────────────────────────────────────── + + @Test fun `an edit is sent beside the original under a name that says so`() { + val before = photo(1) + val after = before.copy(size = 120, modified = before.modified + 3600) + val out = plan(listOf(after), ledger = mapOf(1L to entry(before))) + assertEquals(1, out.size) + assertTrue(out[0].edited) + assertEquals("IMG_1-edited-20261009-130000.jpg", out[0].name) + assertEquals("Backups/bob-photos/2026/2026-10", out[0].dir) + } + + @Test fun `a touch that left the bytes alone sends nothing`() { + val before = photo(1) + val touched = before.copy(modified = before.modified + 60) + assertTrue(plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = true).isEmpty()) + assertEquals(1, plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = false).size) + } + + @Test fun `the bytes are only read when the date moved and the size did not`() { + val before = photo(1) + var asked = 0 + PhotoPlan.plan(listOf(before), config(), base, { entry(before) }, utc) { _, _ -> asked++; true } + PhotoPlan.plan(listOf(before.copy(size = 7, modified = 9)), config(), base, { entry(before) }, utc) { _, _ -> asked++; true } + assertEquals(0, asked) + } + + // ── names ──────────────────────────────────────────────────────────────── + + @Test fun `a name the node would refuse is replaced before it is sent`() { + assertEquals("IMG_1.jpg", PhotoPlan.nameFor(photo(1))) + assertEquals("photo-7.jpg", PhotoPlan.nameFor(photo(7, name = ".hidden.jpg"))) + assertEquals("photo-8.png", PhotoPlan.nameFor(photo(8, name = "_x.png"))) + assertEquals("photo-9.jpg", PhotoPlan.nameFor(photo(9, name = ""))) + assertTrue(PhotoPlan.UPLOAD_NAME.matches("PXL_20261009_120000123.jpg")) + assertFalse(PhotoPlan.UPLOAD_NAME.matches("a.jpg.")) + } + + @Test fun `an edited name stays within the node's length`() { + val long = photo(1, name = "A".repeat(124) + ".jpg", modified = 1) + val name = PhotoPlan.editedName(long, utc) + assertTrue(name.length <= 128) + assertTrue(PhotoPlan.UPLOAD_NAME.matches(name)) + } + + // ── when ───────────────────────────────────────────────────────────────── + + @Test fun `once a day, counted from the last run that finished`() { + assertTrue(PhotoPlan.due(null, oct9)) + assertFalse(PhotoPlan.due(oct9 - 3600_000, oct9)) + assertTrue(PhotoPlan.due(oct9 - PhotoPlan.DAY_MS, oct9)) + assertTrue("a clock moved back must not stop backups for good", PhotoPlan.due(oct9 + 3600_000, oct9)) + } + + // ── settings from the page ─────────────────────────────────────────────── + + @Test fun `photos go into the account's own folder under the destination`() { + assertEquals("Backups/bob-photos", base) + } + + @Test fun `changing the albums keeps the starting point, changing the scope moves it`() { + val o = JSONObject().put("albums", JSONArray(listOf("cam"))).put("includeExisting", false) + val first = SyncConfig.fromJson(o, 100) + assertEquals(100, SyncConfig.fromJson(o.put("albums", JSONArray(listOf("cam", "x"))), 200, first).since) + assertEquals(300, SyncConfig.fromJson(o.put("includeExisting", true), 300, first).since) + } + + @Test fun `settings survive being stored`() { + val c = config(albums = listOf("a", "b")) + assertEquals(c, SyncConfig.parse(c.toJson().toString())) + assertNull(SyncConfig.parse("{}")) + } + + // ── the ledger ─────────────────────────────────────────────────────────── + + @Test fun `the ledger remembers across a restart, last line wins`() { + val f = tmp.newFile("l.jsonl") + PhotoLedger(f).apply { + record(entry(photo(1), sha = "a")) + record(entry(photo(2), sha = "b")) + record(entry(photo(1), sha = "c")) + } + val again = PhotoLedger(f) + assertEquals(2, again.size) + assertEquals("c", again[1]!!.sha256) + } + + @Test fun `a line cut short by a killed process costs that one photo, not the ledger`() { + val f = tmp.newFile("l.jsonl") + PhotoLedger(f).record(entry(photo(1))) + f.appendText("{\"id\":2,\"m\":") + val again = PhotoLedger(f) + assertEquals(1, again.size) + assertNull(again[2]) + } + + @Test fun `a ledger rewritten many times is compacted on load`() { + val f = tmp.newFile("l.jsonl") + val l = PhotoLedger(f) + repeat(300) { l.record(entry(photo(1), sha = "s$it")) } + val again = PhotoLedger(f) + assertEquals(1, again.size) + assertEquals("s299", again[1]!!.sha256) + assertEquals(1, f.readLines().count { it.isNotBlank() }) + } +} 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)) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt new file mode 100644 index 0000000..f3e76fe --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt @@ -0,0 +1,57 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.drive.DriveFile +import org.meshbay.client.drive.DriveKind +import org.meshbay.client.drive.DrivePlan +import org.meshbay.client.drive.WhatsApp +import java.util.TimeZone + +class WhatsAppTest { + private val tree = "content://tree/wa" + + private fun file(sub: String, name: String, size: Long = 10) = + DriveFile(tree, "primary:${WhatsApp.FOLDER}/$sub/$name", sub, name, size, 1000, "application/octet-stream") + + @Test fun `only WhatsApp's own folder can be chosen`() { + assertNull(WhatsApp.refusal("primary:Android/media/com.whatsapp/WhatsApp")) + assertNull(WhatsApp.refusal("primary:Android/media/com.whatsapp.w4b/WhatsApp Business")) + assertEquals("not_whatsapp", WhatsApp.refusal("primary:Android/media/com.whatsapp")) + assertEquals("not_whatsapp", WhatsApp.refusal("primary:Documents")) + assertEquals("not_whatsapp", WhatsApp.refusal("primary:Android/media/com.whatsapp/WhatsApp/Media")) + } + + @Test fun `the chats as they are now, not the dated copies of earlier weeks`() { + assertTrue(WhatsApp.include(file("Databases", "msgstore.db.crypt14"), false)) + assertTrue(WhatsApp.include(file("Databases", "msgstore-increment-1.db.crypt14"), false)) + assertFalse(WhatsApp.include(file("Databases", "msgstore-2026-10-09.1.db.crypt14"), false)) + assertFalse(WhatsApp.include(file("Databases", "msgstore-increment-3-2026-10-09.1.db.crypt14"), false)) + assertTrue(WhatsApp.include(file("Backups", "wa.db.crypt14"), false)) + assertTrue(WhatsApp.include(file("Backups/Stickers", "s.webp"), false)) + } + + @Test fun `media only when asked for, and nothing else of the folder`() { + val photo = file("Media/WhatsApp Images", "IMG-20261010-WA0001.jpg") + assertFalse(WhatsApp.include(photo, false)) + assertTrue(WhatsApp.include(photo, true)) + assertFalse(WhatsApp.include(file(".Shared", "x"), true)) + assertTrue(DriveKind.WHATSAPP.include(photo, JSONObject().put("media", true))) + } + + @Test fun `what a restore needs together is the chats and the backups, not the media`() { + assertTrue(WhatsApp.inRestoreSet(file("Databases", "msgstore.db.crypt14"))) + assertTrue(WhatsApp.inRestoreSet(file("Backups", "wa.db.crypt14"))) + assertFalse(WhatsApp.inRestoreSet(file("Media/WhatsApp Video", "v.mp4"))) + } + + @Test fun `on the node the folders are WhatsApp's own, with no extra level`() { + val out = DrivePlan.plan(listOf(file("Databases", "msgstore.db.crypt14")), "Backups/bob-whatsapp", + mapOf(tree to ""), { null }, emptySet(), TimeZone.getTimeZone("UTC")) { _, _ -> false } + assertEquals("Backups/bob-whatsapp/Databases", out.single().dir) + } +} |