diff --git a/README.md b/README.md index aa2dcf2..f610405 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,16 @@ 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: 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 Pull requests and `main` run bridge tests, strict filesystem tests, a 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..63168d4 --- /dev/null +++ b/src/bridge/background-sync.test.ts @@ -0,0 +1,167 @@ +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> + /** 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 vaultListeners = new Set<() => void>() + const api = await loadMobileModule('./src/bridge/background-sync.ts', { + './cloud-sync-client': { + stopMobileCloudRequests: () => void calls.push('stop'), + resumeMobileCloudRequests: () => void calls.push('resume') + } + }) + return { + api, + calls, + vaultListeners, + 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) + } + } +} + +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('stops at once when the setting is off, without asking whether the vault is ready', async () => { + store.clear() + store.set('zn:background-sync', 'off') + const harness = await load() + harness.api.installBackgroundSync(host(harness)) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['stop']) +}) + +it('finishes a sync on leaving, then stops requests', async () => { + store.clear() + const harness = await load() + 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 without syncing when the vault is not linked to Cloud', async () => { + store.clear() + const harness = await load() + harness.api.installBackgroundSync(host(harness, { ready: false })) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['ready', 'stop']) +}) + +it('keeps requests running when the app comes back before the final sync ends', async () => { + store.clear() + const harness = await load() + 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() + while (!harness.calls.includes('sync')) await new Promise((resolve) => setTimeout(resolve, 10)) + harness.api.returnToApp() + release() + await harness.api.backgroundSyncSettled() + 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 + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + passes += 1 + harness.change() + } + })) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.equal(passes, 2) +}) + +it('starts one final sync however often the app leaves while it runs', async () => { + store.clear() + const harness = await load() + let passes = 0 + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + passes += 1 + } + })) + harness.api.leaveApp() + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.equal(passes, 1) +}) + +it('still stops requests when the final sync fails', async () => { + store.clear() + const harness = await load() + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + throw new Error('offline') + } + })) + 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 new file mode 100644 index 0000000..2fe8ab8 --- /dev/null +++ b/src/bridge/background-sync.ts @@ -0,0 +1,130 @@ +/** + * Background sync: Settings → Cloud → Sync in the background, on by default. + * + * 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. + * + * 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, 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 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 + +/** 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 active = true +let leaving: Promise | null = null + +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. + } +} + +export function installBackgroundSync(next: BackgroundSyncHost): void { + host ??= next +} + +/** The app left the screen (Capacitor's appStateChange, isActive false). */ +export function leaveApp(): void { + active = false + const current = host + if (!current || !backgroundSyncEnabled()) { + stopMobileCloudRequests() + return + } + leaving ??= finishSync(current).finally(() => { + leaving = null + if (!active) stopMobileCloudRequests() + }) +} + +/** 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() +} + +async function finishSync(current: BackgroundSyncHost): Promise { + try { + if (!(await current.ready())) return + 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) + } +} + +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() + } +} + +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..5df9c93 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 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.'} +
+
+
+ {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 {