aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js125
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py13
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py13
-rw-r--r--packages/meshbay-hub/tests/test_drive_sync.py26
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py8
28 files changed, 582 insertions, 54 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)
+ }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
index af59605..631be50 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
@@ -12,7 +12,8 @@ import * as platform from './platform.js';
import { PhotoSyncSection } from './photo-sync-settings.js';
import { DocSyncSection } from './phone-sync-settings.js';
import { DriveSyncSection } from './drive-sync-settings.js';
-import { runDriveSync } from './drive-sync.js';
+import { WhatsAppSyncSection } from './whatsapp-sync-settings.js';
+import { runDriveSync, runWhatsAppSync } from './drive-sync.js';
import { runPhotoSync } from './photo-sync.js';
import { runDocSync, KINDS } from './phone-sync.js';
import {
@@ -25,6 +26,7 @@ function kindDirs(folder, account) {
...(platform.photoSync.available ? ['photos'] : []),
...Object.values(KINDS).filter((k) => k.sync.available).map((k) => k.suffix),
...(platform.driveSync.available ? ['drive'] : []),
+ ...(platform.whatsappSync.available ? ['whatsapp'] : []),
];
return suffixes.map((s) => `${folder}/${account}-${s}`);
}
@@ -46,6 +48,7 @@ export function AndroidSyncPage({ user, groups }) {
if (platform.photoSync.available) runPhotoSync();
for (const kind of Object.keys(KINDS)) if (KINDS[kind].sync.available) runDocSync(kind);
if (platform.driveSync.available) runDriveSync();
+ if (platform.whatsappSync.available) runWhatsAppSync();
};
return html`
@@ -58,6 +61,8 @@ export function AndroidSyncPage({ user, groups }) {
<${DocSyncSection} key=${kind + place} kind=${kind} destination=${mine} />`)}
${platform.driveSync.available && html`
<${DriveSyncSection} key=${'drive' + place} destination=${mine} />`}
+ ${platform.whatsappSync.available && html`
+ <${WhatsAppSyncSection} key=${'whatsapp' + place} destination=${mine} />`}
</div>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index d8818d0..579d1dd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -948,9 +948,11 @@ function App() {
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
- // Files backup (§9.13), the same way.
+ // Files and WhatsApp backups (§9.13), the same way.
useEffect(() => {
- if (!syncAccount || !platform.driveSync.available) return undefined;
+ if (!syncAccount || !(platform.driveSync.available || platform.whatsappSync.available)) {
+ return undefined;
+ }
let stop = null;
let gone = false;
import('./drive-sync.js').then((m) => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js
index 5ddf188..3887e4f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js
@@ -1,4 +1,4 @@
-// Files backup: the folders the person chose through the system's picker,
+// Files and WhatsApp backups: folders chosen through the system's picker,
// sent into `<folder>/<account>-drive/<chosen folder>/…` of the destination
// every backup shares (sync-destination.js), once a day, on an unmetered
// network, like photos (docs/MESHBAY_DESIGN.md §9.13).
@@ -23,7 +23,26 @@ const drive = createBackup({
say: (code) => t('phonesync.notice', { what: t('drivesync.heading'), reason: failureText(code) }),
});
-export const startDriveSync = (context) => drive.start(context);
-export const stopDriveSync = () => drive.stop();
+// WhatsApp's own folder: the same runner over its bridge, into
+// `<folder>/<account>-whatsapp/` (DriveKind.kt says what is sent).
+const whatsapp = createBackup({
+ name: 'WhatsApp',
+ sync: platform.whatsappSync,
+ configured: (status) => Boolean(status.on) && (status.folders || []).length > 0,
+ progress: 'whatsappsync.notif_progress',
+ failures: LASTING,
+ say: (code) => t('phonesync.notice', { what: t('whatsappsync.heading'), reason: failureText(code) }),
+});
+
+export function startDriveSync(context) {
+ drive.start(context);
+ whatsapp.start(context);
+}
+export function stopDriveSync() {
+ drive.stop();
+ whatsapp.stop();
+}
export const runDriveSync = (opts) => drive.run(opts);
export const subscribe = (fn) => drive.subscribe(fn);
+export const runWhatsAppSync = (opts) => whatsapp.run(opts);
+export const subscribeWhatsApp = (fn) => whatsapp.subscribe(fn);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 59c7869..f61f163 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1384,6 +1384,17 @@ export default {
'drivesync.notif_progress': "Dateien werden gesichert: {done} / {total}",
'drivesync.metered_confirm': "Du bist im Mobilfunknetz. Die Dateien trotzdem senden?",
'drivesync.sent_total': "Gesendete Dateien: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Die Chat-Sicherungen von WhatsApp einmal täglich über WLAN kopieren, damit sie auf einem neuen Telefon wiederhergestellt werden können. Sie bleiben von WhatsApp verschlüsselt: Hier lassen sie sich nicht lesen, nur wiederherstellen.",
+ 'whatsappsync.folder_needed': "Der WhatsApp-Ordner ist noch nicht ausgewählt.",
+ 'whatsappsync.folder_chosen': "Der WhatsApp-Ordner ist ausgewählt.",
+ 'whatsappsync.folder_choose': "WhatsApp-Ordner wählen",
+ 'whatsappsync.folder_hint': "Die Auswahl öffnet sich darauf: Android/media/com.whatsapp/WhatsApp. Tippe auf „Diesen Ordner verwenden“.",
+ 'whatsappsync.media': "Auch Medien",
+ 'whatsappsync.media_hint': "Empfangene und gesendete Fotos, Videos, Sprachnachrichten und Dokumente: oft mehrere Gigabyte. Was die Foto-Sicherung schon sendet, wird nicht doppelt gesendet.",
+ 'whatsappsync.refused': "Das ist nicht der WhatsApp-Ordner: Wähle Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Wiederherstellen auf einem neuen Telefon: Kopiere die Ordner Databases und Backups der letzten Kopie nach Android/media/com.whatsapp/WhatsApp, bevor du WhatsApp installierst, und stelle dann mit derselben Telefonnummer wieder her (und dem Passwort oder 64-stelligen Schlüssel, wenn die Ende-zu-Ende-Sicherung aktiv ist).",
+ 'whatsappsync.notif_progress': "WhatsApp wird gesichert: {done} / {total}",
'androidsync.dest_heading': "Ziel der Sicherungen",
'androidsync.dest_intro': "Sicherungen landen in einem Ordner einer Gruppe, die dir gehört und deren einziges Mitglied du bist. Jede Art von Sicherung bekommt dort einen eigenen Ordner.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index ce2b246..a602da2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1365,6 +1365,17 @@ export default {
'drivesync.notif_progress': "Backing up files: {done} / {total}",
'drivesync.metered_confirm': "You are on mobile data. Send the files anyway?",
'drivesync.sent_total': "Files sent: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copy WhatsApp's own chat backups, once a day on Wi-Fi, so they can be restored onto a new phone. They stay encrypted by WhatsApp: they cannot be read here, only restored.",
+ 'whatsappsync.folder_needed': "WhatsApp's folder is not chosen yet.",
+ 'whatsappsync.folder_chosen': "WhatsApp's folder is chosen.",
+ 'whatsappsync.folder_choose': "Choose WhatsApp's folder",
+ 'whatsappsync.folder_hint': "The picker opens on it: Android/media/com.whatsapp/WhatsApp. Press “Use this folder”.",
+ 'whatsappsync.media': "Media too",
+ 'whatsappsync.media_hint': "Photos, videos, voice notes and documents received and sent: often several gigabytes. Those the photo backup already sends are not sent twice.",
+ 'whatsappsync.refused': "This is not WhatsApp's folder: choose Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "To restore on a new phone: copy the Databases and Backups folders of the latest copy into Android/media/com.whatsapp/WhatsApp before installing WhatsApp, then restore with the same phone number (and the password or 64-digit key if end-to-end backup is on).",
+ 'whatsappsync.notif_progress': "Backing up WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Where backups go",
'androidsync.dest_intro': "Backups go into a folder of a group you own and are the only member of. Each kind of backup gets its own folder there.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index b9324e2..8291f6b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1378,6 +1378,17 @@ export default {
'drivesync.notif_progress': "Copia de archivos: {done} / {total}",
'drivesync.metered_confirm': "Estás usando datos móviles. ¿Enviar los archivos de todos modos?",
'drivesync.sent_total': "Archivos enviados: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copiar las copias de seguridad de chats que hace WhatsApp, una vez al día por Wi-Fi, para poder restaurarlas en un teléfono nuevo. Siguen cifradas por WhatsApp: aquí no se pueden leer, solo restaurar.",
+ 'whatsappsync.folder_needed': "Aún no se ha elegido la carpeta de WhatsApp.",
+ 'whatsappsync.folder_chosen': "La carpeta de WhatsApp está elegida.",
+ 'whatsappsync.folder_choose': "Elegir la carpeta de WhatsApp",
+ 'whatsappsync.folder_hint': "El selector se abre en ella: Android/media/com.whatsapp/WhatsApp. Pulsa «Usar esta carpeta».",
+ 'whatsappsync.media': "También los archivos multimedia",
+ 'whatsappsync.media_hint': "Fotos, vídeos, notas de voz y documentos recibidos y enviados: a menudo varios gigabytes. Los que ya envía la copia de fotos no se envían dos veces.",
+ 'whatsappsync.refused': "Esta no es la carpeta de WhatsApp: elige Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Para restaurar en un teléfono nuevo: copia las carpetas Databases y Backups de la última copia en Android/media/com.whatsapp/WhatsApp antes de instalar WhatsApp y restaura con el mismo número (y la contraseña o la clave de 64 dígitos si el cifrado de extremo a extremo está activado).",
+ 'whatsappsync.notif_progress': "Copia de WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Destino de las copias de seguridad",
'androidsync.dest_intro': "Las copias de seguridad van a una carpeta de un grupo del que eres propietario y único miembro. Cada tipo de copia tiene allí su propia carpeta.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 8fa4f40..3ba3232 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1393,6 +1393,17 @@ export default {
'drivesync.notif_progress': "Sauvegarde des fichiers : {done} / {total}",
'drivesync.metered_confirm': "Vous êtes en données mobiles. Envoyer les fichiers quand même ?",
'drivesync.sent_total': "Fichiers envoyés : {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copier les sauvegardes de conversations que fait WhatsApp, une fois par jour en Wi-Fi, pour pouvoir les restaurer sur un nouveau téléphone. Elles restent chiffrées par WhatsApp : on ne peut pas les lire ici, seulement les restaurer.",
+ 'whatsappsync.folder_needed': "Le dossier de WhatsApp n'est pas encore choisi.",
+ 'whatsappsync.folder_chosen': "Le dossier de WhatsApp est choisi.",
+ 'whatsappsync.folder_choose': "Choisir le dossier de WhatsApp",
+ 'whatsappsync.folder_hint': "Le sélecteur s'ouvre dessus : Android/media/com.whatsapp/WhatsApp. Appuyez sur « Utiliser ce dossier ».",
+ 'whatsappsync.media': "Les médias aussi",
+ 'whatsappsync.media_hint': "Photos, vidéos, messages vocaux et documents reçus et envoyés : souvent plusieurs gigaoctets. Ceux que la sauvegarde des photos envoie déjà ne partent pas deux fois.",
+ 'whatsappsync.refused': "Ce n'est pas le dossier de WhatsApp : choisissez Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Pour restaurer sur un nouveau téléphone : copiez les dossiers Databases et Backups de la dernière copie dans Android/media/com.whatsapp/WhatsApp avant d'installer WhatsApp, puis restaurez avec le même numéro (et le mot de passe ou la clé de 64 chiffres si la sauvegarde chiffrée de bout en bout est activée).",
+ 'whatsappsync.notif_progress': "Sauvegarde de WhatsApp : {done} / {total}",
'androidsync.dest_heading': "Destination des sauvegardes",
'androidsync.dest_intro': "Les sauvegardes vont dans un dossier d'un groupe dont vous êtes le propriétaire et le seul membre. Chaque type de sauvegarde y a son propre dossier.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 013f014..02bd661 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1392,6 +1392,17 @@ export default {
'drivesync.notif_progress': "Backup dei file: {done} / {total}",
'drivesync.metered_confirm': "Stai usando i dati mobili. Inviare comunque i file?",
'drivesync.sent_total': "File inviati: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copia i backup delle chat creati da WhatsApp, una volta al giorno in Wi-Fi, per poterli ripristinare su un nuovo telefono. Restano cifrati da WhatsApp: qui non si possono leggere, solo ripristinare.",
+ 'whatsappsync.folder_needed': "La cartella di WhatsApp non è ancora scelta.",
+ 'whatsappsync.folder_chosen': "La cartella di WhatsApp è scelta.",
+ 'whatsappsync.folder_choose': "Scegli la cartella di WhatsApp",
+ 'whatsappsync.folder_hint': "Il selettore si apre su di essa: Android/media/com.whatsapp/WhatsApp. Premi «Usa questa cartella».",
+ 'whatsappsync.media': "Anche i media",
+ 'whatsappsync.media_hint': "Foto, video, note vocali e documenti ricevuti e inviati: spesso diversi gigabyte. Quelli che il backup delle foto invia già non partono due volte.",
+ 'whatsappsync.refused': "Questa non è la cartella di WhatsApp: scegli Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Per ripristinare su un nuovo telefono: copia le cartelle Databases e Backups dell'ultima copia in Android/media/com.whatsapp/WhatsApp prima di installare WhatsApp, poi ripristina con lo stesso numero (e la password o la chiave di 64 cifre se il backup crittografato end-to-end è attivo).",
+ 'whatsappsync.notif_progress': "Backup di WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Destinazione dei backup",
'androidsync.dest_intro': "I backup vanno in una cartella di un gruppo di cui sei proprietario e unico membro. Ogni tipo di backup vi ha la sua cartella.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 6950d4e..ff09dd3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1376,6 +1376,17 @@ export default {
'drivesync.notif_progress': "ファイルをバックアップ中: {done} / {total}",
'drivesync.metered_confirm': "モバイルデータ通信中です。それでもファイルを送りますか?",
'drivesync.sent_total': "送信したファイル: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "WhatsApp が作成するチャットのバックアップを1日1回 Wi-Fi でコピーし、新しいスマートフォンで復元できるようにします。WhatsApp による暗号化はそのままで、ここでは読めず、復元だけができます。",
+ 'whatsappsync.folder_needed': "WhatsApp のフォルダーはまだ選ばれていません。",
+ 'whatsappsync.folder_chosen': "WhatsApp のフォルダーが選ばれています。",
+ 'whatsappsync.folder_choose': "WhatsApp のフォルダーを選ぶ",
+ 'whatsappsync.folder_hint': "選択画面はそのフォルダーで開きます: Android/media/com.whatsapp/WhatsApp。「このフォルダを使用」を押してください。",
+ 'whatsappsync.media': "メディアも含める",
+ 'whatsappsync.media_hint': "送受信した写真、動画、ボイスメッセージ、ドキュメント。数ギガバイトになることもあります。写真のバックアップがすでに送っているものは二重に送りません。",
+ 'whatsappsync.refused': "これは WhatsApp のフォルダーではありません。Android/media/com.whatsapp/WhatsApp を選んでください。",
+ 'whatsappsync.restore_hint': "新しいスマートフォンで復元するには、WhatsApp をインストールする前に、最新のコピーの Databases と Backups フォルダーを Android/media/com.whatsapp/WhatsApp にコピーし、同じ電話番号で復元します (エンドツーエンド暗号化バックアップが有効な場合はパスワードか 64 桁の暗号鍵も必要です)。",
+ 'whatsappsync.notif_progress': "WhatsApp をバックアップ中: {done} / {total}",
'androidsync.dest_heading': "バックアップの保存先",
'androidsync.dest_intro': "バックアップは、あなたが所有者で唯一のメンバーであるグループのフォルダーに保存されます。バックアップの種類ごとに専用のフォルダーが作られます。",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 1327e77..c9a51a2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1394,6 +1394,17 @@ export default {
'drivesync.notif_progress': "Bestanden back-uppen: {done} / {total}",
'drivesync.metered_confirm': "Je gebruikt mobiele data. De bestanden toch versturen?",
'drivesync.sent_total': "Verzonden bestanden: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Kopieer de chatback-ups die WhatsApp maakt, eenmaal per dag via wifi, zodat ze op een nieuwe telefoon teruggezet kunnen worden. Ze blijven door WhatsApp versleuteld: hier kunnen ze niet gelezen worden, alleen teruggezet.",
+ 'whatsappsync.folder_needed': "De map van WhatsApp is nog niet gekozen.",
+ 'whatsappsync.folder_chosen': "De map van WhatsApp is gekozen.",
+ 'whatsappsync.folder_choose': "Map van WhatsApp kiezen",
+ 'whatsappsync.folder_hint': "De kiezer opent erop: Android/media/com.whatsapp/WhatsApp. Tik op ‘Deze map gebruiken’.",
+ 'whatsappsync.media': "Ook media",
+ 'whatsappsync.media_hint': "Ontvangen en verstuurde foto's, video's, spraakberichten en documenten: vaak enkele gigabytes. Wat de fotoback-up al verstuurt, gaat niet twee keer.",
+ 'whatsappsync.refused': "Dit is niet de map van WhatsApp: kies Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Terugzetten op een nieuwe telefoon: kopieer de mappen Databases en Backups van de laatste kopie naar Android/media/com.whatsapp/WhatsApp voordat je WhatsApp installeert, en zet terug met hetzelfde nummer (en het wachtwoord of de sleutel van 64 cijfers als end-to-end-versleutelde back-up aanstaat).",
+ 'whatsappsync.notif_progress': "WhatsApp back-uppen: {done} / {total}",
'androidsync.dest_heading': "Bestemming van back-ups",
'androidsync.dest_intro': "Back-ups gaan naar een map van een groep waarvan jij eigenaar en het enige lid bent. Elk soort back-up krijgt daar een eigen map.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index ee858e5..194e20c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1422,6 +1422,17 @@ export default {
'drivesync.notif_progress': "Kopia plików: {done} / {total}",
'drivesync.metered_confirm': "Używasz danych komórkowych. Wysłać pliki mimo to?",
'drivesync.sent_total': "Wysłane pliki: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Kopiuj kopie zapasowe czatów tworzone przez WhatsApp raz dziennie przez Wi-Fi, aby można je było przywrócić na nowym telefonie. Pozostają zaszyfrowane przez WhatsApp: nie da się ich tu odczytać, tylko przywrócić.",
+ 'whatsappsync.folder_needed': "Folder WhatsApp nie jest jeszcze wybrany.",
+ 'whatsappsync.folder_chosen': "Folder WhatsApp jest wybrany.",
+ 'whatsappsync.folder_choose': "Wybierz folder WhatsApp",
+ 'whatsappsync.folder_hint': "Okno wyboru otworzy się na nim: Android/media/com.whatsapp/WhatsApp. Naciśnij „Użyj tego folderu”.",
+ 'whatsappsync.media': "Również multimedia",
+ 'whatsappsync.media_hint': "Odebrane i wysłane zdjęcia, filmy, wiadomości głosowe i dokumenty: często kilka gigabajtów. To, co wysyła już kopia zdjęć, nie jest wysyłane drugi raz.",
+ 'whatsappsync.refused': "To nie jest folder WhatsApp: wybierz Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Aby przywrócić na nowym telefonie: skopiuj foldery Databases i Backups z ostatniej kopii do Android/media/com.whatsapp/WhatsApp przed instalacją WhatsApp, a potem przywróć z tym samym numerem (i hasłem lub 64-cyfrowym kluczem, jeśli włączona jest kopia szyfrowana end-to-end).",
+ 'whatsappsync.notif_progress': "Kopia WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Miejsce kopii zapasowych",
'androidsync.dest_intro': "Kopie trafiają do folderu grupy, której jesteś właścicielem i jedynym członkiem. Każdy rodzaj kopii ma tam własny folder.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index e2c7860..9afedd9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1379,6 +1379,17 @@ export default {
'drivesync.notif_progress': "Backup de arquivos: {done} / {total}",
'drivesync.metered_confirm': "Você está usando dados móveis. Enviar os arquivos mesmo assim?",
'drivesync.sent_total': "Arquivos enviados: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copiar os backups de conversas que o WhatsApp faz, uma vez por dia pelo Wi-Fi, para poder restaurá-los em um celular novo. Eles continuam criptografados pelo WhatsApp: não dá para lê-los aqui, só restaurar.",
+ 'whatsappsync.folder_needed': "A pasta do WhatsApp ainda não foi escolhida.",
+ 'whatsappsync.folder_chosen': "A pasta do WhatsApp está escolhida.",
+ 'whatsappsync.folder_choose': "Escolher a pasta do WhatsApp",
+ 'whatsappsync.folder_hint': "O seletor abre nela: Android/media/com.whatsapp/WhatsApp. Toque em “Usar esta pasta”.",
+ 'whatsappsync.media': "Mídia também",
+ 'whatsappsync.media_hint': "Fotos, vídeos, mensagens de voz e documentos recebidos e enviados: muitas vezes vários gigabytes. O que o backup de fotos já envia não é enviado duas vezes.",
+ 'whatsappsync.refused': "Esta não é a pasta do WhatsApp: escolha Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Para restaurar em um celular novo: copie as pastas Databases e Backups da última cópia para Android/media/com.whatsapp/WhatsApp antes de instalar o WhatsApp e restaure com o mesmo número (e a senha ou a chave de 64 dígitos se o backup criptografado de ponta a ponta estiver ativado).",
+ 'whatsappsync.notif_progress': "Backup do WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Destino dos backups",
'androidsync.dest_intro': "Os backups vão para uma pasta de um grupo do qual você é dono e único membro. Cada tipo de backup tem a sua própria pasta lá.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 303a09d..fde577c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1365,6 +1365,17 @@ export default {
'drivesync.notif_progress': "正在备份文件:{done} / {total}",
'drivesync.metered_confirm': "你正在使用移动数据。仍要发送这些文件吗?",
'drivesync.sent_total': "已发送文件:{n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "每天一次通过 Wi-Fi 复制 WhatsApp 自己生成的聊天备份,以便在新手机上恢复。它们仍由 WhatsApp 加密:这里无法读取,只能用于恢复。",
+ 'whatsappsync.folder_needed': "尚未选择 WhatsApp 的文件夹。",
+ 'whatsappsync.folder_chosen': "已选择 WhatsApp 的文件夹。",
+ 'whatsappsync.folder_choose': "选择 WhatsApp 的文件夹",
+ 'whatsappsync.folder_hint': "选择器会直接打开它:Android/media/com.whatsapp/WhatsApp。点按“使用此文件夹”。",
+ 'whatsappsync.media': "包括媒体",
+ 'whatsappsync.media_hint': "收发的照片、视频、语音消息和文档:常常有好几 GB。照片备份已发送的内容不会重复发送。",
+ 'whatsappsync.refused': "这不是 WhatsApp 的文件夹:请选择 Android/media/com.whatsapp/WhatsApp。",
+ 'whatsappsync.restore_hint': "在新手机上恢复:安装 WhatsApp 之前,把最新副本中的 Databases 和 Backups 文件夹复制到 Android/media/com.whatsapp/WhatsApp,然后用同一个手机号恢复(如果开启了端到端加密备份,还需要密码或 64 位密钥)。",
+ 'whatsappsync.notif_progress': "正在备份 WhatsApp:{done} / {total}",
'androidsync.dest_heading': "备份保存位置",
'androidsync.dest_intro': "备份会保存到一个由你创建且只有你一个成员的群组的文件夹中。每种备份在那里都有自己的文件夹。",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index b1a8ae9..596c327 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -296,6 +296,27 @@ export const driveSync = {
};
/**
+ * Backing up WhatsApp's own folder, chosen once through the picker (its
+ * encrypted chat backups, and its media if asked), like `driveSync`.
+ */
+const waBridge = bridge && bridge.whatsappSync;
+export const whatsappSync = {
+ available: Boolean(waBridge),
+ status() { return waBridge.status(); },
+ chooseFolder() { return waBridge.chooseFolder(); },
+ forgetFolder(id) { return waBridge.forgetFolder(id); },
+ configure(settings) { return waBridge.configure(settings); },
+ plan() { return waBridge.plan(); },
+ sent(token, dir, name) { return waBridge.sent(token, dir, name); },
+ manifest() { return waBridge.manifest(); },
+ manifestSent(token) { return waBridge.manifestSent(token); },
+ completed() { return waBridge.completed(); },
+ failed(code, text) { return waBridge.failed(code, text); },
+ keepAlive(on, text) { return waBridge.keepAlive(on, text); },
+ file: (token, name, size) => rangedFile('/wasync/', token, name, size),
+};
+
+/**
* Where every backup of this phone goes: one folder of a group the account
* owns and is alone in (docs/MESHBAY_DESIGN.md §9.12). Android only, like
* `photoSync`. See sync-destination.js.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js
new file mode 100644
index 0000000..23aeb32
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js
@@ -0,0 +1,125 @@
+// The WhatsApp section of the Android Sync page, on the Android application
+// only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other client has
+// it. WhatsApp's chats are only ever in its own encrypted backups, which this
+// copies for a restore onto a new phone; its media too, if asked.
+
+import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ask } from './ask.js';
+import * as platform from './platform.js';
+import { subscribeWhatsApp, runWhatsAppSync } from './drive-sync.js';
+import { failureText } from './phone-sync.js';
+
+function stateLine(st, status) {
+ switch (st.phase) {
+ case 'waiting_wifi': return t('photosync.state_waiting_wifi');
+ case 'connecting': return t('photosync.state_connecting');
+ case 'sending': return t('photosync.state_sending', { done: st.done, total: st.total });
+ case 'offline': return t('photosync.state_offline');
+ case 'interrupted': return t('photosync.state_interrupted');
+ case 'up_to_date': return t('photosync.state_up_to_date');
+ default: return status && status.lastCompleted ? t('photosync.state_up_to_date') : '';
+ }
+}
+
+export function WhatsAppSyncSection({ destination }) {
+ const sync = platform.whatsappSync;
+ const [status, setStatus] = useState(null);
+ const [run, setRun] = useState({ phase: 'idle' });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]);
+ useEffect(() => { refresh(); }, [refresh]);
+ useEffect(() => subscribeWhatsApp((st) => {
+ setRun(st);
+ if (st.phase !== 'sending' && st.phase !== 'connecting') refresh();
+ }), [refresh]);
+
+ if (!status) return null;
+ const chosen = (status.folders || []).length > 0;
+ const media = Boolean(status.options && status.options.media);
+ const running = run.phase === 'sending' || run.phase === 'connecting';
+
+ const act = async (fn) => {
+ setBusy(true);
+ setError('');
+ try {
+ const next = await fn();
+ if (next) setStatus(next);
+ } catch (e) {
+ setError(/Refused: not_whatsapp/.test(e.message || '') ? t('whatsappsync.refused') : e.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const turnOn = (withMedia) => act(async () => {
+ const next = await sync.configure({ media: withMedia });
+ runWhatsAppSync();
+ return next;
+ });
+
+ const backUpNow = () => act(async () => {
+ if (!status.unmetered && !await ask(t('drivesync.metered_confirm'))) return null;
+ await runWhatsAppSync({ force: true, metered: !status.unmetered });
+ return null;
+ });
+
+ return html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('whatsappsync.heading')}</h3>
+ ${!status.on && html`<p class="settings-hint">${t('whatsappsync.intro')}</p>`}
+ ${!destination && html`<p class="settings-hint">${t('androidsync.dest_needed')}</p>`}
+ ${destination && html`
+ <div class="settings-row">
+ <span class="settings-label settings-name">
+ ${chosen ? t('whatsappsync.folder_chosen') : t('whatsappsync.folder_needed')}
+ </span>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.chooseFolder())}>
+ ${chosen ? t('photosync.change') : t('whatsappsync.folder_choose')}</button>
+ </div>
+ ${!chosen && html`<p class="settings-hint">${t('whatsappsync.folder_hint')}</p>`}
+ <label class="settings-choice">
+ <input type="checkbox" checked=${media} disabled=${busy || !chosen}
+ onChange=${() => (status.on ? turnOn(!media)
+ : act(async () => ({ ...status, options: { media: !media } })))} />
+ <span class="settings-name">
+ <strong>${t('whatsappsync.media')}</strong>
+ <span class="settings-hint">${t('whatsappsync.media_hint')}</span>
+ </span>
+ </label>
+ `}
+ ${destination && chosen && !status.on && html`
+ <button class="admin-btn" disabled=${busy}
+ onClick=${() => turnOn(media)}>${t('phonesync.turn_on')}</button>
+ `}
+ ${destination && status.on && html`
+ <div class="settings-row" style="margin-top:8px">
+ <span class="settings-label settings-name">
+ ${t('phonesync.summary', { group: destination.groupName, dir: status.dir })}
+ </span>
+ </div>
+ <p class="settings-hint">
+ ${t('drivesync.sent_total', { n: status.sent })}
+ ${' · '}
+ ${status.lastCompleted
+ ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
+ : t('photosync.never')}
+ </p>
+ ${status.failure
+ ? html`<p class="error-msg">${failureText(status.failure)}</p>`
+ : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`}
+ <p class="settings-hint">${t('whatsappsync.restore_hint')}</p>
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap">
+ <button class="admin-btn" disabled=${running || busy || !chosen}
+ onClick=${backUpNow}>${t('photosync.now')}</button>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.configure(null))}>${t('photosync.turn_off')}</button>
+ </div>
+ `}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
index 2a75749..3fcce8a 100644
--- a/packages/meshbay-hub/tests/harness/photo_sync_probe.py
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -118,6 +118,17 @@ window.meshbay = {
sent: async () => true, completed: async () => ({}), failed: async () => true,
keepAlive: async () => true,
},
+ whatsappSync: {
+ status: async () => ({
+ on: MODE === 'configured', unmetered: true, destination: dest, options: { media: false },
+ dir: dest && '__FOLDER__/bob-whatsapp', sent: 4, lastCompleted: Date.now(), failure: null,
+ folders: [{ id: 'wa', name: 'WhatsApp' }],
+ }),
+ chooseFolder: async () => ({}), forgetFolder: async () => ({}), configure: async () => ({}),
+ plan: async () => ({ items: [] }), sent: async () => true, manifest: async () => ({ item: null }),
+ manifestSent: async () => true, completed: async () => ({}), failed: async () => true,
+ keepAlive: async () => true,
+ },
messageSync: {
status: async () => ({
permission: 'granted', on: MODE === 'configured', destination: dest,
@@ -177,7 +188,7 @@ setTimeout(() => {
.map((e) => `${e.tagName}.${e.className} ${Math.round(e.getBoundingClientRect().right)}`);
out[f.id] = {
vw, scrollW: d.documentElement.scrollWidth, wide: wide.slice(0, 6),
- text: d.querySelector('#root').innerText.slice(0, 3000),
+ text: d.querySelector('#root').innerText.slice(0, 8000),
err: d.documentElement.dataset.err || '',
checked: [...d.querySelectorAll('#root input:checked')]
.map((i) => i.closest('label').innerText.split('\\n')[0]),
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 9275d67..001f6d9 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -136,20 +136,24 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
native = set()
for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt",
- SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt",
- SRC / "drive" / "DriveChannels.kt"):
+ SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt"):
native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
# Contacts and messages share one class, its channels named by a prefix.
docs = set(re.findall(r'^\s*"(\w+)" ->', _read(SRC / "phonesync" / "DocChannels.kt"), flags=re.M))
for prefix in re.findall(r'DocChannels\("(\w+)"', _read(SRC / "MainActivity.kt")):
native |= {f"{prefix}:{name}" for name in docs}
+ # The chosen-folder backups too, their prefixes in DriveKind.kt.
+ drive = set(re.findall(r'^\s*"([\w-]+)" ->', _read(SRC / "drive" / "DriveChannels.kt"), flags=re.M))
+ for prefix in re.findall(r'prefix = "(\w+)"', _read(SRC / "drive" / "DriveKind.kt")):
+ native |= {f"{prefix}:{name}" for name in drive}
shim = _shim_channels()
assert shim, "no channel found in the shim"
# A phone has a push distributor to talk to and a camera roll and an
# address book to back up, and a desktop has none of them; those families are the ones the desktop lacks
# rather than the ones it shares.
phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:",
- "calendarsync:", "messagesync:", "drivesync:"))}
+ "calendarsync:", "messagesync:", "drivesync:",
+ "wasync:"))}
assert any(c.startswith("push:") for c in phone_only), "the push channels are gone"
assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone"
assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
@@ -268,7 +272,8 @@ def test_only_the_personal_profile_is_backed_up():
assert "Profiles.isPersonal(this)" in activity and "const BACKUP = $backups;" in activity
shim = _strip_js_comments(_read(SHIM))
gated = shim.split("...(BACKUP ? {", 1)[1].split("} : {}),", 1)[0]
- for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:", "driveSync:"):
+ for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:", "driveSync:",
+ "whatsappSync:"):
assert name in gated, name
for path in [*(SRC / "phonesync").rglob("*.kt"), *(SRC / "photos").rglob("*.kt"),
*(SRC / "drive").rglob("*.kt"),
diff --git a/packages/meshbay-hub/tests/test_drive_sync.py b/packages/meshbay-hub/tests/test_drive_sync.py
index 7fc35d2..342b310 100644
--- a/packages/meshbay-hub/tests/test_drive_sync.py
+++ b/packages/meshbay-hub/tests/test_drive_sync.py
@@ -73,7 +73,19 @@ fake.transport = {
},
};
const hubFetch = async () => ({ admin_id: 'u-bob', members: fake.members, invited: [] });
-win.meshbay = { driveSync: {
+fake.wa = { on: false };
+win.meshbay = { whatsappSync: {
+ status: async () => ({ ...fake.status, on: fake.wa.on, dir: 'Backups/bob-whatsapp',
+ folders: [{ id: 'wa', name: 'WhatsApp' }] }),
+ plan: async () => ({ items: [{ token: 'w1', name: 'msgstore.db.crypt14',
+ dir: 'Backups/bob-whatsapp/Databases', size: 8, edited: false }],
+ manifest: false }),
+ sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; },
+ manifest: async () => ({ item: null }), manifestSent: async () => true,
+ completed: async () => { fake.log.push(['wa-completed']); return fake.status; },
+ failed: async (code) => { fake.log.push(['failed', code]); return true; },
+ keepAlive: async () => true,
+}, driveSync: {
status: async () => fake.status,
plan: async () => { fake.log.push(['plan']); return { items: fake.items, manifest: false }; },
manifest: async () => ({ item: null }),
@@ -148,6 +160,18 @@ def test_a_lasting_refusal_names_the_files_backup(tmp_path):
assert ["completed"] not in log
+def test_whatsapps_chats_go_into_their_own_folder_on_their_own_runner(tmp_path):
+ log = _run("""
+fake.wa.on = true;
+fake.items = [];
+m.startDriveSync(ctx);
+await m.runWhatsAppSync();
+""", tmp_path)
+ assert ["upload", "Backups/bob-whatsapp/Databases", "msgstore.db.crypt14"] in log
+ assert ["fetch", "/wasync/w1", "bytes=0-7"] in log
+ assert ["wa-completed"] in log
+
+
def test_the_backup_has_no_way_to_remove_anything_on_the_node():
for path in (SYNC, STATIC / "photo-sync.js"):
src = path.read_text(encoding="utf-8")
diff --git a/packages/meshbay-hub/tests/test_first_load_is_lean.py b/packages/meshbay-hub/tests/test_first_load_is_lean.py
index 854d755..2c191a5 100644
--- a/packages/meshbay-hub/tests/test_first_load_is_lean.py
+++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py
@@ -27,7 +27,7 @@ ON_DEMAND = {
# The Android application's backups: no browser or desktop has them.
"photo-sync.js", "photo-sync-settings.js", "android-sync-page.js",
"phone-sync.js", "phone-sync-settings.js", "sync-destination.js",
- "drive-sync.js", "drive-sync-settings.js",
+ "drive-sync.js", "drive-sync-settings.js", "whatsapp-sync-settings.js",
}
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 3a01e53..33408bb 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -56,7 +56,7 @@ STATIC_FILES = [
"ask.js",
# The Android application's backups, on their own page loaded on demand.
"settings-page.js", "android-sync-page.js", "photo-sync-settings.js",
- "phone-sync-settings.js", "drive-sync-settings.js",
+ "phone-sync-settings.js", "drive-sync-settings.js", "whatsapp-sync-settings.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
index b3ab59e..da0ceeb 100644
--- a/packages/meshbay-hub/tests/test_photo_sync_ui.py
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -57,7 +57,7 @@ def test_limited_access_is_said_rather_than_failing_quietly(probe):
def test_where_backups_go_lists_each_kinds_folder(probe):
text = probe["dest360"]["text"]
- assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages", "drive"))
+ assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages", "drive", "whatsapp"))
def test_photos_the_photos_tab_will_not_show_are_said(probe):
@@ -85,3 +85,9 @@ def test_the_files_backup_lists_its_folders_and_what_it_leaves_out(probe):
text = probe["configured360"]["text"]
assert "Documents" in text and "Scans_from_the_office_printer" in text
assert "DCIM, Pictures and Movies cannot be chosen" in text
+
+
+def test_the_whatsapp_backup_says_how_to_restore(probe):
+ text = probe["configured360"]["text"]
+ assert "WhatsApp's folder is chosen" in text
+ assert "To restore on a new phone" in text