From 2b9d19521fcd85f2e6c83c869f443c43eff8b3b0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:12:12 +0200 Subject: [PATCH] fix: keep sync on when it is turned back on during a turn-off #34 Turning device sync off uploads what the device changed and then deletes --- .../__tests__/app/lifecycle.test.ts | 20 +++++++++++ src/device-sync/app/lifecycle.ts | 36 +++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/device-sync/__tests__/app/lifecycle.test.ts b/src/device-sync/__tests__/app/lifecycle.test.ts index d328abe6..019dd5c9 100644 --- a/src/device-sync/__tests__/app/lifecycle.test.ts +++ b/src/device-sync/__tests__/app/lifecycle.test.ts @@ -260,6 +260,26 @@ describe('disable', () => { expect(stored.reminderOwner).toBeNull(); }); + it('lets a turn-on wait for a turn-off that is still tearing down, instead of being undone by it', async () => { + await disableDeviceSync(ALICE, CALENDAR_AUTHORITY); + let finishTeardown: () => void = () => undefined; + teardown.mockImplementationOnce(async (_registryId, name, authority, options) => { + h.state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`); + await new Promise((resolve) => { finishTeardown = resolve; }); + return { pending: 0 }; + }); + const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY); + await vi.waitFor(() => expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`)); + // The user opens the settings again and turns contacts sync back on. + const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + finishTeardown(); + expect(await turningOff).toEqual({ done: true, pending: 0 }); + expect(await turningOn).toMatchObject({ kind: 'enabled' }); + expect(h.find('alice')?.sync[CONTACTS_AUTHORITY]?.automatic).toBe(true); + expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true); + expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice'); + }); + it('removes the Android account once both authorities are off', async () => { await disableDeviceSync(ALICE, CALENDAR_AUTHORITY); await disableDeviceSync(ALICE, CONTACTS_AUTHORITY); diff --git a/src/device-sync/app/lifecycle.ts b/src/device-sync/app/lifecycle.ts index f87e9397..91fd5364 100644 --- a/src/device-sync/app/lifecycle.ts +++ b/src/device-sync/app/lifecycle.ts @@ -162,6 +162,24 @@ async function refreshPushState(registryId: string): Promise { await refreshPushSubscriptionTypes(registryId).catch(noop); } +// ─── One step at a time ──────────────────────────────── + +// Turning sync on and off for one app account runs one step after the other: +// a turn-on that starts while a turn-off still tears down (the user left the +// settings and came back) would otherwise be undone by it, down to the +// Android account being removed right after it was turned on again. +const accountSteps = new Map>(); + +function oneAtATime(registryId: string, step: () => Promise): Promise { + const next = (accountSteps.get(registryId) ?? Promise.resolve()).catch(noop).then(step); + const settled = next.catch(noop); + accountSteps.set(registryId, settled); + void settled.then(() => { + if (accountSteps.get(registryId) === settled) accountSteps.delete(registryId); + }); + return next; +} + // ─── Enable ──────────────────────────────────────────── export type EnableOutcome = @@ -174,7 +192,11 @@ export type EnableOutcome = * Turns device sync on for one authority of an app account. The caller has * asked for the permissions (and, for calendars, who reminds the user). */ -export async function enableDeviceSync(registryId: string, authority: Authority): Promise { +export function enableDeviceSync(registryId: string, authority: Authority): Promise { + return oneAtATime(registryId, () => enable(registryId, authority)); +} + +async function enable(registryId: string, authority: Authority): Promise { if (!deviceSyncAvailable()) return { kind: 'failed', message: 'Device sync is not available' }; if (!(await hasSyncPermissions(authority))) return { kind: 'permission' }; await waitForDeviceSyncHydration(); @@ -207,7 +229,11 @@ export async function enableDeviceSync(registryId: string, authority: Authority) } /** Automatic sync back on after the user kept syncing instead of losing changes. */ -export async function resumeDeviceSync(registryId: string, authority: Authority): Promise { +export function resumeDeviceSync(registryId: string, authority: Authority): Promise { + return oneAtATime(registryId, () => resume(registryId, authority)); +} + +async function resume(registryId: string, authority: Authority): Promise { if (!deviceSyncAvailable()) return; await waitForDeviceSyncHydration(); const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name; @@ -285,11 +311,15 @@ function withoutAuthorityStates(entry: AccountDeviceSync, authority: Authority): * how many changes are waiting: call again with `force` once the user agreed * to lose them, or `resumeDeviceSync` to keep syncing. */ -export async function disableDeviceSync( +export function disableDeviceSync( registryId: string, authority: Authority, options: { force?: boolean } = {}, ): Promise { + return oneAtATime(registryId, () => disable(registryId, authority, options)); +} + +async function disable(registryId: string, authority: Authority, options: { force?: boolean }): Promise { if (!deviceSyncAvailable()) return { done: true, pending: 0 }; await waitForDeviceSyncHydration(); const store = useDeviceSyncStore.getState();