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/src/meshbay_hub/static/photo-sync.js | |
| 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/src/meshbay_hub/static/photo-sync.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js | 316 |
1 files changed, 316 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js new file mode 100644 index 0000000..154a009 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js @@ -0,0 +1,316 @@ +// Photo backup: this phone's photos sent to one folder of one group, once a +// day, on an unmetered network (docs/MESHBAY_DESIGN.md §9.12). +// +// The phone (platform.photoSync) lists the photos, keeps the ledger of what was +// sent and hands each photo's bytes over; this module decides when a run is +// due and does the sending, because the transport and the group key are here. +// Loaded only where the bridge offers it — the Android application — so no +// browser and no desktop pays for it. +// +// Additive by construction: nothing here deletes, renames or replaces anything +// on the node. `test_photo_sync.py` reads this file for the operations that +// would, and refuses them. + +import { ConnectionPool } from './connection-pool.js'; +import * as platform from './platform.js'; +import { t } from './i18n.js'; + +const DAY_MS = 24 * 3600 * 1000; +// A run cut short by something passing — the node offline, the network gone — +// is tried again at the next chance, but not more often than this: every +// return to the foreground would otherwise be a connection attempt. +const RETRY_MS = 15 * 60 * 1000; +// A slot the node keeps queued this long means it is busy with others; the +// backup can wait for tomorrow's run, they cannot. +const LEASE_WAIT_MS = 10 * 60 * 1000; + +// Refusals that will hold at the next run too. Said once (a notification, from +// the phone) and retried a day later, never at every opening. +const LASTING = { + disk_full: 'photosync.fail_disk_full', + root_read_only: 'photosync.fail_read_only', + no_writable_root: 'photosync.fail_read_only', + no_such_root: 'photosync.fail_no_folder', + no_such_directory: 'photosync.fail_no_folder', + root_unavailable: 'photosync.fail_unavailable', + not_a_member: 'photosync.fail_not_member', + permission: 'photosync.fail_permission', +}; + +// ── What the settings section shows ───────────────────────────────────────── + +let state = { phase: 'idle', done: 0, total: 0 }; +const listeners = new Set(); + +function setState(patch) { + state = { ...state, ...patch }; + for (const fn of [...listeners]) { + try { fn(state); } catch (e) { console.error('[MeshBay] photo backup listener threw:', e); } + } +} + +export function subscribe(fn) { + listeners.add(fn); + fn(state); + return () => listeners.delete(fn); +} + +// ── When it runs ──────────────────────────────────────────────────────────── + +let ctx = null; // { hub, getUser, getBundleKey } +let running = null; +let timer = 0; +let lastAttempt = 0; +let unmeteredNow = null; + +/** True when `status` says a scheduled run should go now. */ +export function isDue(status, now = Date.now()) { + if (!status || !status.config) return false; + // A lasting refusal waits a day, like a finished run; anything else is + // decided by the last run that finished. + if (status.failure && status.failureAt && now - status.failureAt < DAY_MS) return false; + const last = status.lastCompleted; + return last == null || now - last >= DAY_MS || now < last; +} + +/** + * Checked at start, on coming back to the foreground, when the network turns + * unmetered, and once the next run falls due. Nothing polls. + */ +export function startPhotoSync(context) { + if (!platform.photoSync.available || ctx) return; + ctx = context; + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('meshbay-network', onNetwork); + runPhotoSync(); +} + +export function stopPhotoSync() { + if (!ctx) return; + ctx = null; + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('meshbay-network', onNetwork); +} + +function onVisible() { + if (document.visibilityState === 'visible') runPhotoSync(); +} + +function onNetwork(e) { + unmeteredNow = !!(e.detail && e.detail.unmetered); + if (unmeteredNow) runPhotoSync(); +} + +function scheduleNext(status) { + clearTimeout(timer); + if (!ctx || !status || !status.config) return; + const base = Math.max(status.lastCompleted || 0, status.failure ? (status.failureAt || 0) : 0); + const wait = base + DAY_MS - Date.now(); + // A page left open past midnight runs on time; the cap keeps a timer from + // being parked for days on a clock that may move. + if (wait > 0) timer = setTimeout(runPhotoSync, Math.min(wait, DAY_MS)); +} + +/** + * A run, if one is due. `force` is "Back up now": due or not. `metered` is the + * person's yes to sending over mobile data, asked by the caller. + */ +export function runPhotoSync({ force = false, metered = false } = {}) { + if (!ctx) return Promise.resolve(); + if (running) return running; + running = (async () => { + let status = null; + try { + status = await platform.photoSync.status(); + await run(status, force, metered); + status = await platform.photoSync.status(); + } catch (e) { + console.warn('[MeshBay] photo backup:', e.message); + setState({ phase: 'interrupted', detail: e.message }); + } finally { + running = null; + scheduleNext(status); + } + })(); + return running; +} + +async function run(status, force, metered) { + const cfg = status.config; + const user = ctx && ctx.getUser(); + if (!cfg) { setState({ phase: 'off' }); return; } + if (!user || cfg.account !== user.username) { setState({ phase: 'other_account' }); return; } + if (status.permission === 'denied') { await lasting('permission'); return; } + if (!force && !isDue(status)) { + setState({ phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' }); + return; + } + unmeteredNow = status.unmetered; + if (!status.unmetered && !metered) { setState({ phase: 'waiting_wifi' }); return; } + if (!force && Date.now() - lastAttempt < RETRY_MS) return; + lastAttempt = Date.now(); + + const { items } = await platform.photoSync.plan(); + if (!items.length) { + await platform.photoSync.completed(); + setState({ phase: 'up_to_date', done: 0, total: 0 }); + return; + } + + setState({ phase: 'connecting', done: 0, total: items.length }); + const pool = new ConnectionPool(ctx.hub); + let conn; + try { + const bundleKey = await ctx.getBundleKey(); + conn = await pool.connect(cfg.groupId, user.token, bundleKey, user.username, user.userId); + } catch (e) { + pool.closeAll(); + if (e.reason === 'not_a_member') { await lasting('not_a_member'); return; } + setState({ phase: 'offline' }); + return; + } + + try { + await send(conn.transport, cfg, items, metered); + } finally { + platform.photoSync.keepAlive(false).catch(() => {}); + pool.closeAll(); + } +} + +async function send(transport, cfg, items, metered) { + const index = await transport.fetchIndex(); + const problem = destinationProblem(cfg.folder, index); + if (problem) { await lasting(problem); return; } + + // What is already in each folder, by name and size: after a reinstall the + // ledger is empty, and a photo already there is recorded, not sent again. + const present = new Map(); + for (const e of index.entries || []) { + if (!present.has(e.path)) present.set(e.path, new Map()); + present.get(e.path).set(e.name, e.size); + } + const dirs = new Set(index.dirs || []); + for (const r of index.roots || []) dirs.add(r.name); + + let done = 0; + const progress = () => { + setState({ phase: 'sending', done, total: items.length }); + return t('photosync.notif_progress', { done, total: items.length }); + }; + await platform.photoSync.keepAlive(true, progress()); + + for (const item of items) { + // Off Wi-Fi mid-run: stop after the file in flight. The node keeps the + // partial upload, and the next unmetered moment carries on. + if (!metered && unmeteredNow === false) { setState({ phase: 'waiting_wifi' }); return; } + if (!ctx) return; + + const there = present.get(item.dir); + const known = there && [item.name, item.alsoKnownAs] + .find((n) => n && there.get(n) === item.size); + if (known) { + await platform.photoSync.sent(item.token, item.dir, known); + } else { + try { + await makeDirs(transport, cfg.folder, item.dir, dirs); + const ack = await upload(transport, item); + await platform.photoSync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name); + } catch (e) { + if (e.code && LASTING[e.code]) { await lasting(e.code); return; } + throw e; + } + } + done += 1; + await platform.photoSync.keepAlive(true, progress()); + } + await platform.photoSync.completed(); + setState({ phase: 'up_to_date', done, total: items.length }); +} + +/** One photo, under a slot the node granted — the backup queues like anyone. */ +async function upload(transport, item) { + const lease = transport.openTransfer({ kind: 'upload', bytes: item.size }); + try { + let timeout; + await Promise.race([ + lease.acquire(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('The node is busy')), LEASE_WAIT_MS); + }), + ]).finally(() => clearTimeout(timeout)); + const file = await platform.photoSync.file(item.token, item.name); + return await transport.uploadFile(file, { + dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr, + }); + } finally { + lease.release(); + } +} + +/** `<folder>/YYYY/MM`, one level at a time; a level that exists is fine. */ +async function makeDirs(transport, folder, dir, dirs) { + let at = folder; + for (const name of dir.slice(folder.length + 1).split('/').filter(Boolean)) { + const next = `${at}/${name}`; + if (!dirs.has(next)) { + try { + await transport.createDirectory(at, name); + } catch (e) { + // Somebody else's backup made it a moment ago: what was wanted. + if (!/already exists/i.test(e.message || '')) throw e; + } + dirs.add(next); + } + at = next; + } +} + +/** A reason the chosen folder cannot take photos now, or null. */ +export function destinationProblem(folder, index) { + const root = (index.roots || []).find((r) => r.name === folder.split('/')[0]); + if (!root) return 'no_such_root'; + if (!root.writable) return 'root_read_only'; + if (root.available === false) return 'root_unavailable'; + if (folder !== root.name && !(index.dirs || []).includes(folder)) return 'no_such_directory'; + return null; +} + +async function lasting(code) { + setState({ phase: 'failed', reason: code }); + await platform.photoSync.failed(code, t(LASTING[code] || 'photosync.fail_other')).catch(() => {}); +} + +// ── For the settings section ──────────────────────────────────────────────── + +/** + * The folders of a group this member can back up into: writable, available, + * and shown by the group's Photos tab — a folder outside those would take the + * photos and show them nowhere, which reads as a backup that failed. + */ +export function backupFolders(ack, index) { + const photoDirs = (ack && ack.photo_directories) || []; + const roots = new Map((index.roots || []) + .filter((r) => r.writable && r.available !== false).map((r) => [r.name, r])); + const under = (d) => photoDirs.some((p) => d === p || d.startsWith(`${p}/`)); + const all = new Set([...(index.dirs || []), ...roots.keys()]); + return [...all] + .filter((d) => roots.has(d.split('/')[0]) && under(d)) + .sort((a, b) => a.localeCompare(b)); +} + +/** A one-off look at a group, for choosing a folder: connected, read, closed. */ +export async function probeGroup(groupId) { + const user = ctx && ctx.getUser(); + if (!user) throw new Error('Not signed in'); + const pool = new ConnectionPool(ctx.hub); + try { + const bundleKey = await ctx.getBundleKey(); + const conn = await pool.connect(groupId, user.token, bundleKey, user.username, user.userId); + const index = await conn.transport.fetchIndex(); + return { folders: backupFolders(conn.ack, index) }; + } finally { + pool.closeAll(); + } +} |