From a0793dd37030f7213ecb07a018b11e6a8adf9238 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 12:56:26 +0200 Subject: feat(android): back the phone's contacts up to a group of one's own A Contacts backup section on the Android Sync page sends a dated .vcf into /-contacts once a day when the address book changed, only to a group the account is alone in, checked again at every run. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-hub/tests/test_android_shell.py | 10 +- .../meshbay-hub/tests/test_first_load_is_lean.py | 3 +- packages/meshbay-hub/tests/test_hook_ordering.py | 3 +- packages/meshbay-hub/tests/test_phone_sync.py | 266 +++++++++++++++++++++ 4 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_phone_sync.py (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index 5262aae..b92cfef 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -138,12 +138,16 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each(): SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt", SRC / "photos" / "PhotoChannels.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} shim = _shim_channels() assert shim, "no channel found in the shim" - # A phone has a push distributor to talk to and a camera roll to back up, - # and a desktop has neither; those families are the ones the desktop lacks + # 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:"))} + phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "contactsync:"))} 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}" 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 324ba0c..2e24a6c 100644 --- a/packages/meshbay-hub/tests/test_first_load_is_lean.py +++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py @@ -24,8 +24,9 @@ ON_DEMAND = { "chat-app-settings.js", "video-app-settings.js", "music-app-settings.js", "photos-app-settings.js", "helloworld-app-settings.js", "video-player.js", "group-settings.js", "search-page.js", - # The Android application's photo backup: no browser or desktop has it. + # 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", } diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 124bfb8..6dddcc3 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -54,8 +54,9 @@ STATIC_FILES = [ "menu.js", "playlist-menu.js", # The page's own confirm/alert, mounted outside the app tree. "ask.js", - # The Android application's photo backup, on its own page loaded on demand. + # 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", ] pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") diff --git a/packages/meshbay-hub/tests/test_phone_sync.py b/packages/meshbay-hub/tests/test_phone_sync.py new file mode 100644 index 0000000..7e5a88e --- /dev/null +++ b/packages/meshbay-hub/tests/test_phone_sync.py @@ -0,0 +1,266 @@ +""" +The phone's contacts backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.13). + +Run under Node against the real `phone-sync.js`, with what it talks to +replaced: the Android bridge (`window.meshbay.contactSync`, which writes the +file and remembers what the node took), the hub's member list, and the group +connection (`connection-pool.js`, swapped in the copy). What is pinned is what +the person was promised: once a day, one dated file into +`/-contacts`, only while they are alone in the group, nothing +ever deleted on the node, and a lasting refusal said once. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SYNC = STATIC / "phone-sync.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not SYNC.exists(), + reason="node or the SPA sources are not available") + +DAY = 24 * 3600 * 1000 + +FAKE_POOL = r""" +export class ConnectionPool { + constructor(hub) { globalThis.fake.pools.push(hub); } + async connect(groupId) { + if (globalThis.fake.refuseConnect) { + const e = new Error('refused'); e.reason = globalThis.fake.refuseConnect; throw e; + } + globalThis.fake.log.push(['connect', groupId]); + return { transport: globalThis.fake.transport, ack: {} }; + } + closeAll() { globalThis.fake.log.push(['close']); } +} +""" + +PRELUDE = r""" +const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null, + members: [{ username: 'bob' }], invited: [], membersError: null }; +const win = new EventTarget(); +const doc = new EventTarget(); +doc.visibilityState = 'visible'; +globalThis.window = win; +globalThis.document = doc; +globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} }; +globalThis.fetch = async (url) => { + fake.log.push(['fetch', url]); + return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) }; +}; + +fake.status = { + permission: 'granted', sent: 0, now: Date.now(), + config: { account: 'bob', groupId: 'g1', groupName: 'Mine', owner: 'bob', folder: 'Backups' }, + dir: 'Backups/bob-contacts', + lastCompleted: null, lastSent: null, failure: null, failureAt: null, +}; +fake.item = { token: 't1', name: 'contacts-2026-10-10-0900.vcf', dir: 'Backups/bob-contacts', + size: 4, count: 12 }; +fake.index = { + roots: [{ name: 'Backups', writable: true, available: true }], + dirs: [], + entries: [], +}; +fake.transport = { + connected: true, + async fetchIndex() { return fake.index; }, + async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; }, + openTransfer({ kind, bytes }) { + fake.log.push(['lease', kind, bytes]); + return { tr: 'tr-' + bytes, acquire: async () => {}, + release: () => fake.log.push(['release', bytes]) }; + }, + async uploadFile(file, opts) { + fake.log.push(['upload', opts.dir, file.name, opts.tr]); + if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; } + return { stored_as: file.name, dir: opts.dir }; + }, +}; +const hubFetch = async (path) => { + fake.log.push(['hub', path]); + if (fake.membersError) throw new Error(fake.membersError); + return { members: fake.members, invited: fake.invited }; +}; +win.meshbay = { contactSync: { + status: async () => fake.status, + plan: async () => { fake.log.push(['plan']); return { item: fake.item }; }, + sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; }, + completed: async () => { + fake.log.push(['completed']); + fake.status.lastCompleted = Date.now(); + return fake.status; + }, + failed: async (code, text) => { fake.log.push(['failed', code, text]); return true; }, +} }; +""" + + +def _run(body, tmp_path): + root = tmp_path / "static" + shutil.copytree(STATIC, root) + (root / "package.json").write_text('{"type":"module"}', encoding="utf-8") + (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8") + script = tmp_path / "case.mjs" + script.write_text( + PRELUDE + + f"const m = await import('{(root / 'phone-sync.js').as_uri()}');\n" + + "const ctx = { hub: 'https://hub.invalid', hubFetch, getUser: () => ({ username: 'bob', " + "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n" + + body + # The next run's timer would keep Node alive for a day. + + "\nm.stopPhoneSync();\nconsole.log(JSON.stringify(fake.log));\n", + encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + encoding="utf-8", timeout=30) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def _start(extra=""): + return extra + "\nm.startPhoneSync(ctx);\nawait m.runDocSync('contacts');\n" + + +def _of(log, kind): + return [e[1:] for e in log if e[0] == kind] + + +# ── a run ─────────────────────────────────────────────────────────────────── + +def test_a_due_run_sends_one_dated_file_into_the_account_folder(tmp_path): + log = _run(_start(), tmp_path) + assert _of(log, "mkdir") == [["Backups/bob-contacts"]] + assert _of(log, "upload") == [["Backups/bob-contacts", "contacts-2026-10-10-0900.vcf", "tr-4"]] + assert _of(log, "fetch") == [["/phonesync/t1"]] + assert _of(log, "sent") == [["t1", "Backups/bob-contacts", "contacts-2026-10-10-0900.vcf"]] + assert ["completed"] in log + assert _of(log, "release") == [[4]] + + +def test_a_folder_already_there_is_not_made_again(tmp_path): + log = _run(_start("fake.index.dirs = ['Backups/bob-contacts'];"), tmp_path) + assert _of(log, "mkdir") == [] + assert len(_of(log, "upload")) == 1 + + +def test_nothing_new_finishes_the_run_without_connecting(tmp_path): + log = _run(_start("fake.item = null;"), tmp_path) + assert ["completed"] in log + assert _of(log, "connect") == [] + + +def test_nothing_happens_before_a_day_has_passed(tmp_path): + log = _run(_start("fake.status.lastCompleted = Date.now() - 3600 * 1000;"), tmp_path) + assert _of(log, "plan") == [] and _of(log, "hub") == [] + + +def test_a_day_later_it_runs(tmp_path): + log = _run(_start(f"fake.status.lastCompleted = Date.now() - {DAY + 1000};"), tmp_path) + assert len(_of(log, "upload")) == 1 + + +def test_back_up_now_runs_whenever(tmp_path): + log = _run(""" +fake.status.lastCompleted = Date.now() - 1000; +m.startPhoneSync(ctx); +await m.runDocSync('contacts'); +await m.runDocSync('contacts', { force: true }); +""", tmp_path) + assert len(_of(log, "upload")) == 1 + + +# ── alone in the group, at every run ──────────────────────────────────────── + +def test_the_group_is_asked_about_at_every_run(tmp_path): + log = _run(_start(), tmp_path) + assert _of(log, "hub") == [["/v1/groups/g1/members"]] + + +@pytest.mark.parametrize("setup", [ + "fake.members = [{ username: 'bob' }, { username: 'carol' }];", + "fake.invited = [{ username: 'carol' }];", +]) +def test_somebody_else_in_the_group_stops_it_before_anything_is_read(tmp_path, setup): + log = _run(_start(setup), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["not_private"] + assert _of(log, "plan") == [] and _of(log, "upload") == [] + + +def test_a_member_removed_from_the_group_is_told_once(tmp_path): + log = _run(_start("fake.membersError = 'Not a member';"), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["not_a_member"] + + +def test_an_unreachable_hub_is_not_a_lasting_refusal(tmp_path): + log = _run(_start("fake.membersError = 'Failed to fetch';"), tmp_path) + assert _of(log, "failed") == [] and _of(log, "plan") == [] + + +def test_only_groups_this_account_is_alone_in_are_offered(tmp_path): + log = _run(""" +m.startPhoneSync(ctx); +const real = ctx.hubFetch; +ctx.hubFetch = async (path) => path.includes('/g2/') + ? { members: [{ username: 'bob' }, { username: 'carol' }] } + : path.includes('/g3/') ? Promise.reject(new Error('offline')) : real(path); +fake.log.push(['offered', (await m.privateGroups( + [{ id: 'g1', name: 'Mine' }, { id: 'g2', name: 'Family' }, { id: 'g3', name: 'Far' }])) + .map((g) => g.id)]); +""", tmp_path) + assert _of(log, "offered") == [[["g1"]]] + + +# ── refusals ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("code", ["disk_full", "root_read_only"]) +def test_a_lasting_refusal_stops_and_is_said(tmp_path, code): + log = _run(_start(f"fake.uploadError = '{code}';"), tmp_path) + failed = _of(log, "failed") + assert [e[0] for e in failed] == [code] + assert failed[0][1], "the phone is given nothing to say" + assert ["completed"] not in log + + +def test_a_folder_gone_is_found_before_sending(tmp_path): + log = _run(_start("fake.index.roots = [];"), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["no_such_root"] + assert _of(log, "upload") == [] + + +def test_without_the_permission_it_stops_and_says_so(tmp_path): + log = _run(_start("fake.status.permission = 'denied';"), tmp_path) + assert [e[0] for e in _of(log, "failed")] == ["permission"] + + +def test_another_account_on_the_phone_sends_nothing(tmp_path): + log = _run(_start("fake.status.config.account = 'alice';"), tmp_path) + assert _of(log, "plan") == [] and _of(log, "hub") == [] + + +# ── folders offered ───────────────────────────────────────────────────────── + +def test_every_writable_folder_is_offered_not_only_photo_ones(tmp_path): + log = _run(""" +fake.log.push(['folders', m.writableFolders( + { roots: [{ name: 'Media', writable: true, available: true }, + { name: 'Archive', writable: false, available: true }, + { name: 'Ejected', writable: true, available: false }], + dirs: ['Media/Films', 'Media/Photos', 'Archive/Old', 'Ejected/Stuff'] })]); +""", tmp_path) + assert _of(log, "folders") == [[["Media", "Media/Films", "Media/Photos"]]] + + +# ── nothing is ever removed ───────────────────────────────────────────────── + +def test_the_backup_has_no_way_to_remove_anything_on_the_node(): + """Every copy is a new file; the property is the absence of a call.""" + src = SYNC.read_text(encoding="utf-8") + for op in ("deleteFile", "deleteDirectory", "removeRoot", "file_delete", "dir_delete", + "root_remove", "rename"): + assert not re.search(rf"\b{op}\b", src), f"phone-sync.js reaches {op}" -- cgit v1.2.3