Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string, string> = {
[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()
Expand Down
167 changes: 167 additions & 0 deletions src/bridge/background-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
;(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<string, any>
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<Harness> {
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<void> } = {}) {
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<void>((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'])
})
130 changes: 130 additions & 0 deletions src/bridge/background-sync.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>
/** One sync pass of the open vault; joins a pass that is already running. */
sync(): Promise<void>
onVaultChange(listener: (event: VaultChangeEvent) => void): () => void
}

let host: BackgroundSyncHost | null = null
let active = true
let leaving: Promise<void> | 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<void> {
return leaving ?? Promise.resolve()
}

async function finishSync(current: BackgroundSyncHost): Promise<void> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
23 changes: 23 additions & 0 deletions src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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() ?? ''
Expand Down
7 changes: 5 additions & 2 deletions src/bridge/mobile-cloud-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -270,8 +271,10 @@ export async function configureMobileCloudAuth(appVersion: string): Promise<void
})
await CapApp.addListener('appStateChange', ({ isActive }) => {
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)
Expand Down
Loading
Loading