From 25152ddb61a89ea3ea29d3aef5de13343429d32a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 9 Oct 2026 18:23:52 +0200 Subject: feat: back up the phone's photos to a group, once a day on Wi-Fi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /YYYY/MM; edits are sent beside the original as -edited-. - 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 --- .../meshbay-hub/tests/harness/photo_sync_probe.py | 182 +++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/meshbay-hub/tests/harness/photo_sync_probe.py (limited to 'packages/meshbay-hub/tests/harness/photo_sync_probe.py') 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 +""" +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 = """ + + + +
+ +""".replace("__GROUP__", LONG_GROUP).replace("__OWNER__", LONG_OWNER) \ + .replace("__FOLDER__", LONG_FOLDER) + +OUTER = """ + +""" + + +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('
')
+    if start < 0:
+        print(json.dumps({"error": "no result in the page"}))
+        return 1
+    body = dom[start + len('
'):dom.index("
", start)] + for entity, char in ((""", '"'), ("<", "<"), (">", ">"), ("&", "&")): + body = body.replace(entity, char) + print(json.dumps(json.loads(body))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3