feat: turn on contacts and calendar sync to this device in settings #34

On Android, Settings > Contacts and Settings > Calendar get a "Sync to
this device" section with a row per signed-in account. This makes device
sync reachable:

- The switch asks for the contacts or calendar permission after a short
  explanation (denied: sync stays off and says why, with a link to the
  app's system settings once Android stops asking), creates the
  account's Bulwark account in Android and syncs right away. Turning it
  off uploads what the device changed first and asks before anything
  that could not go up is lost. The switch shows Android's own state, so
  sync turned off in Android Settings shows as paused.
- Which address books or calendars sync: the personal account's are on
  by default, shared ones and the Trusted Senders book off; read-only
  ones are marked, and a note says when another signed-in account
  already syncs the same shared one.
- The address book new contacts from other apps go to, who reminds of
  synced events (the calendar app or Bulwark, asked when calendar sync
  is turned on while Bulwark's reminders are on), and how often Android
  syncs on its own (every 15 minutes to every 6 hours, or not at all).
- "Sync now", the last sync with its result, or the error with what to
  do about it ("Sign in again", "Grant access", "Review deletions"), and
  a link to the account in Android Settings.
- Accounts the app signed out while they synced are listed with "Sign
  in again" and "Remove from this device".

The settings search finds both sections. iOS, and Android builds without
the native module, show nothing new.

Checked: the collection listing and status tests, the settings search
and i18n tests, `npm run typecheck`, `npm test` and `npm run i18n:check`
on this commit, and on the API 32 emulator with usera on the local
server: the section, turning contacts sync on (Android account created,
152 contacts on the device, one per card) and the status after a run.
This commit is contained in:
Linus Rath
2026-09-27 05:15:32 +02:00
parent 9d83b2fb62
commit 77fd494633
14 changed files with 2047 additions and 116 deletions
@@ -40,6 +40,11 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/UpdatesSettings.tsx': [],
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
'settings/settings-section.tsx': [],
// Device sync (Android, #34).
'settings/device-sync/DeviceSyncSection.tsx': [],
'settings/device-sync/DeviceSyncAccountRow.tsx': [],
'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [],
'settings/device-sync/status.ts': [],
'filters/FilterRuleModal.tsx': [],
'filters/SieveEditorSheet.tsx': [],
};
+133 -114
View File
@@ -8,6 +8,9 @@ import {
} from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone';
import { deviceSyncAvailable } from '../../device-sync/app/available';
import { CALENDAR_AUTHORITY } from '../../device-sync/types';
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
// A compact list of IANA zones for the picker (#755). The device zone and
// any previously stored value are always offered too.
@@ -46,6 +49,9 @@ export function CalendarSettings() {
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
// Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
@@ -64,130 +70,143 @@ export function CalendarSettings() {
}, [deviceZone, timeZone, t]);
return (
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
<Select
value={viewMode}
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[
{ value: 'month', label: t('calendar.views.month', 'Month') },
{ value: 'week', label: t('calendar.views.week', 'Week') },
{ value: 'day', label: t('calendar.views.day', 'Day') },
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
]}
/>
</SettingItem>
<>
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
<Select
value={viewMode}
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[
{ value: 'month', label: t('calendar.views.month', 'Month') },
{ value: 'week', label: t('calendar.views.week', 'Week') },
{ value: 'day', label: t('calendar.views.day', 'Day') },
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
<Select
value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[
{ value: '1', label: t('calendar.days.monday', 'Monday') },
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
<Select
value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[
{ value: '1', label: t('calendar.days.monday', 'Monday') },
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
<RadioGroup
value={timeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
<RadioGroup
value={timeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
]}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.time_zone', 'Time zone')}
description={t(
'calendar.settings.time_zone_desc',
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
)}
>
<Select
value={timeZone || AUTO_TIME_ZONE}
onChange={(v) => update('calendarTimeZone', v)}
options={timeZoneOptions}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
description={t(
'calendar.settings.show_time_in_month_view_desc',
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
)}
>
<ToggleSwitch
checked={showTimeInMonth}
onChange={(v) => update('calendarShowTimeInMonth', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
>
<ToggleSwitch
checked={showWeekNumbers}
onChange={(v) => update('calendarShowWeekNumbers', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
description={t(
'calendar.settings.calendar_free_scroll_desc',
'Scroll continuously through months, weeks and days instead of one period at a time',
)}
>
<ToggleSwitch
checked={freeScroll}
onChange={(v) => update('calendarFreeScroll', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description={t(
'calendar.settings.show_birthday_calendar_desc',
'Show a virtual calendar with birthdays from your contacts',
)}
>
<ToggleSwitch
checked={birthdayCal}
onChange={(v) => update('showBirthdayCalendar', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksEnabled && (
<SettingItem
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
label={t('calendar.settings.time_zone', 'Time zone')}
description={t(
'calendar.settings.show_tasks_on_calendar_desc',
'Display task chips on the day and week calendar views',
'calendar.settings.time_zone_desc',
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
)}
>
<Select
value={timeZone || AUTO_TIME_ZONE}
onChange={(v) => update('calendarTimeZone', v)}
options={timeZoneOptions}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
description={t(
'calendar.settings.show_time_in_month_view_desc',
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
)}
>
<ToggleSwitch
checked={showTasksOnCal}
onChange={(v) => update('showTasksOnCalendar', v)}
checked={showTimeInMonth}
onChange={(v) => update('calendarShowTimeInMonth', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
>
<ToggleSwitch
checked={showWeekNumbers}
onChange={(v) => update('calendarShowWeekNumbers', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
description={t(
'calendar.settings.calendar_free_scroll_desc',
'Scroll continuously through months, weeks and days instead of one period at a time',
)}
>
<ToggleSwitch
checked={freeScroll}
onChange={(v) => update('calendarFreeScroll', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description={t(
'calendar.settings.show_birthday_calendar_desc',
'Show a virtual calendar with birthdays from your contacts',
)}
>
<ToggleSwitch
checked={birthdayCal}
onChange={(v) => update('showBirthdayCalendar', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksEnabled && (
<SettingItem
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
description={t(
'calendar.settings.show_tasks_on_calendar_desc',
'Display task chips on the day and week calendar views',
)}
>
<ToggleSwitch
checked={showTasksOnCal}
onChange={(v) => update('showTasksOnCalendar', v)}
/>
</SettingItem>
)}
</SettingsSection>
{deviceSync && (
<DeviceSyncSection
authority={CALENDAR_AUTHORITY}
title={t('calendar.settings.device_sync_title', 'Sync to this device')}
description={t(
'calendar.settings.device_sync_desc',
'Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.',
)}
/>
)}
</SettingsSection>
</>
);
}
@@ -9,11 +9,14 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { deviceSyncAvailable } from '../../device-sync/app/available';
import { CONTACTS_AUTHORITY } from '../../device-sync/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -53,6 +56,8 @@ export function ContactsSettings() {
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
// Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
const exportLabel =
@@ -356,6 +361,17 @@ export function ContactsSettings() {
</View>
</SettingsSection>
{deviceSync && (
<DeviceSyncSection
authority={CONTACTS_AUTHORITY}
title={t('settings.contacts.device_sync_title', 'Sync to this device')}
description={t(
'settings.contacts.device_sync_desc',
'Show your address books in the Contacts app and other apps on this device. Changes sync both ways.',
)}
/>
)}
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
@@ -0,0 +1,502 @@
// One signed-in account in "Sync to this device" (docs/device-sync.md,
// "Settings sections"): the switch (Android's automatic sync for the
// authority), the status or the error with what to do about it, and, while
// sync is on, which collections sync, where new contacts go or who reminds of
// events, the interval, "Sync now" and Android's account settings.
import React from 'react';
import { Alert, StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { AlertTriangle, RefreshCw, Settings as SettingsIcon, XCircle } from 'lucide-react-native';
import { SettingItem, Select, RadioGroup, ToggleSwitch } from '../settings-section';
import Button from '../../Button';
import Dialog from '../../Dialog';
import { spacing, typography, type ThemePalette } from '../../../theme/tokens';
import { useColors } from '../../../theme/colors';
import { useLocaleStore, type TranslateFn } from '../../../stores/locale-store';
import { useSettingsStore } from '../../../stores/settings-store';
import type { AccountEntry } from '../../../stores/account-store';
import {
effectiveSelection,
emptyAccountDeviceSync,
intervalFor,
syncOnInApp,
useDeviceSyncStore,
} from '../../../stores/device-sync-store';
import {
changeReminderOwner,
changeSyncInterval,
disableDeviceSync,
enableDeviceSync,
openAndroidAccountSettings,
resumeDeviceSync,
syncNow,
} from '../../../device-sync/app/lifecycle';
import { hasSyncPermissions, openAppSystemSettings, requestSyncPermissions } from '../../../device-sync/app/permissions';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority, type ReminderOwner } from '../../../device-sync/types';
import type { RootStackParamList } from '../../../navigation/types';
import { DeviceSyncCollectionsSheet } from './DeviceSyncCollectionsSheet';
import { describeSyncStatus, type StatusAction } from './status';
import { useAndroidSync } from './use-android-sync';
import { useSyncCollections } from './use-sync-collections';
interface Props {
account: AccountEntry;
authority: Authority;
}
type Busy = 'on' | 'off' | 'sync' | null;
type Prompt =
| { kind: 'rationale' }
| { kind: 'blocked' }
| { kind: 'pending'; pending: number };
/** Asks who reminds of synced events; null when the user backed out. */
function askReminderOwner(t: TranslateFn): Promise<ReminderOwner | null> {
return new Promise((resolve) => {
let settled = false;
const answer = (owner: ReminderOwner | null) => {
if (settled) return;
settled = true;
resolve(owner);
};
Alert.alert(
t('settings.device_sync.reminder_question_title', 'Who should remind you?'),
t(
'settings.device_sync.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.',
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(null) },
{ text: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark'), onPress: () => answer('bulwark') },
{ text: t('settings.device_sync.reminder_owner_device', 'Calendar app'), onPress: () => answer('device') },
],
{ cancelable: true, onDismiss: () => answer(null) },
);
});
}
export function DeviceSyncAccountRow({ account, authority }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const storedEntry = useDeviceSyncStore((s) => s.accounts[account.id]);
const entry = React.useMemo(() => storedEntry ?? emptyAccountDeviceSync(), [storedEntry]);
const calendarReminders = useSettingsStore((s) => s.calendarNotificationsEnabled);
const isContacts = authority === CONTACTS_AUTHORITY;
const onInApp = syncOnInApp(entry, authority);
const { view, permitted, refresh } = useAndroidSync(entry.androidAccountName, authority);
const [busy, setBusy] = React.useState<Busy>(null);
const [prompt, setPrompt] = React.useState<Prompt | null>(null);
const [chooserOpen, setChooserOpen] = React.useState(false);
// The switch follows Android; until Android answered, what the app knows.
const androidOn = view ? view.exists && view.automatic : onInApp;
const checked = busy === 'on' ? true : busy === 'off' ? false : androidOn;
const paused = onInApp && !!view?.exists && !view.automatic;
const showDetails = (checked || paused) && busy !== 'off';
const { state: collectionsState } = useSyncCollections(account.id, authority, showDetails);
const collections = collectionsState.kind === 'loaded' ? collectionsState.collections : null;
const selectedKeys = React.useMemo(
() => (collections ? effectiveSelection(entry, authority, collections) : null),
[collections, entry, authority],
);
const accountLabel = account.email || account.username;
const status = describeSyncStatus(
{
authority,
on: checked,
paused,
removed: !!entry.removedInAndroidSettings,
permitted,
syncing: !!view?.active,
lastRun: entry.lastRun[authority],
now: Date.now(),
},
t,
);
const alertFailed = (message: string) => {
Alert.alert(
t('settings.device_sync.failed_title', 'Sync could not be turned on'),
message,
[{ text: t('settings.device_sync.ok', 'OK') }],
);
};
const alertDenied = () => {
Alert.alert(
t('settings.device_sync.denied_title', 'Sync stays off'),
isContacts
? t('settings.device_sync.denied_contacts', 'Without access to contacts, Bulwark cannot put your address books on this device.')
: t('settings.device_sync.denied_calendar', 'Without access to calendars, Bulwark cannot put your calendars on this device.'),
[{ text: t('settings.device_sync.ok', 'OK') }],
);
};
const turnOn = async () => {
setPrompt(null);
setBusy('on');
try {
const permission = await requestSyncPermissions(authority);
if (permission === 'blocked') {
setPrompt({ kind: 'blocked' });
return;
}
if (permission === 'denied') {
alertDenied();
return;
}
if (authority === CALENDAR_AUTHORITY && entry.reminderOwner == null) {
// Bulwark reminds nobody while its event notifications are off: the
// calendar app does, without asking.
const owner = calendarReminders ? await askReminderOwner(t) : 'device';
if (!owner) return;
await changeReminderOwner(account.id, owner);
}
const outcome = await enableDeviceSync(account.id, authority);
if (outcome.kind === 'permission') alertDenied();
else if (outcome.kind === 'failed') alertFailed(outcome.message);
} finally {
setBusy(null);
void refresh();
}
};
const startTurnOn = async () => {
// Explain first, unless access was granted before (another account).
if (await hasSyncPermissions(authority)) void turnOn();
else setPrompt({ kind: 'rationale' });
};
const turnOff = async (force: boolean) => {
setPrompt(null);
setBusy('off');
try {
const result = await disableDeviceSync(account.id, authority, { force });
if (result.done) return;
if (!force) {
setPrompt({ kind: 'pending', pending: result.pending });
return;
}
Alert.alert(
t('settings.device_sync.turn_off_failed_title', 'Sync could not be turned off'),
t(
'settings.device_sync.turn_off_failed',
'The synced data could not be removed from this device. Sync is paused; try again later.',
),
[{ text: t('settings.device_sync.ok', 'OK') }],
);
} finally {
setBusy(null);
void refresh();
}
};
const keepSyncing = async () => {
setPrompt(null);
await resumeDeviceSync(account.id, authority);
void refresh();
};
const handleSyncNow = async () => {
setBusy('sync');
try {
await syncNow(account.id, authority);
} catch (err) {
alertFailed(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
void refresh();
}
};
const grantAccess = async () => {
const permission = await requestSyncPermissions(authority);
if (permission === 'blocked') setPrompt({ kind: 'blocked' });
else if (permission === 'denied') alertDenied();
else if (checked) await syncNow(account.id, authority).catch(() => false);
void refresh();
};
const runAction = (action: StatusAction) => {
if (action === 'signIn') navigation.navigate('AddAccount');
else if (action === 'grantAccess') void grantAccess();
else void openAndroidAccountSettings(account.id);
};
const closeChooser = (changed: boolean) => {
setChooserOpen(false);
// A new selection is loaded (or dropped) by the next sync: run it now.
// (A sync Android paused would only report that it is off.)
if (changed && checked) void syncNow(account.id, authority).catch(() => false);
};
const intervalOptions = [
{ value: '900', label: t('settings.device_sync.interval_15m', 'Every 15 minutes') },
{ value: '1800', label: t('settings.device_sync.interval_30m', 'Every 30 minutes') },
{ value: '3600', label: t('settings.device_sync.interval_1h', 'Every hour') },
{ value: '21600', label: t('settings.device_sync.interval_6h', 'Every 6 hours') },
{ value: '0', label: t('settings.device_sync.interval_manual', 'No regular sync') },
];
const writableSelected = (collections ?? []).filter((col) => !col.readOnly && selectedKeys?.includes(col.key));
const newContactsOptions = [
{ value: '', label: t('settings.device_sync.new_contacts_default', 'Default address book') },
...writableSelected.map((col) => ({
value: col.key,
label: col.isPersonal ? col.name : `${col.name} (${col.accountName})`,
})),
];
const newContactsValue = entry.newContactsAddressBook && newContactsOptions.some((o) => o.value === entry.newContactsAddressBook)
? entry.newContactsAddressBook
: '';
const collectionsDescription = collections && selectedKeys
? t('settings.device_sync.collections_count', '{selected} of {total} sync', {
selected: selectedKeys.length,
total: collections.length,
})
: isContacts
? t('settings.device_sync.books_default', 'Your own address books sync; shared ones only when you pick them.')
: t('settings.device_sync.calendars_default', 'Your own calendars sync; shared ones only when you pick them.');
const statusStyle = status.tone === 'error' ? styles.statusError : status.tone === 'warning' ? styles.statusWarning : styles.statusMuted;
const busyText = busy === 'on'
? t('settings.device_sync.turning_on', 'Setting up…')
: busy === 'off'
? t('settings.device_sync.turning_off', 'Uploading changes, then removing the data from this device…')
: null;
return (
<View style={styles.block}>
<SettingItem label={accountLabel} description={busyText ?? undefined} noBorder>
<View style={styles.row}>
{(busy === 'on' || busy === 'off') && <ActivityIndicator size="small" color={c.primary} />}
<ToggleSwitch
checked={checked}
onChange={(next) => {
if (next) void startTurnOn();
else void turnOff(false);
}}
disabled={busy !== null}
accessibilityLabel={t('settings.device_sync.switch_label', 'Sync {account} to this device', { account: accountLabel })}
/>
</View>
</SettingItem>
{!busyText && (
<View style={styles.statusRow} accessibilityRole={status.tone === 'error' ? 'alert' : undefined}>
{status.tone === 'error' && <XCircle size={14} color={c.error} />}
{status.tone === 'warning' && <AlertTriangle size={14} color={c.warning} />}
<Text style={[styles.statusText, statusStyle]}>{status.text}</Text>
</View>
)}
{!busyText && status.action && (
<View style={styles.actions}>
<Button variant="outline" size="sm" onPress={() => runAction(status.action!)}>
{status.action === 'signIn'
? t('settings.device_sync.sign_in_again', 'Sign in again')
: status.action === 'grantAccess'
? t('settings.device_sync.grant_access', 'Grant access')
: t('settings.device_sync.review_deletions', 'Review deletions')}
</Button>
</View>
)}
{showDetails && view && !view.masterAutomatic && (
<Text style={[styles.statusText, styles.statusWarning]}>
{t(
'settings.device_sync.master_off',
'Auto-sync is off on this device, so syncs only run when you tap Sync now.',
)}
</Text>
)}
{showDetails && (
<View style={styles.details}>
<SettingItem
label={isContacts
? t('settings.device_sync.books_label', 'Address books')
: t('settings.device_sync.calendars_label', 'Calendars')}
description={collectionsDescription}
>
<Button variant="outline" size="sm" onPress={() => setChooserOpen(true)}>
{t('settings.device_sync.choose', 'Choose')}
</Button>
</SettingItem>
{isContacts ? (
<SettingItem
label={t('settings.device_sync.new_contacts_label', 'New contacts go to')}
description={t(
'settings.device_sync.new_contacts_desc',
'The address book for contacts created in other apps on this device.',
)}
>
<Select
value={newContactsValue}
onChange={(v) => useDeviceSyncStore.getState().setNewContactsAddressBook(account.id, v || null)}
options={newContactsOptions}
/>
</SettingItem>
) : (
<SettingItem
label={t('settings.device_sync.reminders_label', 'Reminders')}
description={entry.reminderOwner === 'bulwark' && !calendarReminders
? t(
'settings.device_sync.reminders_nobody',
"Bulwark's event notifications are off, so nothing reminds you of synced events.",
)
: t(
'settings.device_sync.reminders_desc',
'Who reminds you of synced events. The other stays quiet, so you are not reminded twice.',
)}
>
<RadioGroup
value={entry.reminderOwner ?? 'device'}
onChange={(v) => { void changeReminderOwner(account.id, v === 'bulwark' ? 'bulwark' : 'device'); }}
options={[
{ value: 'device', label: t('settings.device_sync.reminder_owner_device', 'Calendar app') },
{ value: 'bulwark', label: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark') },
]}
/>
</SettingItem>
)}
<SettingItem
label={t('settings.device_sync.interval_label', 'Sync interval')}
description={t(
'settings.device_sync.interval_desc',
'How often Android syncs on its own. Edits in Bulwark and pushed changes sync sooner.',
)}
noBorder
>
<Select
value={String(intervalFor(entry, authority))}
onChange={(v) => { void changeSyncInterval(account.id, authority, Number(v)); }}
options={intervalOptions}
/>
</SettingItem>
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
onPress={() => { void handleSyncNow(); }}
disabled={busy !== null || !checked}
loading={busy === 'sync'}
icon={<RefreshCw size={14} color={c.text} />}
>
{t('settings.device_sync.sync_now', 'Sync now')}
</Button>
<Button
variant="outline"
size="sm"
onPress={() => { void openAndroidAccountSettings(account.id); }}
disabled={!view?.exists}
icon={<SettingsIcon size={14} color={c.text} />}
>
{t('settings.device_sync.android_settings', 'Android account settings')}
</Button>
</View>
</View>
)}
<DeviceSyncCollectionsSheet
visible={chooserOpen}
onClose={closeChooser}
registryId={account.id}
authority={authority}
accountLabel={accountLabel}
/>
<Dialog
visible={prompt?.kind === 'rationale'}
title={isContacts
? t('settings.device_sync.rationale_title_contacts', 'Allow access to contacts')
: t('settings.device_sync.rationale_title_calendar', 'Allow access to calendars')}
message={isContacts
? t(
'settings.device_sync.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.',
)
: t(
'settings.device_sync.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.',
)}
confirmText={t('settings.device_sync.continue', 'Continue')}
onConfirm={() => { void turnOn(); }}
onCancel={() => setPrompt(null)}
/>
<Dialog
visible={prompt?.kind === 'blocked'}
title={t('settings.device_sync.blocked_title', 'Access is turned off')}
message={isContacts
? t(
'settings.device_sync.blocked_contacts',
"Android no longer asks for access to contacts. Allow it in the app's settings, then turn sync on again.",
)
: t(
'settings.device_sync.blocked_calendar',
"Android no longer asks for access to calendars. Allow it in the app's settings, then turn sync on again.",
)}
confirmText={t('settings.device_sync.open_app_settings', 'Open app settings')}
onConfirm={() => {
setPrompt(null);
void openAppSystemSettings();
}}
onCancel={() => setPrompt(null)}
/>
<Dialog
visible={prompt?.kind === 'pending'}
variant="destructive"
title={t('settings.device_sync.turn_off_title', 'Turn off anyway?')}
message={prompt?.kind === 'pending' && prompt.pending > 0
? t(
'settings.device_sync.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.}}",
{ count: prompt.pending },
)
: t(
'settings.device_sync.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.',
)}
confirmText={t('settings.device_sync.turn_off_anyway', 'Turn off')}
cancelText={t('settings.device_sync.keep_syncing', 'Keep syncing')}
onConfirm={() => { void turnOff(true); }}
onCancel={() => { void keepSyncing(); }}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
block: {
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingBottom: spacing.md,
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
statusRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
statusText: { ...typography.caption, flexShrink: 1 },
statusMuted: { color: c.mutedForeground },
statusWarning: { color: c.warning },
statusError: { color: c.error },
details: {
marginTop: spacing.sm,
marginStart: spacing.sm,
paddingStart: spacing.md,
borderStartWidth: 2,
borderStartColor: c.muted,
},
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
});
}
@@ -0,0 +1,310 @@
// Which address books or calendars of one account sync to this device
// (docs/device-sync.md, "Settings sections"). The account's own collections
// are on unless turned off, shared ones off unless turned on; read-only ones
// are marked, and a collection another signed-in account already syncs gets
// a warning (it would show twice on the phone).
import React from 'react';
import {
ActivityIndicator, Animated, Easing, Modal, Pressable, ScrollView, StyleSheet, Text, View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AlertTriangle, Check, X } from 'lucide-react-native';
import Button from '../../Button';
import { spacing, radius, typography, type ThemePalette } from '../../../theme/tokens';
import { useColors } from '../../../theme/colors';
import { useSheetDrag } from '../../../lib/use-sheet-drag';
import { useLocaleStore } from '../../../stores/locale-store';
import { useAccountStore } from '../../../stores/account-store';
import {
isCollectionSelected,
selectionFor,
useDeviceSyncStore,
emptyAccountDeviceSync,
} from '../../../stores/device-sync-store';
import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections';
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
import { useSyncCollections } from './use-sync-collections';
interface Props {
visible: boolean;
/** `changed`: the selection was edited while the sheet was open. */
onClose: (changed: boolean) => void;
registryId: string;
authority: Authority;
/** The account's address, under the title. */
accountLabel: string;
}
export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, authority, accountLabel }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const { state, reload } = useSyncCollections(registryId, authority, visible);
const entry = useDeviceSyncStore((s) => s.accounts[registryId]);
const allEntries = useDeviceSyncStore((s) => s.accounts);
const setCollectionSelected = useDeviceSyncStore((s) => s.setCollectionSelected);
const registry = useAccountStore((s) => s.accounts);
const changed = React.useRef(false);
const slideY = React.useRef(new Animated.Value(600)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const close = React.useCallback(() => {
const wasChanged = changed.current;
changed.current = false;
onClose(wasChanged);
}, [onClose]);
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose: close });
React.useEffect(() => {
if (visible) {
changed.current = false;
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const selection = selectionFor(entry ?? emptyAccountDeviceSync(), authority);
const collections = state.kind === 'loaded' || state.kind === 'loading' ? state.collections ?? [] : [];
const groups = React.useMemo(() => {
const out: Array<{ jmapAccountId: string; isPersonal: boolean; accountName: string; items: SyncCollection[] }> = [];
for (const collection of collections) {
let group = out.find((g) => g.jmapAccountId === collection.jmapAccountId);
if (!group) {
group = {
jmapAccountId: collection.jmapAccountId,
isPersonal: collection.isPersonal,
accountName: collection.accountName,
items: [],
};
out.push(group);
}
group.items.push(collection);
}
return out;
}, [collections]);
const toggle = (collection: SyncCollection) => {
const on = isCollectionSelected(selection, authority, collection);
setCollectionSelected(registryId, authority, collection.key, !on);
changed.current = true;
};
const errorText = state.kind !== 'error'
? ''
: state.error === 'signedOut'
? t('settings.device_sync.list_error_signed_out', 'This account is not signed in on this device.')
: state.error === 'auth'
? t('settings.device_sync.list_error_auth', 'The server did not accept the sign-in.')
: state.error === 'network'
? t('settings.device_sync.list_error_network', 'Could not reach the server.')
: t('settings.device_sync.list_error_server', 'The server could not list them.');
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={close} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title}>
{authority === CONTACTS_AUTHORITY
? t('settings.device_sync.choose_books_title', 'Address books to sync')
: t('settings.device_sync.choose_calendars_title', 'Calendars to sync')}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>{accountLabel}</Text>
</View>
<Pressable
onPress={close}
hitSlop={8}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView contentContainerStyle={styles.body}>
{state.kind === 'error' && (
<View style={styles.message} accessibilityRole="alert">
<Text style={styles.errorText}>{errorText}</Text>
<Button variant="outline" size="sm" onPress={reload}>
{t('common.retry', 'Retry')}
</Button>
</View>
)}
{(state.kind === 'loading' || state.kind === 'idle') && collections.length === 0 && (
<View style={styles.message}>
<ActivityIndicator size="small" color={c.primary} />
</View>
)}
{state.kind === 'loaded' && collections.length === 0 && (
<View style={styles.message}>
<Text style={styles.muted}>
{authority === CONTACTS_AUTHORITY
? t('settings.device_sync.no_books', 'This account has no address books.')
: t('settings.device_sync.no_calendars', 'This account has no calendars to sync.')}
</Text>
</View>
)}
{groups.map((group) => (
<View key={group.jmapAccountId} style={styles.group}>
<Text style={styles.groupTitle}>
{group.isPersonal
? t('settings.device_sync.own_collections', 'Your own')
: t('settings.device_sync.shared_by', 'Shared by {name}', { name: group.accountName })}
</Text>
{group.items.map((collection) => {
const on = isCollectionSelected(selection, authority, collection);
const others = on ? otherAccountsSyncing(registryId, authority, collection, registry, allEntries) : [];
const tint = collection.color ?? c.primary;
return (
<Pressable
key={collection.key}
onPress={() => toggle(collection)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="checkbox"
accessibilityState={{ checked: on }}
accessibilityLabel={collection.name}
>
<View style={[styles.box, { borderColor: tint, backgroundColor: on ? tint : 'transparent' }]}>
{on && <Check size={12} color={c.textInverse} />}
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={[styles.rowName, !on && styles.rowNameOff]} numberOfLines={1}>
{collection.name}
</Text>
{(collection.readOnly || collection.isDefault) && (
<Text style={styles.muted}>
{[
collection.isDefault ? t('contacts.address_books.default', 'Default') : null,
collection.readOnly ? t('settings.device_sync.read_only', 'Read-only') : null,
].filter(Boolean).join(' · ')}
</Text>
)}
{others.length > 0 && (
<View style={styles.warningRow}>
<AlertTriangle size={12} color={c.warning} />
<Text style={styles.warningText}>
{t(
'settings.device_sync.also_synced_by',
'Also synced for {accounts}, so it shows twice on this device.',
{ accounts: others.join(', ') },
)}
</Text>
</View>
)}
</View>
</Pressable>
);
})}
</View>
))}
<Text style={styles.hint}>
{authority === CONTACTS_AUTHORITY
? t(
'settings.device_sync.books_hint',
'Your own address books sync unless you turn them off; shared ones only when you turn them on.',
)
: t(
'settings.device_sync.calendars_hint',
'Your own calendars sync unless you turn them off; shared ones only when you turn them on. Task lists stay in Bulwark.',
)}
</Text>
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '80%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
subtitle: { ...typography.caption, color: c.mutedForeground },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
body: { paddingBottom: spacing.md },
message: { alignItems: 'center', gap: spacing.sm, padding: spacing.lg },
errorText: { ...typography.caption, color: c.error, textAlign: 'center' },
muted: { ...typography.caption, color: c.mutedForeground },
group: { paddingTop: spacing.sm },
groupTitle: {
...typography.captionMedium,
color: c.mutedForeground,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
box: {
width: 18,
height: 18,
borderRadius: radius.xs,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
rowName: { ...typography.body, color: c.text },
rowNameOff: { color: c.textSecondary },
warningRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 4, marginTop: 2 },
warningText: { ...typography.caption, color: c.warning, flex: 1 },
hint: {
...typography.caption,
color: c.mutedForeground,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
});
}
@@ -0,0 +1,118 @@
// "Sync to this device" in Settings → Contacts and Settings → Calendar
// (Android only, #34; docs/device-sync.md "Settings sections"): one block per
// signed-in account, then the accounts the app signed out while they synced
// (their data stays until the user signs in again or removes it). The panes
// pass the title and description, so the settings search indexes them where
// they are rendered.
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { SettingsSection, SettingItem } from '../settings-section';
import Button from '../../Button';
import Dialog from '../../Dialog';
import { spacing, type ThemePalette } from '../../../theme/tokens';
import { useColors } from '../../../theme/colors';
import { useAccountStore } from '../../../stores/account-store';
import { useDeviceSyncStore } from '../../../stores/device-sync-store';
import { useLocaleStore } from '../../../stores/locale-store';
import { removeSuspendedAccount } from '../../../device-sync/app/lifecycle';
import type { Authority } from '../../../device-sync/types';
import type { RootStackParamList } from '../../../navigation/types';
import { DeviceSyncAccountRow } from './DeviceSyncAccountRow';
interface Props {
authority: Authority;
title: string;
description: string;
}
export function DeviceSyncSection({ authority, title, description }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accounts = useAccountStore((s) => s.accounts);
const syncEntries = useDeviceSyncStore((s) => s.accounts);
const suspended = React.useMemo(
() => Object.keys(syncEntries)
.filter((id) => syncEntries[id].suspended && !accounts.some((a) => a.id === id))
.sort(),
[syncEntries, accounts],
);
if (accounts.length === 0 && suspended.length === 0) return null;
return (
<View style={styles.section}>
<SettingsSection title={title} description={description}>
{accounts.map((account) => (
<DeviceSyncAccountRow key={account.id} account={account} authority={authority} />
))}
{suspended.map((registryId) => (
<SuspendedAccountRow key={registryId} registryId={registryId} styles={styles} />
))}
</SettingsSection>
</View>
);
}
// An account the app signed out (its sign-in failed) while it synced: sync is
// off, the data stays. Signing in again resumes it; removing drops it.
function SuspendedAccountRow({ registryId, styles }: { registryId: string; styles: ReturnType<typeof makeStyles> }) {
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [confirming, setConfirming] = React.useState(false);
const [removing, setRemoving] = React.useState(false);
const remove = async () => {
setConfirming(false);
setRemoving(true);
try {
await removeSuspendedAccount(registryId);
} finally {
setRemoving(false);
}
};
return (
<View style={styles.block}>
<SettingItem
label={registryId}
description={t(
'settings.device_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.',
)}
noBorder
/>
<View style={styles.actions}>
<Button variant="outline" size="sm" onPress={() => navigation.navigate('AddAccount')}>
{t('settings.device_sync.sign_in_again', 'Sign in again')}
</Button>
<Button variant="outline" size="sm" onPress={() => setConfirming(true)} loading={removing} disabled={removing}>
{t('settings.device_sync.remove_from_device', 'Remove from this device')}
</Button>
</View>
<Dialog
visible={confirming}
variant="destructive"
title={t('settings.device_sync.remove_title', 'Remove from this device?')}
message={t(
'settings.device_sync.remove_message',
"The account's contacts and calendars are removed from this device. Changes made here that haven't reached the server are lost.",
)}
confirmText={t('settings.device_sync.remove', 'Remove')}
onConfirm={() => { void remove(); }}
onCancel={() => setConfirming(false)}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
section: { marginTop: spacing.xxxl },
block: {
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingBottom: spacing.md,
},
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
});
}
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { translate, type MessageParams } from '../../../../i18n';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type RunStatus } from '../../../../device-sync/types';
import { describeSyncStatus, relativeTime, type StatusInput } from '../status';
const t = (key: string, fallback?: string, params?: MessageParams) => translate('en', key, fallback, params);
const NOW = Date.UTC(2026, 8, 27, 12, 0, 0);
const run = (outcome: RunStatus['outcome'], extra: Partial<RunStatus> = {}): RunStatus => ({
at: NOW - 5 * 60_000,
outcome,
durationMs: 100,
conflicts: 0,
itemErrors: 0,
stats: {
downloaded: { created: 0, updated: 0, deleted: 0 },
uploaded: { created: 0, updated: 0, deleted: 0 },
entries: 0,
skipped: 0,
},
...extra,
});
const base: StatusInput = {
authority: CONTACTS_AUTHORITY,
on: true,
paused: false,
removed: false,
permitted: true,
syncing: false,
now: NOW,
};
describe('describeSyncStatus', () => {
it('says off, or that the account was removed in Android settings', () => {
expect(describeSyncStatus({ ...base, on: false }, t)).toEqual({ text: 'Off', tone: 'muted' });
expect(describeSyncStatus({ ...base, on: false, removed: true }, t).tone).toBe('warning');
});
it('puts a revoked permission first, with a way to grant it again', () => {
const line = describeSyncStatus({ ...base, permitted: false, lastRun: run('ok') }, t);
expect(line).toMatchObject({ tone: 'error', action: 'grantAccess' });
expect(line.text).toContain('contacts');
expect(describeSyncStatus({ ...base, authority: CALENDAR_AUTHORITY, permitted: false }, t).text).toContain('calendars');
});
it('says when Android paused the account', () => {
expect(describeSyncStatus({ ...base, on: false, paused: true }, t))
.toEqual({ text: 'Paused in Android settings', tone: 'warning' });
});
it('shows a running sync, then when it last synced', () => {
expect(describeSyncStatus({ ...base, syncing: true, lastRun: run('ok') }, t).text).toBe('Syncing…');
expect(describeSyncStatus({ ...base, lastRun: run('ok') }, t)).toEqual({ text: 'Synced 5 min ago', tone: 'muted' });
expect(describeSyncStatus({ ...base, lastRun: run('ok', { itemErrors: 2 }) }, t)).toEqual({
text: 'Synced 5 min ago · 2 items could not be synced.',
tone: 'warning',
});
expect(describeSyncStatus(base, t).text).toBe('Waiting for the first sync');
});
it('offers what fixes a failed run', () => {
expect(describeSyncStatus({ ...base, lastRun: run('auth') }, t)).toMatchObject({ tone: 'error', action: 'signIn' });
expect(describeSyncStatus({ ...base, lastRun: run('tooManyDeletions') }, t)).toMatchObject({ action: 'reviewDeletions' });
expect(describeSyncStatus({ ...base, lastRun: run('permission') }, t)).toMatchObject({ action: 'grantAccess' });
expect(describeSyncStatus({ ...base, lastRun: run('io') }, t).text).toBe('Could not reach the server 5 min ago. Android tries again.');
expect(describeSyncStatus({ ...base, lastRun: run('internal', { message: 'Boom' }) }, t).text)
.toBe('The last sync failed: Boom');
// A run that found sync off says nothing new.
expect(describeSyncStatus({ ...base, lastRun: run('disabled') }, t).text).toBe('Waiting for the next sync');
});
});
describe('relativeTime', () => {
it('counts minutes, hours and days', () => {
expect(relativeTime(NOW - 10_000, NOW, t)).toBe('just now');
expect(relativeTime(NOW - 3 * 3600_000, NOW, t)).toBe('3 h ago');
expect(relativeTime(NOW - 2 * 86_400_000, NOW, t)).toBe('2 d ago');
});
});
@@ -0,0 +1,133 @@
// The status line of one account's device sync in Settings: off, paused in
// Android settings, syncing, when it last synced, or what went wrong and what
// the user can do about it (docs/device-sync.md, "Settings sections",
// "Failure matrix").
import { CONTACTS_AUTHORITY, type Authority, type RunStatus } from '../../../device-sync/types';
import type { TranslateFn } from '../../../stores/locale-store';
/** What the status line offers to do. */
export type StatusAction = 'signIn' | 'grantAccess' | 'reviewDeletions';
export interface StatusLine {
text: string;
tone: 'muted' | 'warning' | 'error';
action?: StatusAction;
}
export interface StatusInput {
authority: Authority;
/** The switch: Android syncs this authority automatically. */
on: boolean;
/** On in the app but off in Android's settings for the account. */
paused: boolean;
/** The Android account was removed in Android Settings. */
removed: boolean;
/** The runtime permissions; null while unknown. */
permitted: boolean | null;
syncing: boolean;
lastRun?: RunStatus;
now: number;
}
/** "just now", "5 min ago", … */
export function relativeTime(at: number, now: number, t: TranslateFn): string {
const diff = Math.max(0, now - at);
if (diff < 60_000) return t('settings.offline.just_now', 'just now');
if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) });
if (diff < 24 * 60 * 60_000) {
return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) });
}
return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) });
}
function permissionLine(authority: Authority, t: TranslateFn): StatusLine {
return {
text: authority === CONTACTS_AUTHORITY
? t('settings.device_sync.status_permission_contacts', 'Access to contacts was turned off, so nothing syncs.')
: t('settings.device_sync.status_permission_calendar', 'Access to calendars was turned off, so nothing syncs.'),
tone: 'error',
action: 'grantAccess',
};
}
function runLine(run: RunStatus, input: StatusInput, t: TranslateFn): StatusLine | null {
const when = relativeTime(run.at, input.now, t);
switch (run.outcome) {
case 'ok': {
const synced = t('settings.device_sync.status_synced', 'Synced {when}', { when });
if (run.itemErrors <= 0) return { text: synced, tone: 'muted' };
const skipped = t(
'settings.device_sync.status_item_errors',
'{count, plural, one {# item could not be synced.} other {# items could not be synced.}}',
{ count: run.itemErrors },
);
return { text: `${synced} · ${skipped}`, tone: 'warning' };
}
case 'auth':
return {
text: t('settings.device_sync.status_auth', 'The server no longer accepts the sign-in. Sign in again to keep syncing.'),
tone: 'error',
action: 'signIn',
};
case 'permission':
return permissionLine(input.authority, t);
case 'tooManyDeletions':
return {
text: t(
'settings.device_sync.status_too_many_deletions',
'Many items were deleted on this device. They are not deleted on the server until you confirm.',
),
tone: 'warning',
action: 'reviewDeletions',
};
case 'io':
return {
text: t('settings.device_sync.status_io', 'Could not reach the server {when}. Android tries again.', { when }),
tone: 'warning',
};
case 'unsupported':
return {
text: input.authority === CONTACTS_AUTHORITY
? t('settings.device_sync.status_unsupported_contacts', 'The server offers no address books for this account.')
: t('settings.device_sync.status_unsupported_calendar', 'The server offers no calendars for this account.'),
tone: 'error',
};
case 'safetyAbort':
return {
text: t(
'settings.device_sync.status_safety_abort',
'Stopped: the server returned nothing while this device still holds items. Check the server, then sync again.',
),
tone: 'error',
};
case 'cancelled':
return { text: t('settings.device_sync.status_cancelled', 'The last sync stopped early. It continues next time.'), tone: 'muted' };
case 'internal':
return {
text: run.message
? t('settings.device_sync.status_failed', 'The last sync failed: {message}', { message: run.message })
: t('settings.device_sync.status_failed_plain', 'The last sync failed.'),
tone: 'error',
};
case 'disabled':
default:
return null;
}
}
export function describeSyncStatus(input: StatusInput, t: TranslateFn): StatusLine {
if (!input.on && !input.paused) {
return input.removed
? {
text: t('settings.device_sync.status_removed', 'Removed in Android settings. Turn on to add the account again.'),
tone: 'warning',
}
: { text: t('settings.device_sync.status_off', 'Off'), tone: 'muted' };
}
if (input.permitted === false) return permissionLine(input.authority, t);
if (input.paused) return { text: t('settings.device_sync.status_paused', 'Paused in Android settings'), tone: 'warning' };
if (input.syncing) return { text: t('settings.device_sync.status_syncing', 'Syncing…'), tone: 'muted' };
if (!input.lastRun) return { text: t('settings.device_sync.status_waiting', 'Waiting for the first sync'), tone: 'muted' };
return runLine(input.lastRun, input, t)
?? { text: t('settings.device_sync.status_waiting_next', 'Waiting for the next sync'), tone: 'muted' };
}
@@ -0,0 +1,101 @@
// Android's side of one account's device sync for the settings: whether the
// Android account exists, its automatic sync for the authority (the switch),
// the device's master auto-sync, whether a sync runs or waits, and whether
// the runtime permissions are still granted. Re-read on mount, when the app
// returns to the foreground, when accounts of our type change, and every few
// seconds while a sync runs or waits.
import React from 'react';
import { AppState } from 'react-native';
import { getSyncSettings, listAndroidAccounts, onAccountsChanged } from '../../../device-sync/native';
import { hasSyncPermissions } from '../../../device-sync/app/permissions';
import type { Authority } from '../../../device-sync/types';
export interface AndroidSyncView {
/** The Android account exists. */
exists: boolean;
/** Automatic sync for the authority: the toggle in Settings → Accounts. */
automatic: boolean;
/** The device's "Auto-sync data". */
masterAutomatic: boolean;
active: boolean;
pending: boolean;
}
const POLL_MS = 3000;
export function useAndroidSync(accountName: string | undefined, authority: Authority): {
view: AndroidSyncView | null;
permitted: boolean | null;
refresh: () => Promise<void>;
} {
const [view, setView] = React.useState<AndroidSyncView | null>(null);
const [permitted, setPermitted] = React.useState<boolean | null>(null);
const alive = React.useRef(true);
React.useEffect(() => {
alive.current = true;
return () => { alive.current = false; };
}, []);
// Read at call time: turning sync on creates the account while the
// caller still holds the refresh of the render before.
const nameRef = React.useRef(accountName);
nameRef.current = accountName;
// Only the latest read may land; an older one resolving late is stale.
const reads = React.useRef(0);
const refresh = React.useCallback(async () => {
const read = ++reads.current;
const accountName = nameRef.current;
const granted = await hasSyncPermissions(authority);
let next: AndroidSyncView = { exists: false, automatic: false, masterAutomatic: true, active: false, pending: false };
if (accountName) {
try {
const accounts = await listAndroidAccounts();
if (accounts.some((a) => a.name === accountName)) {
const settings = await getSyncSettings(accountName);
const own = settings.authorities?.[authority];
next = {
exists: true,
automatic: !!own?.automatic,
masterAutomatic: settings.masterAutomatic !== false,
active: !!own?.active,
pending: !!own?.pending,
};
}
} catch {
// Shown as off until the next read.
}
}
if (!alive.current || read !== reads.current) return;
setPermitted(granted);
setView(next);
}, [authority]);
React.useEffect(() => {
void refresh();
}, [refresh, accountName]);
React.useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') void refresh();
});
let unsubscribe: () => void = () => undefined;
try {
unsubscribe = onAccountsChanged(() => { void refresh(); });
} catch {
// Foreground reads still catch up.
}
return () => {
subscription.remove();
unsubscribe();
};
}, [refresh]);
const busy = !!view?.active || !!view?.pending;
React.useEffect(() => {
if (!busy) return undefined;
const timer = setInterval(() => { void refresh(); }, POLL_MS);
return () => clearInterval(timer);
}, [busy, refresh]);
return { view, permitted, refresh };
}
@@ -0,0 +1,61 @@
// The address books or calendars of one account for the device sync settings,
// listed through the account's own client (device-sync/app/collections) and
// cached for a little while, so the chooser, the counts and the "new contacts
// go to" picker share one request.
import React from 'react';
import {
cachedSyncCollections,
CollectionsError,
listSyncCollections,
type CollectionsErrorKind,
type SyncCollection,
} from '../../../device-sync/app/collections';
import { CALENDAR_AUTHORITY, type Authority } from '../../../device-sync/types';
import { useAuthStore } from '../../../stores/auth-store';
import { useCalendarSubscriptionsStore } from '../../../stores/calendar-subscriptions-store';
export type CollectionsState =
| { kind: 'idle' }
| { kind: 'loading'; collections: SyncCollection[] | null }
| { kind: 'loaded'; collections: SyncCollection[] }
| { kind: 'error'; error: CollectionsErrorKind };
export function useSyncCollections(registryId: string, authority: Authority, active: boolean): {
state: CollectionsState;
reload: () => void;
} {
const [state, setState] = React.useState<CollectionsState>(() => {
const cached = cachedSyncCollections(registryId, authority);
return cached ? { kind: 'loaded', collections: cached } : { kind: 'idle' };
});
const [attempt, setAttempt] = React.useState(0);
// Calendars mirroring subscribed feeds are read-only on the device. Only
// the active account's subscriptions are known here.
const isActive = useAuthStore((s) => s.activeAccountId === registryId);
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const feeds = React.useMemo(
() => (authority === CALENDAR_AUTHORITY && isActive
? subscriptions.map((sub) => ({ calendarId: sub.calendarId, accountId: sub.accountId }))
: []),
[authority, isActive, subscriptions],
);
React.useEffect(() => {
if (!active) return undefined;
let cancelled = false;
const force = attempt > 0;
setState((prev) => ({ kind: 'loading', collections: prev.kind === 'loaded' ? prev.collections : null }));
listSyncCollections(registryId, authority, { force, feeds })
.then((collections) => {
if (!cancelled) setState({ kind: 'loaded', collections });
})
.catch((err: unknown) => {
if (cancelled) return;
setState({ kind: 'error', error: err instanceof CollectionsError ? err.kind : 'server' });
});
return () => { cancelled = true; };
}, [registryId, authority, active, attempt, feeds]);
const reload = React.useCallback(() => setAttempt((n) => n + 1), []);
return { state, reload };
}
@@ -0,0 +1,230 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
CollectionsError,
forgetSyncCollections,
listSyncCollections,
otherAccountsSyncing,
type CollectionsClient,
} from '../../app/collections';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, JMAP_CALENDARS, JMAP_CONTACTS } from '../../types';
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
import type { JMAPMethodCall } from '../../../api/types';
type Handler = (args: Record<string, any>, calls: JMAPMethodCall[]) => any;
// A client for one account with the personal JMAP account `c` and a shared
// account `team`, answering the calls the listing makes.
function fakeClient(handlers: Record<string, Handler>, options: { loaded?: boolean | Error; maxCalls?: number } = {}) {
const requests: JMAPMethodCall[][] = [];
const client: CollectionsClient = {
async loadAccount() {
if (options.loaded instanceof Error) throw options.loaded;
return options.loaded ?? true;
},
get currentSession() {
return {
primaryAccounts: { [JMAP_CONTACTS]: 'c', [JMAP_CALENDARS]: 'c' },
accounts: {
c: { name: 'alice@example.org', isPersonal: true, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
team: { name: 'Team', isPersonal: false, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
mailonly: { name: 'Archive', isPersonal: false, accountCapabilities: {} },
},
};
},
async request(calls) {
requests.push(calls);
const responses: Array<[string, any, string]> = [];
for (const [name, args, id] of calls) {
const handler = handlers[`${name} ${args.accountId}`] ?? handlers[name];
if (!handler) {
responses.push(['error', { type: 'unknownMethod' }, id]);
continue;
}
const body = handler(args, calls);
responses.push(body === null ? ['error', { type: 'forbidden' }, id] : [name, body, id]);
}
return { methodResponses: responses };
},
getMaxCallsInRequest: () => options.maxCalls ?? 16,
};
return { client, requests };
}
beforeEach(async () => {
await waitForDeviceSyncHydration();
useDeviceSyncStore.setState({ accounts: {} });
forgetSyncCollections();
});
describe('listSyncCollections', () => {
it('lists the personal address books first, then the shared ones, and marks read-only ones', async () => {
const { client, requests } = fakeClient({
'AddressBook/get c': () => ({ list: [
{ id: 'b2', name: 'Work', sortOrder: 2, myRights: { mayWrite: true } },
{ id: 'b1', name: 'Personal', isDefault: true, sortOrder: 1, myRights: { mayWrite: true } },
] }),
'AddressBook/get team': () => ({ list: [{ id: 'b1', name: 'Team contacts', myRights: { mayWrite: false } }] }),
});
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
expect(list.map((c) => [c.key, c.name, c.isPersonal, c.readOnly, c.isDefault])).toEqual([
['c/b1', 'Personal', true, false, true],
['c/b2', 'Work', true, false, false],
['team/b1', 'Team contacts', false, true, false],
]);
expect(list[2].accountName).toBe('Team');
// Explicit properties, the contacts capability, and never the mail-only account.
const [calls] = requests;
expect(calls.map(([name, args]) => `${name} ${args.accountId}`)).toEqual(['AddressBook/get c', 'AddressBook/get team']);
expect(calls[0][1].properties).toEqual(expect.arrayContaining(['id', 'name', 'myRights']));
});
it('remembers the personal JMAP account for the push routes', async () => {
const { client } = fakeClient({ 'AddressBook/get': () => ({ list: [] }) });
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
expect(useDeviceSyncStore.getState().accounts['alice@x']?.primaryJmapAccounts).toEqual({ [CONTACTS_AUTHORITY]: 'c' });
});
it('keeps the list when a shared account refuses', async () => {
const { client } = fakeClient({
'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }),
'AddressBook/get team': () => null,
});
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
expect(list.map((c) => c.key)).toEqual(['c/b1']);
});
it('leaves out calendars it may not read and calendars holding only tasks', async () => {
// What each calendar's scan finds: `todo` holds only tasks, `empty` nothing.
const scans: Record<string, any[]> = {
cal1: [{ id: 'e', '@type': 'Event' }],
todo: [{ id: 't', '@type': 'Task' }, { id: 'u', due: '2026-10-01T00:00:00' }],
empty: [],
};
const { client } = fakeClient({
'Calendar/get c': () => ({ list: [
{ id: 'cal1', name: 'Personal', color: '#ff0000', myRights: { mayReadItems: true, mayWriteAll: true } },
{ id: 'todo', name: 'Tasks', myRights: { mayReadItems: true, mayWriteAll: true } },
{ id: 'empty', name: 'New', myRights: { mayReadItems: true, mayWriteAll: true } },
{ id: 'hidden', name: 'Free/busy only', myRights: { mayReadItems: false } },
] }),
'Calendar/get team': () => ({ list: [
{ id: 'cal1', name: 'Team', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false } },
] }),
'CalendarEvent/query': (args) => ({ ids: (scans[args.filter.inCalendar] ?? []).map((o) => o.id) }),
'CalendarEvent/get': (args, calls) => {
// The get reads the ids of the query it references.
const query = calls.find(([, , id]) => id === args['#ids'].resultOf)!;
return { list: scans[(query[1].filter as { inCalendar: string }).inCalendar] ?? [] };
},
});
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
// By name within an account (no sort order given).
expect(list.map((c) => [c.key, c.readOnly])).toEqual([
['c/empty', false],
['c/cal1', false],
['team/cal1', true],
]);
expect(list.find((c) => c.key === 'c/cal1')?.color).toBe('#ff0000');
});
it('marks calendars mirroring a subscribed feed read-only', async () => {
const { client } = fakeClient({
'Calendar/get c': () => ({ list: [
{ id: 'feed', name: 'Holidays', myRights: { mayReadItems: true, mayWriteAll: true } },
] }),
'Calendar/get team': () => ({ list: [] }),
'CalendarEvent/query': () => ({ ids: [] }),
'CalendarEvent/get': () => ({ list: [] }),
});
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client, feeds: [{ calendarId: 'feed' }] });
expect(list.map((c) => [c.key, c.readOnly])).toEqual([['c/feed', true]]);
});
it('keeps a query and the get that reads it in one request', async () => {
const { client, requests } = fakeClient({
'Calendar/get': () => ({ list: [
{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' },
] }),
'CalendarEvent/query': () => ({ ids: [] }),
'CalendarEvent/get': () => ({ list: [] }),
}, { maxCalls: 5 });
await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
const scanRequests = requests.filter((calls) => calls[0][0] === 'CalendarEvent/query');
for (const calls of scanRequests) {
expect(calls.length % 2).toBe(0);
expect(calls.length).toBeLessThanOrEqual(5);
}
});
it('says why the list could not be loaded', async () => {
const signedOut = fakeClient({}, { loaded: false }).client;
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: signedOut }))
.rejects.toMatchObject({ kind: 'signedOut' });
const auth = Object.assign(new Error('Invalid credentials'), { name: 'AuthenticationError' });
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
.rejects.toBeInstanceOf(CollectionsError);
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
.rejects.toMatchObject({ kind: 'auth' });
const offline = Object.assign(new Error('Server unreachable'), { name: 'NetworkError' });
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: offline }).client }))
.rejects.toMatchObject({ kind: 'network' });
});
});
describe('otherAccountsSyncing', () => {
const registry = [
{ id: 'alice@mail.example.org', serverUrl: 'https://mail.example.org', email: 'alice@example.org', username: 'alice' },
{ id: 'bob@mail.example.org', serverUrl: 'https://mail.example.org/', email: 'bob@example.org', username: 'bob' },
{ id: 'bob@other.example.net', serverUrl: 'https://other.example.net', email: 'bob@example.net', username: 'bob' },
];
it('names the other accounts on the same server that sync the same collection', () => {
const accounts = {
'bob@mail.example.org': {
contactsSelection: { 'team/b1': true },
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
enabled: { [CONTACTS_AUTHORITY]: true },
},
// Another server: the same JMAP ids mean other data.
'bob@other.example.net': {
contactsSelection: { 'team/b1': true },
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
enabled: { [CONTACTS_AUTHORITY]: true },
},
};
const collection = { key: 'team/b1', jmapAccountId: 'team' };
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts))
.toEqual(['bob@example.org']);
// Not for another authority, nor once Bob's contacts sync is off.
expect(otherAccountsSyncing('alice@mail.example.org', CALENDAR_AUTHORITY, collection, registry, accounts)).toEqual([]);
accounts['bob@mail.example.org'].enabled = { [CONTACTS_AUTHORITY]: false };
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts)).toEqual([]);
});
it("counts the other account's own collections, which sync by default", () => {
const accounts = {
'bob@mail.example.org': {
contactsSelection: {},
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
enabled: { [CALENDAR_AUTHORITY]: true },
primaryJmapAccounts: { [CALENDAR_AUTHORITY]: 'bobacct' },
},
};
expect(otherAccountsSyncing(
'alice@mail.example.org',
CALENDAR_AUTHORITY,
{ key: 'bobacct/cal1', jmapAccountId: 'bobacct' },
registry,
accounts,
)).toEqual(['bob@example.org']);
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* The address books and calendars an app account can sync, for the chooser in
* Settings (docs/device-sync.md, "Settings sections").
*
* Listed through a JMAPClient of the account's own, never the UI's
* singleton: the chooser covers every signed-in account, and re-binding the
* singleton would send the UI's requests with another account's credentials.
* Collections of the capability's primary account are "personal" (on by
* default); every other session account that advertises the capability holds
* shared ones (off by default). Calendars without `mayReadItems` and
* calendars that hold only tasks are not offered (the engine does not sync
* them); the birthday calendar is the app's own and never on the server.
*/
import { AuthenticationError, JMAPClient, NetworkError } from '../../api/jmap-client';
import type { JMAPMethodCall } from '../../api/types';
import {
findTasksOnlyCalendarIds,
SCAN_PROPERTIES,
type ScannedCalendarObject,
} from '../../lib/calendar-component-detection';
import {
isCollectionSelected,
selectionFor,
syncOnInApp,
useDeviceSyncStore,
type AccountDeviceSync,
} from '../../stores/device-sync-store';
import { collectionKey } from '../common/ids';
import {
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
JMAP_CALENDARS,
JMAP_CONTACTS,
JMAP_CORE,
type Authority,
type CollectionKey,
} from '../types';
export interface SyncCollection {
/** `<jmapAccountId>/<id>`, as in the selections. */
key: CollectionKey;
jmapAccountId: string;
id: string;
name: string;
/** The JMAP account's name (who shares it, for a shared collection). */
accountName: string;
/** In the capability's primary account: synced unless the user turned it off. */
isPersonal: boolean;
/** No write rights, or (calendars) a subscribed feed. */
readOnly: boolean;
isDefault: boolean;
color?: string;
}
/** Why the list could not be loaded. */
export type CollectionsErrorKind = 'signedOut' | 'auth' | 'network' | 'server';
export class CollectionsError extends Error {
constructor(readonly kind: CollectionsErrorKind, message: string) {
super(message);
this.name = 'CollectionsError';
}
}
/** The part of JMAPClient the listing uses. */
export interface CollectionsClient {
loadAccount(registryId: string): Promise<boolean>;
readonly currentSession: {
primaryAccounts: Record<string, string>;
accounts: Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>;
} | null;
request(calls: JMAPMethodCall[], using?: string[]): Promise<{ methodResponses: Array<[string, any, string]> }>;
getMaxCallsInRequest(): number;
}
export interface ListOptions {
/** Skip the cache. */
force?: boolean;
/** For tests: the client to list with. */
client?: CollectionsClient;
/** Calendars mirroring subscribed iCal feeds (read-only on the device); `accountId` absent = the primary account. */
feeds?: ReadonlyArray<{ calendarId: string; accountId?: string }>;
}
const CACHE_TTL_MS = 2 * 60_000;
/** How many objects of a calendar tell whether it holds only tasks (the engine checks the same). */
const TASK_SCAN_LIMIT = 50;
const ADDRESS_BOOK_PROPERTIES = ['id', 'name', 'isDefault', 'sortOrder', 'myRights'];
const CALENDAR_PROPERTIES = ['id', 'name', 'color', 'isDefault', 'sortOrder', 'isSubscribed', 'myRights'];
const cache = new Map<string, { at: number; collections: SyncCollection[] }>();
const inFlight = new Map<string, Promise<SyncCollection[]>>();
const cacheKey = (registryId: string, authority: Authority) => `${registryId}\n${authority}`;
/** The last list loaded for an account, if it is still fresh. */
export function cachedSyncCollections(registryId: string, authority: Authority): SyncCollection[] | null {
const hit = cache.get(cacheKey(registryId, authority));
return hit && Date.now() - hit.at < CACHE_TTL_MS ? hit.collections : null;
}
/** Drops the cached lists of one account, or of all (tests). */
export function forgetSyncCollections(registryId?: string): void {
for (const key of [...cache.keys()]) {
if (!registryId || key.startsWith(`${registryId}\n`)) cache.delete(key);
}
}
/** The collections of one app account for an authority, personal ones first. */
export function listSyncCollections(
registryId: string,
authority: Authority,
options: ListOptions = {},
): Promise<SyncCollection[]> {
const key = cacheKey(registryId, authority);
if (!options.force && !options.client) {
const hit = cachedSyncCollections(registryId, authority);
if (hit) return Promise.resolve(hit);
const running = inFlight.get(key);
if (running) return running;
}
const run = load(registryId, authority, options).then((collections) => {
cache.set(key, { at: Date.now(), collections });
return collections;
});
if (!options.client) {
inFlight.set(key, run);
void run.finally(() => {
if (inFlight.get(key) === run) inFlight.delete(key);
}).catch(() => undefined);
}
return run;
}
async function openClient(registryId: string, client: CollectionsClient): Promise<CollectionsClient> {
let ok: boolean;
try {
ok = await client.loadAccount(registryId);
} catch (err) {
if (err instanceof AuthenticationError || (err instanceof Error && err.name === 'AuthenticationError')) {
throw new CollectionsError('auth', err.message);
}
if (err instanceof NetworkError || (err instanceof Error && err.name === 'NetworkError')) {
throw new CollectionsError('network', err.message);
}
throw new CollectionsError('server', err instanceof Error ? err.message : String(err));
}
if (!ok) throw new CollectionsError('signedOut', 'No stored credentials');
return client;
}
async function send(
client: CollectionsClient,
calls: JMAPMethodCall[],
using: string[],
): Promise<Array<[string, any, string]>> {
const out: Array<[string, any, string]> = [];
// Result references only point inside one request, so a query and the
// get that reads it always go together.
const perRequest = Math.max(2, client.getMaxCallsInRequest() - (client.getMaxCallsInRequest() % 2));
for (let i = 0; i < calls.length; i += perRequest) {
try {
const res = await client.request(calls.slice(i, i + perRequest), using);
out.push(...(res.methodResponses ?? []));
} catch (err) {
if (err instanceof Error && err.name === 'AuthenticationError') throw new CollectionsError('auth', err.message);
throw new CollectionsError('network', err instanceof Error ? err.message : String(err));
}
}
return out;
}
function responseOf(responses: Array<[string, any, string]>, callId: string): any {
const hit = responses.find((r) => r[2] === callId);
return hit && hit[0] !== 'error' ? hit[1] : null;
}
interface RawCollection {
id: string;
name?: string;
color?: string;
isDefault?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean | undefined>;
}
async function load(registryId: string, authority: Authority, options: ListOptions): Promise<SyncCollection[]> {
const client = await openClient(registryId, options.client ?? new JMAPClient());
const session = client.currentSession;
if (!session) throw new CollectionsError('server', 'No session');
const capability = authority === CONTACTS_AUTHORITY ? JMAP_CONTACTS : JMAP_CALENDARS;
const using = [JMAP_CORE, capability];
const capable = Object.entries(session.accounts ?? {})
.filter(([, info]) => !!info?.accountCapabilities?.[capability])
.map(([id]) => id);
const primary = session.primaryAccounts?.[capability] ?? capable[0];
if (!primary) return [];
const accountIds = [primary, ...capable.filter((id) => id !== primary)];
// For the push routes and the trigger filter; only into a store that has
// been read, which would otherwise be overwritten.
if (useDeviceSyncStore.persist.hasHydrated()
&& useDeviceSyncStore.getState().accounts[registryId]?.primaryJmapAccounts?.[authority] !== primary) {
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(registryId, authority, primary);
}
const method = authority === CONTACTS_AUTHORITY ? 'AddressBook/get' : 'Calendar/get';
const properties = authority === CONTACTS_AUTHORITY ? ADDRESS_BOOK_PROPERTIES : CALENDAR_PROPERTIES;
const responses = await send(
client,
accountIds.map((accountId, i) => [method, { accountId, properties }, `g${i}`]),
using,
);
const listed: Array<{ jmapAccountId: string; raw: RawCollection }> = [];
accountIds.forEach((jmapAccountId, i) => {
const body = responseOf(responses, `g${i}`);
// An account that refuses (a revoked share) must not hide the others.
if (!body) {
if (jmapAccountId === primary) throw new CollectionsError('server', `${method} failed`);
return;
}
for (const raw of (body.list ?? []) as RawCollection[]) {
if (raw && typeof raw.id === 'string') listed.push({ jmapAccountId, raw });
}
});
let offered = listed;
if (authority === CALENDAR_AUTHORITY) {
offered = listed.filter(({ raw }) => raw.myRights?.mayReadItems !== false);
const taskOnly = await taskOnlyCalendars(client, offered, using);
offered = offered.filter(({ jmapAccountId, raw }) => !taskOnly.has(collectionKey(jmapAccountId, raw.id)));
}
const feeds = new Set((options.feeds ?? []).map((f) => collectionKey(f.accountId ?? primary, f.calendarId)));
const collections = offered.map(({ jmapAccountId, raw }): SyncCollection => {
const key = collectionKey(jmapAccountId, raw.id);
const rights = raw.myRights;
const readOnly = authority === CONTACTS_AUTHORITY
? rights?.mayWrite === false
: feeds.has(key) || (!!rights && !rights.mayWriteAll && !rights.mayWriteOwn);
return {
key,
jmapAccountId,
id: raw.id,
name: raw.name?.trim() || raw.id,
accountName: session.accounts?.[jmapAccountId]?.name ?? jmapAccountId,
isPersonal: jmapAccountId === primary,
readOnly,
isDefault: !!raw.isDefault,
...(typeof raw.color === 'string' && raw.color ? { color: raw.color } : {}),
};
});
const order = new Map(accountIds.map((id, i) => [id, i]));
const sortOf = new Map(offered.map(({ jmapAccountId, raw }) => [collectionKey(jmapAccountId, raw.id), raw.sortOrder ?? 0]));
return collections.sort((a, b) =>
(order.get(a.jmapAccountId)! - order.get(b.jmapAccountId)!)
|| (sortOf.get(a.key)! - sortOf.get(b.key)!)
|| a.name.localeCompare(b.name));
}
/**
* Calendars whose first objects are all tasks. One query and one get per
* calendar; a calendar whose scan fails is kept (offered).
*/
async function taskOnlyCalendars(
client: CollectionsClient,
calendars: Array<{ jmapAccountId: string; raw: RawCollection }>,
using: string[],
): Promise<Set<CollectionKey>> {
const calls: JMAPMethodCall[] = [];
calendars.forEach(({ jmapAccountId, raw }, i) => {
calls.push(['CalendarEvent/query', { accountId: jmapAccountId, filter: { inCalendar: raw.id }, limit: TASK_SCAN_LIMIT }, `q${i}`]);
calls.push([
'CalendarEvent/get',
{
accountId: jmapAccountId,
'#ids': { resultOf: `q${i}`, name: 'CalendarEvent/query', path: '/ids' },
properties: SCAN_PROPERTIES,
},
`o${i}`,
]);
});
const out = new Set<CollectionKey>();
if (calls.length === 0) return out;
let responses: Array<[string, any, string]>;
try {
responses = await send(client, calls, using);
} catch {
return out;
}
calendars.forEach(({ jmapAccountId, raw }, i) => {
const body = responseOf(responses, `o${i}`);
if (!body) return;
const objects = ((body.list ?? []) as ScannedCalendarObject[])
.map((o) => ({ ...o, calendarIds: { [raw.id]: true } }));
if (findTasksOnlyCalendarIds(objects, [raw.id]).has(raw.id)) out.add(collectionKey(jmapAccountId, raw.id));
});
return out;
}
function serverOf(serverUrl: string): string {
try {
return new URL(serverUrl).host.toLowerCase();
} catch {
return serverUrl.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
}
}
/**
* The other signed-in accounts on the same server that already sync this
* collection (the same JMAP account and id): the chooser warns about the
* second copy on the device, it does not refuse it.
*/
export function otherAccountsSyncing(
registryId: string,
authority: Authority,
collection: Pick<SyncCollection, 'key' | 'jmapAccountId'> & Partial<Pick<SyncCollection, 'name'>>,
registry: ReadonlyArray<{ id: string; serverUrl: string; email?: string; username: string }>,
accounts: Record<string, AccountDeviceSync>,
): string[] {
const self = registry.find((a) => a.id === registryId);
if (!self) return [];
const server = serverOf(self.serverUrl);
return registry
.filter((other) => other.id !== registryId && serverOf(other.serverUrl) === server)
.filter((other) => {
const entry = accounts[other.id];
if (!entry || !syncOnInApp(entry, authority)) return false;
const isPersonal = entry.primaryJmapAccounts?.[authority] === collection.jmapAccountId;
return isCollectionSelected(selectionFor(entry, authority), authority, { ...collection, isPersonal });
})
.map((other) => other.email || other.username);
}
+15
View File
@@ -93,6 +93,7 @@ describe('settings search index (English catalog)', () => {
downloads: 'DownloadsSettings.tsx',
security: 'AccountSecuritySettings.tsx',
calendar: 'CalendarSettings.tsx',
contacts: 'ContactsSettings.tsx',
files: 'FilesSettings.tsx',
sidebar_apps: 'SidebarAppsSettings.tsx',
about_data: 'AboutDataSettings.tsx',
@@ -120,6 +121,20 @@ describe('settings search index (English catalog)', () => {
expect(labelsFor('calendar')).not.toContain('Free scrolling');
expect(labelsFor('notifications')).not.toContain('Notification sound');
});
it('finds device sync in Contacts and Calendar on Android (#34)', () => {
// The tests run as Android (src/test-setup.ts).
expect(labelsFor('contacts')).toContain('Sync to this device');
expect(labelsFor('calendar')).toContain('Sync to this device');
const index = buildSettingsSearchIndex(en, tEn);
expect(subResultsForQuery(index, 'contacts', 'contacts app')).toEqual([
{
label: 'Sync to this device',
description: 'Show your address books in the Contacts app and other apps on this device. Changes sync both ways.',
},
]);
expect(tabMatchesQuery(index, 'calendar', 'Calendar', 'android')).toBe(true);
});
});
describe('buildSettingsSearchIndex', () => {
+8 -2
View File
@@ -11,6 +11,8 @@
// through t() for the current locale, with the English fallback.
// Kept free of React so it can be unit-tested.
import { supportsDeviceSync } from './platform-capabilities';
export type SettingsTabId =
| 'account' | 'language' | 'notifications'
| 'appearance' | 'layout'
@@ -166,6 +168,8 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'calendar.settings.show_birthday_calendar',
'calendar.settings.enable_tasks',
'calendar.settings.show_tasks_on_calendar',
// Android only: calendars in the phone's Calendar app (#34).
...(supportsDeviceSync ? ['calendar.settings.device_sync_title'] : []),
],
contacts: [
'settings.contacts.group_by_letter_label',
@@ -173,6 +177,8 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'settings.contacts.import_label',
'settings.contacts.export_label',
'settings.contacts.manage_title',
// Android only: address books in the phone's Contacts app (#34).
...(supportsDeviceSync ? ['settings.contacts.device_sync_title'] : []),
],
files: [
'files.settings_display',
@@ -238,8 +244,8 @@ export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
security: 'password 2fa two-factor totp app password mfa api key certificate encryption',
encryption: 's/mime smime certificate sign encrypt',
content_senders: 'block sender remote images privacy tracking',
calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact',
calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`,
contacts: `address book contact${supportsDeviceSync ? ' android phone device sync' : ''}`,
files: 'attachments cloud drive storage upload',
sidebar_apps: 'apps webview iframe',
about_data: 'export import backup offline cache debug logs reset version',