aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 14:41:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 14:41:02 +0200
commitbe8b7fcff56a1104e7cd974cc0f506d90f1299a8 (patch)
tree7bd1f987fd2890e6527a84b7b47018dc598675a6 /packages
parentaed32f20625a6206628b577d743d96552f81e91a (diff)
downloadmeshbay-be8b7fcff56a1104e7cd974cc0f506d90f1299a8.tar.gz
feat(android): back up the personal profile only
A copy of the application inside a work profile offers no backup, and no source reads another profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js81
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt12
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt24
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py14
4 files changed, 90 insertions, 41 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 fb613a3..fc7231c 100644
--- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
+++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
@@ -15,11 +15,12 @@
* that refuses: `platform.js` decides what to show from whether an object
* exists (`platform.node.available`, `platform.folder.available`, …).
*
- * `HUB_BASE`, `BINARY`, `CAST` and `MESSAGES` are prepended by the shell when
- * it injects this file: the interface asks for the hub while its modules load, before
- * anything can await; BINARY says whether the WebView carries ArrayBuffer
- * messages; CAST whether this device can cast at all; MESSAGES whether this
- * build backs text messages up (not the Play build).
+ * `HUB_BASE`, `BINARY`, `CAST`, `BACKUP` and `MESSAGES` are prepended by the
+ * shell when it injects this file: the interface asks for the hub while its
+ * modules load, before anything can await; BINARY says whether the WebView carries ArrayBuffer
+ * messages; CAST whether this device can cast at all; BACKUP whether this is
+ * the personal profile, the only one backed up; MESSAGES whether this build
+ * backs text messages up (not the Play build).
*/
(function () {
'use strict';
@@ -198,41 +199,45 @@
call('push:remember', subscription, account, secret, since),
},
- // Photo backup (§9.12): the phone lists its photos, keeps what was sent,
- // and hands each photo's bytes over at /photosync/<token> on this origin.
- // The page decides when and does the sending. Phone-only, like `push`.
- photoSync: {
- status: () => call('photosync:status'),
- permit: () => call('photosync:permit'),
- albums: () => call('photosync:albums'),
- configure: (settings) => call('photosync:configure', settings || null),
- estimate: (settings) => call('photosync:estimate', settings),
- plan: () => call('photosync:plan'),
- sent: (token, dir, name) => call('photosync:sent', token, dir, name),
- completed: () => call('photosync:completed'),
- failed: (code, text) => call('photosync:failed', code, text),
- keepAlive: (on, text) => call('photosync:keep-alive', on === true, text || ''),
- },
+ // Backups (§9.12, §9.13), of the personal profile only: a copy of the
+ // application inside a work profile has none of these (Profiles.kt).
+ ...(BACKUP ? {
+ // Photo backup (§9.12): the phone lists its photos, keeps what was sent,
+ // and hands each photo's bytes over at /photosync/<token> on this origin.
+ // The page decides when and does the sending. Phone-only, like `push`.
+ photoSync: {
+ status: () => call('photosync:status'),
+ permit: () => call('photosync:permit'),
+ albums: () => call('photosync:albums'),
+ configure: (settings) => call('photosync:configure', settings || null),
+ estimate: (settings) => call('photosync:estimate', settings),
+ plan: () => call('photosync:plan'),
+ sent: (token, dir, name) => call('photosync:sent', token, dir, name),
+ completed: () => call('photosync:completed'),
+ failed: (code, text) => call('photosync:failed', code, text),
+ keepAlive: (on, text) => call('photosync:keep-alive', on === true, text || ''),
+ },
- // Where every backup goes (§9.12): one folder of one group the account
- // owns and is alone in. Each kind goes into `<account>-<kind>` under it.
- phoneSync: {
- destination: () => call('phonesync:destination'),
- setDestination: (d) => call('phonesync:set-destination', d || null),
- },
+ // Where every backup goes (§9.12): one folder of one group the account
+ // owns and is alone in. Each kind goes into `<account>-<kind>` under it.
+ phoneSync: {
+ destination: () => call('phonesync:destination'),
+ setDestination: (d) => call('phonesync:set-destination', d || null),
+ },
- // Contacts backup (§9.13): the phone writes the address book into one
- // file, served at /phonesync/<token> on this origin, when it changed since
- // the last one sent. The page decides when and does the sending.
- contactSync: {
- status: () => call('contactsync:status'),
- permit: () => call('contactsync:permit'),
- configure: (settings) => call('contactsync:configure', settings || null),
- plan: () => call('contactsync:plan'),
- sent: (token, dir, name) => call('contactsync:sent', token, dir, name),
- completed: () => call('contactsync:completed'),
- failed: (code, text) => call('contactsync:failed', code, text),
- },
+ // Contacts backup (§9.13): the phone writes the address book into one
+ // file, served at /phonesync/<token> on this origin, when it changed since
+ // the last one sent. The page decides when and does the sending.
+ contactSync: {
+ status: () => call('contactsync:status'),
+ permit: () => call('contactsync:permit'),
+ configure: (settings) => call('contactsync:configure', settings || null),
+ plan: () => call('contactsync:plan'),
+ sent: (token, dir, name) => call('contactsync:sent', token, dir, name),
+ completed: () => call('contactsync:completed'),
+ failed: (code, text) => call('contactsync:failed', code, text),
+ },
+ } : {}),
// Messages backup (§9.13), the same way as contacts: the messages added
// since the last file the node took. Only in a build that may read them.
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 0545ead..09cef0e 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
@@ -40,6 +40,7 @@ import org.meshbay.client.phonesync.ContactSource
import org.meshbay.client.phonesync.DestinationChannels
import org.meshbay.client.phonesync.DocChannels
import org.meshbay.client.phonesync.Flavor
+import org.meshbay.client.phonesync.Profiles
import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.save.SaveSinks
import org.meshbay.client.shell.Pickers
@@ -64,6 +65,8 @@ class MainActivity : Activity() {
private lateinit var channels: Channels
private lateinit var photos: PhotoChannels
private var docs: List<DocChannels> = emptyList()
+ /** Backups exist in the personal profile only (Profiles.kt). */
+ private var backups = false
private var network: android.net.ConnectivityManager.NetworkCallback? = null
private val pickers = Pickers(this)
private val text = NativeText { code ->
@@ -101,11 +104,12 @@ class MainActivity : Activity() {
Thread { saves.cleanUpAfterAKilledProcess() }.start()
cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting = on; keepAlive() } },
tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } })
+ backups = Profiles.isPersonal(this)
val destinations = DestinationChannels(getSharedPreferences(DestinationChannels.PREFS, Context.MODE_PRIVATE))
photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE), destinations,
java.io.File(filesDir, "photosync"),
onKeepAlive = { on, line -> runOnUiThread { backup(on, line) } })
- docs = listOf(
+ docs = if (!backups) emptyList() else listOf(
DocChannels("contactsync", this, getSharedPreferences("contactsync", Context.MODE_PRIVATE), destinations,
java.io.File(cacheDir, "contactsync"), ContactSource(this),
notifyId = 10, permissionRequest = 4209),
@@ -121,7 +125,8 @@ class MainActivity : Activity() {
channelNames = { mapOf(
Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale),
Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }),
- photos = photos, destinations = destinations, docs = docs)
+ photos = photos.takeIf { backups }, destinations = destinations.takeIf { backups },
+ docs = docs)
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
cast.control.warmUp()
installShim()
@@ -167,7 +172,7 @@ class MainActivity : Activity() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url
if (url.host == UiAssets.HOST && url.path?.startsWith(PhotoChannels.PATH) == true) {
- return photos.serve(url.path ?: "") ?: refused()
+ return photos.takeIf { backups }?.serve(url.path ?: "") ?: refused()
}
if (url.host == UiAssets.HOST && url.path?.startsWith(DocChannels.PATH) == true) {
val path = url.path ?: ""
@@ -253,6 +258,7 @@ class MainActivity : Activity() {
val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)
val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n" +
"const CAST = ${cast.control.available()};\n" +
+ "const BACKUP = $backups;\n" +
"const MESSAGES = ${docs.any { it.handles("messagesync:") }};\n"
shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN))
}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt
new file mode 100644
index 0000000..660cfaa
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/phonesync/Profiles.kt
@@ -0,0 +1,24 @@
+package org.meshbay.client.phonesync
+
+import android.content.Context
+import android.os.Build
+import android.os.UserManager
+
+/**
+ * Backups are of the personal profile only, never of a work profile: a copy
+ * of the application installed inside a work profile offers none of them.
+ *
+ * In the personal profile, every source reads that profile alone: MediaStore,
+ * ContactsContract, the SMS and calendar providers answer for the profile
+ * they are asked from, and none of the cross-profile (`ENTERPRISE_*`) URIs
+ * is used (`test_android_shell.py` checks for them).
+ */
+object Profiles {
+ fun isPersonal(context: Context): Boolean {
+ val users = context.getSystemService(UserManager::class.java)
+ // Before Android 11 an application cannot ask whether its own profile
+ // is managed; a work profile is never the system user, so that is
+ // the test there (a secondary user on a shared tablet loses backups).
+ return if (Build.VERSION.SDK_INT >= 30) !users.isManagedProfile else users.isSystemUser
+ }
+}
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 7b2e79f..2990e9b 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -260,6 +260,20 @@ def test_a_release_is_signed_with_the_release_key_or_not_built():
assert not re.search(r'storePassword = "', build)
+def test_only_the_personal_profile_is_backed_up():
+ """A copy of the application inside a work profile offers no backup, and
+ nothing reads another profile's data through a cross-profile URI."""
+ activity = _read(SRC / "MainActivity.kt")
+ 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:"):
+ assert name in gated, name
+ for path in [*(SRC / "phonesync").rglob("*.kt"), *(SRC / "photos").rglob("*.kt"),
+ *(APP / "src" / "full").rglob("*.kt")]:
+ assert not re.search(r"\.ENTERPRISE_\w+", _read(path)), path.name
+
+
def test_the_play_build_cannot_read_text_messages():
"""Play's policy keeps READ_SMS for the default SMS application: the Play
build has neither the permission nor the code that reads messages."""