diff --git a/App.tsx b/App.tsx index 31b0ddc7..1eba8d4d 100644 --- a/App.tsx +++ b/App.tsx @@ -18,6 +18,7 @@ import { startCalendarReminderTapHandling, } from './src/lib/calendar-notifications'; import { startLivenessMonitor } from './src/lib/connection-liveness'; +import { startDeviceSyncTriggers } from './src/device-sync/app/triggers'; import { setPendingCalendarOpen, type CalendarReminderTarget, @@ -399,6 +400,9 @@ export default function App() { // Calendar reminders are kept scheduled from launch, not only once the // Calendar tab has been opened. startCalendarNotificationSync(); + // Device sync (Android, #34): live changes, the app's own edits and the + // foreground ask Android to sync; accounts are checked against Android's. + startDeviceSyncTriggers(); return useNetworkStore.getState().init(); }, []); diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt index 41c553db..3959e5b6 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat +import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter import com.facebook.react.bridge.Arguments import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage @@ -26,12 +27,16 @@ class BulwarkMessagingService : FirebaseMessagingService() { val data = message.data + // Contact and calendar changes become syncs of the Android accounts + // they feed (device sync, #34); only mail needs the push task. + val mail = DeviceSyncPushRouter.route(applicationContext, data) + // Hand off to JS via a headless task only when the app isn't already // running in the foreground - HeadlessJsTaskContext throws if started // while foreground. When the app is open, the main JS instance already // receives JMAP push directly, so the fcm:message event below is // sufficient for it to refresh state. - if (!isAppInForeground()) { + if (mail && !isAppInForeground()) { startHeadlessTask(applicationContext, data) } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt index 81f9f196..5fac3d44 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt @@ -1,6 +1,7 @@ package com.anonymous.bulwarkmobile import android.content.Context +import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter import com.facebook.react.bridge.Arguments import org.json.JSONArray import org.json.JSONObject @@ -31,7 +32,10 @@ class BulwarkUnifiedPushService : PushService() { BulwarkMessagingService.ensureChannel(this) val data = parsePayload(message.content) ?: return - if (!BulwarkMessagingService.isAppInForeground()) { + // Same routing as FCM: contact and calendar changes request device + // syncs, and only mail starts the push task. + val mail = DeviceSyncPushRouter.route(applicationContext, data) + if (mail && !BulwarkMessagingService.isAppInForeground()) { BulwarkMessagingService.startHeadlessTask(applicationContext, data) } diff --git a/locales/rn/en.json b/locales/rn/en.json index b880e06a..ff5d09ef 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -93,6 +93,8 @@ "until_date": "Until {date}" }, "settings": { + "device_sync_desc": "Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.", + "device_sync_title": "Sync to this device", "show_week_numbers_mobile_desc": "Display week numbers in the month view.", "time_zone": "Time zone", "time_zone_auto": "Device time zone", @@ -255,7 +257,14 @@ "device_sync": { "auth_problem_text": "The server no longer accepts the sign-in of {account}, so its contacts and calendars on this device stopped syncing. Sign in again to resume. Changes made on this device are kept.", "auth_problem_title": "Sign in again to keep syncing", - "problem_channel": "Sync problems" + "problem_channel": "Sync problems", + "sign_out_anyway": "Sign out", + "sign_out_pending_message": "{count, plural, one {# change made on this device hasn't reached the server. It is lost if you sign out now.} other {# changes made on this device haven't reached the server. They are lost if you sign out now.}}", + "sign_out_pending_title": "Sign out anyway?", + "sign_out_unknown_message": "Changes made on this device may not have reached the server. They are lost if you sign out now.", + "signed_out_text": "{account} was signed out, so its contacts and calendars on this device no longer sync. Sign in again to resume. Changes made on this device are kept.", + "signed_out_title": "Sign in to keep syncing", + "signing_out": "Removing synced contacts and calendars from this device…" }, "email_composer": { "attach_camera": "Camera", @@ -613,11 +622,106 @@ "book_count": "{count, plural, one {# contact} other {# contacts}}", "default_book": "Default address book", "description_mobile": "Display preferences and address-book tools.", + "device_sync_desc": "Show your address books in the Contacts app and other apps on this device. Changes sync both ways.", + "device_sync_title": "Sync to this device", "export_description_count": "{count, plural, one {Export # contact as a single vCard file.} other {Export # contacts as a single vCard file.}}", "export_empty": "No contacts to export", "import_description_mobile": "Import contacts from a vCard (.vcf) file with duplicate detection.", "manage_description_mobile": "Create, rename, and remove the address books that organize your contacts." }, + "device_sync": { + "also_synced_by": "Also synced for {accounts}, so it shows twice on this device.", + "android_settings": "Android account settings", + "blocked_calendar": "Android no longer asks for access to calendars. Allow it in the app's settings, then turn sync on again.", + "blocked_contacts": "Android no longer asks for access to contacts. Allow it in the app's settings, then turn sync on again.", + "blocked_title": "Access is turned off", + "books_default": "Your own address books sync; shared ones only when you pick them.", + "books_hint": "Your own address books sync unless you turn them off; shared ones only when you turn them on.", + "books_label": "Address books", + "calendars_default": "Your own calendars sync; shared ones only when you pick them.", + "calendars_hint": "Your own calendars sync unless you turn them off; shared ones only when you turn them on. Task lists stay in Bulwark.", + "calendars_label": "Calendars", + "choose": "Choose", + "choose_books_title": "Address books to sync", + "choose_calendars_title": "Calendars to sync", + "collections_count": "{selected} of {total} sync", + "continue": "Continue", + "denied_calendar": "Without access to calendars, Bulwark cannot put your calendars on this device.", + "denied_contacts": "Without access to contacts, Bulwark cannot put your address books on this device.", + "denied_title": "Sync stays off", + "failed_title": "Sync could not be turned on", + "grant_access": "Grant access", + "interval_15m": "Every 15 minutes", + "interval_1h": "Every hour", + "interval_30m": "Every 30 minutes", + "interval_6h": "Every 6 hours", + "interval_desc": "How often Android syncs on its own. Edits in Bulwark and pushed changes sync sooner.", + "interval_label": "Sync interval", + "interval_manual": "No regular sync", + "keep_syncing": "Keep syncing", + "list_error_auth": "The server did not accept the sign-in.", + "list_error_network": "Could not reach the server.", + "list_error_server": "The server could not list them.", + "list_error_signed_out": "This account is not signed in on this device.", + "master_off": "Auto-sync is off on this device, so syncs only run when you tap Sync now.", + "new_contacts_default": "Default address book", + "new_contacts_desc": "The address book for contacts created in other apps on this device.", + "new_contacts_label": "New contacts go to", + "no_books": "This account has no address books.", + "no_calendars": "This account has no calendars to sync.", + "ok": "OK", + "open_app_settings": "Open app settings", + "own_collections": "Your own", + "rationale_calendar": "To show your calendars in the Calendar app, Bulwark needs access to the calendars on this device. It only changes the calendars of its own account.", + "rationale_contacts": "To show your address books in the Contacts app, Bulwark needs access to the contacts on this device. It only changes the contacts of its own account.", + "rationale_title_calendar": "Allow access to calendars", + "rationale_title_contacts": "Allow access to contacts", + "read_only": "Read-only", + "reminder_owner_bulwark": "Bulwark", + "reminder_owner_device": "Calendar app", + "reminder_question_message": "Synced events can remind you from the calendar app on this device or from Bulwark. Pick one so you are not reminded twice. You can change it later.", + "reminder_question_title": "Who should remind you?", + "reminders_desc": "Who reminds you of synced events. The other stays quiet, so you are not reminded twice.", + "reminders_label": "Reminders", + "reminders_nobody": "Bulwark's event notifications are off, so nothing reminds you of synced events.", + "remove": "Remove", + "remove_from_device": "Remove from this device", + "remove_message": "The account's contacts and calendars are removed from this device. Changes made here that haven't reached the server are lost.", + "remove_title": "Remove from this device?", + "review_deletions": "Review deletions", + "shared_by": "Shared by {name}", + "sign_in_again": "Sign in again", + "status_auth": "The server no longer accepts the sign-in. Sign in again to keep syncing.", + "status_cancelled": "The last sync stopped early. It continues next time.", + "status_failed": "The last sync failed: {message}", + "status_failed_plain": "The last sync failed.", + "status_io": "Could not reach the server {when}. Android tries again.", + "status_item_errors": "{count, plural, one {# item could not be synced.} other {# items could not be synced.}}", + "status_off": "Off", + "status_paused": "Paused in Android settings", + "status_permission_calendar": "Access to calendars was turned off, so nothing syncs.", + "status_permission_contacts": "Access to contacts was turned off, so nothing syncs.", + "status_removed": "Removed in Android settings. Turn on to add the account again.", + "status_safety_abort": "Stopped: the server returned nothing while this device still holds items. Check the server, then sync again.", + "status_synced": "Synced {when}", + "status_syncing": "Syncing…", + "status_too_many_deletions": "Many items were deleted on this device. They are not deleted on the server until you confirm.", + "status_unsupported_calendar": "The server offers no calendars for this account.", + "status_unsupported_contacts": "The server offers no address books for this account.", + "status_waiting": "Waiting for the first sync", + "status_waiting_next": "Waiting for the next sync", + "suspended_desc": "Signed out. Its contacts and calendars stay on this device, with the changes made here, and sync again once you sign in.", + "switch_label": "Sync {account} to this device", + "sync_now": "Sync now", + "turn_off_anyway": "Turn off", + "turn_off_failed": "The synced data could not be removed from this device. Sync is paused; try again later.", + "turn_off_failed_title": "Sync could not be turned off", + "turn_off_pending": "{count, plural, one {# change made on this device hasn't reached the server yet. It is lost if you turn sync off now.} other {# changes made on this device haven't reached the server yet. They are lost if you turn sync off now.}}", + "turn_off_title": "Turn off anyway?", + "turn_off_unknown": "Bulwark could not check whether changes made on this device reached the server. They are lost if you turn sync off now.", + "turning_off": "Uploading changes, then removing the data from this device…", + "turning_on": "Setting up…" + }, "downloads": { "attachment_template": { "description_mobile": "Template used when saving or sharing an attachment." diff --git a/src/device-sync/__tests__/app/lifecycle.test.ts b/src/device-sync/__tests__/app/lifecycle.test.ts new file mode 100644 index 00000000..d328abe6 --- /dev/null +++ b/src/device-sync/__tests__/app/lifecycle.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + CALENDAR_AUTHORITY, + CONTACTS_AUTHORITY, + type Authority, +} from '../../types'; + +// A fake of the native module: Android's accounts of our type and their sync +// settings, with every call logged in order. +const h = vi.hoisted(() => { + type Settings = { automatic: boolean; periodicSeconds: number }; + const state = { + available: true, + permitted: true, + master: true, + accounts: [] as Array<{ name: string; registryId: string | null; sync: Record }>, + log: [] as string[], + failSetSyncEnabled: false, + }; + const find = (name: string) => state.accounts.find((a) => a.name === name); + const settingsOf = (name: string, authority: string) => { + const account = find(name); + if (!account) return { automatic: false, periodicSeconds: 0 }; + account.sync[authority] ??= { automatic: false, periodicSeconds: 0 }; + return account.sync[authority]; + }; + const teardown = async ( + _registryId: string, + name: string, + authority: string, + options?: { uploadFirst?: boolean; timeoutMs?: number; force?: boolean }, + ) => { + state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`); + return { pending: 0 }; + }; + return { state, find, settingsOf, teardown }; +}); + +vi.mock('../../native', () => ({ + isDeviceSyncAvailable: () => h.state.available, + listAndroidAccounts: vi.fn(async () => h.state.accounts.map((a) => ({ name: a.name, registryId: a.registryId }))), + ensureAndroidAccount: vi.fn(async (name: string, registryId: string) => { + h.state.log.push(`ensure ${name}`); + const existing = h.find(name); + if (existing && existing.registryId !== registryId) throw new Error('conflict'); + if (existing) return false; + h.state.accounts.push({ name, registryId, sync: {} }); + return true; + }), + removeAndroidAccount: vi.fn(async (name: string) => { + h.state.log.push(`remove ${name}`); + h.state.accounts = h.state.accounts.filter((a) => a.name !== name); + return true; + }), + getSyncSettings: vi.fn(async (name: string) => ({ + masterAutomatic: h.state.master, + authorities: { + 'com.android.contacts': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.contacts') }, + 'com.android.calendar': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.calendar') }, + }, + })), + setSyncEnabled: vi.fn(async (name: string, authority: string, enabled: boolean) => { + if (h.state.failSetSyncEnabled) throw new Error('boom'); + h.state.log.push(`auto ${name} ${authority} ${enabled}`); + h.settingsOf(name, authority).automatic = enabled; + }), + setPeriodicSync: vi.fn(async (name: string, authority: string, seconds: number) => { + h.state.log.push(`periodic ${name} ${authority} ${seconds}`); + h.settingsOf(name, authority).periodicSeconds = seconds; + }), + requestSync: vi.fn(async (name: string, authority: string, options?: Record) => { + h.state.log.push(`sync ${name} ${authority} ${JSON.stringify(options ?? {})}`); + }), + openAccountSettings: vi.fn(async () => undefined), + setPushRoutes: vi.fn(async () => undefined), + showSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`problem ${name}`); }), + clearSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`clear ${name}`); }), + onAccountsChanged: vi.fn(() => () => undefined), +})); + +vi.mock('../../task', () => ({ teardownAuthority: vi.fn(h.teardown) })); + +vi.mock('../../app/permissions', () => ({ + hasSyncPermissions: vi.fn(async () => h.state.permitted), +})); + +vi.mock('../../../lib/push-notifications', () => ({ + refreshPushSubscriptionTypes: vi.fn(async () => undefined), +})); + +import * as native from '../../native'; +import * as task from '../../task'; +import { refreshPushSubscriptionTypes } from '../../../lib/push-notifications'; +import { useAccountStore, type AccountEntry } from '../../../stores/account-store'; +import { + accountDeviceSync, + useDeviceSyncStore, + waitForDeviceSyncHydration, +} from '../../../stores/device-sync-store'; +import { + changeReminderOwner, + changeSyncInterval, + deviceSyncSignedIn, + disableDeviceSync, + enableDeviceSync, + preferredAndroidAccountName, + reconcileDeviceAccounts, + releaseDeviceSyncBeforeSignOut, + removeSuspendedAccount, + resumeDeviceSync, + TEARDOWN_TIMEOUT_MS, +} from '../../app/lifecycle'; + +const ALICE = 'alice@mail.example.org'; +const ALICE2 = 'alice@other.example.net'; +const BOB = 'bob@mail.example.org'; + +function entry(id: string, username: string): AccountEntry { + return { + id, + serverUrl: `https://${id.split('@').pop()}`, + username, + displayName: username, + email: `${username}@example.org`, + avatarColor: '#000', + lastLoginAt: 0, + isConnected: true, + hasError: false, + isDefault: false, + }; +} + +async function ready(registry: AccountEntry[]): Promise { + await waitForDeviceSyncHydration(); + if (!useAccountStore.persist.hasHydrated()) { + await new Promise((resolve) => { + const off = useAccountStore.persist.onFinishHydration(() => { off(); resolve(); }); + }); + } + useAccountStore.setState({ accounts: registry, activeAccountId: registry[0]?.id ?? null }); +} + +const teardown = vi.mocked(task.teardownAuthority); +const setSyncEnabled = vi.mocked(native.setSyncEnabled); + +beforeEach(async () => { + vi.clearAllMocks(); + h.state.available = true; + h.state.permitted = true; + h.state.master = true; + h.state.accounts = []; + h.state.log = []; + h.state.failSetSyncEnabled = false; + teardown.mockImplementation(h.teardown); + await AsyncStorage.clear(); + useDeviceSyncStore.setState({ accounts: {} }); + await ready([entry(ALICE, 'alice'), entry(BOB, 'bob')]); +}); + +describe('enable', () => { + it('creates the Android account under the login, turns automatic and periodic sync on and syncs', async () => { + useDeviceSyncStore.getState().setIntervalSeconds(ALICE, CONTACTS_AUTHORITY, 1800); + const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + expect(outcome).toEqual({ kind: 'enabled', accountName: 'alice' }); + expect(h.state.log).toEqual([ + 'ensure alice', + `auto alice ${CONTACTS_AUTHORITY} true`, + `periodic alice ${CONTACTS_AUTHORITY} 1800`, + `sync alice ${CONTACTS_AUTHORITY} {"manual":true}`, + ]); + const stored = accountDeviceSync(ALICE); + expect(stored.androidAccountName).toBe('alice'); + expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true }); + await vi.waitFor(() => expect(refreshPushSubscriptionTypes).toHaveBeenCalledWith(ALICE)); + }); + + it('uses the default hourly interval', async () => { + await enableDeviceSync(ALICE, CALENDAR_AUTHORITY); + expect(h.state.log).toContain(`periodic alice ${CALENDAR_AUTHORITY} 3600`); + }); + + it('does nothing without the runtime permissions', async () => { + h.state.permitted = false; + expect(await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ kind: 'permission' }); + expect(h.state.log).toEqual([]); + expect(accountDeviceSync(ALICE).enabled).toBeUndefined(); + }); + + it('reuses the Android account of the app account', async () => { + h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} }); + await enableDeviceSync(ALICE, CALENDAR_AUTHORITY); + expect(h.state.log.filter((l) => l.startsWith('ensure'))).toEqual([]); + expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice'); + }); + + it('names the account after its registry id when another app account has the login', async () => { + await ready([entry(ALICE, 'alice'), entry(ALICE2, 'alice')]); + h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} }); + expect(preferredAndroidAccountName(ALICE2, await native.listAndroidAccounts())).toBe(ALICE2); + const outcome = await enableDeviceSync(ALICE2, CONTACTS_AUTHORITY); + expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE2 }); + }); + + it('falls back to the registry id when the login turns out to be taken', async () => { + const list = vi.mocked(native.listAndroidAccounts); + // A stale list: the clash only shows when the account is added. + list.mockResolvedValueOnce([]); + h.state.accounts.push({ name: 'alice', registryId: ALICE2, sync: {} }); + const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE }); + }); + + it('leaves no account behind when turning sync on fails', async () => { + h.state.failSetSyncEnabled = true; + const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + expect(outcome.kind).toBe('failed'); + expect(h.state.accounts).toEqual([]); + expect(accountDeviceSync(ALICE).enabled).toBeUndefined(); + }); + + it('keeps the account of the other authority when turning one on fails', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + vi.mocked(native.setPeriodicSync).mockRejectedValueOnce(new Error('boom')); + expect((await enableDeviceSync(ALICE, CALENDAR_AUTHORITY)).kind).toBe('failed'); + expect(h.find('alice')?.sync[CALENDAR_AUTHORITY].automatic).toBe(false); + expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true); + expect(accountDeviceSync(ALICE)).toMatchObject({ + androidAccountName: 'alice', + enabled: { [CONTACTS_AUTHORITY]: true }, + }); + }); + + it('is a no-op where device sync is not available', async () => { + h.state.available = false; + expect((await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).kind).toBe('failed'); + expect(h.state.log).toEqual([]); + }); +}); + +describe('disable', () => { + beforeEach(async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + await enableDeviceSync(ALICE, CALENDAR_AUTHORITY); + await changeReminderOwner(ALICE, 'bulwark'); + h.state.log = []; + }); + + it('turns automatic sync off before the teardown uploads and deletes', async () => { + const result = await disableDeviceSync(ALICE, CALENDAR_AUTHORITY); + expect(result).toEqual({ done: true, pending: 0 }); + expect(h.state.log).toEqual([ + `auto alice ${CALENDAR_AUTHORITY} false`, + `teardown alice ${CALENDAR_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`, + ]); + const stored = accountDeviceSync(ALICE); + expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: false }); + // Contacts still sync: the account stays; the reminder question comes again next time. + expect(h.find('alice')).toBeDefined(); + expect(stored.reminderOwner).toBeNull(); + }); + + it('removes the Android account once both authorities are off', async () => { + await disableDeviceSync(ALICE, CALENDAR_AUTHORITY); + await disableDeviceSync(ALICE, CONTACTS_AUTHORITY); + expect(h.state.accounts).toEqual([]); + expect(h.state.log).toContain('remove alice'); + expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined(); + }); + + it('deletes nothing and reports the waiting changes when they could not be uploaded', async () => { + teardown.mockResolvedValueOnce({ pending: 3 }); + const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY); + expect(result).toEqual({ done: false, pending: 3 }); + expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true); + + // The user keeps syncing: automatic sync comes back. + h.state.log = []; + await resumeDeviceSync(ALICE, CONTACTS_AUTHORITY); + expect(h.state.log).toEqual([ + `auto alice ${CONTACTS_AUTHORITY} true`, + `sync alice ${CONTACTS_AUTHORITY} {"manual":true}`, + ]); + }); + + it('deletes on the user\'s word with force', async () => { + teardown.mockResolvedValueOnce({ pending: 2 }); + await disableDeviceSync(ALICE, CONTACTS_AUTHORITY); + h.state.log = []; + const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY, { force: true }); + expect(result.done).toBe(true); + expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"force":true,"timeoutMs":5000}`); + expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(false); + }); + + it('reports a failed teardown as unknown changes', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + teardown.mockRejectedValueOnce(new Error('provider gone')); + expect(await disableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ done: false, pending: 0, failed: true }); + warn.mockRestore(); + }); +}); + +describe('sign-out', () => { + it('lets an account without device sync go at once', async () => { + const confirm = vi.fn(async () => true); + expect(await releaseDeviceSyncBeforeSignOut([BOB], confirm)).toBe(true); + expect(teardown).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('uploads, deletes and removes the Android account before the credentials go', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + h.state.log = []; + const confirm = vi.fn(async () => true); + expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS }); + expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CALENDAR_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS }); + expect(h.state.accounts).toEqual([]); + expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined(); + // Automatic sync went off before the first teardown. + expect(h.state.log.indexOf(`auto alice ${CONTACTS_AUTHORITY} false`)) + .toBeLessThan(h.state.log.findIndex((l) => l.startsWith('teardown'))); + }); + + it('asks before losing changes, and keeps syncing when the user stays', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + teardown.mockImplementation(async (_r: string, _n: string, authority: string) => ({ pending: authority === CONTACTS_AUTHORITY ? 4 : 0 })); + const confirm = vi.fn(async () => false); + expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(false); + expect(confirm).toHaveBeenCalledWith(4); + expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true); + expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice'); + }); + + it('deletes what is left once the user agrees', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + teardown.mockImplementation(async (_r: string, _n: string, authority: string, options?: { force?: boolean }) => + ({ pending: authority === CONTACTS_AUTHORITY && !options?.force ? 4 : 0 })); + const confirm = vi.fn(async () => true); + expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true); + expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { force: true, timeoutMs: 5_000 }); + expect(h.state.accounts).toEqual([]); + }); + + it('asks once for several accounts', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + await enableDeviceSync(BOB, CALENDAR_AUTHORITY); + teardown.mockImplementation(async (_r: string, _n: string, _a: string, options?: { force?: boolean }) => ({ pending: options?.force ? 0 : 1 })); + const confirm = vi.fn(async () => true); + expect(await releaseDeviceSyncBeforeSignOut([ALICE, BOB], confirm)).toBe(true); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledWith(4); + expect(h.state.accounts).toEqual([]); + }); +}); + +describe('reconcile', () => { + it('suspends an Android account whose app account the registry dropped, once', async () => { + h.state.accounts.push({ + name: 'carol', + registryId: 'carol@mail.example.org', + sync: { [CONTACTS_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } }, + }); + await reconcileDeviceAccounts(); + expect(h.find('carol')?.sync[CONTACTS_AUTHORITY].automatic).toBe(false); + expect(h.find('carol')).toBeDefined(); + expect(native.showSyncProblem).toHaveBeenCalledTimes(1); + expect(vi.mocked(native.showSyncProblem).mock.calls[0][3]).toBe('bulwarkmobile://settings/account'); + const carol = accountDeviceSync('carol@mail.example.org'); + expect(carol.suspended).toBe(true); + expect(carol.enabled).toEqual({ [CONTACTS_AUTHORITY]: true }); + + await reconcileDeviceAccounts(); + expect(native.showSyncProblem).toHaveBeenCalledTimes(1); + }); + + it('drops a suspended account and its data when the user removes it', async () => { + h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} }); + await reconcileDeviceAccounts(); + h.state.log = []; + await removeSuspendedAccount('carol@mail.example.org'); + expect(h.state.log).toEqual(['remove carol', 'clear carol']); + expect(useDeviceSyncStore.getState().accounts['carol@mail.example.org']).toBeUndefined(); + }); + + it('never acts on an empty registry', async () => { + await ready([]); + h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} }); + await reconcileDeviceAccounts(); + expect(native.showSyncProblem).not.toHaveBeenCalled(); + expect(setSyncEnabled).not.toHaveBeenCalled(); + }); + + it('shows sync off for an account removed in Android Settings and never recreates it', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + h.state.accounts = []; + vi.mocked(native.ensureAndroidAccount).mockClear(); + await reconcileDeviceAccounts(); + const alice = accountDeviceSync(ALICE); + expect(alice.removedInAndroidSettings).toBe(true); + expect(alice.androidAccountName).toBeUndefined(); + expect(alice.enabled).toEqual({}); + await reconcileDeviceAccounts(); + expect(native.ensureAndroidAccount).not.toHaveBeenCalled(); + expect(h.state.accounts).toEqual([]); + }); + + it('forgets an account that is neither signed in nor on the device', async () => { + useDeviceSyncStore.getState().update('gone@mail.example.org', { androidAccountName: 'gone', enabled: { [CONTACTS_AUTHORITY]: true } }); + await reconcileDeviceAccounts(); + expect(useDeviceSyncStore.getState().accounts['gone@mail.example.org']).toBeUndefined(); + }); + + it('takes an authority turned on in Android Settings as on', async () => { + h.state.accounts.push({ + name: 'alice', + registryId: ALICE, + sync: { [CALENDAR_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } }, + }); + await reconcileDeviceAccounts(); + const alice = accountDeviceSync(ALICE); + expect(alice.androidAccountName).toBe('alice'); + expect(alice.enabled).toEqual({ [CALENDAR_AUTHORITY]: true }); + }); + + it('hands the push router the accounts that feed each Android account', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c'); + await reconcileDeviceAccounts(); + expect(native.setPushRoutes).toHaveBeenLastCalledWith({ + c: [{ accountName: 'alice', authorities: [CONTACTS_AUTHORITY] }], + }); + }); +}); + +describe('sign-in', () => { + it('clears the sign-in notice and resumes a suspended account where it synced', async () => { + h.state.accounts.push({ + name: 'alice', + registryId: ALICE, + sync: { [CONTACTS_AUTHORITY]: { automatic: false, periodicSeconds: 3600 } }, + }); + useDeviceSyncStore.getState().update(ALICE, { + androidAccountName: 'alice', + suspended: true, + enabled: { [CONTACTS_AUTHORITY]: true }, + }); + await deviceSyncSignedIn(ALICE); + expect(h.state.log).toEqual([ + 'clear alice', + `auto alice ${CONTACTS_AUTHORITY} true`, + `sync alice ${CONTACTS_AUTHORITY} {"manual":true}`, + ]); + expect(accountDeviceSync(ALICE).suspended).toBeUndefined(); + }); + + it('does nothing for an account without an Android account', async () => { + await deviceSyncSignedIn(BOB); + expect(h.state.log).toEqual([]); + }); +}); + +describe('settings actions', () => { + it('applies a new interval to Android while the authority syncs', async () => { + await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + h.state.log = []; + await changeSyncInterval(ALICE, CONTACTS_AUTHORITY, 0); + expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]); + await changeSyncInterval(ALICE, CALENDAR_AUTHORITY, 900); + expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]); + expect(accountDeviceSync(ALICE).intervalSeconds[CALENDAR_AUTHORITY as Authority]).toBe(900); + }); + + it('syncs the calendar when the reminder owner changes', async () => { + await enableDeviceSync(ALICE, CALENDAR_AUTHORITY); + h.state.log = []; + await changeReminderOwner(ALICE, 'device'); + expect(h.state.log).toEqual([`sync alice ${CALENDAR_AUTHORITY} {"manual":true}`]); + h.state.log = []; + await changeReminderOwner(ALICE, 'device'); + expect(h.state.log).toEqual([]); + }); +}); diff --git a/src/device-sync/__tests__/app/triggers.test.ts b/src/device-sync/__tests__/app/triggers.test.ts new file mode 100644 index 00000000..ab057388 --- /dev/null +++ b/src/device-sync/__tests__/app/triggers.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const h = vi.hoisted(() => ({ + available: true, + appState: [] as Array<(state: string) => void>, + accountsChanged: [] as Array<() => void>, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'android', Version: 34, select: (s: { android?: T; default?: T }) => s.android ?? s.default }, + NativeModules: {}, + NativeEventEmitter: class { + addListener() { + return { remove: () => undefined }; + } + }, + AppState: { + currentState: 'active', + addEventListener: (_type: string, listener: (state: string) => void) => { + h.appState.push(listener); + return { remove: () => { h.appState = h.appState.filter((l) => l !== listener); } }; + }, + }, +})); + +vi.mock('../../native', () => ({ + isDeviceSyncAvailable: () => h.available, + requestSync: vi.fn(async () => undefined), + onAccountsChanged: vi.fn((listener: () => void) => { + h.accountsChanged.push(listener); + return () => { h.accountsChanged = h.accountsChanged.filter((l) => l !== listener); }; + }), +})); + +vi.mock('../../app/lifecycle', () => ({ + reconcileDeviceAccounts: vi.fn(async () => undefined), + refreshPushRoutes: vi.fn(async () => undefined), +})); + +import * as native from '../../native'; +import * as lifecycle from '../../app/lifecycle'; +import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types'; +import { dispatchStateChange } from '../../../lib/state-change-bus'; +import { useAccountStore, type AccountEntry } from '../../../stores/account-store'; +import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store'; +import { cancelDeviceSyncRequests, requestDeviceSync, TRIGGER_DELAY_MS } from '../../app/request'; +import { + FOREGROUND_MIN_INTERVAL_MS, + handleForeground, + handleStateChange, + startDeviceSyncTriggers, +} from '../../app/triggers'; + +const ALICE = 'alice@mail.example.org'; +const BOB = 'bob@mail.example.org'; +const requestSync = vi.mocked(native.requestSync); + +function account(id: string): AccountEntry { + return { + id, + serverUrl: 'https://mail.example.org', + username: id.split('@')[0], + displayName: id, + email: id, + avatarColor: '#000', + lastLoginAt: 0, + isConnected: true, + hasError: false, + isDefault: false, + }; +} + +// Alice syncs contacts and calendars; Bob only calendars. +function syncing(): void { + useDeviceSyncStore.setState({ + accounts: { + [ALICE]: { + contactsSelection: {}, + calendarSelection: { 'team/cal': true }, + reminderOwner: null, + intervalSeconds: {}, + lastRun: {}, + androidAccountName: 'alice', + enabled: { [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: true }, + }, + [BOB]: { + contactsSelection: {}, + calendarSelection: {}, + reminderOwner: null, + intervalSeconds: {}, + lastRun: {}, + androidAccountName: 'bob', + enabled: { [CALENDAR_AUTHORITY]: true }, + }, + }, + }); +} + +let stop: (() => void) | null = null; + +beforeEach(async () => { + await AsyncStorage.clear(); + await waitForDeviceSyncHydration(); + vi.clearAllMocks(); + vi.useFakeTimers(); + h.available = true; + cancelDeviceSyncRequests(); + useAccountStore.setState({ accounts: [account(ALICE), account(BOB)], activeAccountId: ALICE }); + syncing(); +}); + +afterEach(() => { + stop?.(); + stop = null; + cancelDeviceSyncRequests(); + vi.useRealTimers(); +}); + +describe('requestDeviceSync', () => { + it('makes one sync of the edits and echoes of 5 seconds', async () => { + requestDeviceSync(CONTACTS_AUTHORITY); + await vi.advanceTimersByTimeAsync(2000); + requestDeviceSync(CONTACTS_AUTHORITY, ALICE); + requestDeviceSync(CONTACTS_AUTHORITY, ALICE, { jmapAccountId: 'c', type: 'ContactCard', state: 's2' }); + expect(requestSync).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS - 2000); + expect(requestSync).toHaveBeenCalledTimes(1); + expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {}); + + // The next change arms a new request. + requestDeviceSync(CONTACTS_AUTHORITY); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledTimes(2); + }); + + it('keeps accounts and authorities apart', async () => { + requestDeviceSync(CONTACTS_AUTHORITY, ALICE); + requestDeviceSync(CALENDAR_AUTHORITY, ALICE); + requestDeviceSync(CALENDAR_AUTHORITY, BOB); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([ + `alice ${CALENDAR_AUTHORITY}`, + `alice ${CONTACTS_AUTHORITY}`, + `bob ${CALENDAR_AUTHORITY}`, + ]); + }); + + it('asks nothing for an authority the account does not sync, or without device sync', async () => { + requestDeviceSync(CONTACTS_AUTHORITY, BOB); + h.available = false; + requestDeviceSync(CALENDAR_AUTHORITY, BOB); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).not.toHaveBeenCalled(); + }); + + it('drops a request when sync was turned off meanwhile', async () => { + requestDeviceSync(CONTACTS_AUTHORITY, ALICE); + useDeviceSyncStore.getState().setEnabled(ALICE, CONTACTS_AUTHORITY, false); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).not.toHaveBeenCalled(); + }); + + it('can be cancelled', async () => { + requestDeviceSync(CONTACTS_AUTHORITY, ALICE); + requestDeviceSync(CALENDAR_AUTHORITY, ALICE); + cancelDeviceSyncRequests(ALICE, CONTACTS_AUTHORITY); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledTimes(1); + expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {}); + }); +}); + +describe('StateChanges of the live stream', () => { + it('syncs the active account on a contact or calendar change', async () => { + handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'c', 'e7'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {}); + }); + + it('skips the echo of what the engine synced itself', async () => { + useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's9'); + handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's9'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).not.toHaveBeenCalled(); + }); + + it('skips an echo that arrived before the engine recorded its state', async () => { + handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's10'); + // The engine's upload returns and records the state it moved the account to. + useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's10'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).not.toHaveBeenCalled(); + }); + + it('still syncs when an edit in the app joined the echo', async () => { + handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's11'); + requestDeviceSync(CONTACTS_AUTHORITY); + useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's11'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledTimes(1); + }); + + it('ignores shared accounts nothing is synced from, once the personal account is known', async () => { + handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x1'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + // Unknown yet: anything may feed the device. + expect(requestSync).toHaveBeenCalledTimes(1); + + useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CALENDAR_AUTHORITY, 'c'); + handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x2'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledTimes(1); + + // The shared account a calendar was picked in does feed it. + handleStateChange(CALENDAR_AUTHORITY, 'Calendar', 'team', 'x3'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledTimes(2); + }); + + it('does nothing for an authority the active account does not sync', async () => { + useAccountStore.setState({ activeAccountId: BOB }); + handleStateChange(CONTACTS_AUTHORITY, 'AddressBook', 'b', 'x'); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).not.toHaveBeenCalled(); + }); +}); + +describe('foreground', () => { + it('reconciles the accounts, then syncs everything that syncs, at most once a minute', async () => { + await handleForeground(1_000_000); + expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([ + `alice ${CALENDAR_AUTHORITY}`, + `alice ${CONTACTS_AUTHORITY}`, + `bob ${CALENDAR_AUTHORITY}`, + ]); + + await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS - 1); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledTimes(3); + + await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledTimes(6); + }); +}); + +describe('startDeviceSyncTriggers', () => { + it('listens to the stream, the foreground, account changes and the preferences', async () => { + stop = startDeviceSyncTriggers(); + // Launch counts as coming to the foreground. + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1); + requestSync.mockClear(); + + dispatchStateChange({ '@type': 'StateChange', changed: { c: { AddressBook: 'a2', Email: 'm1' } } }); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {}); + + for (const listener of h.appState) listener('background'); + for (const listener of h.appState) listener('active'); + await vi.advanceTimersByTimeAsync(0); + expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(2); + + for (const listener of h.accountsChanged) listener(); + expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(3); + + useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c'); + await vi.advanceTimersByTimeAsync(1000); + expect(lifecycle.refreshPushRoutes).toHaveBeenCalled(); + + // Started once only. + expect(startDeviceSyncTriggers()).toBe(stop); + }); + + it('starts nothing without device sync', async () => { + h.available = false; + startDeviceSyncTriggers()(); + await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS); + expect(lifecycle.reconcileDeviceAccounts).not.toHaveBeenCalled(); + expect(native.onAccountsChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/device-sync/app/available.ts b/src/device-sync/app/available.ts new file mode 100644 index 00000000..dde03815 --- /dev/null +++ b/src/device-sync/app/available.ts @@ -0,0 +1,17 @@ +/** + * The gate of every app-side device sync feature (docs/device-sync.md, "App + * integration"): Android, and a build that carries the native module. iOS, + * and an Android build without the module, behave as if device sync did not + * exist. + */ +import { supportsDeviceSync } from '../../lib/platform-capabilities'; +import { isDeviceSyncAvailable } from '../native'; + +export function deviceSyncAvailable(): boolean { + if (!supportsDeviceSync) return false; + try { + return isDeviceSyncAvailable(); + } catch { + return false; + } +} diff --git a/src/device-sync/app/confirm.ts b/src/device-sync/app/confirm.ts new file mode 100644 index 00000000..ddd357cc --- /dev/null +++ b/src/device-sync/app/confirm.ts @@ -0,0 +1,40 @@ +/** + * The questions device sync asks outside the settings screen, as system + * alerts: signing out while changes made on the device have not reached the + * server (docs/device-sync.md, "Lifecycle"). + */ +import { Alert } from 'react-native'; +import { t } from '../../stores/locale-store'; + +/** + * Asks whether to sign out although `pending` device changes (unknown when + * the upload could not even be tried) would be lost. Resolves true to go on. + */ +export function confirmSignOutLosingChanges(pending: number): Promise { + return new Promise((resolve) => { + let settled = false; + const answer = (value: boolean) => { + if (settled) return; + settled = true; + resolve(value); + }; + Alert.alert( + t('device_sync.sign_out_pending_title', 'Sign out anyway?'), + pending > 0 + ? t( + 'device_sync.sign_out_pending_message', + "{count, plural, one {# change made on this device hasn't reached the server. It is lost if you sign out now.} other {# changes made on this device haven't reached the server. They are lost if you sign out now.}}", + { count: pending }, + ) + : t( + 'device_sync.sign_out_unknown_message', + "Changes made on this device may not have reached the server. They are lost if you sign out now.", + ), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) }, + { text: t('device_sync.sign_out_anyway', 'Sign out'), style: 'destructive', onPress: () => answer(true) }, + ], + { cancelable: true, onDismiss: () => answer(false) }, + ); + }); +} diff --git a/src/device-sync/app/lifecycle.ts b/src/device-sync/app/lifecycle.ts new file mode 100644 index 00000000..f87e9397 --- /dev/null +++ b/src/device-sync/app/lifecycle.ts @@ -0,0 +1,622 @@ +/** + * Turning device sync on and off, signing out, and keeping Android's accounts + * in step with the app's account registry (docs/device-sync.md, "Lifecycle"). + * + * - Enable: permissions (asked by the settings first) → the Android account + * → automatic sync on → the periodic sync → a manual sync → push routes and + * push types. + * - Disable an authority: automatic sync off, then the engine's teardown + * uploads what the device changed and deletes the authority's rows. When + * changes could not be uploaded nothing is deleted and the caller asks the + * user; only a confirmed turn-off deletes them. With both authorities off + * the Android account goes. + * - Sign-out: the same for every authority, before the credentials go. + * - Reconcile (launch, foreground, accounts changed): an Android account whose + * app account the registry dropped is suspended (sync off, one notification, + * data kept); an app account whose Android account was removed in Android + * Settings shows sync off and is never recreated on its own. + * + * The engine (`../task`) is imported on first use only, so starting the app + * does not load it. + */ +import { + clearSyncProblem, + ensureAndroidAccount, + getSyncSettings, + listAndroidAccounts, + openAccountSettings, + removeAndroidAccount, + requestSync, + setPeriodicSync, + setPushRoutes, + setSyncEnabled, + showSyncProblem, +} from '../native'; +import { + AUTHORITIES, + CALENDAR_AUTHORITY, + type AndroidAccount, + type Authority, + type ReminderOwner, +} from '../types'; +import { useAccountStore } from '../../stores/account-store'; +import { + accountDeviceSync, + anySyncOnInApp, + computePushRoutes, + intervalFor, + syncOnInApp, + useDeviceSyncStore, + waitForDeviceSyncHydration, + type AccountDeviceSync, +} from '../../stores/device-sync-store'; +import { t } from '../../stores/locale-store'; +import { toast } from '../../stores/toast-store'; +import { refreshPushSubscriptionTypes } from '../../lib/push-notifications'; +import { deviceSyncAvailable } from './available'; +import { confirmSignOutLosingChanges } from './confirm'; +import { hasSyncPermissions } from './permissions'; +import { STATE_TYPES_BY_AUTHORITY } from './prefs'; +import { cancelDeviceSyncRequests } from './request'; + +/** How long the teardown may spend uploading before it gives up (and asks). */ +export const TEARDOWN_TIMEOUT_MS = 30_000; + +/** After the user agreed to lose what is waiting: one short last try to upload it. */ +export const FORCED_TEARDOWN_TIMEOUT_MS = 5_000; + +/** Where the "sign in again" notification of a suspended account leads: the accounts pane. */ +export const SIGN_IN_AGAIN_URI = 'bulwarkmobile://settings/account'; + +const REGISTRY_HYDRATION_TIMEOUT_MS = 5000; + +const noop = () => undefined; + +function errorMessage(err: unknown): string { + return err instanceof Error && err.message ? err.message : String(err); +} + +/** The registry, once read; null when it could not be read in time. */ +async function readRegistry(): Promise['accounts'] | null> { + const persist = useAccountStore.persist; + if (!persist.hasHydrated()) { + const hydrated = await new Promise((resolve) => { + const timer = setTimeout(() => { + unsubscribe(); + resolve(false); + }, REGISTRY_HYDRATION_TIMEOUT_MS); + const unsubscribe = persist.onFinishHydration(() => { + clearTimeout(timer); + unsubscribe(); + resolve(true); + }); + }); + if (!hydrated) return null; + } + return useAccountStore.getState().accounts; +} + +// ─── Android account names ───────────────────────────── + +/** + * The Android account name for an app account: its login, or its registry id + * when another app account's Android account already has that name (the same + * login on two servers). + */ +export function preferredAndroidAccountName(registryId: string, androidAccounts: readonly AndroidAccount[]): string { + const login = useAccountStore.getState().getAccountById(registryId)?.username?.trim(); + if (!login) return registryId; + const takenOnDevice = androidAccounts.some((a) => a.name === login && a.registryId !== registryId); + const takenInApp = Object.entries(useDeviceSyncStore.getState().accounts) + .some(([id, entry]) => id !== registryId && entry.androidAccountName === login); + return takenOnDevice || takenInApp ? registryId : login; +} + +/** Creates the app account's Android account if needed; resolves its name. */ +async function ensureAccountFor(registryId: string): Promise { + const androidAccounts = await listAndroidAccounts(); + const own = androidAccounts.find((a) => a.registryId === registryId); + if (own) return own.name; + const name = preferredAndroidAccountName(registryId, androidAccounts); + try { + await ensureAndroidAccount(name, registryId); + return name; + } catch (err) { + // The name belongs to another app account after all (the list was stale). + if (name === registryId) throw err; + await ensureAndroidAccount(registryId, registryId); + return registryId; + } +} + +async function androidAccountOf(registryId: string): Promise { + try { + return (await listAndroidAccounts()).find((a) => a.registryId === registryId) ?? null; + } catch { + return null; + } +} + +// ─── Push ────────────────────────────────────────────── + +let lastRoutesJson: string | null = null; + +/** Hands the native push router the JMAP accounts that feed each Android account. */ +export async function refreshPushRoutes(): Promise { + if (!deviceSyncAvailable()) return; + await waitForDeviceSyncHydration(); + const routes = computePushRoutes(useDeviceSyncStore.getState().accounts); + const json = JSON.stringify(routes); + if (json === lastRoutesJson) return; + try { + await setPushRoutes(routes); + lastRoutesJson = json; + } catch { + // Kept for the next refresh. + } +} + +/** Push routes plus the account's push subscription types, after a toggle changed. */ +async function refreshPushState(registryId: string): Promise { + await refreshPushRoutes(); + await refreshPushSubscriptionTypes(registryId).catch(noop); +} + +// ─── Enable ──────────────────────────────────────────── + +export type EnableOutcome = + | { kind: 'enabled'; accountName: string } + /** The runtime permissions are missing: the caller asks for them first. */ + | { kind: 'permission' } + | { kind: 'failed'; message: string }; + +/** + * 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 { + if (!deviceSyncAvailable()) return { kind: 'failed', message: 'Device sync is not available' }; + if (!(await hasSyncPermissions(authority))) return { kind: 'permission' }; + await waitForDeviceSyncHydration(); + let accountName: string | null = null; + try { + accountName = await ensureAccountFor(registryId); + useDeviceSyncStore.getState().update(registryId, { + androidAccountName: accountName, + removedInAndroidSettings: undefined, + suspended: undefined, + }); + await setSyncEnabled(accountName, authority, true); + await setPeriodicSync(accountName, authority, intervalFor(accountDeviceSync(registryId), authority)); + const entry = accountDeviceSync(registryId); + useDeviceSyncStore.getState().update(registryId, { enabled: { ...entry.enabled, [authority]: true } }); + await requestSync(accountName, authority, { manual: true }).catch(noop); + void refreshPushState(registryId); + return { kind: 'enabled', accountName }; + } catch (err) { + if (accountName) { + await setSyncEnabled(accountName, authority, false).catch(noop); + // Don't leave an account behind that syncs nothing. + if (!anySyncOnInApp(accountDeviceSync(registryId))) { + useDeviceSyncStore.getState().update(registryId, { androidAccountName: undefined }); + await removeAndroidAccount(accountName).catch(noop); + } + } + return { kind: 'failed', message: errorMessage(err) }; + } +} + +/** Automatic sync back on after the user kept syncing instead of losing changes. */ +export async function resumeDeviceSync(registryId: string, authority: Authority): Promise { + if (!deviceSyncAvailable()) return; + await waitForDeviceSyncHydration(); + const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name; + if (!accountName) return; + await setSyncEnabled(accountName, authority, true).catch(noop); + await requestSync(accountName, authority, { manual: true }).catch(noop); +} + +// ─── Disable ─────────────────────────────────────────── + +export interface TurnOffResult { + /** Rows deleted and the authority off in the app. */ + done: boolean; + /** Device changes that could not be uploaded (nothing was deleted). */ + pending: number; + /** The teardown itself failed: whether changes are waiting is unknown. */ + failed?: boolean; +} + +interface TeardownResult { + pending: number; + failed: boolean; +} + +// The engine, loaded once on first use (sign-outs tear several accounts down +// at the same time). +let engine: Promise | null = null; + +function loadEngine(): Promise { + engine ??= import('../task').catch((err: unknown) => { + engine = null; + throw err; + }); + return engine; +} + +async function teardown( + registryId: string, + accountName: string, + authority: Authority, + force: boolean, +): Promise { + try { + const { teardownAuthority } = await loadEngine(); + const result = await teardownAuthority( + registryId, + accountName, + authority, + force + ? { force: true, timeoutMs: FORCED_TEARDOWN_TIMEOUT_MS } + : { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS }, + ); + return { pending: Math.max(0, result?.pending ?? 0), failed: false }; + } catch (err) { + console.warn('[device-sync] teardown failed', errorMessage(err)); + return { pending: 0, failed: true }; + } +} + +/** The authority's JMAP states, dropped with its rows so no old state passes for an echo. */ +function withoutAuthorityStates(entry: AccountDeviceSync, authority: Authority): AccountDeviceSync['knownStates'] { + if (!entry.knownStates) return undefined; + const types = STATE_TYPES_BY_AUTHORITY[authority]; + const out: Record> = {}; + for (const [jmapAccountId, states] of Object.entries(entry.knownStates)) { + const kept = Object.fromEntries(Object.entries(states ?? {}).filter(([type]) => !types.includes(type))); + if (Object.keys(kept).length > 0) out[jmapAccountId] = kept; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Turns device sync off for one authority. Uploads what the device changed + * first; when that is not possible, nothing is deleted and the result says + * 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( + registryId: string, + authority: Authority, + options: { force?: boolean } = {}, +): Promise { + if (!deviceSyncAvailable()) return { done: true, pending: 0 }; + await waitForDeviceSyncHydration(); + const store = useDeviceSyncStore.getState(); + const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name; + cancelDeviceSyncRequests(registryId, authority); + if (!accountName) { + store.setEnabled(registryId, authority, false); + return { done: true, pending: 0 }; + } + await setSyncEnabled(accountName, authority, false).catch(noop); + const result = await teardown(registryId, accountName, authority, !!options.force); + if (result.failed || result.pending > 0) { + return { done: false, pending: result.pending, failed: result.failed || undefined }; + } + + const before = accountDeviceSync(registryId); + const { [authority]: _lastRun, ...lastRun } = before.lastRun; + useDeviceSyncStore.getState().update(registryId, { + enabled: { ...before.enabled, [authority]: false }, + lastRun, + knownStates: withoutAuthorityStates(before, authority), + // Asked again the next time calendar sync is turned on. + ...(authority === CALENDAR_AUTHORITY ? { reminderOwner: null } : {}), + }); + if (!anySyncOnInApp(accountDeviceSync(registryId))) { + // Forget the name first: the removal fires `accountsChanged`, and the + // reconcile must not take it for a removal in Android Settings. + useDeviceSyncStore.getState().update(registryId, { androidAccountName: undefined, knownStates: undefined }); + await removeAndroidAccount(accountName).catch(noop); + await clearSyncProblem(accountName).catch(noop); + } + void refreshPushState(registryId); + return { done: true, pending: 0 }; +} + +// ─── Sign-out ────────────────────────────────────────── + +/** + * Before app accounts sign out (`auth-store` logout, logoutAll, + * removeAccount): uploads what their devices changed, deletes the synced + * rows and removes their Android accounts, while the credentials are still + * there. When changes could not be uploaded, `confirm` asks whether to sign + * out anyway. Resolves false when the user chose to stay signed in; their + * accounts then keep syncing. + */ +export async function releaseDeviceSyncBeforeSignOut( + registryIds: readonly string[], + confirm: (pending: number) => Promise = confirmSignOutLosingChanges, +): Promise { + if (!deviceSyncAvailable() || registryIds.length === 0) return true; + try { + return await release(registryIds, confirm); + } catch (err) { + // Never keep the user from signing out over a bug here; an Android + // account left behind is suspended by the next reconcile. + console.warn('[device-sync] release before sign-out failed', errorMessage(err)); + return true; + } +} + +async function release( + registryIds: readonly string[], + confirm: (pending: number) => Promise, +): Promise { + await waitForDeviceSyncHydration(); + let androidAccounts: AndroidAccount[] = []; + try { + androidAccounts = await listAndroidAccounts(); + } catch { + androidAccounts = []; + } + const targets = registryIds.flatMap((registryId) => { + const accountName = androidAccounts.find((a) => a.registryId === registryId)?.name + ?? accountDeviceSync(registryId).androidAccountName; + return accountName ? [{ registryId, accountName }] : []; + }); + if (targets.length > 0) { + toast.info(t('device_sync.signing_out', 'Removing synced contacts and calendars from this device…')); + } + + // Accounts in parallel (each has its own server and run lock), the + // authorities of one account one after the other. + const leftovers: Array<{ registryId: string; accountName: string; authority: Authority }> = []; + let pending = 0; + await Promise.all(targets.map(async ({ registryId, accountName }) => { + cancelDeviceSyncRequests(registryId); + for (const authority of AUTHORITIES) { + await setSyncEnabled(accountName, authority, false).catch(noop); + const result = await teardown(registryId, accountName, authority, false); + if (result.failed || result.pending > 0) { + leftovers.push({ registryId, accountName, authority }); + pending += result.pending; + } + } + })); + + if (leftovers.length > 0) { + if (!(await confirm(pending))) { + // Staying signed in: sync goes on where it was on. + for (const { registryId, accountName } of targets) { + const entry = accountDeviceSync(registryId); + for (const authority of AUTHORITIES) { + if (syncOnInApp(entry, authority)) await setSyncEnabled(accountName, authority, true).catch(noop); + } + } + return false; + } + for (const { registryId, accountName, authority } of leftovers) { + await teardown(registryId, accountName, authority, true); + } + } + + // Forgotten before the removal: its `accountsChanged` must not read as a + // removal in Android Settings. + for (const registryId of registryIds) { + cancelDeviceSyncRequests(registryId); + useDeviceSyncStore.getState().forget(registryId); + } + for (const { accountName } of targets) { + await removeAndroidAccount(accountName).catch(noop); + await clearSyncProblem(accountName).catch(noop); + } + void refreshPushRoutes(); + return true; +} + +// ─── Sign-in and reconcile ───────────────────────────── + +/** + * After an app account signed in successfully: clears its "sign in again" + * notification, resumes a suspended account where it synced, and syncs what + * a failed sign-in held back. + */ +export async function deviceSyncSignedIn(registryId: string): Promise { + if (!deviceSyncAvailable()) return; + try { + await waitForDeviceSyncHydration(); + const account = await androidAccountOf(registryId); + if (!account) return; + await clearSyncProblem(account.name).catch(noop); + const entry = accountDeviceSync(registryId); + useDeviceSyncStore.getState().update(registryId, { androidAccountName: account.name, suspended: undefined }); + const resumed = accountDeviceSync(registryId); + for (const authority of AUTHORITIES) { + if (!syncOnInApp(resumed, authority)) continue; + if (entry.suspended) await setSyncEnabled(account.name, authority, true).catch(noop); + await requestSync(account.name, authority, { manual: true }).catch(noop); + } + await refreshPushRoutes(); + } catch { + // Best effort: the next reconcile catches up. + } +} + +/** An Android account whose app account the registry dropped: sync off, one notification, data kept. */ +async function suspendAccount(account: AndroidAccount & { registryId: string }): Promise { + const entry = useDeviceSyncStore.getState().accounts[account.registryId]; + if (entry?.suspended) return; + // Remember what synced, so signing in again resumes exactly that. + const enabled: Partial> = { ...entry?.enabled }; + try { + const settings = await getSyncSettings(account.name); + for (const authority of AUTHORITIES) { + if (settings.authorities?.[authority]?.automatic) enabled[authority] = true; + } + } catch { + // What the app knows will do. + } + for (const authority of AUTHORITIES) await setSyncEnabled(account.name, authority, false).catch(noop); + cancelDeviceSyncRequests(account.registryId); + useDeviceSyncStore.getState().update(account.registryId, { + androidAccountName: account.name, + enabled, + suspended: true, + }); + await showSyncProblem( + account.name, + t('device_sync.signed_out_title', 'Sign in to keep syncing'), + t( + 'device_sync.signed_out_text', + '{account} was signed out, so its contacts and calendars on this device no longer sync. Sign in again to resume. Changes made on this device are kept.', + { account: account.name }, + ), + SIGN_IN_AGAIN_URI, + t('device_sync.problem_channel', 'Sync problems'), + ).catch(noop); +} + +let reconciling: Promise | null = null; + +/** + * Brings the app's view in step with Android's accounts: at launch, on + * foreground and when accounts of our type change. Concurrent calls share + * one pass. + */ +export function reconcileDeviceAccounts(): Promise { + if (!deviceSyncAvailable()) return Promise.resolve(); + if (!reconciling) { + reconciling = reconcile() + .catch((err) => console.warn('[device-sync] reconcile failed', errorMessage(err))) + .finally(() => { + reconciling = null; + }); + } + return reconciling; +} + +async function reconcile(): Promise { + await waitForDeviceSyncHydration(); + const registry = await readRegistry(); + // Never act on an empty or unreadable registry: a failed read must not + // suspend or forget anything. + if (!registry || registry.length === 0) return; + const androidAccounts = await listAndroidAccounts(); + const registryIds = new Set(registry.map((a) => a.id)); + const store = () => useDeviceSyncStore.getState(); + + // Android accounts that are gone: removed in Android Settings (or by the + // system). Sync shows off and the app does not recreate them. + for (const [registryId, entry] of Object.entries(store().accounts)) { + const name = entry.androidAccountName; + if (!name || androidAccounts.some((a) => a.name === name && (a.registryId ?? registryId) === registryId)) continue; + cancelDeviceSyncRequests(registryId); + if (!registryIds.has(registryId)) { + // Neither signed in nor on the device any more: nothing left to keep. + store().forget(registryId); + continue; + } + store().update(registryId, { + androidAccountName: undefined, + enabled: {}, + suspended: undefined, + knownStates: undefined, + removedInAndroidSettings: anySyncOnInApp(entry) || entry.removedInAndroidSettings || undefined, + }); + void refreshPushSubscriptionTypes(registryId).catch(noop); + } + + for (const account of androidAccounts) { + const registryId = account.registryId; + if (!registryId) continue; + if (!registryIds.has(registryId)) { + await suspendAccount({ ...account, registryId }); + continue; + } + const entry = accountDeviceSync(registryId); + if (entry.suspended) { + // Signed in again through a path that did not report it. + await deviceSyncSignedIn(registryId); + continue; + } + // Android is the source of truth: an authority turned on in Android + // Settings counts as on in the app too. + const enabled = { ...entry.enabled }; + let changed = entry.androidAccountName !== account.name || !!entry.removedInAndroidSettings; + try { + const settings = await getSyncSettings(account.name); + for (const authority of AUTHORITIES) { + if (settings.authorities?.[authority]?.automatic && !enabled[authority]) { + enabled[authority] = true; + changed = true; + } + } + } catch { + // Keep what the app knows. + } + if (changed) { + store().update(registryId, { androidAccountName: account.name, enabled, removedInAndroidSettings: undefined }); + } + } + await refreshPushRoutes(); +} + +// ─── Settings actions ────────────────────────────────── + +/** The periodic sync interval (0 = manual only). */ +export async function changeSyncInterval(registryId: string, authority: Authority, seconds: number): Promise { + await waitForDeviceSyncHydration(); + useDeviceSyncStore.getState().setIntervalSeconds(registryId, authority, seconds); + const entry = accountDeviceSync(registryId); + if (!deviceSyncAvailable() || !entry.androidAccountName || !syncOnInApp(entry, authority)) return; + await setPeriodicSync(entry.androidAccountName, authority, intervalFor(entry, authority)).catch(noop); +} + +/** Who reminds the user of synced events; a change rewrites the device's reminders with a calendar sync. */ +export async function changeReminderOwner(registryId: string, owner: ReminderOwner): Promise { + await waitForDeviceSyncHydration(); + const before = accountDeviceSync(registryId).reminderOwner; + useDeviceSyncStore.getState().setReminderOwner(registryId, owner); + const entry = accountDeviceSync(registryId); + if (before === owner || !deviceSyncAvailable() || !entry.androidAccountName) return; + if (!syncOnInApp(entry, CALENDAR_AUTHORITY)) return; + await requestSync(entry.androidAccountName, CALENDAR_AUTHORITY, { manual: true }).catch(noop); +} + +/** A sync right away: manual and expedited. Resolves false when the account has no Android account. */ +export async function syncNow(registryId: string, authority: Authority): Promise { + const accountName = accountDeviceSync(registryId).androidAccountName; + if (!deviceSyncAvailable() || !accountName) return false; + await requestSync(accountName, authority, { manual: true, expedited: true }); + return true; +} + +/** Android's sync screen of the account ("Android account settings", "Review deletions"). */ +export async function openAndroidAccountSettings(registryId: string): Promise { + const accountName = accountDeviceSync(registryId).androidAccountName; + if (!deviceSyncAvailable() || !accountName) return false; + await openAccountSettings(accountName); + return true; +} + +/** + * Drops a suspended account (one the app signed out while it synced) from + * this device: its Android account and, with it, every synced row. Changes + * made on the device that never reached the server are lost; the caller + * asks first. + */ +export async function removeSuspendedAccount(registryId: string): Promise { + if (!deviceSyncAvailable()) return; + await waitForDeviceSyncHydration(); + const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name; + cancelDeviceSyncRequests(registryId); + // Forgotten first, so the removal's `accountsChanged` finds nothing to mark. + useDeviceSyncStore.getState().forget(registryId); + if (accountName) { + await removeAndroidAccount(accountName).catch(noop); + await clearSyncProblem(accountName).catch(noop); + } + void refreshPushRoutes(); +} diff --git a/src/device-sync/app/permissions.ts b/src/device-sync/app/permissions.ts new file mode 100644 index 00000000..ff54d7ff --- /dev/null +++ b/src/device-sync/app/permissions.ts @@ -0,0 +1,54 @@ +/** + * Runtime permissions device sync needs (docs/device-sync.md, "Settings + * sections"): read and write access to contacts or calendars. Without them + * Android fails every sync before our code runs, so the settings check them + * themselves. + */ +import { Linking, PermissionsAndroid } from 'react-native'; +import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../types'; + +type Permission = Parameters[0]; + +export const SYNC_PERMISSIONS: Record = { + [CONTACTS_AUTHORITY]: ['android.permission.READ_CONTACTS', 'android.permission.WRITE_CONTACTS'], + [CALENDAR_AUTHORITY]: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'], +}; + +/** + * `granted`: both permissions held. `denied`: the user said no; asking again + * shows the dialog again. `blocked`: Android no longer asks ("don't ask + * again", or denied twice), only the app's system settings can grant them. + */ +export type PermissionOutcome = 'granted' | 'denied' | 'blocked'; + +export async function hasSyncPermissions(authority: Authority): Promise { + try { + const held = await Promise.all(SYNC_PERMISSIONS[authority].map((p) => PermissionsAndroid.check(p))); + return held.every(Boolean); + } catch { + return false; + } +} + +export async function requestSyncPermissions(authority: Authority): Promise { + if (await hasSyncPermissions(authority)) return 'granted'; + const wanted = SYNC_PERMISSIONS[authority]; + try { + const results = (await PermissionsAndroid.requestMultiple([...wanted])) as Record; + const answers = wanted.map((p) => results[p]); + if (answers.every((a) => a === 'granted')) return 'granted'; + if (answers.some((a) => a === 'never_ask_again')) return 'blocked'; + return 'denied'; + } catch { + return 'denied'; + } +} + +/** The app's page in Android's settings, where blocked permissions are granted. */ +export async function openAppSystemSettings(): Promise { + try { + await Linking.openSettings(); + } catch { + // Nothing else to offer. + } +} diff --git a/src/device-sync/app/prefs.ts b/src/device-sync/app/prefs.ts new file mode 100644 index 00000000..12190c3d --- /dev/null +++ b/src/device-sync/app/prefs.ts @@ -0,0 +1,80 @@ +/** + * Pure helpers over the persisted device sync preferences, shared by the + * store (src/stores/device-sync-store.ts) and by code that must not create + * the store: the push setup reads the persisted JSON straight from + * AsyncStorage (docs/device-sync.md, "Triggers"). + * + * Types only; nothing here may import react-native or a store. + */ +import { + AUTHORITIES, + CALENDAR_AUTHORITY, + CONTACTS_AUTHORITY, + type Authority, +} from '../types'; + +/** AsyncStorage key of the device sync store (zustand `persist` name). */ +export const DEVICE_SYNC_STORAGE_KEY = 'device-sync:v1'; + +/** The fields that say whether an account syncs an authority. */ +export interface SyncSwitches { + /** Authorities the user turned on in the app. */ + enabled?: Partial>; + /** The Android account was removed in Android Settings. */ + removedInAndroidSettings?: boolean; + /** The app dropped the account from its registry; automatic sync is off until the user signs in again. */ + suspended?: boolean; +} + +/** + * Whether the app has device sync on for this authority: turned on in the + * app and not removed in Android Settings since. Android's own toggle can + * still pause it (see `getSyncAutomatically`). + */ +export function syncOnInApp(entry: SyncSwitches | undefined | null, authority: Authority): boolean { + return !!entry && !!entry.enabled?.[authority] && !entry.removedInAndroidSettings && !entry.suspended; +} + +/** JMAP data types whose changes a synced authority cares about. */ +export const STATE_TYPES_BY_AUTHORITY: Record = { + [CONTACTS_AUTHORITY]: ['ContactCard', 'AddressBook'], + [CALENDAR_AUTHORITY]: ['CalendarEvent', 'Calendar'], +}; + +/** The authority a JMAP data type belongs to, if any. */ +export function authorityOfType(type: string): Authority | null { + for (const authority of AUTHORITIES) { + if (STATE_TYPES_BY_AUTHORITY[authority].includes(type)) return authority; + } + return null; +} + +/** + * Push types device sync adds to an account's push subscription: + * `ContactCard`, `AddressBook` while contacts sync, `CalendarEvent`, + * `Calendar` while calendars sync. + */ +export function deviceSyncPushTypes(entry: SyncSwitches | undefined | null): string[] { + const types: string[] = []; + for (const authority of AUTHORITIES) { + if (syncOnInApp(entry, authority)) types.push(...STATE_TYPES_BY_AUTHORITY[authority]); + } + return types; +} + +/** + * The persisted accounts map from the store's JSON (`{ state: { accounts } }`), + * or an empty map when it is missing or unreadable. + */ +export function persistedAccounts(raw: string | null): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as { state?: { accounts?: unknown } } | null; + const accounts = parsed?.state?.accounts; + return accounts && typeof accounts === 'object' && !Array.isArray(accounts) + ? (accounts as Record) + : {}; + } catch { + return {}; + } +} diff --git a/src/device-sync/app/request.ts b/src/device-sync/app/request.ts new file mode 100644 index 00000000..4d24e891 --- /dev/null +++ b/src/device-sync/app/request.ts @@ -0,0 +1,92 @@ +/** + * Asks Android to sync one account's contacts or calendars because something + * changed while the app runs (docs/device-sync.md, "Triggers"): a StateChange + * on the live stream, the app's own contact or calendar edit, the app coming + * back to the foreground. + * + * Requests are coalesced per account and authority: the first one arms a + * 5-second timer and the rest ride along, so an edit and the StateChange + * echoing it make one sync. A request caused only by StateChanges is dropped + * when the timer fires if the engine has synced exactly those states in the + * meantime (its own uploads echo back). + * + * The request is an ordinary (not manual) one: Android drops it while the + * user has paused the account or the device's auto-sync is off. + */ +import { requestSync } from '../native'; +import type { Authority } from '../types'; +import { useAccountStore } from '../../stores/account-store'; +import { isKnownState, syncOnInApp, useDeviceSyncStore } from '../../stores/device-sync-store'; +import { deviceSyncAvailable } from './available'; + +export const TRIGGER_DELAY_MS = 5000; + +/** The StateChange that asked for a sync. */ +export interface StateReason { + jmapAccountId: string; + type: string; + state: string; +} + +interface Pending { + timer: ReturnType; + reasons: StateReason[]; + /** Asked for by something other than a StateChange: never skipped as an echo. */ + unconditional: boolean; +} + +const pending = new Map(); + +const slotKey = (registryId: string, authority: Authority) => `${registryId}\n${authority}`; + +/** + * Request a sync of `authority` for an app account (the active one when + * `registryId` is omitted), coalesced for 5 seconds. A no-op unless the + * account has device sync on for the authority. + */ +export function requestDeviceSync(authority: Authority, registryId?: string | null, reason?: StateReason): void { + if (!deviceSyncAvailable()) return; + const id = registryId ?? useAccountStore.getState().activeAccountId; + if (!id) return; + if (!syncOnInApp(useDeviceSyncStore.getState().accounts[id], authority)) return; + const key = slotKey(id, authority); + let slot = pending.get(key); + if (!slot) { + slot = { + timer: setTimeout(() => fire(key, id, authority), TRIGGER_DELAY_MS), + reasons: [], + unconditional: false, + }; + pending.set(key, slot); + } + if (reason) slot.reasons.push(reason); + else slot.unconditional = true; +} + +function fire(key: string, registryId: string, authority: Authority): void { + const slot = pending.get(key); + pending.delete(key); + if (!slot) return; + const entry = useDeviceSyncStore.getState().accounts[registryId]; + const accountName = entry?.androidAccountName; + if (!accountName || !syncOnInApp(entry, authority)) return; + const echoesOnly = !slot.unconditional + && slot.reasons.every((r) => isKnownState(registryId, r.jmapAccountId, r.type, r.state)); + if (echoesOnly) return; + void Promise.resolve() + .then(() => requestSync(accountName, authority, {})) + .catch(() => undefined); +} + +/** + * Drops waiting requests: of one authority of an account, of every authority + * of an account, or all of them. Used before sync is turned off. + */ +export function cancelDeviceSyncRequests(registryId?: string, authority?: Authority): void { + for (const [key, slot] of pending) { + if (registryId && authority && key !== slotKey(registryId, authority)) continue; + if (registryId && !key.startsWith(`${registryId}\n`)) continue; + clearTimeout(slot.timer); + pending.delete(key); + } +} diff --git a/src/device-sync/app/triggers.ts b/src/device-sync/app/triggers.ts new file mode 100644 index 00000000..73a29c6a --- /dev/null +++ b/src/device-sync/app/triggers.ts @@ -0,0 +1,119 @@ +/** + * What makes the running app ask Android for device syncs + * (docs/device-sync.md, "Triggers"), started once from the app's launch + * effect: + * + * - StateChanges on the live stream for `AddressBook`/`ContactCard` and + * `Calendar`/`CalendarEvent` (the stream serves the active account), except + * the states the engine synced itself; + * - the app returning to the foreground, and the launch itself: every account + * with sync on, after the accounts were reconciled with Android's; + * - accounts of our type changing (removed in Android Settings); + * - the preferences changing: the native push router's routes follow them. + * + * The app's own contact and calendar edits call `requestDeviceSync` from the + * stores. Every request is coalesced per account and authority (./request). + */ +import { AppState, type AppStateStatus } from 'react-native'; +import { onAccountsChanged } from '../native'; +import { AUTHORITIES, type Authority } from '../types'; +import { onStateChangeType } from '../../lib/state-change-bus'; +import { useAccountStore } from '../../stores/account-store'; +import { + feedingJmapAccounts, + isKnownState, + syncOnInApp, + useDeviceSyncStore, +} from '../../stores/device-sync-store'; +import { deviceSyncAvailable } from './available'; +import { reconcileDeviceAccounts, refreshPushRoutes } from './lifecycle'; +import { STATE_TYPES_BY_AUTHORITY } from './prefs'; +import { requestDeviceSync } from './request'; + +/** Foreground syncs of one account and authority at most this often. */ +export const FOREGROUND_MIN_INTERVAL_MS = 60_000; +const ROUTES_DELAY_MS = 1000; + +let stopTriggers: (() => void) | null = null; +const lastForegroundRequest = new Map(); + +/** A StateChange of the active account's stream. */ +export function handleStateChange(authority: Authority, type: string, jmapAccountId: string, state: string): void { + const registryId = useAccountStore.getState().activeAccountId; + if (!registryId) return; + const entry = useDeviceSyncStore.getState().accounts[registryId]; + if (!entry || !syncOnInApp(entry, authority)) return; + // The engine's own upload echoing back. + if (isKnownState(registryId, jmapAccountId, type, state)) return; + // A shared account nothing is synced from, once the app knows which + // accounts feed the device. + if (entry.primaryJmapAccounts?.[authority] && !feedingJmapAccounts(entry, authority).includes(jmapAccountId)) return; + requestDeviceSync(authority, registryId, { jmapAccountId, type, state }); +} + +/** Launch and every return to the foreground. */ +export async function handleForeground(now: number = Date.now()): Promise { + await reconcileDeviceAccounts(); + const { accounts } = useDeviceSyncStore.getState(); + for (const account of useAccountStore.getState().accounts) { + for (const authority of AUTHORITIES) { + if (!syncOnInApp(accounts[account.id], authority)) continue; + const key = `${account.id}\n${authority}`; + const last = lastForegroundRequest.get(key); + if (last !== undefined && now - last < FOREGROUND_MIN_INTERVAL_MS) continue; + lastForegroundRequest.set(key, now); + requestDeviceSync(authority, account.id); + } + } +} + +/** Starts the triggers; idempotent. Returns the stop function (for tests). */ +export function startDeviceSyncTriggers(): () => void { + if (stopTriggers) return stopTriggers; + if (!deviceSyncAvailable()) return () => undefined; + const cleanups: Array<() => void> = []; + + for (const authority of AUTHORITIES) { + for (const type of STATE_TYPES_BY_AUTHORITY[authority]) { + cleanups.push(onStateChangeType(type, (jmapAccountId, state) => { + handleStateChange(authority, type, jmapAccountId, state); + })); + } + } + + let appState: AppStateStatus = AppState.currentState; + const appStateSubscription = AppState.addEventListener('change', (next) => { + const cameBack = next === 'active' && appState !== 'active'; + appState = next; + if (cameBack) void handleForeground(); + }); + cleanups.push(() => appStateSubscription.remove()); + + try { + cleanups.push(onAccountsChanged(() => { void reconcileDeviceAccounts(); })); + } catch { + // Without the event, launch and foreground still reconcile. + } + + let routesTimer: ReturnType | null = null; + cleanups.push(useDeviceSyncStore.subscribe((state, prev) => { + if (state.accounts === prev.accounts) return; + if (routesTimer) clearTimeout(routesTimer); + routesTimer = setTimeout(() => { + routesTimer = null; + void refreshPushRoutes(); + }, ROUTES_DELAY_MS); + })); + cleanups.push(() => { + if (routesTimer) clearTimeout(routesTimer); + }); + + void handleForeground(); + + stopTriggers = () => { + for (const cleanup of cleanups) cleanup(); + stopTriggers = null; + lastForegroundRequest.clear(); + }; + return stopTriggers; +} diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index ee1c02ae..c8984ad9 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -15,6 +15,7 @@ import { NativeModules } from 'react-native'; import { jmapClient } from '../../api/jmap-client'; import { secureFetch } from '../client-cert'; import { + carriesNoMail, matchAccountsForPush, parseRelayPushData, pushBackgroundTask, @@ -186,6 +187,58 @@ describe('pushBackgroundTask notifications', () => { }); }); +describe('pushes for device sync (#34)', () => { + const LOCAL = 'alice@mail.example.com'; + const showNotification = vi.fn(async () => undefined); + const stateChange = (changed: Record>) => ({ + kind: 'jmap-state-change', + accountLabel: 'alice', + accountId: Object.keys(changed)[0], + emailIds: '[]', + changed: JSON.stringify(changed), + }); + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([LOCAL])); + await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'jmap-primary' })); + (NativeModules as Record).BulwarkFcm = { showNotification }; + (jmapClient.getStoredCredentials as ReturnType).mockResolvedValue({ + serverUrl: 'https://mail.example.com', + username: 'alice', + password: 'secret', + }); + (secureFetch as ReturnType).mockResolvedValue({ ok: false, status: 500 }); + }); + + afterEach(() => { + delete (NativeModules as Record).BulwarkFcm; + }); + + it('tells contact and calendar changes from new mail', () => { + expect(carriesNoMail(parseRelayPushData(stateChange({ a: { ContactCard: 's1', AddressBook: 's2' } })))).toBe(true); + expect(carriesNoMail(parseRelayPushData(stateChange({ a: { CalendarEvent: 'e1' }, b: { Calendar: 'c1' } })))).toBe(true); + // Mail in any account of the map, an EmailPush, or a map it can't read: as before. + expect(carriesNoMail(parseRelayPushData(stateChange({ a: { ContactCard: 's1' }, b: { EmailDelivery: 'm1' } })))).toBe(false); + expect(carriesNoMail(parseRelayPushData({ kind: 'jmap-email-push', accountId: 'a', emailIds: '["m1"]' }))).toBe(false); + expect(carriesNoMail(parseRelayPushData({ kind: 'jmap-state-change', emailIds: '[]', changed: '{}' }))).toBe(false); + expect(carriesNoMail(parseRelayPushData({ kind: 'jmap-state-change', emailIds: '[]' }))).toBe(false); + }); + + it('neither looks for mail nor notifies for a change without new mail', async () => { + await pushBackgroundTask(stateChange({ 'jmap-primary': { ContactCard: 's1' }, team: { CalendarEvent: 'e1' } })); + expect(jmapClient.getStoredCredentials).not.toHaveBeenCalled(); + expect(secureFetch).not.toHaveBeenCalled(); + expect(showNotification).not.toHaveBeenCalled(); + }); + + it('still checks for mail when the map carries new mail', async () => { + await pushBackgroundTask(stateChange({ 'jmap-primary': { EmailDelivery: 'm1', ContactCard: 's1' } })); + expect(jmapClient.getStoredCredentials).toHaveBeenCalledWith(LOCAL); + }); +}); + describe('selectNotifiableEmails', () => { const email = (id: string, keywords: Record = {}): Email => ({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email); diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index a324a717..519b47ce 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -42,6 +42,12 @@ const { CREATED } = vi.hoisted(() => ({ CREATED: { id: 'new-server-id', expires: null as string | null }, })); +// A client of another signed-in account (device sync patches its push types +// without the singleton). +const { DETACHED } = vi.hoisted(() => ({ + DETACHED: { loaded: true, types: ['EmailDelivery'] as string[], log: [] as unknown[] }, +})); + vi.mock('../../api/jmap-client', () => ({ jmapClient: { username: 'user@example.com', @@ -49,6 +55,20 @@ vi.mock('../../api/jmap-client', () => ({ accountId: 'jmap-primary', currentSession: { capabilities: { 'urn:ietf:params:jmap:core': {} } }, }, + JMAPClient: class { + async loadAccount(id: string) { + DETACHED.log.push(['load', id]); + return DETACHED.loaded; + } + async request(calls: Array<[string, Record, string]>) { + const [name, args, id] = calls[0]; + DETACHED.log.push([name, args]); + if (name === 'PushSubscription/get') { + return { methodResponses: [[name, { list: [{ id: args.ids[0], types: DETACHED.types }] }, id]] }; + } + return { methodResponses: [[name, { updated: { [Object.keys(args.update)[0]]: null } }, id]] }; + } + }, })); vi.mock('../../api/email', () => ({ @@ -77,6 +97,8 @@ import { readPushAccountIds, readPushJmapAccountIds, PushSetupError, + pushTypesFor, + refreshPushSubscriptionTypes, resyncPushNotifications, revokePushDevice, teardownPushNotificationsForAccount, @@ -354,6 +376,117 @@ describe('setupPushNotifications subscription shape', () => { }); }); +describe('push types for device sync (#34)', () => { + const DEVICE_SYNC_KEY = 'device-sync:v1'; + const CONTACTS = 'com.android.contacts'; + const CALENDAR = 'com.android.calendar'; + const healthy = (types: string[]) => ({ + id: 'existing', + deviceClientId: OUR_DCID, + expires: new Date(Date.now() + 80 * 86400000).toISOString(), + types, + }); + + async function syncing(enabled: Record, extra: Record = {}): Promise { + await AsyncStorage.setItem(DEVICE_SYNC_KEY, JSON.stringify({ + state: { accounts: { [ACCOUNT_ID]: { enabled, ...extra } } }, + version: 0, + })); + } + + beforeEach(async () => { + vi.clearAllMocks(); + await AsyncStorage.clear(); + await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID); + (jmapClient as { currentSession: unknown }).currentSession = { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + }; + listMock.mockResolvedValue([]); + updateMock.mockResolvedValue(true); + installFetch({}); + }); + + it('adds the contact and calendar types of what the account syncs to the device', async () => { + expect(await pushTypesFor(ACCOUNT_ID)).toEqual(['EmailDelivery']); + await syncing({ [CONTACTS]: true, [CALENDAR]: false }); + expect(await pushTypesFor(ACCOUNT_ID)).toEqual(['EmailDelivery', 'ContactCard', 'AddressBook']); + expect(await pushTypesFor('someone@else.example')).toEqual(['EmailDelivery']); + // Removed in Android Settings: nothing to route any more. + await syncing({ [CONTACTS]: true }, { removedInAndroidSettings: true }); + expect(await pushTypesFor(ACCOUNT_ID)).toEqual(['EmailDelivery']); + }); + + it('subscribes with them', async () => { + await syncing({ [CALENDAR]: true }); + await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(createMock.mock.calls[0][0].types).toEqual(['EmailDelivery', 'CalendarEvent', 'Calendar']); + }); + + it('patches a subscription made before sync was turned on', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([healthy(['EmailDelivery'])]); + await syncing({ [CONTACTS]: true, [CALENDAR]: true }); + await setupPushNotifications({ relayBaseUrl: RELAY }); + expect(createMock).not.toHaveBeenCalled(); + expect(updateMock.mock.calls[0][1].types) + .toEqual(['EmailDelivery', 'ContactCard', 'AddressBook', 'CalendarEvent', 'Calendar']); + }); + + it('re-applies the types after a toggle', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + listMock.mockResolvedValue([healthy(['EmailDelivery'])]); + await syncing({ [CONTACTS]: true }); + await refreshPushSubscriptionTypes(ACCOUNT_ID); + expect(updateMock).toHaveBeenCalledWith('existing', { types: ['EmailDelivery', 'ContactCard', 'AddressBook'] }); + + // An account without a push subscription has nothing to patch. + updateMock.mockClear(); + DETACHED.log = []; + await refreshPushSubscriptionTypes('someone@else.example'); + expect(updateMock).not.toHaveBeenCalled(); + expect(DETACHED.log).toEqual([]); + + // Already what it should be, in another order: nothing to send. + listMock.mockResolvedValue([healthy(['AddressBook', 'EmailDelivery', 'ContactCard'])]); + await refreshPushSubscriptionTypes(ACCOUNT_ID); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("patches another account's subscription through a client of its own", async () => { + const OTHER = 'bob@other.example.net'; + await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'bob-sub'); + await AsyncStorage.setItem(DEVICE_SYNC_KEY, JSON.stringify({ + state: { accounts: { [OTHER]: { enabled: { [CALENDAR]: true } } } }, + version: 0, + })); + DETACHED.log = []; + DETACHED.types = ['EmailDelivery']; + await refreshPushSubscriptionTypes(OTHER); + expect(DETACHED.log).toEqual([ + ['load', OTHER], + ['PushSubscription/get', { ids: ['bob-sub'], properties: ['id', 'types'] }], + ['PushSubscription/set', { update: { 'bob-sub': { types: ['EmailDelivery', 'CalendarEvent', 'Calendar'] } } }], + ]); + // The singleton's subscriptions were not touched. + expect(updateMock).not.toHaveBeenCalled(); + + // Already right: read, nothing written. + DETACHED.log = []; + DETACHED.types = ['Calendar', 'CalendarEvent', 'EmailDelivery']; + await refreshPushSubscriptionTypes(OTHER); + expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']); + }); + + it('leaves the subscription of an account that turned push off alone', async () => { + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(`push:optedOut:v1:${ACCOUNT_ID}`, '1'); + listMock.mockResolvedValue([healthy(['EmailDelivery'])]); + await syncing({ [CONTACTS]: true }); + await refreshPushSubscriptionTypes(ACCOUNT_ID); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); + describe('setupPushNotifications with ACL-shared accounts (B18)', () => { const REFUSED_KEY = 'push:emailPushRefused:v1:' + ACCOUNT_ID; const sharedMock = getSharedMailboxes as ReturnType; diff --git a/src/lib/platform-capabilities.ts b/src/lib/platform-capabilities.ts index 785786c6..60489183 100644 --- a/src/lib/platform-capabilities.ts +++ b/src/lib/platform-capabilities.ts @@ -14,3 +14,15 @@ import { Platform } from 'react-native'; * check) is hidden when this is false. */ export const supportsSideloadUpdates = Platform.OS === 'android'; + +/** + * Device sync: address books and calendars in Android's Contacts and Calendar + * providers, under an account of the app's own type (#34, + * docs/device-sync.md). + * + * Android only: iOS has no third-party sync-provider API for either. The UI + * also needs the native module (`isDeviceSyncAvailable()` in + * `device-sync/native`), so a build without it hides the feature instead of + * failing. + */ +export const supportsDeviceSync = Platform.OS === 'android'; diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index dacd2778..776b39c4 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -378,12 +378,29 @@ async function detachedNewestUnreadInboxIds( return (qBody.ids as string[]) ?? []; } +/** + * A StateChange push in which no account got new mail: only contact or + * calendar changes, which the native push router already turned into device + * syncs (#34, docs/device-sync.md "Triggers"). Without `EmailDelivery` there + * is nothing to notify, and the legacy path (the newest unread mail) must + * not run for it. An empty or missing `changed` map keeps the old behaviour. + */ +export function carriesNoMail(payload: RelayPushData): boolean { + if (payload.kind !== 'jmap-state-change' || !payload.changed) return false; + const accounts = Object.values(payload.changed); + if (accounts.length === 0) return false; + return !accounts.some((types) => !!types && typeof types === 'object' && 'EmailDelivery' in types); +} + // Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a // short-lived headless JS runtime - keep it fast, catch all errors, and always // resolve so the native service can release its wake lock. Never touches the // singleton jmapClient's session (see "Detached JMAP access" above). export async function pushBackgroundTask(data: unknown): Promise { try { + const payload = parseRelayPushData(data); + if (carriesNoMail(payload)) return; + if (!(await emailNotificationsAllowed())) return; await migrateLegacyPushKeys(); @@ -392,7 +409,6 @@ export async function pushBackgroundTask(data: unknown): Promise { if (accountIds.length === 0) return; const registry = await readAccountRegistry(); - const payload = parseRelayPushData(data); const jmapAccountIds = await readPushJmapAccountIds(); const accountsToCheck = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index e25a79b8..4151665b 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -9,12 +9,13 @@ import { } from '../api/push'; import { getMailboxes, getSharedMailboxes } from '../api/email'; import { loadedMailboxes } from './mailbox-source'; -import { jmapClient } from '../api/jmap-client'; -import { JMAPMethodError } from '../api/jmap-result'; +import { jmapClient, JMAPClient } from '../api/jmap-client'; +import { JMAPMethodError, requireMethodResult } from '../api/jmap-result'; import { CAPABILITIES } from '../api/types'; import type { EmailPushConfig, JMAPAccountInfo, Mailbox } from '../api/types'; import { generateAccountId } from './account-utils'; import { t } from '../stores/locale-store'; +import { DEVICE_SYNC_STORAGE_KEY, deviceSyncPushTypes, persistedAccounts } from '../device-sync/app/prefs'; import { getUnifiedPushDistributors, isUnifiedPushSupported, @@ -206,6 +207,25 @@ export function isValidRelayUrl(value: string): boolean { // In-app sync uses the separate SSE channel and is unaffected. export const PUSH_TYPES = ['EmailDelivery'] as const; +/** + * The push types of one account's subscription: `EmailDelivery`, plus the + * contact and calendar types while the account syncs them to the device + * (#34, docs/device-sync.md "Triggers"): the native push router turns those + * into device syncs, and a push carrying only them never notifies. + * + * Read from the device sync store's persisted JSON rather than the store, so + * code that runs without it (the push task) never creates it. + */ +export async function pushTypesFor(accountId: string): Promise { + let extra: string[] = []; + try { + extra = deviceSyncPushTypes(persistedAccounts(await AsyncStorage.getItem(DEVICE_SYNC_STORAGE_KEY))[accountId]); + } catch { + extra = []; + } + return [...PUSH_TYPES, ...extra]; +} + // draft-ietf-jmap-emailpush (Stalwart >= 0.16.16). `EmailDelivery` alone // still fires for every ingested message - including spam the server files // straight into Junk - because the server can't know which folders a client @@ -927,6 +947,7 @@ async function setupPushNotificationsInner( : null; const subKey = subscriptionIdKey(accountId); const storedServerId = await AsyncStorage.getItem(subKey); + const types = await pushTypesFor(accountId); let jmapAccountId: string | null = null; try { jmapAccountId = jmapClient.accountId; @@ -939,7 +960,7 @@ async function setupPushNotificationsInner( if (match) { if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires); if (!params.forceRecreate) { - const refreshed = await refreshSubscriptionExpires(match, emailPush); + const refreshed = await refreshSubscriptionExpires(match, emailPush, types); if (refreshed) { await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); await addPushAccountId(accountId); @@ -989,7 +1010,7 @@ async function setupPushNotificationsInner( createPushSubscription({ deviceClientId, url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), - types: [...PUSH_TYPES], + types: [...types], expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), ...(filter ? { emailPush: filter } : {}), }), @@ -1040,10 +1061,10 @@ async function addPushAccountId(accountId: string): Promise { // Push the subscription's expires forward when it's getting close to the // server's ceiling, and re-sync `types` / the delivery filter when they drift // from what this client wants (a subscription created by an older build still -// listens to `Email`/`Mailbox`; a Junk mailbox id can change under us). -// Returns false if the server rejects the update, which the caller treats as -// "replace"; otherwise the emailPush map the update installed (null when it -// left the filter alone). +// listens to `Email`/`Mailbox`; device sync was turned on or off; a Junk +// mailbox id can change under us). Returns false if the server rejects the +// update, which the caller treats as "replace"; otherwise the emailPush map +// the update installed (null when it left the filter alone). async function refreshSubscriptionExpires( sub: { id: string; @@ -1053,8 +1074,9 @@ async function refreshSubscriptionExpires( }, // null when the server has no emailPush support - leave the property alone. desiredEmailPush: Record | null, + types: readonly string[], ): Promise | null }> { - const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES); + const typesNeedUpdate = !sameTypes(sub.types, types); const emailPushNeedsUpdate = desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush); if (!typesNeedUpdate && !emailPushNeedsUpdate && sub.expires) { @@ -1069,7 +1091,7 @@ async function refreshSubscriptionExpires( const patch: { expires?: string; types?: string[] } = { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), }; - if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; + if (typesNeedUpdate) patch.types = [...types]; const { emailPush } = await writeWithEmailPush( emailPushNeedsUpdate ? desiredEmailPush : null, (filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch), @@ -1198,6 +1220,47 @@ export async function resyncPushNotifications( return setupPushNotifications(params); } +/** + * Re-apply an account's push types to its subscription after device sync was + * turned on or off for it (#34). The account the client serves goes through + * the api/push helpers; another signed-in account through a client of its + * own, never the singleton. Best effort: the next resync of the account + * applies them anyway. + */ +export async function refreshPushSubscriptionTypes(accountId: string): Promise { + if (await AsyncStorage.getItem(optedOutKey(accountId))) return; + const subscriptionId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + if (!subscriptionId) return; + const types = await pushTypesFor(accountId); + const username = jmapClient.username; + const serverUrl = jmapClient.serverUrl; + const served = !!username && !!serverUrl && generateAccountId(username, serverUrl) === accountId; + try { + if (served) { + const current = (await listPushSubscriptions()).find((s) => s.id === subscriptionId); + if (!current || sameTypes(current.types, types)) return; + await updatePushSubscription(subscriptionId, { types: [...types] }); + return; + } + const client = new JMAPClient(); + if (!(await client.loadAccount(accountId))) return; + const using = [CAPABILITIES.CORE]; + const res = await client.request( + [['PushSubscription/get', { ids: [subscriptionId], properties: ['id', 'types'] }, '0']], + using, + ); + const current = (requireMethodResult(res, '0', 'PushSubscription/get').list as Array<{ id: string; types?: string[] | null }> | undefined) + ?.find((s) => s.id === subscriptionId); + if (!current || sameTypes(current.types, types)) return; + await client.request( + [['PushSubscription/set', { update: { [subscriptionId]: { types: [...types] } } }, '0']], + using, + ); + } catch { + // The next resync re-applies them. + } +} + /** * Tear down push for ALL accounts on this device. Used by the logout-all * flow; best-effort because we typically aren't authenticated to every diff --git a/src/stores/__tests__/device-sync-store.test.ts b/src/stores/__tests__/device-sync-store.test.ts new file mode 100644 index 00000000..9484c300 --- /dev/null +++ b/src/stores/__tests__/device-sync-store.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type RunStatus } from '../../device-sync/types'; +import { + DEVICE_SYNC_STORAGE_KEY, + deviceSyncPushTypes, + persistedAccounts, + syncOnInApp, +} from '../../device-sync/app/prefs'; + +type StoreModule = typeof import('../device-sync-store'); + +// A fresh store module, hydrated from whatever AsyncStorage holds now. +async function freshStore(): Promise { + vi.resetModules(); + const mod = await import('../device-sync-store'); + await mod.waitForDeviceSyncHydration(); + return mod; +} + +async function stored(): Promise> { + const raw = await AsyncStorage.getItem(DEVICE_SYNC_STORAGE_KEY); + return raw ? JSON.parse(raw).state.accounts : {}; +} + +const ALICE = 'alice@mail.example.org'; +const BOB = 'bob@mail.example.org'; + +const run = (outcome: RunStatus['outcome']): RunStatus => ({ + at: 1, + outcome, + durationMs: 10, + conflicts: 0, + itemErrors: 0, + stats: { + downloaded: { created: 0, updated: 0, deleted: 0 }, + uploaded: { created: 0, updated: 0, deleted: 0 }, + entries: 0, + skipped: 0, + }, +}); + +beforeEach(async () => { + await AsyncStorage.clear(); +}); + +describe('selection', () => { + it("syncs the personal account's collections unless turned off and shared ones only when turned on", async () => { + const { isCollectionSelected } = await freshStore(); + expect(isCollectionSelected({}, CONTACTS_AUTHORITY, { key: 'c/b1', isPersonal: true })).toBe(true); + expect(isCollectionSelected({}, CONTACTS_AUTHORITY, { key: 'd/b9', isPersonal: false })).toBe(false); + expect(isCollectionSelected({ 'c/b1': false }, CONTACTS_AUTHORITY, { key: 'c/b1', isPersonal: true })).toBe(false); + expect(isCollectionSelected({ 'd/b9': true }, CONTACTS_AUTHORITY, { key: 'd/b9', isPersonal: false })).toBe(true); + // The webmail's allow-list book stays off unless the user picks it. + const trusted = { key: 'c/b2', isPersonal: true, name: 'Trusted Senders' }; + expect(isCollectionSelected({}, CONTACTS_AUTHORITY, trusted)).toBe(false); + expect(isCollectionSelected({ 'c/b2': true }, CONTACTS_AUTHORITY, trusted)).toBe(true); + expect(isCollectionSelected({}, CALENDAR_AUTHORITY, trusted)).toBe(true); + }); + + it('lists the collections that sync, per authority, from explicit choices and the default', async () => { + const m = await freshStore(); + const store = m.useDeviceSyncStore.getState(); + store.setCollectionSelected(ALICE, CONTACTS_AUTHORITY, 'c/b2', false); + store.setCollectionSelected(ALICE, CONTACTS_AUTHORITY, 'd/b9', true); + store.setCollectionSelected(ALICE, CALENDAR_AUTHORITY, 'c/cal1', false); + const entry = m.accountDeviceSync(ALICE); + const books = [ + { key: 'c/b1', isPersonal: true }, + { key: 'c/b2', isPersonal: true }, + { key: 'd/b9', isPersonal: false }, + { key: 'd/b8', isPersonal: false }, + ]; + expect(m.effectiveSelection(entry, CONTACTS_AUTHORITY, books)).toEqual(['c/b1', 'd/b9']); + // The calendar choice does not leak into the contacts selection. + expect(m.selectionFor(entry, CONTACTS_AUTHORITY)).toEqual({ 'c/b2': false, 'd/b9': true }); + expect(m.selectionFor(entry, CALENDAR_AUTHORITY)).toEqual({ 'c/cal1': false }); + }); + + it('gives the engine its preferences, with the calendar app reminding until the user chose', async () => { + const m = await freshStore(); + expect(m.accountSyncPrefs(ALICE)).toEqual({ + contactsSelection: {}, + calendarSelection: {}, + newContactsAddressBook: undefined, + reminderOwner: 'device', + }); + m.useDeviceSyncStore.getState().setReminderOwner(ALICE, 'bulwark'); + m.useDeviceSyncStore.getState().setNewContactsAddressBook(ALICE, 'c/b2'); + expect(m.accountSyncPrefs(ALICE)).toMatchObject({ reminderOwner: 'bulwark', newContactsAddressBook: 'c/b2' }); + m.useDeviceSyncStore.getState().setNewContactsAddressBook(ALICE, null); + expect(m.accountSyncPrefs(ALICE).newContactsAddressBook).toBeUndefined(); + }); +}); + +describe('actions', () => { + it('records what the user turned on in the app, apart from removals and suspensions', async () => { + const m = await freshStore(); + const store = m.useDeviceSyncStore.getState(); + store.setEnabled(ALICE, CONTACTS_AUTHORITY, true); + expect(syncOnInApp(m.accountDeviceSync(ALICE), CONTACTS_AUTHORITY)).toBe(true); + expect(syncOnInApp(m.accountDeviceSync(ALICE), CALENDAR_AUTHORITY)).toBe(false); + store.setRemovedInAndroidSettings(ALICE, true); + expect(syncOnInApp(m.accountDeviceSync(ALICE), CONTACTS_AUTHORITY)).toBe(false); + store.setRemovedInAndroidSettings(ALICE, false); + expect(m.accountDeviceSync(ALICE).removedInAndroidSettings).toBeUndefined(); + store.update(ALICE, { suspended: true }); + expect(m.anySyncOnInApp(m.accountDeviceSync(ALICE))).toBe(false); + }); + + it('keeps an interval per authority and defaults to an hour', async () => { + const m = await freshStore(); + const store = m.useDeviceSyncStore.getState(); + expect(m.intervalFor(m.accountDeviceSync(ALICE), CONTACTS_AUTHORITY)).toBe(m.DEFAULT_INTERVAL_SECONDS); + store.setIntervalSeconds(ALICE, CONTACTS_AUTHORITY, 900); + store.setIntervalSeconds(ALICE, CALENDAR_AUTHORITY, 0); + expect(m.intervalFor(m.accountDeviceSync(ALICE), CONTACTS_AUTHORITY)).toBe(900); + expect(m.intervalFor(m.accountDeviceSync(ALICE), CALENDAR_AUTHORITY)).toBe(0); + store.setIntervalSeconds(ALICE, CALENDAR_AUTHORITY, -5); + expect(m.intervalFor(m.accountDeviceSync(ALICE), CALENDAR_AUTHORITY)).toBe(0); + }); + + it('records run statuses and the states the engine synced, and knows its own echo', async () => { + const m = await freshStore(); + const store = m.useDeviceSyncStore.getState(); + store.recordRunStatus(ALICE, CONTACTS_AUTHORITY, run('ok')); + store.recordRunStatus(ALICE, CALENDAR_AUTHORITY, run('io')); + store.recordKnownState(ALICE, 'c', 'ContactCard', 's1'); + store.recordKnownState(ALICE, 'c', 'CalendarEvent', 'e1'); + const entry = m.accountDeviceSync(ALICE); + expect(entry.lastRun[CONTACTS_AUTHORITY]?.outcome).toBe('ok'); + expect(entry.lastRun[CALENDAR_AUTHORITY]?.outcome).toBe('io'); + expect(m.isKnownState(ALICE, 'c', 'ContactCard', 's1')).toBe(true); + expect(m.isKnownState(ALICE, 'c', 'ContactCard', 's2')).toBe(false); + expect(m.isKnownState(ALICE, 'd', 'ContactCard', 's1')).toBe(false); + expect(m.isKnownState(BOB, 'c', 'ContactCard', 's1')).toBe(false); + }); + + it('forgets an account entirely', async () => { + const m = await freshStore(); + m.useDeviceSyncStore.getState().setEnabled(ALICE, CONTACTS_AUTHORITY, true); + m.useDeviceSyncStore.getState().setEnabled(BOB, CONTACTS_AUTHORITY, true); + m.useDeviceSyncStore.getState().forget(ALICE); + expect(Object.keys(m.useDeviceSyncStore.getState().accounts)).toEqual([BOB]); + }); +}); + +describe('persistence', () => { + it('writes every change at once under device-sync:v1', async () => { + const m = await freshStore(); + m.useDeviceSyncStore.getState().setEnabled(ALICE, CALENDAR_AUTHORITY, true); + // No write delay: the change is in AsyncStorage before any timer runs. + await Promise.resolve(); + expect((await stored())[ALICE].enabled).toEqual({ [CALENDAR_AUTHORITY]: true }); + }); + + it('reads the stored preferences back on the next start', async () => { + let m = await freshStore(); + m.useDeviceSyncStore.getState().setCollectionSelected(ALICE, CONTACTS_AUTHORITY, 'd/b9', true); + m.useDeviceSyncStore.getState().update(ALICE, { androidAccountName: 'alice' }); + await Promise.resolve(); + m = await freshStore(); + const entry = m.accountDeviceSync(ALICE); + expect(entry.contactsSelection).toEqual({ 'd/b9': true }); + expect(entry.androidAccountName).toBe('alice'); + }); + + it('waits for the stored state before a headless writer may set anything', async () => { + await AsyncStorage.setItem(DEVICE_SYNC_STORAGE_KEY, JSON.stringify({ + state: { accounts: { [ALICE]: { contactsSelection: { 'c/b1': false }, calendarSelection: {}, reminderOwner: 'bulwark', intervalSeconds: {}, lastRun: {} } } }, + version: 0, + })); + vi.resetModules(); + const m = await import('../device-sync-store'); + await m.waitForDeviceSyncHydration(); + expect(m.useDeviceSyncStore.persist.hasHydrated()).toBe(true); + // A run status recorded after hydration keeps the stored preferences. + m.useDeviceSyncStore.getState().recordRunStatus(ALICE, CONTACTS_AUTHORITY, run('ok')); + await Promise.resolve(); + const entry = (await stored())[ALICE]; + expect(entry.contactsSelection).toEqual({ 'c/b1': false }); + expect(entry.reminderOwner).toBe('bulwark'); + expect(entry.lastRun[CONTACTS_AUTHORITY].outcome).toBe('ok'); + }); + + it('drops malformed entries and fields instead of failing to start', async () => { + await AsyncStorage.setItem(DEVICE_SYNC_STORAGE_KEY, JSON.stringify({ + state: { + accounts: { + [ALICE]: { + contactsSelection: 'nope', + reminderOwner: 'someone', + lastRun: [], + enabled: { [CONTACTS_AUTHORITY]: true }, + knownStates: { c: { ContactCard: 's1' }, d: 'broken' }, + primaryJmapAccounts: 7, + androidAccountName: 12, + }, + [BOB]: 42, + }, + }, + version: 0, + })); + const m = await freshStore(); + expect(Object.keys(m.useDeviceSyncStore.getState().accounts)).toEqual([ALICE]); + const entry = m.accountDeviceSync(ALICE); + expect(entry.contactsSelection).toEqual({}); + expect(entry.calendarSelection).toEqual({}); + expect(entry.reminderOwner).toBeNull(); + expect(entry.lastRun).toEqual({}); + expect(entry.enabled).toEqual({ [CONTACTS_AUTHORITY]: true }); + expect(entry.knownStates).toEqual({ c: { ContactCard: 's1' } }); + expect(entry.primaryJmapAccounts).toBeUndefined(); + expect(entry.androidAccountName).toBeUndefined(); + // Readers built on the entry keep working. + expect(m.computePushRoutes(m.useDeviceSyncStore.getState().accounts)).toEqual({}); + }); +}); + +describe('routes and push types', () => { + it('knows which JMAP accounts feed an authority', async () => { + const m = await freshStore(); + const store = m.useDeviceSyncStore.getState(); + store.rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c'); + store.recordKnownState(ALICE, 'g', 'AddressBook', 'x'); + store.recordKnownState(ALICE, 'h', 'CalendarEvent', 'y'); + store.setCollectionSelected(ALICE, CONTACTS_AUTHORITY, 'd/b9', true); + store.setCollectionSelected(ALICE, CONTACTS_AUTHORITY, 'e/b1', false); + const entry = m.accountDeviceSync(ALICE); + expect(m.feedingJmapAccounts(entry, CONTACTS_AUTHORITY)).toEqual(['c', 'd', 'g']); + expect(m.feedingJmapAccounts(entry, CALENDAR_AUTHORITY)).toEqual(['h']); + }); + + it('routes every feeding JMAP account to the Android accounts that sync it', async () => { + const m = await freshStore(); + const store = m.useDeviceSyncStore.getState(); + store.update(ALICE, { + androidAccountName: 'alice', + enabled: { [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: true }, + primaryJmapAccounts: { [CONTACTS_AUTHORITY]: 'c', [CALENDAR_AUTHORITY]: 'c' }, + }); + store.setCollectionSelected(ALICE, CALENDAR_AUTHORITY, 'team/cal', true); + // Bob syncs nothing yet, a removed account and a suspended one route nowhere. + store.update(BOB, { androidAccountName: 'bob', enabled: {} }); + store.update('carol@x', { androidAccountName: 'carol', enabled: { [CONTACTS_AUTHORITY]: true }, removedInAndroidSettings: true, primaryJmapAccounts: { [CONTACTS_AUTHORITY]: 'k' } }); + store.update('dave@x', { androidAccountName: 'dave', enabled: { [CONTACTS_AUTHORITY]: true }, suspended: true, primaryJmapAccounts: { [CONTACTS_AUTHORITY]: 'k' } }); + expect(m.computePushRoutes(m.useDeviceSyncStore.getState().accounts)).toEqual({ + c: [{ accountName: 'alice', authorities: [CONTACTS_AUTHORITY, CALENDAR_AUTHORITY] }], + team: [{ accountName: 'alice', authorities: [CALENDAR_AUTHORITY] }], + }); + }); + + it('adds the contact and calendar push types while they sync', () => { + expect(deviceSyncPushTypes(undefined)).toEqual([]); + expect(deviceSyncPushTypes({ enabled: { [CONTACTS_AUTHORITY]: true } })).toEqual(['ContactCard', 'AddressBook']); + expect(deviceSyncPushTypes({ enabled: { [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: true } })) + .toEqual(['ContactCard', 'AddressBook', 'CalendarEvent', 'Calendar']); + expect(deviceSyncPushTypes({ enabled: { [CALENDAR_AUTHORITY]: true }, removedInAndroidSettings: true })).toEqual([]); + }); + + it('reads the persisted accounts without the store, tolerating garbage', () => { + expect(persistedAccounts(null)).toEqual({}); + expect(persistedAccounts('{not json')).toEqual({}); + expect(persistedAccounts(JSON.stringify({ state: { accounts: [] } }))).toEqual({}); + expect(persistedAccounts(JSON.stringify({ state: { accounts: { [ALICE]: { enabled: {} } } } }))) + .toEqual({ [ALICE]: { enabled: {} } }); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index bfac097a..3bdcbbe4 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -47,6 +47,8 @@ import { } from '../lib/recurrence-instances'; import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection'; import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache'; +import { requestDeviceSync } from '../device-sync/app/request'; +import { CALENDAR_AUTHORITY } from '../device-sync/types'; // extendRange merges up to twice the requested range (at least half a year, // at most about three years); past that it starts over with the request. @@ -777,6 +779,7 @@ export const useCalendarStore = create()( ? expandRecurringEvents([mapped], loadedRange.after, loadedRange.before) : [mapped]; set({ events: [...get().events, ...inserted] }); + requestDeviceSync(CALENDAR_AUTHORITY); return full; }, @@ -818,6 +821,7 @@ export const useCalendarStore = create()( set({ events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial) } : e)), }); + requestDeviceSync(CALENDAR_AUTHORITY); // A series mutation (an occurrence override, a truncated/changed rule, a // master edit) touches every expanded sibling, and the optimistic merge // above only updated the tapped one; server-expanded occurrences also @@ -853,6 +857,7 @@ export const useCalendarStore = create()( && !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))), }); } + requestDeviceSync(CALENDAR_AUTHORITY); if (touchesSeries) { await get().refresh(); } @@ -915,6 +920,7 @@ export const useCalendarStore = create()( : undefined; await apiRsvpEvent(realId, participantId, status, repair, accountId); } + requestDeviceSync(CALENDAR_AUTHORITY); set({ events: get().events.map((e) => { if (e.id !== eventId || !e.participants?.[participantId]) return e; @@ -996,7 +1002,10 @@ export const useCalendarStore = create()( } } finally { // Also when a chunk was refused: show what did get in. - if (count > 0 || linked > 0) await get().refresh(); + if (count > 0 || linked > 0) { + requestDeviceSync(CALENDAR_AUTHORITY); + await get().refresh(); + } } if (count + linked === 0 && refused.length > 0) throw new ImportRefusedError(refused); return { imported: count + linked, refused }; @@ -1005,6 +1014,7 @@ export const useCalendarStore = create()( createCalendar: async (name, color, description) => { const created = await apiCreateCalendar(name, color, description); set({ calendars: [...get().calendars, created] }); + requestDeviceSync(CALENDAR_AUTHORITY); return created; }, @@ -1014,6 +1024,7 @@ export const useCalendarStore = create()( set({ calendars: get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c)), }); + requestDeviceSync(CALENDAR_AUTHORITY); }, removeCalendar: async (id) => { @@ -1025,12 +1036,15 @@ export const useCalendarStore = create()( tasks: get().tasks.filter((t) => !t.calendarIds?.[id]), hiddenCalendarIds: get().hiddenCalendarIds.filter((x) => x !== id), }); + requestDeviceSync(CALENDAR_AUTHORITY); }, clearCalendarEvents: async (id) => { const cal = get().calendars.find((c) => c.id === id); try { - return await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId); + const cleared = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId); + requestDeviceSync(CALENDAR_AUTHORITY); + return cleared; } finally { // Also after a refused batch: earlier batches may have gone through. await get().refresh(); diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index b4a588fb..366f399f 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -33,6 +33,8 @@ import { matchesContactSearch, } from '../lib/contact-utils'; import { sanitizeDisplayName, splitMailbox } from '../lib/rfc5322-mailbox'; +import { requestDeviceSync } from '../device-sync/app/request'; +import { CONTACTS_AUTHORITY } from '../device-sync/types'; const SELECTED_CATEGORY_STORAGE_KEY = 'webmail:contacts:category:v1'; @@ -403,6 +405,7 @@ export const useContactsStore = create()( const { originalId, accountId, book } = bookTarget(addressBookId); const created = tagCreated(await apiCreateContact(contact, originalId, accountId), book); set({ contacts: [...get().contacts, created] }); + requestDeviceSync(CONTACTS_AUTHORITY); return created; }, @@ -422,12 +425,14 @@ export const useContactsStore = create()( return merged; }), }); + requestDeviceSync(CONTACTS_AUTHORITY); }, deleteContact: async (id) => { const { originalId, accountId } = contactTarget(id); await apiDeleteContacts([originalId], accountId); set({ contacts: cleanGroupMembers(get().contacts, new Set([id])).filter((c) => c.id !== id) }); + requestDeviceSync(CONTACTS_AUTHORITY); }, bulkDelete: async (ids) => { @@ -460,6 +465,7 @@ export const useContactsStore = create()( contacts: cleanGroupMembers(get().contacts, removed).filter((c) => !removed.has(c.id)), ...(failed > 0 ? { error: `Failed to delete ${failed} contact${failed === 1 ? '' : 's'}` } : {}), }); + if (removed.size > 0) requestDeviceSync(CONTACTS_AUTHORITY); }, importContacts: async (incoming, addressBookId) => { @@ -478,6 +484,7 @@ export const useContactsStore = create()( console.warn('[contacts-store] import failed for one contact', err); } } + if (imported > 0) requestDeviceSync(CONTACTS_AUTHORITY); return { imported, failed }; }, @@ -544,6 +551,7 @@ export const useContactsStore = create()( set({ contacts: get().contacts.map((c) => (c.id === id ? created : c)) }); } } + requestDeviceSync(CONTACTS_AUTHORITY); }, addContactsToGroup: async (groupId, contactIds) => { @@ -602,6 +610,7 @@ export const useContactsStore = create()( if (!trimmed) throw new Error('Address book name is required'); const book = await apiCreateAddressBook(trimmed); set({ addressBooks: [...get().addressBooks, book] }); + requestDeviceSync(CONTACTS_AUTHORITY); return book; }, @@ -613,6 +622,7 @@ export const useContactsStore = create()( set({ addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)), }); + requestDeviceSync(CONTACTS_AUTHORITY); }, deleteAddressBook: async (id) => { @@ -624,6 +634,7 @@ export const useContactsStore = create()( addressBooks: get().addressBooks.filter((b) => b.id !== id), contacts: get().contacts.filter((c) => !c.addressBookIds?.[id]), }); + requestDeviceSync(CONTACTS_AUTHORITY); const category = get().selectedCategory; if (category.type === 'addressBook' && category.addressBookId === id) { get().setSelectedCategory({ type: 'all' }); @@ -799,6 +810,7 @@ export const useContactsStore = create()( bookId, ); set({ trustedSenderEmails: [...get().trustedSenderEmails, email] }); + requestDeviceSync(CONTACTS_AUTHORITY); }, removeFromTrustedSendersBook: async (input) => { @@ -810,6 +822,7 @@ export const useContactsStore = create()( c.emails && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === email)); if (matches.length > 0) await apiDeleteContacts(matches.map((m) => m.id)); set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== email) }); + if (matches.length > 0) requestDeviceSync(CONTACTS_AUTHORITY); }, isTrustedAddressBookSender: (email) => diff --git a/src/stores/device-sync-store.ts b/src/stores/device-sync-store.ts index 2350fb4d..bfa98ce4 100644 --- a/src/stores/device-sync-store.ts +++ b/src/stores/device-sync-store.ts @@ -2,9 +2,11 @@ * Device sync preferences and status per app account (Android only, #34; * docs/device-sync.md, "App integration"). Keyed by `AccountEntry.id`. * - * Whether sync is on is not stored here: Android's sync settings + * Whether sync runs is not stored here: Android's sync settings * (`getSyncAutomatically` per authority) are the source of truth, so the - * toggles in Settings → Accounts and the app always agree. + * toggles in Settings → Accounts and the app always agree. `enabled` only + * records what the user turned on in the app, which tells "paused in Android + * settings" (on here, off there) from "off". * * Persisted under `device-sync:v1` without the write delay the caches use: a * headless sync run may be the only thing alive, and its status must survive @@ -15,13 +17,23 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createPersistStorage } from './persist-storage'; -import type { - AccountSyncPrefs, - Authority, - CollectionKey, - ReminderOwner, - RunStatus, +import { + AUTHORITIES, + CONTACTS_AUTHORITY, + type AccountSyncPrefs, + type Authority, + type CollectionKey, + type ReminderOwner, + type RunStatus, } from '../device-sync/types'; +import { + DEVICE_SYNC_STORAGE_KEY, + STATE_TYPES_BY_AUTHORITY, + syncOnInApp, +} from '../device-sync/app/prefs'; +import { collectionDefaultOn } from '../device-sync/engine/selection'; + +export { syncOnInApp }; /** Periodic sync interval when the user has not picked one: push covers freshness. */ export const DEFAULT_INTERVAL_SECONDS = 3600; @@ -47,6 +59,16 @@ export interface AccountDeviceSync { * our own echo and triggers no sync. */ knownStates?: Record>; + /** Authorities the user turned on in the app (Android may still pause them). */ + enabled?: Partial>; + /** + * The app dropped the account from its registry (its sign-in failed) while + * it synced: automatic sync was turned off and the device data kept until + * the user signs in again. + */ + suspended?: boolean; + /** The JMAP account holding the personal collections, per authority, as last seen. */ + primaryJmapAccounts?: Partial>; } export function emptyAccountDeviceSync(): AccountDeviceSync { @@ -65,53 +87,114 @@ interface DeviceSyncState { recordRunStatus: (registryId: string, authority: Authority, status: RunStatus) => void; recordKnownState: (registryId: string, jmapAccountId: string, type: string, state: string) => void; forget: (registryId: string) => void; + + /** Turns an authority on or off in the app (not in Android). */ + setEnabled: (registryId: string, authority: Authority, on: boolean) => void; + /** An explicit choice for one address book or calendar. */ + setCollectionSelected: (registryId: string, authority: Authority, key: CollectionKey, on: boolean) => void; + /** Where contacts created on the device go; null = the server's default book. */ + setNewContactsAddressBook: (registryId: string, key: CollectionKey | null) => void; + setIntervalSeconds: (registryId: string, authority: Authority, seconds: number) => void; + setReminderOwner: (registryId: string, owner: ReminderOwner | null) => void; + setRemovedInAndroidSettings: (registryId: string, removed: boolean) => void; + setAndroidAccountName: (registryId: string, name: string | undefined) => void; + rememberPrimaryJmapAccount: (registryId: string, authority: Authority, jmapAccountId: string) => void; +} + +function selectionField(authority: Authority): 'contactsSelection' | 'calendarSelection' { + return authority === CONTACTS_AUTHORITY ? 'contactsSelection' : 'calendarSelection'; +} + +// Persisted entries are trusted only as far as their shape: a field of the +// wrong type falls back to the empty default instead of crashing a reader. +function sanitizeEntry(value: unknown): AccountDeviceSync | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as Record; + const isRecord = (x: unknown): x is Record => !!x && typeof x === 'object' && !Array.isArray(x); + const record = (x: unknown) => (isRecord(x) ? x : {}); + const optionalRecord = (x: unknown) => (isRecord(x) ? x : undefined); + const knownStates = isRecord(v.knownStates) + ? Object.fromEntries(Object.entries(v.knownStates).filter(([, states]) => isRecord(states))) + : undefined; + return { + ...(v as Partial), + contactsSelection: record(v.contactsSelection) as Record, + calendarSelection: record(v.calendarSelection) as Record, + reminderOwner: v.reminderOwner === 'device' || v.reminderOwner === 'bulwark' ? v.reminderOwner : null, + intervalSeconds: record(v.intervalSeconds) as Partial>, + lastRun: record(v.lastRun) as Partial>, + androidAccountName: typeof v.androidAccountName === 'string' && v.androidAccountName ? v.androidAccountName : undefined, + enabled: optionalRecord(v.enabled) as AccountDeviceSync['enabled'], + knownStates: knownStates as AccountDeviceSync['knownStates'], + primaryJmapAccounts: optionalRecord(v.primaryJmapAccounts) as AccountDeviceSync['primaryJmapAccounts'], + }; } export const useDeviceSyncStore = create()( persist( - (set) => ({ - accounts: {}, - update: (registryId, patch) => - set((s) => ({ - accounts: { - ...s.accounts, - [registryId]: { ...(s.accounts[registryId] ?? emptyAccountDeviceSync()), ...patch }, - }, - })), - recordRunStatus: (registryId, authority, status) => + (set) => { + // Applies `change` to one account's entry, creating it when missing. + const edit = (registryId: string, change: (current: AccountDeviceSync) => Partial) => set((s) => { const current = s.accounts[registryId] ?? emptyAccountDeviceSync(); - return { - accounts: { - ...s.accounts, - [registryId]: { ...current, lastRun: { ...current.lastRun, [authority]: status } }, - }, - }; - }), - recordKnownState: (registryId, jmapAccountId, type, state) => - set((s) => { - const current = s.accounts[registryId] ?? emptyAccountDeviceSync(); - const known = current.knownStates ?? {}; - return { - accounts: { - ...s.accounts, - [registryId]: { - ...current, - knownStates: { ...known, [jmapAccountId]: { ...(known[jmapAccountId] ?? {}), [type]: state } }, - }, - }, - }; - }), - forget: (registryId) => - set((s) => { - const { [registryId]: _gone, ...rest } = s.accounts; - return { accounts: rest }; - }), - }), + return { accounts: { ...s.accounts, [registryId]: { ...current, ...change(current) } } }; + }); + + return { + accounts: {}, + update: (registryId, patch) => edit(registryId, () => patch), + recordRunStatus: (registryId, authority, status) => + edit(registryId, (current) => ({ lastRun: { ...current.lastRun, [authority]: status } })), + recordKnownState: (registryId, jmapAccountId, type, state) => + edit(registryId, (current) => { + const known = current.knownStates ?? {}; + return { + knownStates: { ...known, [jmapAccountId]: { ...(known[jmapAccountId] ?? {}), [type]: state } }, + }; + }), + forget: (registryId) => + set((s) => { + const { [registryId]: _gone, ...rest } = s.accounts; + return { accounts: rest }; + }), + + setEnabled: (registryId, authority, on) => + edit(registryId, (current) => ({ enabled: { ...current.enabled, [authority]: on } })), + setCollectionSelected: (registryId, authority, key, on) => + edit(registryId, (current) => { + const field = selectionField(authority); + return { [field]: { ...current[field], [key]: on } }; + }), + setNewContactsAddressBook: (registryId, key) => + edit(registryId, () => ({ newContactsAddressBook: key ?? undefined })), + setIntervalSeconds: (registryId, authority, seconds) => + edit(registryId, (current) => ({ + intervalSeconds: { ...current.intervalSeconds, [authority]: Math.max(0, Math.round(seconds)) }, + })), + setReminderOwner: (registryId, owner) => edit(registryId, () => ({ reminderOwner: owner })), + setRemovedInAndroidSettings: (registryId, removed) => + edit(registryId, () => ({ removedInAndroidSettings: removed || undefined })), + setAndroidAccountName: (registryId, name) => edit(registryId, () => ({ androidAccountName: name })), + rememberPrimaryJmapAccount: (registryId, authority, jmapAccountId) => + edit(registryId, (current) => ({ + primaryJmapAccounts: { ...current.primaryJmapAccounts, [authority]: jmapAccountId }, + })), + }; + }, { - name: 'device-sync:v1', + name: DEVICE_SYNC_STORAGE_KEY, storage: createPersistStorage({ writeDelayMs: 0 }), partialize: (state) => ({ accounts: state.accounts }), + merge: (persisted, current) => { + const stored = (persisted as { accounts?: unknown } | undefined)?.accounts; + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return current; + const accounts: Record = {}; + for (const [id, value] of Object.entries(stored as Record)) { + const entry = sanitizeEntry(value); + if (entry) accounts[id] = entry; + } + return { ...current, accounts }; + }, }, ), ); @@ -127,9 +210,14 @@ export function waitForDeviceSyncHydration(): Promise { }); } +/** One account's entry, or the empty default. */ +export function accountDeviceSync(registryId: string): AccountDeviceSync { + return useDeviceSyncStore.getState().accounts[registryId] ?? emptyAccountDeviceSync(); +} + /** The engine's view of one account's preferences. */ export function accountSyncPrefs(registryId: string): AccountSyncPrefs { - const a = useDeviceSyncStore.getState().accounts[registryId] ?? emptyAccountDeviceSync(); + const a = accountDeviceSync(registryId); return { contactsSelection: a.contactsSelection, calendarSelection: a.calendarSelection, @@ -137,3 +225,111 @@ export function accountSyncPrefs(registryId: string): AccountSyncPrefs { reminderOwner: a.reminderOwner ?? 'device', }; } + +// ─── Selection ───────────────────────────────────────── + +/** The explicit choices for an authority's collections. */ +export function selectionFor(entry: AccountDeviceSync, authority: Authority): Record { + return entry[selectionField(authority)] ?? {}; +} + +/** What the default rule needs to know about a collection. */ +export interface SelectableCollection { + key: CollectionKey; + isPersonal: boolean; + name?: string | null; +} + +/** + * Whether a collection syncs: the explicit choice when there is one, else the + * engine's default (`collectionDefaultOn`): the personal account's + * collections on, shared ones and the Trusted Senders address book off. + */ +export function isCollectionSelected( + selection: Record, + authority: Authority, + collection: SelectableCollection, +): boolean { + const explicit = selection[collection.key]; + return typeof explicit === 'boolean' ? explicit : collectionDefaultOn(collection.isPersonal, authority, collection.name); +} + +/** The keys of the collections that sync, out of those the server lists. */ +export function effectiveSelection( + entry: AccountDeviceSync, + authority: Authority, + collections: ReadonlyArray, +): CollectionKey[] { + const selection = selectionFor(entry, authority); + return collections.filter((c) => isCollectionSelected(selection, authority, c)).map((c) => c.key); +} + +/** The periodic sync interval of an authority in seconds (0 = manual only). */ +export function intervalFor(entry: AccountDeviceSync, authority: Authority): number { + const seconds = entry.intervalSeconds?.[authority]; + return typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0 ? seconds : DEFAULT_INTERVAL_SECONDS; +} + +// ─── Echoes and routes ───────────────────────────────── + +/** True when the engine already synced exactly this state (a StateChange that is our own echo). */ +export function isKnownState(registryId: string, jmapAccountId: string, type: string, state: string): boolean { + return useDeviceSyncStore.getState().accounts[registryId]?.knownStates?.[jmapAccountId]?.[type] === state; +} + +/** + * The JMAP accounts whose data feeds this account's Android account for an + * authority, as far as the app knows: the personal one, every account the + * engine synced a state of, and every account a collection was explicitly + * picked in. + */ +export function feedingJmapAccounts(entry: AccountDeviceSync, authority: Authority): string[] { + const out = new Set(); + const primary = entry.primaryJmapAccounts?.[authority]; + if (primary) out.add(primary); + const types = STATE_TYPES_BY_AUTHORITY[authority]; + for (const [jmapAccountId, states] of Object.entries(entry.knownStates ?? {})) { + if (states && types.some((type) => type in states)) out.add(jmapAccountId); + } + for (const [key, on] of Object.entries(selectionFor(entry, authority))) { + const slash = key.indexOf('/'); + if (on && slash > 0) out.add(key.slice(0, slash)); + } + return [...out].sort(); +} + +export interface PushRoute { + accountName: string; + authorities: Authority[]; +} + +/** + * Routes for the native push router: every JMAP account id that feeds an + * Android account, with the account and the authorities to sync. + */ +export function computePushRoutes(accounts: Record): Record { + const routes: Record = {}; + for (const registryId of Object.keys(accounts).sort()) { + const entry = accounts[registryId]; + const accountName = entry.androidAccountName; + if (!accountName) continue; + for (const authority of AUTHORITIES) { + if (!syncOnInApp(entry, authority)) continue; + for (const jmapAccountId of feedingJmapAccounts(entry, authority)) { + const list = (routes[jmapAccountId] ??= []); + let route = list.find((r) => r.accountName === accountName); + if (!route) { + route = { accountName, authorities: [] }; + list.push(route); + } + if (!route.authorities.includes(authority)) route.authorities.push(authority); + } + } + } + return routes; +} + +/** Whether any authority of the account is on in the app. */ +export function anySyncOnInApp(entry: AccountDeviceSync | undefined): boolean { + return AUTHORITIES.some((authority) => syncOnInApp(entry, authority)); +}