aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
diff options
context:
space:
mode:
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.js208
1 files changed, 208 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..dcfb6f9
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
@@ -0,0 +1,208 @@
+// Contacts, calendar and messages backup: a copy of this phone's address book
+// and calendars, and the text messages added since the last copy, sent to
+// `<folder>/<account>-contacts`, `-calendar` and `-messages` of the destination
+// every backup shares (sync-destination.js), once a day when there is
+// something new (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';
+import { setSyncContext, checkDestination } from './sync-destination.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' },
+ calendar: { sync: platform.calendarSync, heading: 'calendarsync.heading', suffix: 'calendar' },
+ messages: { sync: platform.messageSync, heading: 'messagesync.heading', suffix: 'messages' },
+};
+
+// 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.
+export 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.on || !status.destination) 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;
+ setSyncContext(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.on || !status.destination) 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.destination;
+ const user = ctx && ctx.getUser();
+ if (!status.on || !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();
+
+ // Before anything is read: still this account's own group, and nobody else's.
+ setState(kind, { phase: 'connecting' });
+ const refused = await checkDestination(cfg, user);
+ if (refused === 'offline') { setState(kind, { phase: 'offline' }); return; }
+ if (refused) { await lasting(kind, sync, refused); 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(() => {});
+}