aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-android/app')
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js16
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt21
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt4
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveChannels.kt95
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DriveKind.kt79
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt6
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/WhatsAppTest.kt57
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)
+ }
+}