diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 12:56:26 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 12:56:26 +0200 |
| commit | a0793dd37030f7213ecb07a018b11e6a8adf9238 (patch) | |
| tree | 5de1046eae9b3747ac97a81652590fb6af99622b /packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js | |
| parent | 044c585f73cfd548e300859e89eaf5dcb40d3d3c (diff) | |
| download | meshbay-a0793dd37030f7213ecb07a018b11e6a8adf9238.tar.gz | |
feat(android): back the phone's contacts up to a group of one's own
A Contacts backup section on the Android Sync page sends a dated .vcf into
<folder>/<account>-contacts once a day when the address book changed, only
to a group the account is alone in, checked again at every run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js new file mode 100644 index 0000000..964f76c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js @@ -0,0 +1,249 @@ +// Contacts backup: a copy of this phone's address book sent to a folder of a +// group only its owner belongs to, once a day when it changed +// (docs/MESHBAY_DESIGN.md §9.13). +// +// The same split as photos (photo-sync.js): the phone writes what is new into +// one file and remembers what the node took; this module decides when a run +// is due and does the sending. Loaded only where the bridge offers it, the +// Android application. +// +// Additive by construction, like photos: every copy is a new dated file, and +// nothing here deletes, renames or replaces anything on the node. +// `test_phone_sync.py` reads this file for the operations that would. + +import { ConnectionPool } from './connection-pool.js'; +import * as platform from './platform.js'; +import { t } from './i18n.js'; +import { destinationProblem, makeDirs, upload } from './photo-sync.js'; + +const DAY_MS = 24 * 3600 * 1000; +// A run cut short by something passing is tried again at the next chance, +// but not more often than this. +const RETRY_MS = 15 * 60 * 1000; + +/** + * Each kind of data: the bridge object that reads it, its heading, and the + * folder it goes into under the chosen one (`<account>-<suffix>`, as the + * phone names it in DocPlan.kt). + */ +export const KINDS = { + contacts: { sync: platform.contactSync, heading: 'contactsync.heading', suffix: 'contacts' }, +}; + +// 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: 'phonesync.fail_disk_full', + root_read_only: 'phonesync.fail_read_only', + no_writable_root: 'phonesync.fail_read_only', + no_such_root: 'phonesync.fail_no_folder', + no_such_directory: 'phonesync.fail_no_folder', + root_unavailable: 'phonesync.fail_unavailable', + not_a_member: 'phonesync.fail_not_member', + not_private: 'phonesync.fail_not_private', + permission: 'phonesync.fail_permission', +}; + +export function failureText(code) { + return t(LASTING[code] || 'phonesync.fail_other'); +} + +// ── What the sections show ────────────────────────────────────────────────── + +const states = {}; +const listeners = {}; + +function setState(kind, patch) { + states[kind] = { ...(states[kind] || { phase: 'idle' }), ...patch }; + for (const fn of [...(listeners[kind] || [])]) { + try { fn(states[kind]); } catch (e) { console.error('[MeshBay] backup listener threw:', e); } + } +} + +export function subscribe(kind, fn) { + (listeners[kind] = listeners[kind] || new Set()).add(fn); + fn(states[kind] || { phase: 'idle' }); + return () => listeners[kind].delete(fn); +} + +// ── When it runs ──────────────────────────────────────────────────────────── + +let ctx = null; // { hub, hubFetch, getUser, getBundleKey } +const running = {}; +const timers = {}; +const lastAttempt = {}; + +function available() { + return Object.keys(KINDS).filter((k) => KINDS[k].sync.available); +} + +/** True when `status` says a scheduled run should go now. */ +export function isDue(status, now = Date.now()) { + if (!status || !status.config) return false; + 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, and once the next run falls due. */ +export function startPhoneSync(context) { + if (ctx || !available().length) return; + ctx = context; + document.addEventListener('visibilitychange', onVisible); + for (const kind of available()) runDocSync(kind); +} + +export function stopPhoneSync() { + if (!ctx) return; + ctx = null; + for (const kind of Object.keys(timers)) clearTimeout(timers[kind]); + document.removeEventListener('visibilitychange', onVisible); +} + +function onVisible() { + if (document.visibilityState !== 'visible') return; + for (const kind of available()) runDocSync(kind); +} + +function scheduleNext(kind, status) { + clearTimeout(timers[kind]); + 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(); + if (wait > 0) timers[kind] = setTimeout(() => runDocSync(kind), Math.min(wait, DAY_MS)); +} + +/** A run of one kind, if one is due; `force` is "Back up now". */ +export function runDocSync(kind, { force = false } = {}) { + if (!ctx || !KINDS[kind]) return Promise.resolve(); + if (running[kind]) return running[kind]; + const sync = KINDS[kind].sync; + running[kind] = (async () => { + let status = null; + try { + status = await sync.status(); + await run(kind, sync, status, force); + status = await sync.status(); + } catch (e) { + console.warn(`[MeshBay] ${kind} backup:`, e.message); + setState(kind, { phase: 'interrupted', detail: e.message }); + } finally { + running[kind] = null; + scheduleNext(kind, status); + } + })(); + return running[kind]; +} + +async function run(kind, sync, status, force) { + const cfg = status.config; + const user = ctx && ctx.getUser(); + if (!cfg) { setState(kind, { phase: 'off' }); return; } + if (!user || cfg.account !== user.username) { setState(kind, { phase: 'other_account' }); return; } + if (status.permission !== 'granted') { await lasting(kind, sync, 'permission'); return; } + if (!force && !isDue(status)) { + setState(kind, { phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' }); + return; + } + if (!force && Date.now() - (lastAttempt[kind] || 0) < RETRY_MS) return; + lastAttempt[kind] = Date.now(); + + // Asked of the hub at every run, not only at set-up: somebody who joins the + // group later would otherwise read every copy from then on. + setState(kind, { phase: 'connecting' }); + let alone; + try { + alone = await isPrivate(cfg.groupId, user); + } catch (e) { + if (/not a member|not found/i.test(e.message || '')) { await lasting(kind, sync, 'not_a_member'); return; } + setState(kind, { phase: 'offline' }); + return; + } + if (!alone) { await lasting(kind, sync, 'not_private'); return; } + + const { item } = await sync.plan(); + if (!item) { + await sync.completed(); + setState(kind, { phase: 'up_to_date' }); + return; + } + + 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(kind, sync, 'not_a_member'); return; } + setState(kind, { phase: 'offline' }); + return; + } + + try { + const index = await conn.transport.fetchIndex(); + const problem = destinationProblem(cfg.folder, index); + if (problem) { await lasting(kind, sync, problem); return; } + const dirs = new Set(index.dirs || []); + for (const r of index.roots || []) dirs.add(r.name); + setState(kind, { phase: 'sending', count: item.count }); + try { + await makeDirs(conn.transport, cfg.folder, item.dir, dirs); + const ack = await upload(conn.transport, item, (token, name) => sync.file(token, name)); + await sync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name); + } catch (e) { + if (e.code && LASTING[e.code]) { await lasting(kind, sync, e.code); return; } + throw e; + } + await sync.completed(); + setState(kind, { phase: 'up_to_date' }); + } finally { + pool.closeAll(); + } +} + +async function lasting(kind, sync, code) { + setState(kind, { phase: 'failed', reason: code }); + const text = t('phonesync.notice', { what: t(KINDS[kind].heading), reason: failureText(code) }); + await sync.failed(code, text).catch(() => {}); +} + +// ── For the sections ──────────────────────────────────────────────────────── + +/** True when this account is the group's one member and nobody is invited. */ +export async function isPrivate(groupId, user) { + const r = await ctx.hubFetch(`/v1/groups/${groupId}/members`, { token: user.token }); + const members = r.members || []; + return members.length === 1 && members[0].username === user.username + && !(r.invited || []).length; +} + +/** The groups among `groups` this account is alone in; a failed look leaves one out. */ +export async function privateGroups(groups) { + const user = ctx && ctx.getUser(); + if (!user) return []; + const alone = await Promise.all(groups.map((g) => isPrivate(g.id, user).catch(() => false))); + return groups.filter((_, i) => alone[i]); +} + +/** Every folder a member can add to: on a writable root whose drive is there. */ +export function writableFolders(index) { + const roots = new Set((index.roots || []) + .filter((r) => r.writable && r.available !== false).map((r) => r.name)); + const all = new Set([...(index.dirs || []), ...roots]); + return [...all].filter((d) => roots.has(d.split('/')[0])).sort((a, b) => a.localeCompare(b)); +} + +/** A one-off look at a group, for choosing a folder: connected, read, closed. */ +export async function probeWritable(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); + return { folders: writableFolders(await conn.transport.fetchIndex()) }; + } finally { + pool.closeAll(); + } +} |