diff --git a/src/components/__tests__/settings-i18n.test.ts b/src/components/__tests__/settings-i18n.test.ts index 5e1fadb3..cfd9ce8b 100644 --- a/src/components/__tests__/settings-i18n.test.ts +++ b/src/components/__tests__/settings-i18n.test.ts @@ -40,6 +40,11 @@ const TRANSLATED_PANES: Record = { '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': [], }; diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index fa50d6b8..dab6f860 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -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 ( - - - 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') }, + ]} + /> + - - 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') }, + ]} + /> + - - 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') }, - ]} - /> - + + 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') }, + ]} + /> + - - update('calendarTimeZone', v)} + options={timeZoneOptions} + /> + + + update('showTasksOnCalendar', v)} + checked={showTimeInMonth} + onChange={(v) => update('calendarShowTimeInMonth', v)} /> + + + update('calendarShowWeekNumbers', v)} + /> + + + + update('calendarFreeScroll', v)} + /> + + + + update('showBirthdayCalendar', v)} + /> + + + + update('enableCalendarTasks', v)} + /> + + + {tasksEnabled && ( + + update('showTasksOnCalendar', v)} + /> + + )} + + + {deviceSync && ( + )} - + ); } diff --git a/src/components/settings/ContactsSettings.tsx b/src/components/settings/ContactsSettings.tsx index da8cb4bc..04b468c4 100644 --- a/src/components/settings/ContactsSettings.tsx +++ b/src/components/settings/ContactsSettings.tsx @@ -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() { + {deviceSync && ( + + )} + setImportOpen(false)} diff --git a/src/components/settings/device-sync/DeviceSyncAccountRow.tsx b/src/components/settings/device-sync/DeviceSyncAccountRow.tsx new file mode 100644 index 00000000..dc3d68c3 --- /dev/null +++ b/src/components/settings/device-sync/DeviceSyncAccountRow.tsx @@ -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 { + 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>(); + 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(null); + const [prompt, setPrompt] = React.useState(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 ( + + + + {(busy === 'on' || busy === 'off') && } + { + 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 })} + /> + + + + {!busyText && ( + + {status.tone === 'error' && } + {status.tone === 'warning' && } + {status.text} + + )} + {!busyText && status.action && ( + + + + )} + {showDetails && view && !view.masterAutomatic && ( + + {t( + 'settings.device_sync.master_off', + 'Auto-sync is off on this device, so syncs only run when you tap Sync now.', + )} + + )} + + {showDetails && ( + + + + + + {isContacts ? ( + + { void changeSyncInterval(account.id, authority, Number(v)); }} + options={intervalOptions} + /> + + + + + + + + )} + + + + { void turnOn(); }} + onCancel={() => setPrompt(null)} + /> + + { + setPrompt(null); + void openAppSystemSettings(); + }} + onCancel={() => setPrompt(null)} + /> + + 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(); }} + /> + + ); +} + +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 }, + }); +} diff --git a/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx b/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx new file mode 100644 index 00000000..7f440ed1 --- /dev/null +++ b/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx @@ -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 ( + + + + + + + + + + + + + {authority === CONTACTS_AUTHORITY + ? t('settings.device_sync.choose_books_title', 'Address books to sync') + : t('settings.device_sync.choose_calendars_title', 'Calendars to sync')} + + {accountLabel} + + + + + + + + + {state.kind === 'error' && ( + + {errorText} + + + )} + {(state.kind === 'loading' || state.kind === 'idle') && collections.length === 0 && ( + + + + )} + {state.kind === 'loaded' && collections.length === 0 && ( + + + {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.')} + + + )} + + {groups.map((group) => ( + + + {group.isPersonal + ? t('settings.device_sync.own_collections', 'Your own') + : t('settings.device_sync.shared_by', 'Shared by {name}', { name: group.accountName })} + + {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 ( + toggle(collection)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + accessibilityRole="checkbox" + accessibilityState={{ checked: on }} + accessibilityLabel={collection.name} + > + + {on && } + + + + {collection.name} + + {(collection.readOnly || collection.isDefault) && ( + + {[ + collection.isDefault ? t('contacts.address_books.default', 'Default') : null, + collection.readOnly ? t('settings.device_sync.read_only', 'Read-only') : null, + ].filter(Boolean).join(' · ')} + + )} + {others.length > 0 && ( + + + + {t( + 'settings.device_sync.also_synced_by', + 'Also synced for {accounts}, so it shows twice on this device.', + { accounts: others.join(', ') }, + )} + + + )} + + + ); + })} + + ))} + + + {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.', + )} + + + + + ); +} + +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, + }, + }); +} diff --git a/src/components/settings/device-sync/DeviceSyncSection.tsx b/src/components/settings/device-sync/DeviceSyncSection.tsx new file mode 100644 index 00000000..46382501 --- /dev/null +++ b/src/components/settings/device-sync/DeviceSyncSection.tsx @@ -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 ( + + + {accounts.map((account) => ( + + ))} + {suspended.map((registryId) => ( + + ))} + + + ); +} + +// 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 }) { + const t = useLocaleStore((s) => s.t); + const navigation = useNavigation>(); + 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 ( + + + + + + + { void remove(); }} + onCancel={() => setConfirming(false)} + /> + + ); +} + +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 }, + }); +} diff --git a/src/components/settings/device-sync/__tests__/status.test.ts b/src/components/settings/device-sync/__tests__/status.test.ts new file mode 100644 index 00000000..9de1f544 --- /dev/null +++ b/src/components/settings/device-sync/__tests__/status.test.ts @@ -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 => ({ + 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'); + }); +}); diff --git a/src/components/settings/device-sync/status.ts b/src/components/settings/device-sync/status.ts new file mode 100644 index 00000000..57d6a940 --- /dev/null +++ b/src/components/settings/device-sync/status.ts @@ -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' }; +} diff --git a/src/components/settings/device-sync/use-android-sync.ts b/src/components/settings/device-sync/use-android-sync.ts new file mode 100644 index 00000000..22f5fc75 --- /dev/null +++ b/src/components/settings/device-sync/use-android-sync.ts @@ -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; +} { + const [view, setView] = React.useState(null); + const [permitted, setPermitted] = React.useState(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 }; +} diff --git a/src/components/settings/device-sync/use-sync-collections.ts b/src/components/settings/device-sync/use-sync-collections.ts new file mode 100644 index 00000000..3f5baf6b --- /dev/null +++ b/src/components/settings/device-sync/use-sync-collections.ts @@ -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(() => { + 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 }; +} diff --git a/src/device-sync/__tests__/app/collections.test.ts b/src/device-sync/__tests__/app/collections.test.ts new file mode 100644 index 00000000..023268f9 --- /dev/null +++ b/src/device-sync/__tests__/app/collections.test.ts @@ -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, 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, 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 = { + 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']); + }); +}); diff --git a/src/device-sync/app/collections.ts b/src/device-sync/app/collections.ts new file mode 100644 index 00000000..923faf4b --- /dev/null +++ b/src/device-sync/app/collections.ts @@ -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 { + /** `/`, 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; + readonly currentSession: { + primaryAccounts: Record; + accounts: Record }>; + } | 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(); +const inFlight = new Map>(); + +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 { + 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 { + 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> { + 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; +} + +async function load(registryId: string, authority: Authority, options: ListOptions): Promise { + 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> { + 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(); + 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 & Partial>, + registry: ReadonlyArray<{ id: string; serverUrl: string; email?: string; username: string }>, + accounts: Record, +): 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); +} diff --git a/src/lib/__tests__/settings-search.test.ts b/src/lib/__tests__/settings-search.test.ts index d2ceb492..0b08cb57 100644 --- a/src/lib/__tests__/settings-search.test.ts +++ b/src/lib/__tests__/settings-search.test.ts @@ -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', () => { diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index c8bdb69a..86c0f776 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -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 = { '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 = { '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 = { 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',