diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:41:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:41:02 +0200 |
| commit | be8b7fcff56a1104e7cd974cc0f506d90f1299a8 (patch) | |
| tree | 7bd1f987fd2890e6527a84b7b47018dc598675a6 | |
| parent | aed32f20625a6206628b577d743d96552f81e91a (diff) | |
| download | meshbay-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>
6 files changed, 103 insertions, 41 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index e733c7f..29b31a2 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -3349,6 +3349,15 @@ where `platform.phoneSync` exists. Settings holds what the account prefers on every client; this page holds what one phone sends: photos, contacts (§9.13), and the kinds to come (messages, calendar, files). +**The personal profile only.** Every backup is of the phone's personal +profile, never of a work profile: a copy of the application installed inside +a work profile is offered none of them (`Profiles.kt`; the bridge objects are +absent, so the page shows no Android Sync). In the personal profile each +source reads that profile alone, since MediaStore and the contacts, SMS and +calendar providers answer for the profile they are asked from; none of the +cross-profile `ENTERPRISE_*` URIs is used. An account added to the personal +profile is part of it, whatever its use. + **One destination for every kind.** The top of the page chooses it once: a group and one of its writable folders (`Destination.kt`, `sync-destination.js`). Each kind goes into its own folder under it, `<folder>/<account>-photos`, diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 3047b3f..e0c29da 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -387,6 +387,10 @@ that matters to you. On the Android application, **Phone → Android Sync** in the side menu backs up your phone's photos, contacts and text messages to a group. +Only your **personal profile** is backed up. If your phone has a work profile, +nothing in it is sent, and MeshBay installed inside the work profile offers no +backup at all. + **Where backups go** is chosen once, at the top of the page, for every kind: - **Only a group you own and are the only member of** can receive them: your 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.""" |