From 1c97ffb91bb4928022e995709d7e55b19cfd18eb Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 15:50:39 +0200 Subject: feat(android): back up the files of folders the person chooses A Files section takes folders through the system picker (no storage permission), refuses DCIM, Pictures and Movies, skips what the photo backup sends, and runs on the photo backup's own runner into /-drive. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/photo-sync.js | 386 +++++++++++---------- 1 file changed, 208 insertions(+), 178 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js index ffb07aa..1439c20 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js @@ -40,205 +40,239 @@ const LASTING = { 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, hubFetch, 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 || !status.destination) 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; -} +// ── A runner, for photos here and for chosen folders (drive-sync.js) ──────── /** - * Checked at start, on coming back to the foreground, when the network turns - * unmetered, and once the next run falls due. Nothing polls. + * One backup's scheduling and sending: what is due, when, on which network, + * a file at a time under a slot, the ledger kept by the phone (`sync`, its + * bridge object). `configured(status)` says whether the person turned it on; + * `progress` is the notification line's key; `failures` holds the refusals + * that will hold tomorrow too, and `say(code)` the words for one. */ -export function startPhotoSync(context) { - if (!platform.photoSync.available || ctx) return; - ctx = context; - setSyncContext(context); - document.addEventListener('visibilitychange', onVisible); - window.addEventListener('meshbay-network', onNetwork); - runPhotoSync(); -} +export function createBackup({ + name, sync, configured, progress, failures, say, +}) { + let state = { phase: 'idle', done: 0, total: 0 }; + const listeners = new Set(); + let ctx = null; // { hub, hubFetch, getUser, getBundleKey } + let running = null; + let timer = 0; + let lastAttempt = 0; + let unmeteredNow = null; -export function stopPhotoSync() { - if (!ctx) return; - ctx = null; - clearTimeout(timer); - document.removeEventListener('visibilitychange', onVisible); - window.removeEventListener('meshbay-network', onNetwork); -} + // ── What the settings section shows ─────────────────────────────────────── -function onVisible() { - if (document.visibilityState === 'visible') runPhotoSync(); -} + function setState(patch) { + state = { ...state, ...patch }; + for (const fn of [...listeners]) { + try { fn(state); } catch (e) { console.error(`[MeshBay] ${name} backup listener threw:`, e); } + } + } -function onNetwork(e) { - unmeteredNow = !!(e.detail && e.detail.unmetered); - if (unmeteredNow) runPhotoSync(); -} + function subscribe(fn) { + listeners.add(fn); + fn(state); + return () => listeners.delete(fn); + } -function scheduleNext(status) { - clearTimeout(timer); - if (!ctx || !status || !status.config || !status.destination) 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)); -} + // ── When it runs ────────────────────────────────────────────────────────── -/** - * 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; -} + /** True when `status` says a scheduled run should go now. */ + function isDue(status, now = Date.now()) { + if (!status || !configured(status) || !status.destination) 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; + } -async function run(status, force, metered) { - const dest = status.destination; - const user = ctx && ctx.getUser(); - if (!status.config || !dest) { setState({ phase: 'off' }); return; } - if (!user || dest.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; + /** + * Checked at start, on coming back to the foreground, when the network turns + * unmetered, and once the next run falls due. Nothing polls. + */ + function start(context) { + if (!sync.available || ctx) return; + ctx = context; + setSyncContext(context); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('meshbay-network', onNetwork); + runNow(); } - unmeteredNow = status.unmetered; - if (!status.unmetered && !metered) { setState({ phase: 'waiting_wifi' }); return; } - if (!force && Date.now() - lastAttempt < RETRY_MS) return; - lastAttempt = Date.now(); - - // Before anything is read: still this account's own group, and nobody else's. - setState({ phase: 'connecting', done: 0, total: 0 }); - const refused = await checkDestination(dest, user); - if (refused === 'offline') { setState({ phase: 'offline' }); return; } - if (refused) { await lasting(refused); return; } - - const { items } = await platform.photoSync.plan(); - if (!items.length) { - await platform.photoSync.completed(); - setState({ phase: 'up_to_date', done: 0, total: 0 }); - return; + + function stop() { + if (!ctx) return; + ctx = null; + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('meshbay-network', onNetwork); } - 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(dest.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; + function onVisible() { + if (document.visibilityState === 'visible') runNow(); } - try { - await send(conn.transport, dest, items, metered); - } finally { - platform.photoSync.keepAlive(false).catch(() => {}); - pool.closeAll(); + function onNetwork(e) { + unmeteredNow = !!(e.detail && e.detail.unmetered); + if (unmeteredNow) runNow(); } -} -async function send(transport, dest, items, metered) { - const index = await transport.fetchIndex(); - const problem = destinationProblem(dest.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); + function scheduleNext(status) { + clearTimeout(timer); + if (!ctx || !status || !configured(status) || !status.destination) 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(runNow, Math.min(wait, DAY_MS)); } - 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 { + /** + * 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. + */ + function runNow({ force = false, metered = false } = {}) { + if (!ctx) return Promise.resolve(); + if (running) return running; + running = (async () => { + let status = null; try { - await makeDirs(transport, dest.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); + status = await sync.status(); + await run(status, force, metered); + status = await sync.status(); } catch (e) { - if (e.code && LASTING[e.code]) { await lasting(e.code); return; } - throw e; + console.warn(`[MeshBay] ${name} backup:`, e.message); + setState({ phase: 'interrupted', detail: e.message }); + } finally { + running = null; + scheduleNext(status); } + })(); + return running; + } + + async function run(status, force, metered) { + const dest = status.destination; + const user = ctx && ctx.getUser(); + if (!configured(status) || !dest) { setState({ phase: 'off' }); return; } + if (!user || dest.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(); + + // Before anything is read: still this account's own group, and nobody else's. + setState({ phase: 'connecting', done: 0, total: 0 }); + const refused = await checkDestination(dest, user); + if (refused === 'offline') { setState({ phase: 'offline' }); return; } + if (refused) { await lasting(refused); return; } + + const { items } = await sync.plan(); + if (!items.length) { + await sync.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(dest.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, dest, items, metered); + } finally { + sync.keepAlive(false).catch(() => {}); + pool.closeAll(); } - done += 1; - await platform.photoSync.keepAlive(true, progress()); } - await platform.photoSync.completed(); - setState({ phase: 'up_to_date', done, total: items.length }); + + async function send(transport, dest, items, metered) { + const index = await transport.fetchIndex(); + const problem = destinationProblem(dest.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 report = () => { + setState({ phase: 'sending', done, total: items.length }); + return t(progress, { done, total: items.length }); + }; + await sync.keepAlive(true, report()); + + 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 sync.sent(item.token, item.dir, known); + } else { + try { + await makeDirs(transport, dest.folder, item.dir, dirs); + const ack = await upload(transport, item, sync.file); + await sync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name); + } catch (e) { + if (e.code && failures[e.code]) { await lasting(e.code); return; } + throw e; + } + } + done += 1; + await sync.keepAlive(true, report()); + } + await sync.completed(); + setState({ phase: 'up_to_date', done, total: items.length }); + } + + async function lasting(code) { + setState({ phase: 'failed', reason: code }); + await sync.failed(code, say(code)).catch(() => {}); + } + + return { start, stop, run: runNow, subscribe, isDue }; } +const photos = createBackup({ + name: 'photo', + sync: platform.photoSync, + configured: (status) => Boolean(status.config), + progress: 'photosync.notif_progress', + failures: LASTING, + say: (code) => t(LASTING[code] || 'photosync.fail_other'), +}); + +export const startPhotoSync = (context) => photos.start(context); +export const stopPhotoSync = () => photos.stop(); +export const runPhotoSync = (opts) => photos.run(opts); +export const subscribe = (fn) => photos.subscribe(fn); +export const isDue = (status, now) => photos.isDue(status, now); + /** * One photo, under a slot the node granted: the backup queues like anyone. * `file` is how its bytes come from the phone; contacts and messages pass theirs. @@ -290,7 +324,3 @@ export function destinationProblem(folder, index) { return null; } -async function lasting(code) { - setState({ phase: 'failed', reason: code }); - await platform.photoSync.failed(code, t(LASTING[code] || 'photosync.fail_other')).catch(() => {}); -} -- cgit v1.2.3