From 77e7b974a73e1405cbf2ace9b7472940af8b27d5 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 09:24:26 -0500 Subject: [PATCH 1/2] feat(mobile): sync in the background, after leaving and every 15 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Cloud → Sync in the background, on by default and per device. Leaving the app used to stop every Cloud request at once (the request lifetime in cloud-sync-client.ts), so an edit made just before switching away waited on the phone until the app was opened again. Now, with the setting on and the vault linked: - Leaving finishes a sync before the requests stop, and queues a catch-up WorkManager run (in 30 seconds, dropped once that sync ends) in case Android freezes the cached app midway. - A WorkManager job runs every 15 minutes with the network up and, while Android still has ZenNotes in memory, asks the WebView for a sync (BackgroundSyncPlugin's `sync` event, answered with `finished`). With the activity gone it does nothing, and the next launch syncs. The job is scheduled only while the setting is on and the vault is linked, so a phone without Cloud is never woken for it. With the setting off, leaving stops the requests at once, as before; signing out still does either way. The sync engine runs in the WebView, which exists only while MainActivity does, so the job cannot sync from a cold start; a native engine would mean two more copies of the sync and encryption code. The leaving half is the iPhone app's, verified there on a device; the job is the one place the shells differ, documented in the README. WorkManager 2.12.0 adds WAKE_LOCK, ACCESS_NETWORK_STATE and RECEIVE_BOOT_COMPLETED to the merged manifest. Its FOREGROUND_SERVICE is removed (tools:node="remove"): ZenNotes starts no foreground work. The setting (zn:background-sync, absent = on) rides the native prefs mirror in bootstrap.ts. Checked: npm run upstream, npm test (290, 12 new), testDebugUnitTest, lintDebug (0 errors, no new warnings) and assembleDebug. --- README.md | 12 + android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 6 +- .../md/zennotes/BackgroundSyncPlugin.java | 126 +++++++++ .../md/zennotes/BackgroundSyncWorker.java | 44 +++ .../main/java/md/zennotes/MainActivity.java | 1 + android/variables.gradle | 1 + src/bootstrap.ts | 8 +- src/bridge/background-sync.test.ts | 259 ++++++++++++++++++ src/bridge/background-sync.ts | 216 +++++++++++++++ src/bridge/mobile-bridge.ts | 23 ++ src/bridge/mobile-cloud-auth.ts | 7 +- src/main.tsx | 5 +- src/ui-mobile/MobileShell.tsx | 90 ++++++ src/viewport.ts | 5 + 15 files changed, 799 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java create mode 100644 android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java create mode 100644 src/bridge/background-sync.test.ts create mode 100644 src/bridge/background-sync.ts diff --git a/README.md b/README.md index aa2dcf2..7ae09d1 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,18 @@ upload flow. `DirectUploadPlugin` streams decoded bytes without loading a second binary copy, works back to minSdk 24, rejects redirects and unsafe URLs, and never receives the Cloud account token. +Settings → Cloud → Sync in the background (on by default, per device) lets a +sync finish after you leave the app and keeps syncing while Android keeps the +app in memory. The sync engine runs in the WebView, which exists only while +`MainActivity` does, so `BackgroundSyncPlugin` schedules a WorkManager job +(every 15 minutes, network required, only while the setting is on and the +vault is linked) that asks the live WebView for a sync and does nothing when +the activity is gone. Leaving also queues a one-off catch-up run, dropped when +the final sync ends, in case Android freezes the app midway. This is the one +place the shells differ: the iPhone app has the leaving half only, since iOS +suspends its WebView within seconds (`src/bridge/background-sync.ts`). With the +setting off, leaving stops Cloud requests at once, as before. + ## Release verification Pull requests and `main` run bridge tests, strict filesystem tests, a diff --git a/android/app/build.gradle b/android/app/build.gradle index 61b1d9d..fdd65cd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -56,6 +56,8 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + // Background sync's periodic job (BackgroundSyncPlugin). + implementation "androidx.work:work-runtime:$androidxWorkVersion" implementation project(':capacitor-android') testImplementation "junit:junit:$junitVersion" // Local unit tests only see android.jar's stubbed org.json; the shared diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fb95aef..7823d69 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + diff --git a/android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java b/android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java new file mode 100644 index 0000000..fe3e7a9 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java @@ -0,0 +1,126 @@ +package md.zennotes; + +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * The native half of background sync (src/bridge/background-sync.ts). The + * sync engine is JavaScript in this activity's WebView, so the jobs scheduled + * here only ask it to run: {@link BackgroundSyncWorker} finds the live plugin, + * sends a {@code sync} event and waits for JS to answer {@code finished}. + * Once the activity is gone there is no WebView to ask, and a job does + * nothing; the next launch syncs. + */ +@CapacitorPlugin(name = "ZenBackgroundSync") +public class BackgroundSyncPlugin extends Plugin { + + static final String PERIODIC_WORK = "zennotes-cloud-sync"; + static final String CATCH_UP_WORK = "zennotes-cloud-sync-catch-up"; + /** An ordinary final sync ends well before this and cancels the catch-up. */ + private static final long CATCH_UP_DELAY_SECONDS = 30; + + private static volatile BackgroundSyncPlugin current; + private final Map pending = new ConcurrentHashMap<>(); + + static BackgroundSyncPlugin current() { + return current; + } + + @Override + public void load() { + current = this; + } + + @Override + protected void handleOnDestroy() { + if (current == this) current = null; + // Nothing will answer a job still waiting: release it now. + for (CountDownLatch answered : pending.values()) answered.countDown(); + pending.clear(); + } + + /** Leaving: queue a catch-up run for a final sync Android freezes midway. */ + @PluginMethod + public void begin(PluginCall call) { + OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(BackgroundSyncWorker.class) + .setConstraints(networkConstraints()) + .setInitialDelay(CATCH_UP_DELAY_SECONDS, TimeUnit.SECONDS) + .build(); + WorkManager.getInstance(getContext()).enqueueUniqueWork(CATCH_UP_WORK, ExistingWorkPolicy.REPLACE, request); + call.resolve(); + } + + /** The final sync ended: the catch-up run is not needed. */ + @PluginMethod + public void end(PluginCall call) { + WorkManager.getInstance(getContext()).cancelUniqueWork(CATCH_UP_WORK); + call.resolve(); + } + + @PluginMethod + public void schedule(PluginCall call) { + // WorkManager refuses shorter repeat intervals. + long minutes = Math.max(PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS / 60_000L, call.getInt("minutes", 15)); + PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(BackgroundSyncWorker.class, minutes, TimeUnit.MINUTES) + .setConstraints(networkConstraints()) + .build(); + WorkManager.getInstance(getContext()).enqueueUniquePeriodicWork( + PERIODIC_WORK, + ExistingPeriodicWorkPolicy.UPDATE, + request + ); + call.resolve(); + } + + @PluginMethod + public void cancel(PluginCall call) { + WorkManager.getInstance(getContext()).cancelUniqueWork(PERIODIC_WORK); + call.resolve(); + } + + /** JS answering a {@code sync} event: the job that sent it can end. */ + @PluginMethod + public void finished(PluginCall call) { + String id = call.getString("id"); + CountDownLatch answered = id == null ? null : pending.remove(id); + if (answered != null) answered.countDown(); + call.resolve(); + } + + /** + * Asks JS for a sync. The latch opens when JS answers, or when the + * activity goes away; null when nothing listens yet (the app is still + * starting), in which case the job ends at once. + */ + CountDownLatch requestSync(String id) { + if (!hasListeners("sync")) return null; + CountDownLatch answered = new CountDownLatch(1); + pending.put(id, answered); + JSObject data = new JSObject(); + data.put("id", id); + notifyListeners("sync", data); + return answered; + } + + void forget(String id) { + pending.remove(id); + } + + private static Constraints networkConstraints() { + return new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(); + } +} diff --git a/android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java b/android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java new file mode 100644 index 0000000..adbb20f --- /dev/null +++ b/android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java @@ -0,0 +1,44 @@ +package md.zennotes; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * The periodic and catch-up job of background sync (BackgroundSyncPlugin). + * It hands the sync to the WebView and waits for the answer. It reports + * success whatever happened: a sync that failed, or could not run because + * the app is not in memory, waits for the next period rather than for + * WorkManager's retry backoff. + */ +public class BackgroundSyncWorker extends Worker { + + /** WorkManager stops a job after ten minutes; give up well before that. */ + private static final long ANSWER_TIMEOUT_MINUTES = 5; + + public BackgroundSyncWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @NonNull + @Override + public Result doWork() { + BackgroundSyncPlugin plugin = BackgroundSyncPlugin.current(); + if (plugin == null) return Result.success(); + String id = UUID.randomUUID().toString(); + CountDownLatch answered = plugin.requestSync(id); + if (answered == null) return Result.success(); + try { + answered.await(ANSWER_TIMEOUT_MINUTES, TimeUnit.MINUTES); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + plugin.forget(id); + } + return Result.success(); + } +} diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index ee357d3..cd3b25e 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -39,6 +39,7 @@ public void onCreate(Bundle savedInstanceState) { registerPlugin(WidgetBridgePlugin.class); registerPlugin(ImagePastePlugin.class); registerPlugin(EdgeSwipePlugin.class); + registerPlugin(BackgroundSyncPlugin.class); super.onCreate(savedInstanceState); // Cold-start share: the launch intent IS the share. Stash it now; the // WebView drains the inbox after the vault opens (importPendingShares). diff --git a/android/variables.gradle b/android/variables.gradle index ee4ba41..f2054a9 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -9,6 +9,7 @@ ext { androidxFragmentVersion = '1.8.9' coreSplashScreenVersion = '1.2.0' androidxWebkitVersion = '1.14.0' + androidxWorkVersion = '2.12.0' junitVersion = '4.13.2' androidxJunitVersion = '1.3.0' androidxEspressoCoreVersion = '3.7.0' diff --git a/src/bootstrap.ts b/src/bootstrap.ts index ece1f11..5d0bc73 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,6 +1,6 @@ import './random-uuid-polyfill' import { Preferences } from '@capacitor/preferences' -import { GESTURES_KEY, HIDE_STATUS_BAR_KEY, LAYOUT_MODE_KEY, START_SCREEN_KEY } from './viewport' +import { BACKGROUND_SYNC_KEY, GESTURES_KEY, HIDE_STATUS_BAR_KEY, LAYOUT_MODE_KEY, START_SCREEN_KEY } from './viewport' const WEB_PREFERENCES_KEY = 'zen:prefs:v2' const NATIVE_PREFERENCES_KEY = 'zn-app-preferences-v2' @@ -14,12 +14,16 @@ const NATIVE_GESTURES_KEY = 'zn-gestures' // And the Start screen choice (start-screen.ts): read at cold launch, so it // must be back in localStorage before main.tsx evaluates. const NATIVE_START_SCREEN_KEY = 'zn-start-screen' +// And Sync in the background (bridge/background-sync.ts), so an evicted +// WebView store does not quietly turn it back on. +const NATIVE_BACKGROUND_SYNC_KEY = 'zn-background-sync' const MIRRORED_KEYS: Record = { [WEB_PREFERENCES_KEY]: NATIVE_PREFERENCES_KEY, [LAYOUT_MODE_KEY]: NATIVE_LAYOUT_MODE_KEY, [HIDE_STATUS_BAR_KEY]: NATIVE_HIDE_STATUS_BAR_KEY, [GESTURES_KEY]: NATIVE_GESTURES_KEY, - [START_SCREEN_KEY]: NATIVE_START_SCREEN_KEY + [START_SCREEN_KEY]: NATIVE_START_SCREEN_KEY, + [BACKGROUND_SYNC_KEY]: NATIVE_BACKGROUND_SYNC_KEY } let persistenceQueue = Promise.resolve() diff --git a/src/bridge/background-sync.test.ts b/src/bridge/background-sync.test.ts new file mode 100644 index 0000000..4c4d6a3 --- /dev/null +++ b/src/bridge/background-sync.test.ts @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const store = new Map() +;(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key) +} + +interface Harness { + api: Record + calls: string[] + vaultListeners: Set<() => void> + /** Sends the native `sync` event, as BackgroundSyncWorker does. */ + job(id: string): void + /** Emits a vault change, as an in-app write or the sync's rescan does. */ + change(): void +} + +async function load(): Promise { + const calls: string[] = [] + const listeners = new Map void>() + const vaultListeners = new Set<() => void>() + const plugin = { + begin: async () => void calls.push('begin'), + end: async () => void calls.push('end'), + schedule: async ({ minutes }: { minutes: number }) => void calls.push(`schedule ${minutes}`), + cancel: async () => void calls.push('cancel'), + finished: async ({ id, ok }: { id: string; ok: boolean }) => void calls.push(`finished ${id} ${ok}`), + addListener: async (event: string, listener: (event: { id: string }) => void) => { + listeners.set(event, listener) + return { remove: async () => {} } + } + } + const api = await loadMobileModule('./src/bridge/background-sync.ts', { + '@capacitor/core': { registerPlugin: () => plugin }, + './cloud-sync-client': { + stopMobileCloudRequests: () => void calls.push('stop'), + resumeMobileCloudRequests: () => void calls.push('resume') + } + }) + return { + api, + calls, + vaultListeners, + job: (id) => listeners.get('sync')?.({ id }), + change: () => { + for (const listener of vaultListeners) listener() + } + } +} + +function host(harness: Harness, options: { ready?: boolean; sync?: () => Promise } = {}) { + return { + ready: async () => { + harness.calls.push('ready') + return options.ready ?? true + }, + sync: options.sync ?? (async () => void harness.calls.push('sync')), + onVaultChange: (listener: () => void) => { + harness.vaultListeners.add(listener) + return () => void harness.vaultListeners.delete(listener) + } + } +} + +/** Installs and lets the first schedule update land, then forgets its calls. */ +async function installed(harness: Harness, options: Parameters[1] = {}): Promise { + harness.api.installBackgroundSync(host(harness, options)) + await until(() => harness.calls.some((call) => call.startsWith('schedule') || call === 'cancel')) + harness.calls.length = 0 +} + +async function until(done: () => boolean): Promise { + for (let tries = 0; !done(); tries += 1) { + assert.ok(tries < 300, 'timed out waiting') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +/** The calls with the schedule update leaving makes taken out. */ +function withoutScheduling(calls: string[]): string[] { + const out = [...calls] + const scheduleAt = out.findIndex((call) => call.startsWith('schedule') || call === 'cancel') + if (scheduleAt >= 0) { + out.splice(scheduleAt, 1) + // Its readiness check is the first 'ready' (refreshSchedule runs first). + out.splice(out.indexOf('ready'), 1) + } + return out +} + +it('is on by default, and turning it on again removes the stored choice', async () => { + store.clear() + const { api } = await load() + assert.equal(api.backgroundSyncEnabled(), true) + api.setBackgroundSyncEnabled(false) + assert.equal(store.get(api.BACKGROUND_SYNC_KEY), 'off') + assert.equal(api.backgroundSyncEnabled(), false) + api.setBackgroundSyncEnabled(true) + assert.equal(store.has(api.BACKGROUND_SYNC_KEY), false) +}) + +it('stops Cloud requests at once on leaving before it is installed, as leaving always did', async () => { + store.clear() + const harness = await load() + harness.api.leaveApp() + harness.api.returnToApp() + assert.deepEqual(harness.calls, ['stop', 'resume']) +}) + +it('schedules the job only while the setting is on and the vault is linked', async () => { + store.clear() + const linked = await load() + linked.api.installBackgroundSync(host(linked)) + await until(() => linked.calls.length >= 2) + assert.deepEqual(linked.calls, ['ready', `schedule ${linked.api.SYNC_EVERY_MINUTES}`]) + + const unlinked = await load() + unlinked.api.installBackgroundSync(host(unlinked, { ready: false })) + await until(() => unlinked.calls.length >= 2) + assert.deepEqual(unlinked.calls, ['ready', 'cancel']) + + store.set('zn:background-sync', 'off') + const off = await load() + off.api.installBackgroundSync(host(off)) + await until(() => off.calls.length >= 1) + assert.deepEqual(off.calls, ['cancel'], 'a phone with the setting off is never woken for it') +}) + +it('cancels and reschedules the job as the setting changes', async () => { + store.clear() + const harness = await load() + await installed(harness) + harness.api.setBackgroundSyncEnabled(false) + await until(() => harness.calls.length >= 1) + assert.deepEqual(harness.calls, ['cancel']) + harness.calls.length = 0 + harness.api.setBackgroundSyncEnabled(true) + await until(() => harness.calls.length >= 2) + assert.deepEqual(harness.calls, ['ready', 'schedule 15']) +}) + +it('stops at once when the setting is off', async () => { + store.clear() + store.set('zn:background-sync', 'off') + const harness = await load() + await installed(harness) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['stop']) +}) + +it('finishes a sync on leaving, queuing and then dropping the catch-up run', async () => { + store.clear() + const harness = await load() + await installed(harness) + harness.api.leaveApp() + assert.equal(harness.calls.includes('stop'), false, 'requests keep running while the final sync is pending') + await harness.api.backgroundSyncSettled() + await until(() => harness.calls.some((call) => call.startsWith('schedule'))) + assert.deepEqual(withoutScheduling(harness.calls), ['ready', 'begin', 'sync', 'end', 'stop']) +}) + +it('runs a second pass when the vault changed during the first, and never a third', async () => { + store.clear() + const harness = await load() + let passes = 0 + await installed(harness, { + sync: async () => { + passes += 1 + harness.change() + } + }) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.equal(passes, 2) +}) + +it('syncs when the job asks while the app is away, then stops and answers', async () => { + store.clear() + const harness = await load() + await installed(harness) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + harness.calls.length = 0 + harness.job('job-1') + await until(() => harness.calls.some((call) => call.startsWith('finished'))) + assert.deepEqual(harness.calls, ['resume', 'ready', 'sync', 'stop', 'finished job-1 true']) +}) + +it('leaves requests open when the job asks while the app is on screen', async () => { + store.clear() + const harness = await load() + await installed(harness) + harness.job('job-2') + await until(() => harness.calls.some((call) => call.startsWith('finished'))) + assert.deepEqual(harness.calls, ['resume', 'ready', 'sync', 'finished job-2 true']) +}) + +it('lets the final sync from leaving end before the job runs its own', async () => { + store.clear() + const harness = await load() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let first = true + await installed(harness, { + sync: async () => { + harness.calls.push('sync') + if (first) { + first = false + await gate + } + } + }) + harness.api.leaveApp() + await until(() => harness.calls.includes('sync')) + harness.job('job-3') + release() + await until(() => harness.calls.some((call) => call.startsWith('finished'))) + const calls = withoutScheduling(harness.calls) + assert.ok(calls.indexOf('end') < calls.indexOf('resume'), calls.join(' ')) + assert.deepEqual(calls.slice(calls.indexOf('resume')), ['resume', 'ready', 'sync', 'stop', 'finished job-3 true']) +}) + +it('answers the job without syncing when the setting is off or the vault is not linked', async () => { + store.clear() + store.set('zn:background-sync', 'off') + const off = await load() + await installed(off) + off.job('job-4') + await until(() => off.calls.some((call) => call.startsWith('finished'))) + assert.deepEqual(off.calls, ['finished job-4 true']) + + store.clear() + const unlinked = await load() + await installed(unlinked, { ready: false }) + unlinked.job('job-5') + await until(() => unlinked.calls.some((call) => call.startsWith('finished'))) + assert.deepEqual(unlinked.calls, ['resume', 'ready', 'finished job-5 true']) +}) + +it('answers a failed job run so the job ends', async () => { + store.clear() + const harness = await load() + await installed(harness, { + sync: async () => { + throw new Error('offline') + } + }) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + harness.calls.length = 0 + harness.job('job-6') + await until(() => harness.calls.some((call) => call.startsWith('finished'))) + assert.deepEqual(harness.calls, ['resume', 'ready', 'stop', 'finished job-6 false']) +}) diff --git a/src/bridge/background-sync.ts b/src/bridge/background-sync.ts new file mode 100644 index 0000000..5c89e03 --- /dev/null +++ b/src/bridge/background-sync.ts @@ -0,0 +1,216 @@ +/** + * Background sync: Settings → Cloud → Sync in the background, on by default. + * + * The sync engine runs in this WebView, which exists only while MainActivity + * does, so Android cannot run it from a cold start. What this does: + * + * - When you leave ZenNotes, it finishes a sync before stopping, so an edit + * made just before leaving reaches your other devices without opening the + * app again. Android may freeze a cached app within seconds, so leaving + * also queues a catch-up run (`begin`), dropped once the sync ends (`end`). + * - While the vault is linked, a WorkManager job runs about every 15 minutes + * and, if Android still has ZenNotes in memory, asks this module for a + * sync (the `sync` event); otherwise it does nothing and the next open + * syncs. + * + * Leaving used to stop every Cloud request at once (cloud-sync-client.ts + * binds them to one lifetime), which abandoned a sync in progress. With the + * setting on, requests stay open while background work runs and stop when + * the last run ends; with it off, or before this module is installed, + * leaving stops them at once, as before. Signing out stops them at once + * either way. Kept close to the iPhone app's copy, which has the leaving + * half only. + */ +import { registerPlugin, type PluginListenerHandle } from '@capacitor/core' +import type { VaultChangeEvent } from '@zennotes/bridge-contract/ipc' +import { BACKGROUND_SYNC_KEY } from '../viewport' +import { resumeMobileCloudRequests, stopMobileCloudRequests } from './cloud-sync-client' + +export { BACKGROUND_SYNC_KEY } + +/** The editor writes a note 350 ms after the last keystroke (app-core's + * PATH_SAVE_DEBOUNCE_MS). Waiting a little longer lets an edit made just + * before leaving reach the disk before the sync reads the vault. */ +const SAVE_SETTLE_MS = 500 + +/** A second pass runs only when the vault changed during the first. Never + * more: the sync's own rescan reports changes too. */ +const MAX_PASSES = 2 + +/** WorkManager's shortest repeat interval. */ +export const SYNC_EVERY_MINUTES = 15 + +export interface BackgroundSyncPlugin { + /** Queues the catch-up run for a final sync Android freezes midway. */ + begin(): Promise + /** The final sync ended: drops the catch-up run. */ + end(): Promise + /** Runs the periodic job every `minutes` while the network is up. */ + schedule(options: { minutes: number }): Promise + cancel(): Promise + /** Answers a `sync` event, ending the job that sent it. */ + finished(options: { id: string; ok: boolean }): Promise + addListener(event: 'sync', listener: (event: { id: string }) => void): Promise +} + +/** What this module needs from the open vault (mobile-bridge.ts). */ +export interface BackgroundSyncHost { + /** Signed in, and the open vault is linked to this account's Cloud. */ + ready(): Promise + /** One sync pass of the open vault; joins a pass that is already running. */ + sync(): Promise + onVaultChange(listener: (event: VaultChangeEvent) => void): () => void +} + +let host: BackgroundSyncHost | null = null +let plugin: BackgroundSyncPlugin | null = null +let active = true +let leaving: Promise | null = null +let runs = 0 + +/** Registered on first use, so loading this module never touches native code. */ +function native(): BackgroundSyncPlugin { + plugin ??= registerPlugin('ZenBackgroundSync') + return plugin +} + +export function backgroundSyncEnabled(): boolean { + try { + return localStorage.getItem(BACKGROUND_SYNC_KEY) !== 'off' + } catch { + return true + } +} + +export function setBackgroundSyncEnabled(enabled: boolean): void { + try { + // On is the default, so it removes the key: a fresh install and a reset + // look the same. + if (enabled) localStorage.removeItem(BACKGROUND_SYNC_KEY) + else localStorage.setItem(BACKGROUND_SYNC_KEY, 'off') + } catch { + // Storage unavailable: the choice applies to this session only. + } + void refreshSchedule() +} + +export function installBackgroundSync(next: BackgroundSyncHost): void { + if (host) return + host = next + void native() + .addListener('sync', ({ id }) => void syncForJob(id)) + .catch(() => {}) + void refreshSchedule() +} + +/** The app left the screen (Capacitor's appStateChange, isActive false). */ +export function leaveApp(): void { + active = false + const current = host + if (!current || !backgroundSyncEnabled()) { + stopMobileCloudRequests() + return + } + // Linking, unlinking and signing in or out all happen on screen, so + // leaving is when the job's schedule catches up with them. + void refreshSchedule() + leaving ??= holdRequests(() => finishSync(current)).finally(() => { + leaving = null + }) +} + +/** The app is back on screen. */ +export function returnToApp(): void { + active = true + resumeMobileCloudRequests() +} + +/** Settles once the final sync started by leaving has ended (tests). */ +export function backgroundSyncSettled(): Promise { + return leaving ?? Promise.resolve() +} + +/** Background work keeps Cloud requests open; the last run to end stops them + * if the app is still away. */ +async function holdRequests(work: () => Promise): Promise { + runs += 1 + try { + await work() + } finally { + runs -= 1 + if (runs === 0 && !active) stopMobileCloudRequests() + } +} + +async function finishSync(current: BackgroundSyncHost): Promise { + let began = false + try { + if (!(await current.ready())) return + await native().begin() + began = true + await delay(SAVE_SETTLE_MS) + await syncPasses(current) + } catch (error) { + // The next foreground sync picks it up; nothing here can be shown. + console.warn('Background sync did not finish', error) + } finally { + if (began) await native().end().catch(() => {}) + } +} + +/** A run of the periodic (or catch-up) job. Always answers, so the job ends. */ +async function syncForJob(id: string): Promise { + let ok = false + try { + // A final sync from leaving goes first; this run then finds less to do. + await leaving + const current = host + if (current && backgroundSyncEnabled()) { + await holdRequests(async () => { + resumeMobileCloudRequests() + if (await current.ready()) await syncPasses(current) + }) + } + ok = true + } catch (error) { + console.warn('Background sync did not finish', error) + } finally { + await native().finished({ id, ok }).catch(() => {}) + } +} + +async function syncPasses(current: BackgroundSyncHost): Promise { + let changed = false + const stopWatching = current.onVaultChange(() => { + changed = true + }) + try { + for (let pass = 0; pass < MAX_PASSES; pass += 1) { + changed = false + await current.sync() + if (!changed) break + } + } finally { + stopWatching() + } +} + +/** The job runs only while the setting is on and the vault is linked, so a + * phone without Cloud is never woken for it. */ +async function refreshSchedule(): Promise { + const current = host + if (!current) return + try { + if (backgroundSyncEnabled() && (await current.ready())) { + await native().schedule({ minutes: SYNC_EVERY_MINUTES }) + } else { + await native().cancel() + } + } catch (error) { + console.warn('Background sync schedule was not updated', error) + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index ceb2a0c..273c403 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -84,6 +84,7 @@ import { resolveExternalVault } from './folder-picker' import { emitVaultChange, onVaultChange, onOpenNoteRequested, requestOpenNote } from './events' +import type { BackgroundSyncHost } from './background-sync' import { randomUUID } from './uuid' import { renderTikzOnDevice } from './tikz' import { fetchLinkMetadataOnDevice } from './link-metadata' @@ -443,6 +444,28 @@ function activeMobileVault(): MobileVault { return current } +/** The open vault as background sync sees it: ready when it is an on-device + * vault linked to the signed-in account, the check app-core's automatic + * sync makes before each run. */ +export const mobileBackgroundSyncHost: BackgroundSyncHost = { + async ready() { + let current: MobileVault + try { + current = activeMobileVault() + } catch { + return false + } + const status = await getMobileCloudAccountStatus() + if (status.state !== 'connected' || !status.account) return false + const link = await getMobileCloudVaultLink(current) + return link !== null && link.base_url === status.account.base_url + }, + async sync() { + await syncMobileCloudVault(activeMobileVault()) + }, + onVaultChange +} + function vaultNameFromRoot(root: string): string { const raw = root.startsWith(VAULT_ROOT_PREFIX) ? root.slice(VAULT_ROOT_PREFIX.length) : root const name = decodeURIComponent(raw).split('/').filter(Boolean).pop() ?? '' diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index 38f2c12..c95f7ba 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -28,6 +28,7 @@ import { } from '@zennotes/shared-domain/cloud-e2ee-account' import { createCloudSyncClient, stopMobileCloudRequests, resumeMobileCloudRequests, mobileCloudRequestSignal } from './cloud-sync-client' import { publishWithStagedUploads, type CloudPublishAssetPlatform } from '@zennotes/shared-domain/cloud-publish-uploads' +import { leaveApp, returnToApp } from './background-sync' /** * Whether this build lets ZenNotes Cloud serve it end-to-end encrypted vaults @@ -270,8 +271,10 @@ export async function configureMobileCloudAuth(appVersion: string): Promise { appActive = isActive - if (isActive) resumeMobileCloudRequests() - else stopMobileCloudRequests() + // Leaving stops Cloud requests, after a final sync when background sync + // is on (background-sync.ts). + if (isActive) returnToApp() + else leaveApp() }) const launch = await CapApp.getLaunchUrl() if (launch?.url && isCloudAuthUrl(launch.url)) scheduleAuthCallback(launch.url) diff --git a/src/main.tsx b/src/main.tsx index 14a5d9b..2b03f86 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,8 +16,10 @@ import { installMobileBridge, loadNativeAppVersion, bootVault, - importPendingShares + importPendingShares, + mobileBackgroundSyncHost } from './bridge/mobile-bridge' +import { installBackgroundSync } from './bridge/background-sync' import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth' import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding' import { installWidgetPublisher } from './bridge/widgets' @@ -78,6 +80,7 @@ async function boot(): Promise { const appVersion = await loadNativeAppVersion() await configureMobileCloudAuth(appVersion) installMobileBridge() + installBackgroundSync(mobileBackgroundSyncHost) // FIRST store subscriber, ahead of everything React mounts: the guard // undoes app-core's null-active-tab fallback inside the same notification // pass, and zustand notifies in subscription order — installed any later, diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 63b6501..b83d9bd 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -55,6 +55,7 @@ import { WELCOME_PENDING_KEY, FAB_HINT_KEY } from './Onboarding' import { WELCOME_NOTE_PATH } from '../bridge/welcome-note' import ensoUrl from '../assets/enso.png' import { getStoragePref } from '../bridge/icloud' +import { backgroundSyncEnabled, setBackgroundSyncEnabled } from '../bridge/background-sync' import { getPinnedNotes, loadPins } from './pins' import { vaultSettingsAccessForLayout } from './vault-settings-access' import { getLayoutMode, isPhoneViewport, setLayoutMode, type LayoutMode } from '../viewport' @@ -2782,6 +2783,94 @@ function useLayoutSettingsRow(): void { }, []) } +/** + * Settings → Cloud → Sync in the background (island under the linked-vault + * card, on every width: a tablet leaves the app the same way). What the + * setting does is bridge/background-sync.ts's. + */ +function SettingsBackgroundSyncRow(): React.JSX.Element { + const [enabled, setEnabled] = useState(() => backgroundSyncEnabled()) + const choose = (next: boolean): void => { + setBackgroundSyncEnabled(next) + setEnabled(next) + } + return ( +
+
+
Sync in the background
+
+ {enabled + ? 'When you leave ZenNotes, it finishes sending your latest changes. While Android keeps it in memory, it also syncs about every 15 minutes.' + : 'Leaving ZenNotes stops syncing at once. Anything not sent yet goes up when you open it again.'} +
+
+
+ {BACKGROUND_SYNC_CHOICES.map((choice) => ( + + ))} +
+
+ ) +} + +const BACKGROUND_SYNC_CHOICES: Array<{ value: boolean; label: string }> = [ + { value: true, label: 'On' }, + { value: false, label: 'Off' } +] + +function useCloudSettingsRow(): void { + useEffect(() => { + let container: HTMLElement | null = null + let root: ReturnType | null = null + const sync = (): void => { + // Anchor on the vault card (app-core's cloud-vault section, walked up + // to the Cloud page's direct child) and sit right below it, with the + // keep-alive discipline of the Layout island: the container moves or + // detaches, the React tree survives. + const vaultCard = document.querySelector( + '[data-settings-search-id="cloud-vault"]' + ) + let anchor: HTMLElement | null = vaultCard + while ( + anchor && + !(anchor.parentElement?.className ?? '').split(/\s+/).includes('space-y-6') + ) { + anchor = anchor.parentElement + } + const parent = anchor?.parentElement + if (!anchor || !parent) { + container?.remove() + return + } + if (container?.parentElement === parent && container.previousElementSibling === anchor) return + if (!container) { + container = document.createElement('div') + container.className = 'zn-settings-layout-host' + root = ReactDOM.createRoot(container) + root.render() + } + parent.insertBefore(container, anchor.nextSibling) + } + const observer = new MutationObserver(() => sync()) + observer.observe(document.body, { childList: true, subtree: true }) + sync() + return () => { + observer.disconnect() + root?.unmount() + container?.remove() + } + }, []) +} + // --------------------------------------------------------------------------- // Settings → About → GitHub links (Discord feedback, 2026-08-20: "About // section has no link to Github"). App-core's About pane links only to @@ -2898,6 +2987,7 @@ function MobileShellRoot(): React.JSX.Element { useYouTubeLiteEmbeds() useVaultSettingsRows() useLayoutSettingsRow() + useCloudSettingsRow() useAboutGitHubLinks() useAtlasTouchGestures() useSystemBackClose() diff --git a/src/viewport.ts b/src/viewport.ts index 89a8928..1dd8918 100644 --- a/src/viewport.ts +++ b/src/viewport.ts @@ -99,6 +99,11 @@ export const GESTURES_KEY = 'zn:gestures' * like the other shell prefs. */ export const START_SCREEN_KEY = 'zn:start-screen' +/** localStorage key for Settings → Cloud → Sync in the background ('off'; + * absent = on), see bridge/background-sync.ts. Mirrored natively like the + * other shell prefs. */ +export const BACKGROUND_SYNC_KEY = 'zn:background-sync' + /** Publish the current decision to CSS. Safe to call repeatedly. */ export function syncPhoneClass(): void { From 97c511570e4480c05fcc9a0fad24e55714717680 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 10:17:35 -0500 Subject: [PATCH 2/2] fix(mobile): background sync is the final sync on leaving, with no timer The 15-minute WorkManager job from the previous commit did not survive a test against real Cloud. With the QA account on two emulators, the job fired on schedule while the app had been in the background for 16 minutes, but Android's cached-app freezer had frozen the app, and a job unfreezes the app's own process, not the WebView renderer the sync engine runs in. The sync never ran, the worker waited out its five minutes, and the other device's change arrived only when the app was opened. The catch-up run has the same flaw. The job's first run, two seconds after leaving, worked only because nothing was frozen yet. The final sync on leaving holds up: a note written just before pressing Home went up 1.6 seconds later, and the second device downloaded the exact text. Android keeps a backgrounded app running for a few seconds before freezing it, so that sync needs no native help. So this removes BackgroundSyncPlugin, BackgroundSyncWorker, WorkManager and the manifest change; android/ is main's again, with no new permissions. The setting now says what the app does, in the iPhone app's words, and the README says why there is no timer: only a foreground service with a notification, or a native sync engine, could run one. Checked: npm run upstream, npm test (287, 9 new). --- README.md | 18 +- android/app/build.gradle | 2 - android/app/src/main/AndroidManifest.xml | 6 +- .../md/zennotes/BackgroundSyncPlugin.java | 126 ------------ .../md/zennotes/BackgroundSyncWorker.java | 44 ---- .../main/java/md/zennotes/MainActivity.java | 1 - android/variables.gradle | 1 - src/bridge/background-sync.test.ts | 192 +++++------------- src/bridge/background-sync.ts | 124 ++--------- src/ui-mobile/MobileShell.tsx | 2 +- 10 files changed, 79 insertions(+), 437 deletions(-) delete mode 100644 android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java delete mode 100644 android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java diff --git a/README.md b/README.md index 7ae09d1..f610405 100644 --- a/README.md +++ b/README.md @@ -271,16 +271,14 @@ second binary copy, works back to minSdk 24, rejects redirects and unsafe URLs, and never receives the Cloud account token. Settings → Cloud → Sync in the background (on by default, per device) lets a -sync finish after you leave the app and keeps syncing while Android keeps the -app in memory. The sync engine runs in the WebView, which exists only while -`MainActivity` does, so `BackgroundSyncPlugin` schedules a WorkManager job -(every 15 minutes, network required, only while the setting is on and the -vault is linked) that asks the live WebView for a sync and does nothing when -the activity is gone. Leaving also queues a one-off catch-up run, dropped when -the final sync ends, in case Android freezes the app midway. This is the one -place the shells differ: the iPhone app has the leaving half only, since iOS -suspends its WebView within seconds (`src/bridge/background-sync.ts`). With the -setting off, leaving stops Cloud requests at once, as before. +sync finish after you leave the app: Android keeps a backgrounded app running +for a few seconds before its cached-app freezer stops it, WebView included, +which is enough for that final sync (`src/bridge/background-sync.ts`, no +native code). Nothing runs on a timer while the app is away. A WorkManager job +every 15 minutes was tried against real Cloud: it fired on schedule, but a job +unfreezes the app's process and not the WebView renderer the sync engine runs +in, so the sync never ran. Changes from other devices arrive when the app +opens. With the setting off, leaving stops Cloud requests at once, as before. ## Release verification diff --git a/android/app/build.gradle b/android/app/build.gradle index fdd65cd..61b1d9d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -56,8 +56,6 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" - // Background sync's periodic job (BackgroundSyncPlugin). - implementation "androidx.work:work-runtime:$androidxWorkVersion" implementation project(':capacitor-android') testImplementation "junit:junit:$junitVersion" // Local unit tests only see android.jar's stubbed org.json; the shared diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7823d69..fb95aef 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + - - diff --git a/android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java b/android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java deleted file mode 100644 index fe3e7a9..0000000 --- a/android/app/src/main/java/md/zennotes/BackgroundSyncPlugin.java +++ /dev/null @@ -1,126 +0,0 @@ -package md.zennotes; - -import androidx.work.Constraints; -import androidx.work.ExistingPeriodicWorkPolicy; -import androidx.work.ExistingWorkPolicy; -import androidx.work.NetworkType; -import androidx.work.OneTimeWorkRequest; -import androidx.work.PeriodicWorkRequest; -import androidx.work.WorkManager; -import com.getcapacitor.JSObject; -import com.getcapacitor.Plugin; -import com.getcapacitor.PluginCall; -import com.getcapacitor.PluginMethod; -import com.getcapacitor.annotation.CapacitorPlugin; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * The native half of background sync (src/bridge/background-sync.ts). The - * sync engine is JavaScript in this activity's WebView, so the jobs scheduled - * here only ask it to run: {@link BackgroundSyncWorker} finds the live plugin, - * sends a {@code sync} event and waits for JS to answer {@code finished}. - * Once the activity is gone there is no WebView to ask, and a job does - * nothing; the next launch syncs. - */ -@CapacitorPlugin(name = "ZenBackgroundSync") -public class BackgroundSyncPlugin extends Plugin { - - static final String PERIODIC_WORK = "zennotes-cloud-sync"; - static final String CATCH_UP_WORK = "zennotes-cloud-sync-catch-up"; - /** An ordinary final sync ends well before this and cancels the catch-up. */ - private static final long CATCH_UP_DELAY_SECONDS = 30; - - private static volatile BackgroundSyncPlugin current; - private final Map pending = new ConcurrentHashMap<>(); - - static BackgroundSyncPlugin current() { - return current; - } - - @Override - public void load() { - current = this; - } - - @Override - protected void handleOnDestroy() { - if (current == this) current = null; - // Nothing will answer a job still waiting: release it now. - for (CountDownLatch answered : pending.values()) answered.countDown(); - pending.clear(); - } - - /** Leaving: queue a catch-up run for a final sync Android freezes midway. */ - @PluginMethod - public void begin(PluginCall call) { - OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(BackgroundSyncWorker.class) - .setConstraints(networkConstraints()) - .setInitialDelay(CATCH_UP_DELAY_SECONDS, TimeUnit.SECONDS) - .build(); - WorkManager.getInstance(getContext()).enqueueUniqueWork(CATCH_UP_WORK, ExistingWorkPolicy.REPLACE, request); - call.resolve(); - } - - /** The final sync ended: the catch-up run is not needed. */ - @PluginMethod - public void end(PluginCall call) { - WorkManager.getInstance(getContext()).cancelUniqueWork(CATCH_UP_WORK); - call.resolve(); - } - - @PluginMethod - public void schedule(PluginCall call) { - // WorkManager refuses shorter repeat intervals. - long minutes = Math.max(PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS / 60_000L, call.getInt("minutes", 15)); - PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(BackgroundSyncWorker.class, minutes, TimeUnit.MINUTES) - .setConstraints(networkConstraints()) - .build(); - WorkManager.getInstance(getContext()).enqueueUniquePeriodicWork( - PERIODIC_WORK, - ExistingPeriodicWorkPolicy.UPDATE, - request - ); - call.resolve(); - } - - @PluginMethod - public void cancel(PluginCall call) { - WorkManager.getInstance(getContext()).cancelUniqueWork(PERIODIC_WORK); - call.resolve(); - } - - /** JS answering a {@code sync} event: the job that sent it can end. */ - @PluginMethod - public void finished(PluginCall call) { - String id = call.getString("id"); - CountDownLatch answered = id == null ? null : pending.remove(id); - if (answered != null) answered.countDown(); - call.resolve(); - } - - /** - * Asks JS for a sync. The latch opens when JS answers, or when the - * activity goes away; null when nothing listens yet (the app is still - * starting), in which case the job ends at once. - */ - CountDownLatch requestSync(String id) { - if (!hasListeners("sync")) return null; - CountDownLatch answered = new CountDownLatch(1); - pending.put(id, answered); - JSObject data = new JSObject(); - data.put("id", id); - notifyListeners("sync", data); - return answered; - } - - void forget(String id) { - pending.remove(id); - } - - private static Constraints networkConstraints() { - return new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(); - } -} diff --git a/android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java b/android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java deleted file mode 100644 index adbb20f..0000000 --- a/android/app/src/main/java/md/zennotes/BackgroundSyncWorker.java +++ /dev/null @@ -1,44 +0,0 @@ -package md.zennotes; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -/** - * The periodic and catch-up job of background sync (BackgroundSyncPlugin). - * It hands the sync to the WebView and waits for the answer. It reports - * success whatever happened: a sync that failed, or could not run because - * the app is not in memory, waits for the next period rather than for - * WorkManager's retry backoff. - */ -public class BackgroundSyncWorker extends Worker { - - /** WorkManager stops a job after ten minutes; give up well before that. */ - private static final long ANSWER_TIMEOUT_MINUTES = 5; - - public BackgroundSyncWorker(@NonNull Context context, @NonNull WorkerParameters params) { - super(context, params); - } - - @NonNull - @Override - public Result doWork() { - BackgroundSyncPlugin plugin = BackgroundSyncPlugin.current(); - if (plugin == null) return Result.success(); - String id = UUID.randomUUID().toString(); - CountDownLatch answered = plugin.requestSync(id); - if (answered == null) return Result.success(); - try { - answered.await(ANSWER_TIMEOUT_MINUTES, TimeUnit.MINUTES); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } finally { - plugin.forget(id); - } - return Result.success(); - } -} diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index cd3b25e..ee357d3 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -39,7 +39,6 @@ public void onCreate(Bundle savedInstanceState) { registerPlugin(WidgetBridgePlugin.class); registerPlugin(ImagePastePlugin.class); registerPlugin(EdgeSwipePlugin.class); - registerPlugin(BackgroundSyncPlugin.class); super.onCreate(savedInstanceState); // Cold-start share: the launch intent IS the share. Stash it now; the // WebView drains the inbox after the vault opens (importPendingShares). diff --git a/android/variables.gradle b/android/variables.gradle index f2054a9..ee4ba41 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -9,7 +9,6 @@ ext { androidxFragmentVersion = '1.8.9' coreSplashScreenVersion = '1.2.0' androidxWebkitVersion = '1.14.0' - androidxWorkVersion = '2.12.0' junitVersion = '4.13.2' androidxJunitVersion = '1.3.0' androidxEspressoCoreVersion = '3.7.0' diff --git a/src/bridge/background-sync.test.ts b/src/bridge/background-sync.test.ts index 4c4d6a3..63168d4 100644 --- a/src/bridge/background-sync.test.ts +++ b/src/bridge/background-sync.test.ts @@ -13,29 +13,14 @@ interface Harness { api: Record calls: string[] vaultListeners: Set<() => void> - /** Sends the native `sync` event, as BackgroundSyncWorker does. */ - job(id: string): void /** Emits a vault change, as an in-app write or the sync's rescan does. */ change(): void } async function load(): Promise { const calls: string[] = [] - const listeners = new Map void>() const vaultListeners = new Set<() => void>() - const plugin = { - begin: async () => void calls.push('begin'), - end: async () => void calls.push('end'), - schedule: async ({ minutes }: { minutes: number }) => void calls.push(`schedule ${minutes}`), - cancel: async () => void calls.push('cancel'), - finished: async ({ id, ok }: { id: string; ok: boolean }) => void calls.push(`finished ${id} ${ok}`), - addListener: async (event: string, listener: (event: { id: string }) => void) => { - listeners.set(event, listener) - return { remove: async () => {} } - } - } const api = await loadMobileModule('./src/bridge/background-sync.ts', { - '@capacitor/core': { registerPlugin: () => plugin }, './cloud-sync-client': { stopMobileCloudRequests: () => void calls.push('stop'), resumeMobileCloudRequests: () => void calls.push('resume') @@ -45,7 +30,6 @@ async function load(): Promise { api, calls, vaultListeners, - job: (id) => listeners.get('sync')?.({ id }), change: () => { for (const listener of vaultListeners) listener() } @@ -66,32 +50,6 @@ function host(harness: Harness, options: { ready?: boolean; sync?: () => Promise } } -/** Installs and lets the first schedule update land, then forgets its calls. */ -async function installed(harness: Harness, options: Parameters[1] = {}): Promise { - harness.api.installBackgroundSync(host(harness, options)) - await until(() => harness.calls.some((call) => call.startsWith('schedule') || call === 'cancel')) - harness.calls.length = 0 -} - -async function until(done: () => boolean): Promise { - for (let tries = 0; !done(); tries += 1) { - assert.ok(tries < 300, 'timed out waiting') - await new Promise((resolve) => setTimeout(resolve, 5)) - } -} - -/** The calls with the schedule update leaving makes taken out. */ -function withoutScheduling(calls: string[]): string[] { - const out = [...calls] - const scheduleAt = out.findIndex((call) => call.startsWith('schedule') || call === 'cancel') - if (scheduleAt >= 0) { - out.splice(scheduleAt, 1) - // Its readiness check is the first 'ready' (refreshSchedule runs first). - out.splice(out.indexOf('ready'), 1) - } - return out -} - it('is on by default, and turning it on again removes the stored choice', async () => { store.clear() const { api } = await load() @@ -111,149 +69,99 @@ it('stops Cloud requests at once on leaving before it is installed, as leaving a assert.deepEqual(harness.calls, ['stop', 'resume']) }) -it('schedules the job only while the setting is on and the vault is linked', async () => { +it('stops at once when the setting is off, without asking whether the vault is ready', async () => { store.clear() - const linked = await load() - linked.api.installBackgroundSync(host(linked)) - await until(() => linked.calls.length >= 2) - assert.deepEqual(linked.calls, ['ready', `schedule ${linked.api.SYNC_EVERY_MINUTES}`]) - - const unlinked = await load() - unlinked.api.installBackgroundSync(host(unlinked, { ready: false })) - await until(() => unlinked.calls.length >= 2) - assert.deepEqual(unlinked.calls, ['ready', 'cancel']) - store.set('zn:background-sync', 'off') - const off = await load() - off.api.installBackgroundSync(host(off)) - await until(() => off.calls.length >= 1) - assert.deepEqual(off.calls, ['cancel'], 'a phone with the setting off is never woken for it') + const harness = await load() + harness.api.installBackgroundSync(host(harness)) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['stop']) }) -it('cancels and reschedules the job as the setting changes', async () => { +it('finishes a sync on leaving, then stops requests', async () => { store.clear() const harness = await load() - await installed(harness) - harness.api.setBackgroundSyncEnabled(false) - await until(() => harness.calls.length >= 1) - assert.deepEqual(harness.calls, ['cancel']) - harness.calls.length = 0 - harness.api.setBackgroundSyncEnabled(true) - await until(() => harness.calls.length >= 2) - assert.deepEqual(harness.calls, ['ready', 'schedule 15']) + harness.api.installBackgroundSync(host(harness)) + harness.api.leaveApp() + assert.equal(harness.calls.includes('stop'), false, 'requests keep running while the final sync is pending') + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['ready', 'sync', 'stop']) }) -it('stops at once when the setting is off', async () => { +it('stops without syncing when the vault is not linked to Cloud', async () => { store.clear() - store.set('zn:background-sync', 'off') const harness = await load() - await installed(harness) + harness.api.installBackgroundSync(host(harness, { ready: false })) harness.api.leaveApp() await harness.api.backgroundSyncSettled() - assert.deepEqual(harness.calls, ['stop']) + assert.deepEqual(harness.calls, ['ready', 'stop']) }) -it('finishes a sync on leaving, queuing and then dropping the catch-up run', async () => { +it('keeps requests running when the app comes back before the final sync ends', async () => { store.clear() const harness = await load() - await installed(harness) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + harness.calls.push('sync') + await gate + } + })) harness.api.leaveApp() - assert.equal(harness.calls.includes('stop'), false, 'requests keep running while the final sync is pending') + while (!harness.calls.includes('sync')) await new Promise((resolve) => setTimeout(resolve, 10)) + harness.api.returnToApp() + release() await harness.api.backgroundSyncSettled() - await until(() => harness.calls.some((call) => call.startsWith('schedule'))) - assert.deepEqual(withoutScheduling(harness.calls), ['ready', 'begin', 'sync', 'end', 'stop']) + assert.deepEqual(harness.calls, ['ready', 'sync', 'resume']) }) it('runs a second pass when the vault changed during the first, and never a third', async () => { store.clear() const harness = await load() let passes = 0 - await installed(harness, { + harness.api.installBackgroundSync(host(harness, { sync: async () => { passes += 1 harness.change() } - }) + })) harness.api.leaveApp() await harness.api.backgroundSyncSettled() assert.equal(passes, 2) }) -it('syncs when the job asks while the app is away, then stops and answers', async () => { +it('starts one final sync however often the app leaves while it runs', async () => { store.clear() const harness = await load() - await installed(harness) - harness.api.leaveApp() - await harness.api.backgroundSyncSettled() - harness.calls.length = 0 - harness.job('job-1') - await until(() => harness.calls.some((call) => call.startsWith('finished'))) - assert.deepEqual(harness.calls, ['resume', 'ready', 'sync', 'stop', 'finished job-1 true']) -}) - -it('leaves requests open when the job asks while the app is on screen', async () => { - store.clear() - const harness = await load() - await installed(harness) - harness.job('job-2') - await until(() => harness.calls.some((call) => call.startsWith('finished'))) - assert.deepEqual(harness.calls, ['resume', 'ready', 'sync', 'finished job-2 true']) -}) - -it('lets the final sync from leaving end before the job runs its own', async () => { - store.clear() - const harness = await load() - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - let first = true - await installed(harness, { + let passes = 0 + harness.api.installBackgroundSync(host(harness, { sync: async () => { - harness.calls.push('sync') - if (first) { - first = false - await gate - } + passes += 1 } - }) + })) harness.api.leaveApp() - await until(() => harness.calls.includes('sync')) - harness.job('job-3') - release() - await until(() => harness.calls.some((call) => call.startsWith('finished'))) - const calls = withoutScheduling(harness.calls) - assert.ok(calls.indexOf('end') < calls.indexOf('resume'), calls.join(' ')) - assert.deepEqual(calls.slice(calls.indexOf('resume')), ['resume', 'ready', 'sync', 'stop', 'finished job-3 true']) -}) - -it('answers the job without syncing when the setting is off or the vault is not linked', async () => { - store.clear() - store.set('zn:background-sync', 'off') - const off = await load() - await installed(off) - off.job('job-4') - await until(() => off.calls.some((call) => call.startsWith('finished'))) - assert.deepEqual(off.calls, ['finished job-4 true']) - - store.clear() - const unlinked = await load() - await installed(unlinked, { ready: false }) - unlinked.job('job-5') - await until(() => unlinked.calls.some((call) => call.startsWith('finished'))) - assert.deepEqual(unlinked.calls, ['resume', 'ready', 'finished job-5 true']) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.equal(passes, 1) }) -it('answers a failed job run so the job ends', async () => { +it('still stops requests when the final sync fails', async () => { store.clear() const harness = await load() - await installed(harness, { + harness.api.installBackgroundSync(host(harness, { sync: async () => { throw new Error('offline') } - }) - harness.api.leaveApp() - await harness.api.backgroundSyncSettled() - harness.calls.length = 0 - harness.job('job-6') - await until(() => harness.calls.some((call) => call.startsWith('finished'))) - assert.deepEqual(harness.calls, ['resume', 'ready', 'stop', 'finished job-6 false']) + })) + const warn = console.warn + console.warn = () => {} + try { + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + } finally { + console.warn = warn + } + assert.deepEqual(harness.calls, ['ready', 'stop']) }) diff --git a/src/bridge/background-sync.ts b/src/bridge/background-sync.ts index 5c89e03..2fe8ab8 100644 --- a/src/bridge/background-sync.ts +++ b/src/bridge/background-sync.ts @@ -1,27 +1,27 @@ /** * Background sync: Settings → Cloud → Sync in the background, on by default. * - * The sync engine runs in this WebView, which exists only while MainActivity - * does, so Android cannot run it from a cold start. What this does: + * When you leave ZenNotes, it finishes a sync before stopping, so an edit + * made just before leaving reaches your other devices without opening the + * app again. Android keeps a backgrounded app running for a few seconds + * before its cached-app freezer stops it, WebView included, which is enough + * for that final sync; no native help is needed. Changes from other devices + * arrive when the app comes back. * - * - When you leave ZenNotes, it finishes a sync before stopping, so an edit - * made just before leaving reaches your other devices without opening the - * app again. Android may freeze a cached app within seconds, so leaving - * also queues a catch-up run (`begin`), dropped once the sync ends (`end`). - * - While the vault is linked, a WorkManager job runs about every 15 minutes - * and, if Android still has ZenNotes in memory, asks this module for a - * sync (the `sync` event); otherwise it does nothing and the next open - * syncs. + * Nothing runs on a timer while the app is away. A WorkManager job every 15 + * minutes was built and tested against real Cloud: it fired on schedule, but + * the job unfreezes the app's own process and not the WebView renderer the + * sync engine runs in, so the sync never ran and the job waited out its + * timeout. Only a foreground service with a notification, or a native sync + * engine, could change that. * * Leaving used to stop every Cloud request at once (cloud-sync-client.ts * binds them to one lifetime), which abandoned a sync in progress. With the - * setting on, requests stay open while background work runs and stop when - * the last run ends; with it off, or before this module is installed, - * leaving stops them at once, as before. Signing out stops them at once - * either way. Kept close to the iPhone app's copy, which has the leaving - * half only. + * setting on, the requests stop after that final sync instead; with it off, + * or before this module is installed, leaving stops them at once, as before. + * Signing out stops them at once either way. Kept close to the iPhone app's + * copy, which also asks iOS for its grace period. */ -import { registerPlugin, type PluginListenerHandle } from '@capacitor/core' import type { VaultChangeEvent } from '@zennotes/bridge-contract/ipc' import { BACKGROUND_SYNC_KEY } from '../viewport' import { resumeMobileCloudRequests, stopMobileCloudRequests } from './cloud-sync-client' @@ -37,22 +37,6 @@ const SAVE_SETTLE_MS = 500 * more: the sync's own rescan reports changes too. */ const MAX_PASSES = 2 -/** WorkManager's shortest repeat interval. */ -export const SYNC_EVERY_MINUTES = 15 - -export interface BackgroundSyncPlugin { - /** Queues the catch-up run for a final sync Android freezes midway. */ - begin(): Promise - /** The final sync ended: drops the catch-up run. */ - end(): Promise - /** Runs the periodic job every `minutes` while the network is up. */ - schedule(options: { minutes: number }): Promise - cancel(): Promise - /** Answers a `sync` event, ending the job that sent it. */ - finished(options: { id: string; ok: boolean }): Promise - addListener(event: 'sync', listener: (event: { id: string }) => void): Promise -} - /** What this module needs from the open vault (mobile-bridge.ts). */ export interface BackgroundSyncHost { /** Signed in, and the open vault is linked to this account's Cloud. */ @@ -63,16 +47,8 @@ export interface BackgroundSyncHost { } let host: BackgroundSyncHost | null = null -let plugin: BackgroundSyncPlugin | null = null let active = true let leaving: Promise | null = null -let runs = 0 - -/** Registered on first use, so loading this module never touches native code. */ -function native(): BackgroundSyncPlugin { - plugin ??= registerPlugin('ZenBackgroundSync') - return plugin -} export function backgroundSyncEnabled(): boolean { try { @@ -91,16 +67,10 @@ export function setBackgroundSyncEnabled(enabled: boolean): void { } catch { // Storage unavailable: the choice applies to this session only. } - void refreshSchedule() } export function installBackgroundSync(next: BackgroundSyncHost): void { - if (host) return - host = next - void native() - .addListener('sync', ({ id }) => void syncForJob(id)) - .catch(() => {}) - void refreshSchedule() + host ??= next } /** The app left the screen (Capacitor's appStateChange, isActive false). */ @@ -111,11 +81,9 @@ export function leaveApp(): void { stopMobileCloudRequests() return } - // Linking, unlinking and signing in or out all happen on screen, so - // leaving is when the job's schedule catches up with them. - void refreshSchedule() - leaving ??= holdRequests(() => finishSync(current)).finally(() => { + leaving ??= finishSync(current).finally(() => { leaving = null + if (!active) stopMobileCloudRequests() }) } @@ -130,52 +98,14 @@ export function backgroundSyncSettled(): Promise { return leaving ?? Promise.resolve() } -/** Background work keeps Cloud requests open; the last run to end stops them - * if the app is still away. */ -async function holdRequests(work: () => Promise): Promise { - runs += 1 - try { - await work() - } finally { - runs -= 1 - if (runs === 0 && !active) stopMobileCloudRequests() - } -} - async function finishSync(current: BackgroundSyncHost): Promise { - let began = false try { if (!(await current.ready())) return - await native().begin() - began = true await delay(SAVE_SETTLE_MS) await syncPasses(current) } catch (error) { // The next foreground sync picks it up; nothing here can be shown. console.warn('Background sync did not finish', error) - } finally { - if (began) await native().end().catch(() => {}) - } -} - -/** A run of the periodic (or catch-up) job. Always answers, so the job ends. */ -async function syncForJob(id: string): Promise { - let ok = false - try { - // A final sync from leaving goes first; this run then finds less to do. - await leaving - const current = host - if (current && backgroundSyncEnabled()) { - await holdRequests(async () => { - resumeMobileCloudRequests() - if (await current.ready()) await syncPasses(current) - }) - } - ok = true - } catch (error) { - console.warn('Background sync did not finish', error) - } finally { - await native().finished({ id, ok }).catch(() => {}) } } @@ -195,22 +125,6 @@ async function syncPasses(current: BackgroundSyncHost): Promise { } } -/** The job runs only while the setting is on and the vault is linked, so a - * phone without Cloud is never woken for it. */ -async function refreshSchedule(): Promise { - const current = host - if (!current) return - try { - if (backgroundSyncEnabled() && (await current.ready())) { - await native().schedule({ minutes: SYNC_EVERY_MINUTES }) - } else { - await native().cancel() - } - } catch (error) { - console.warn('Background sync schedule was not updated', error) - } -} - function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index b83d9bd..5df9c93 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -2800,7 +2800,7 @@ function SettingsBackgroundSyncRow(): React.JSX.Element {
Sync in the background
{enabled - ? 'When you leave ZenNotes, it finishes sending your latest changes. While Android keeps it in memory, it also syncs about every 15 minutes.' + ? 'When you leave ZenNotes, it keeps going for a few seconds to send your latest changes. Android pauses it after that, so changes from your other devices arrive when you open it again.' : 'Leaving ZenNotes stops syncing at once. Anything not sent yet goes up when you open it again.'}