aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
commita0793dd37030f7213ecb07a018b11e6a8adf9238 (patch)
tree5de1046eae9b3747ac97a81652590fb6af99622b /packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
parent044c585f73cfd548e300859e89eaf5dcb40d3d3c (diff)
downloadmeshbay-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.js249
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();
+ }
+}