diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-09 18:23:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-09 18:23:56 +0200 |
| commit | 25152ddb61a89ea3ea29d3aef5de13343429d32a (patch) | |
| tree | ebf8f11f66146caaa3c01270cd6cfcb0ee51723c /packages/meshbay-hub/tests/harness/photo_sync_probe.py | |
| parent | c85c7f48e8f29923038d4c90cc1a6f9b8bcd7673 (diff) | |
| download | meshbay-25152ddb61a89ea3ea29d3aef5de13343429d32a.tar.gz | |
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.py | 182 |
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 ((""", '"'), ("<", "<"), (">", ">"), ("&", "&")): + body = body.replace(entity, char) + print(json.dumps(json.loads(body))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |