aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/photo_sync_probe.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-09 18:23:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-09 18:23:56 +0200
commit25152ddb61a89ea3ea29d3aef5de13343429d32a (patch)
treeebf8f11f66146caaa3c01270cd6cfcb0ee51723c /packages/meshbay-hub/tests/harness/photo_sync_probe.py
parentc85c7f48e8f29923038d4c90cc1a6f9b8bcd7673 (diff)
downloadmeshbay-25152ddb61a89ea3ea29d3aef5de13343429d32a.tar.gz
feat: back up the phone's photos to a group, once a day on Wi-FiHEADmain
The Android application sends the photos taken on the phone to one folder of one group chosen by the member (docs/MESHBAY_DESIGN.md §9.12). The phone lists MediaStore, keeps a ledger of what was sent and hands each photo's bytes to the page by an opaque token on the packaged origin; the page decides when a run is due and uploads through the existing path, one photo at a time under a slot. - Once a day from the last finished run, on an unmetered network only; "Back up now" asks first on mobile data. Leaving Wi-Fi stops after the file in flight. - Photos already on the phone are sent by default, newest first, under <folder>/YYYY/MM; edits are sent beside the original as -edited-<date>. - Additive by construction: nothing is ever deleted, renamed or replaced on the node, and a photo deleted on the node is not sent again. - A confirmation names the group, owner, members, folder and size when the destination or starting point changes; a lasting refusal (disk full, folder read-only or gone, no longer a member) is said once and retried a day later. - No ACCESS_MEDIA_LOCATION, so the platform redacts photo locations. - A dataSync foreground service keeps a run going with the screen off. HEIC/HEIF photos are sent but not shown in Photos yet (§15.2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness/photo_sync_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py182
1 files changed, 182 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
new file mode 100644
index 0000000..48d9270
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""
+The phone's photo backup section (photo-sync-settings.js), rendered in Chrome
+at phone widths with names nobody would choose for a fixture.
+
+Only the Android application has this section, and its engine is Chromium, so
+one engine is the whole of it. The bridge is a stand-in that answers the way
+the phone does; the group connection (`connection-pool.js`) is replaced in the
+copy by one that hands out a node with long, space-free folder names, because
+those are what widen a page (CLAUDE.md). Three states: setting up, set up with
+a refusal showing, and setting up with only selected photos allowed.
+
+ photo_sync_probe.py prints one JSON object, keyed <state><width>
+"""
+import functools
+import http.server
+import json
+import shutil
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+
+LONG_FOLDER = "Media/Photos/Family_holidays_summer_2026_at_the_seaside_with_everyone_and_the_dog"
+LONG_GROUP = "A_group_name_with_no_spaces_at_all_whatsoever_long"
+LONG_OWNER = "someone_with_a_long_handle"
+
+FAKE_POOL = """
+export class ConnectionPool {
+ async connect() {
+ return {
+ transport: {
+ fetchIndex: async () => ({
+ roots: [{ name: 'Media', writable: true, available: true }],
+ dirs: ['Media/Photos',
+ 'Media/Photos/Some_Band_Complete_Discography_1992-2000_FLAC_and_more_pictures',
+ '__FOLDER__'],
+ }),
+ },
+ ack: { photo_directories: ['Media/Photos'] },
+ };
+ }
+ closeAll() {}
+}
+""".replace("__FOLDER__", LONG_FOLDER)
+
+PROBE = """<!doctype html><html><head><meta charset=utf-8>
+<meta name=viewport content="width=device-width">
+<link rel=stylesheet href="style.css"></head><body>
+<script>
+const note = (m) => {
+ const el = document.documentElement;
+ el.dataset.err = (el.dataset.err || '') + ' ' + m;
+};
+window.addEventListener('error', (e) => note(e.message));
+window.addEventListener('unhandledrejection',
+ (e) => note((e.reason && e.reason.message) || e.reason));
+const MODE = location.hash.slice(1);
+const cfg = {
+ account: 'bob', groupId: 'g1', groupName: '__GROUP__', owner: '__OWNER__',
+ folder: '__FOLDER__', albums: ['cam'], includeExisting: true, since: 0,
+};
+window.meshbay = {
+ hubBase: () => 'https://hub.invalid',
+ photoSync: {
+ status: async () => ({
+ permission: MODE === 'partial' ? 'partial' : 'granted', unmetered: true, sent: 1240,
+ config: MODE === 'configured' ? cfg : null, lastCompleted: Date.now(),
+ failure: MODE === 'configured' ? 'disk_full' : null,
+ }),
+ albums: async () => [
+ { id: 'cam', name: 'Camera', count: 1240, bytes: 4.1e9, camera: true },
+ { id: 'x', name: 'Screenshots_and_saved_images_from_a_messaging_application_long',
+ count: 12, bytes: 3e6, camera: false },
+ ],
+ permit: async () => ({}), configure: async () => ({}),
+ estimate: async () => ({ count: 1, bytes: 1 }), plan: async () => ({ items: [] }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ keepAlive: async () => true,
+ },
+};
+</script>
+<main class="main"><div id=root></div></main>
+<script type=module>
+import { html, render } from './vendor/htm-preact.js';
+import { initLocale } from './i18n.js';
+import { startPhotoSync } from './photo-sync.js';
+import { PhotoSyncSection } from './photo-sync-settings.js';
+await initLocale();
+startPhotoSync({ hub: '', getUser: () => ({ username: 'bob', token: 't' }),
+ getBundleKey: async () => '' });
+const groups = [{ id: 'g1', name: '__GROUP__', owner_username: '__OWNER__' }];
+render(html`<${PhotoSyncSection} user=${{ username: 'bob', token: 't' }} groups=${groups} />`,
+ document.getElementById('root'));
+setTimeout(() => {
+ if (MODE === 'configured') return;
+ [...document.querySelectorAll('button')].find((b) => b.textContent.includes('Set up')).click();
+ setTimeout(() => {
+ const sel = document.querySelector('select');
+ sel.value = 'g1';
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
+ }, 300);
+}, 500);
+</script></body></html>
+""".replace("__GROUP__", LONG_GROUP).replace("__OWNER__", LONG_OWNER) \
+ .replace("__FOLDER__", LONG_FOLDER)
+
+OUTER = """<!doctype html><html><body style="margin:0">
+<script>
+for (const m of ['setup', 'configured', 'partial']) for (const w of [360, 420]) {
+ const f = document.createElement('iframe');
+ f.id = m + w;
+ f.style.cssText = `width:${w}px;height:900px;border:0;display:block`;
+ f.src = 'probe.html#' + m;
+ document.body.appendChild(f);
+}
+setTimeout(() => {
+ const out = {};
+ for (const f of document.querySelectorAll('iframe')) {
+ const d = f.contentDocument;
+ const vw = f.contentWindow.innerWidth;
+ const wide = [...d.querySelectorAll('#root *')]
+ .filter((e) => e.getBoundingClientRect().right > vw + 0.5)
+ .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, 600),
+ err: d.documentElement.dataset.err || '',
+ checked: [...d.querySelectorAll('#root input:checked')]
+ .map((i) => i.closest('label').innerText.split('\\n')[0]),
+ };
+ }
+ const pre = document.createElement('pre');
+ pre.id = 'out';
+ pre.textContent = JSON.stringify(out);
+ document.body.appendChild(pre);
+}, 4000);
+</script></body></html>
+"""
+
+
+class _Quiet(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *args) -> None:
+ pass
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp) / "static"
+ shutil.copytree(STATIC, root)
+ (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8")
+ (root / "probe.html").write_text(PROBE, encoding="utf-8")
+ (root / "outer.html").write_text(OUTER, encoding="utf-8")
+ handler = functools.partial(_Quiet, directory=str(root))
+ with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd:
+ port = httpd.server_address[1]
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ try:
+ dom = subprocess.run(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-first-run",
+ f"--user-data-dir={tmp}/profile", "--virtual-time-budget=8000",
+ "--dump-dom", f"http://127.0.0.1:{port}/outer.html"],
+ capture_output=True, text=True, timeout=120).stdout
+ finally:
+ httpd.shutdown()
+ start = dom.find('<pre id="out">')
+ if start < 0:
+ print(json.dumps({"error": "no result in the page"}))
+ return 1
+ body = dom[start + len('<pre id="out">'):dom.index("</pre>", start)]
+ for entity, char in (("&quot;", '"'), ("&lt;", "<"), ("&gt;", ">"), ("&amp;", "&")):
+ body = body.replace(entity, char)
+ print(json.dumps(json.loads(body)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())