diff options
Diffstat (limited to 'packages/meshbay-android/app')
7 files changed, 239 insertions, 39 deletions
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 d8237ec..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 @@ -267,6 +267,22 @@ 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 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 7c0e79e..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 @@ -37,6 +37,7 @@ 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 @@ -67,7 +68,7 @@ class MainActivity : Activity() { private lateinit var channels: Channels private lateinit var photos: PhotoChannels private var docs: List<DocChannels> = emptyList() - private var drive: DriveChannels? = null + 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 @@ -114,11 +115,12 @@ class MainActivity : Activity() { photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE), destinations, java.io.File(filesDir, "photosync"), onKeepAlive = { on, line -> runOnUiThread { backup("photos", on, line) } }) - drive = if (!backups) null else DriveChannels(this, - getSharedPreferences(DriveChannels.PREFS, Context.MODE_PRIVATE), java.io.File(filesDir, "drivesync"), - destinations, pickers, photosBackedUp = { photos.backedUpPaths() }, - unmetered = { photos.unmetered() }, - onKeepAlive = { on, line -> runOnUiThread { backup("drive", 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), @@ -139,7 +141,7 @@ class MainActivity : Activity() { Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale), Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }), photos = photos.takeIf { backups }, destinations = destinations.takeIf { backups }, - docs = docs, drive = drive) + docs = docs, drives = drives) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() @@ -188,9 +190,10 @@ class MainActivity : Activity() { val range = request.requestHeaders.entries.firstOrNull { it.key.equals("Range", true) }?.value return photos.takeIf { backups }?.serve(url.path ?: "", range) ?: refused() } - if (url.host == UiAssets.HOST && url.path?.startsWith(DriveChannels.PATH) == true) { + 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 drive?.serve(url.path ?: "", range) ?: refused() + return driveKind.serve(url.path ?: "", range) ?: refused() } if (url.host == UiAssets.HOST && url.path?.startsWith(DocChannels.PATH) == true) { val path = url.path ?: "" 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 9d180c0..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 @@ -35,7 +35,7 @@ class Channels( private val photos: PhotoChannels? = null, private val destinations: DestinationChannels? = null, private val docs: List<DocChannels> = emptyList(), - private val drive: DriveChannels? = null, + private val drives: List<DriveChannels> = emptyList(), ) { @Volatile var locale = "en" private set @@ -66,7 +66,7 @@ class Channels( push != null && push.handles(channel) -> push.call(channel, args) photos != null && photos.handles(channel) -> photos.call(channel, args) destinations != null && destinations.handles(channel) -> destinations.call(channel, args) - drive != null && drive.handles(channel) -> drive.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 index 3f87359..c6fb476 100644 --- 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 @@ -31,6 +31,7 @@ import java.util.concurrent.ConcurrentHashMap * out (DrivePlan): nothing is stored twice. */ class DriveChannels( + private val kind: DriveKind, private val activity: Activity, private val prefs: SharedPreferences, private val dir: File, @@ -53,20 +54,23 @@ class DriveChannels( } } - fun handles(channel: String) = channel.startsWith("drivesync:") + /** `/drivesync/`, `/wasync/`: where this kind's files are served on the packaged origin. */ + val path: String get() = kind.path - fun call(channel: String, args: JSONArray): Any? = when (channel) { - "drivesync:status" -> status() - "drivesync:add-folder" -> { addFolder(); status() } - "drivesync:remove-folder" -> { removeFolder(args.optString(0, "")); status() } - "drivesync:configure" -> { configure(args.optJSONObject(0)); status() } - "drivesync:plan" -> plan() - "drivesync:sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } - "drivesync:manifest" -> manifest() - "drivesync:manifest-sent" -> { manifestSent(args.optString(0, "")); true } - "drivesync:completed" -> { completed(); status() } - "drivesync:failed" -> failed(args.optString(0, ""), args.optString(1, "")) - "drivesync:keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true } + 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") } @@ -86,6 +90,7 @@ class DriveChannels( /** 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 @@ -94,7 +99,9 @@ class DriveChannels( } } - private fun base(d: Destination) = DocPlan.dirFor(d, SUFFIX) + 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) @@ -108,6 +115,7 @@ class DriveChannels( 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) @@ -121,12 +129,19 @@ class DriveChannels( /** The system's folder picker; a media folder or a whole volume is refused, with the reason. */ private fun addFolder() { - val result = pickers.run(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) ?: return + 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 - DrivePlan.refusal(DocumentsContract.getTreeDocumentId(tree))?.let { throw Refused("Refused: $it") } + kind.refusal(DocumentsContract.getTreeDocumentId(tree))?.let { throw Refused("Refused: $it") } activity.contentResolver.takePersistableUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION) - val list = folders() + 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" })) } @@ -141,6 +156,7 @@ class DriveChannels( } 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) { @@ -148,7 +164,8 @@ class DriveChannels( return } if (destinations.get() == null) throw Refused("Refused: no destination") - prefs.edit().putBoolean(ON, true).apply() + val options = JSONObject().put("media", o.optBoolean("media", false)) + prefs.edit().putBoolean(ON, true).putString(OPTIONS, options.toString()).apply() } private fun completed() { @@ -160,7 +177,7 @@ class DriveChannels( 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, NOTIFY_ID, text.take(300)) + PhotoChannels.notice(activity, kind.notifyId, text.take(300)) return true } @@ -172,7 +189,10 @@ class DriveChannels( 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() @@ -201,12 +221,39 @@ class DriveChannels( .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 @@ -224,10 +271,10 @@ class DriveChannels( /** 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(PATH) }?.let { (_, file) -> + 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(PATH)] ?: return null + 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) @@ -238,10 +285,8 @@ class DriveChannels( } catch (e: Exception) { null } companion object { - const val PATH = "/drivesync/" - const val SUFFIX = "drive" - const val PREFS = "drivesync" - private const val NOTIFY_ID = 13 + 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" 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/DrivePlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt index 660c173..eb373ba 100644 --- 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 @@ -78,10 +78,10 @@ object DrivePlan { return out.sortedBy { it.file.size } } - /** `<base>/<chosen folder>/<sub>`, each level a name the node accepts. */ + /** `<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, safeName(top)) + sub.split('/').filter { it.isNotEmpty() }.map(::safeName)) - .joinToString("/") + (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 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) + } +} |