// 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 (`-`, 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(); } }