feat: ask android to sync when contacts or calendars change #34

With device sync on, Android should hear about a change as soon as the
app does, not only at the next periodic sync. This adds what tells it:

- The app's own contact and calendar edits, StateChanges on the live
  stream for `AddressBook`/`ContactCard` and `Calendar`/`CalendarEvent`,
  and the app coming back to the foreground request a sync of that
  account and authority. Requests within 5 s fold into one, and a
  StateChange the engine caused itself (its own upload echoing back) is
  skipped.
- While the app is closed, the account's push subscription carries
  those types too. The FCM and UnifiedPush services hand every push to
  the native router from f743ab0, which requests the syncs, and start
  the mail push task only when a push has `EmailDelivery`; the task also
  returns early for such pushes, so a contact change can never show the
  "newest unread mail" notification.
- At launch, on foreground and when Android's accounts change, the app
  checks Android's accounts against its own: an account removed in
  Android Settings shows sync off and is not recreated, and one whose app
  account is gone stops syncing and posts "Sign in again", keeping its
  data.

The functions that turn sync on and off (`src/device-sync/app/lifecycle.ts`)
come with this; the settings that call them land in a later commit, so
nothing here runs before an account has device sync on.

Checked: the new store, lifecycle, trigger and push tests, `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit, and the
debug build.
This commit is contained in:
Linus Rath
2026-09-27 04:36:51 +02:00
parent a948738506
commit e67e240840
22 changed files with 2742 additions and 63 deletions
+4
View File
@@ -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();
}, []);
@@ -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)
}
@@ -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)
}
+105 -1
View File
@@ -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."
@@ -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<string, Settings> }>,
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<string, unknown>) => {
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<void> {
await waitForDeviceSyncHydration();
if (!useAccountStore.persist.hasHydrated()) {
await new Promise<void>((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([]);
});
});
@@ -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: <T,>(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();
});
});
+17
View File
@@ -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;
}
}
+40
View File
@@ -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<boolean> {
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) },
);
});
}
+622
View File
@@ -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<ReturnType<typeof useAccountStore.getState>['accounts'] | null> {
const persist = useAccountStore.persist;
if (!persist.hasHydrated()) {
const hydrated = await new Promise<boolean>((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<string> {
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<AndroidAccount | null> {
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<void> {
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<void> {
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<EnableOutcome> {
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<void> {
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<typeof import('../task')> | null = null;
function loadEngine(): Promise<typeof import('../task')> {
engine ??= import('../task').catch((err: unknown) => {
engine = null;
throw err;
});
return engine;
}
async function teardown(
registryId: string,
accountName: string,
authority: Authority,
force: boolean,
): Promise<TeardownResult> {
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<string, Record<string, string>> = {};
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<TurnOffResult> {
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<boolean> = confirmSignOutLosingChanges,
): Promise<boolean> {
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<boolean>,
): Promise<boolean> {
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<void> {
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<void> {
const entry = useDeviceSyncStore.getState().accounts[account.registryId];
if (entry?.suspended) return;
// Remember what synced, so signing in again resumes exactly that.
const enabled: Partial<Record<Authority, boolean>> = { ...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<void> | 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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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();
}
+54
View File
@@ -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<typeof PermissionsAndroid.check>[0];
export const SYNC_PERMISSIONS: Record<Authority, readonly Permission[]> = {
[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<boolean> {
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<PermissionOutcome> {
if (await hasSyncPermissions(authority)) return 'granted';
const wanted = SYNC_PERMISSIONS[authority];
try {
const results = (await PermissionsAndroid.requestMultiple([...wanted])) as Record<string, string | undefined>;
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<void> {
try {
await Linking.openSettings();
} catch {
// Nothing else to offer.
}
}
+80
View File
@@ -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<Record<Authority, boolean>>;
/** 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<Authority, readonly string[]> = {
[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<string, SyncSwitches> {
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<string, SyncSwitches>)
: {};
} catch {
return {};
}
}
+92
View File
@@ -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<typeof setTimeout>;
reasons: StateReason[];
/** Asked for by something other than a StateChange: never skipped as an echo. */
unconditional: boolean;
}
const pending = new Map<string, Pending>();
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);
}
}
+119
View File
@@ -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<string, number>();
/** 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<void> {
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<typeof setTimeout> | 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;
}
@@ -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<string, Record<string, string>>) => ({
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<string, unknown>).BulwarkFcm = { showNotification };
(jmapClient.getStoredCredentials as ReturnType<typeof vi.fn>).mockResolvedValue({
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 'secret',
});
(secureFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false, status: 500 });
});
afterEach(() => {
delete (NativeModules as Record<string, unknown>).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<string, boolean> = {}): Email =>
({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email);
@@ -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, any>, 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<string, boolean>, extra: Record<string, unknown> = {}): Promise<void> {
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<typeof vi.fn>;
+12
View File
@@ -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';
+17 -1
View File
@@ -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<void> {
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<void> {
if (accountIds.length === 0) return;
const registry = await readAccountRegistry();
const payload = parseRelayPushData(data);
const jmapAccountIds = await readPushJmapAccountIds();
const accountsToCheck = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry);
+73 -10
View File
@@ -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<string[]> {
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<void> {
// 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<string, EmailPushConfig> | null,
types: readonly string[],
): Promise<false | { emailPush: Record<string, EmailPushConfig> | 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<void> {
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
@@ -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<StoreModule> {
vi.resetModules();
const mod = await import('../device-sync-store');
await mod.waitForDeviceSyncHydration();
return mod;
}
async function stored(): Promise<Record<string, any>> {
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: {} } });
});
});
+16 -2
View File
@@ -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<CalendarState>()(
? 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<CalendarState>()(
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : 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<CalendarState>()(
&& !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))),
});
}
requestDeviceSync(CALENDAR_AUTHORITY);
if (touchesSeries) {
await get().refresh();
}
@@ -915,6 +920,7 @@ export const useCalendarStore = create<CalendarState>()(
: 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<CalendarState>()(
}
} 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<CalendarState>()(
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<CalendarState>()(
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<CalendarState>()(
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();
+13
View File
@@ -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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
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<ContactsState>()(
bookId,
);
set({ trustedSenderEmails: [...get().trustedSenderEmails, email] });
requestDeviceSync(CONTACTS_AUTHORITY);
},
removeFromTrustedSendersBook: async (input) => {
@@ -810,6 +822,7 @@ export const useContactsStore = create<ContactsState>()(
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) =>
+243 -47
View File
@@ -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<string, Record<string, string>>;
/** Authorities the user turned on in the app (Android may still pause them). */
enabled?: Partial<Record<Authority, boolean>>;
/**
* 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<Record<Authority, string>>;
}
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<string, unknown>;
const isRecord = (x: unknown): x is Record<string, unknown> => !!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<AccountDeviceSync>),
contactsSelection: record(v.contactsSelection) as Record<CollectionKey, boolean>,
calendarSelection: record(v.calendarSelection) as Record<CollectionKey, boolean>,
reminderOwner: v.reminderOwner === 'device' || v.reminderOwner === 'bulwark' ? v.reminderOwner : null,
intervalSeconds: record(v.intervalSeconds) as Partial<Record<Authority, number>>,
lastRun: record(v.lastRun) as Partial<Record<Authority, RunStatus>>,
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<DeviceSyncState>()(
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<AccountDeviceSync>) =>
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<string, AccountDeviceSync> = {};
for (const [id, value] of Object.entries(stored as Record<string, unknown>)) {
const entry = sanitizeEntry(value);
if (entry) accounts[id] = entry;
}
return { ...current, accounts };
},
},
),
);
@@ -127,9 +210,14 @@ export function waitForDeviceSyncHydration(): Promise<void> {
});
}
/** 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<CollectionKey, boolean> {
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<CollectionKey, boolean>,
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<SelectableCollection>,
): 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<string>();
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<string, AccountDeviceSync>): Record<string, PushRoute[]> {
const routes: Record<string, PushRoute[]> = {};
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));
}