feat: merge contacts and calendar sync with the phone on Android #34

Brings feat/device-sync into main: on Android, each account's address
books and calendars can sync both ways with the phone's Contacts and
Calendar apps (Settings -> Contacts / Calendar -> Sync to this
device), through a "Bulwark" Android account with a contacts and a
calendar sync adapter.

- native side: the account, both sync adapters, provider access scoped
  to the account, push routing to the syncs, sync problem notices
- the sync engine in `src/device-sync`, run as a headless task
- the settings sections, sign-out and removal handling, translations
- design, limitations and verification in `docs/device-sync.md`
This commit is contained in:
Linus Rath
2026-09-27 12:14:37 +02:00
219 changed files with 47834 additions and 219 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,532 @@
// 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,
resolveDeletions,
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) },
);
});
}
/** How often the status line's "… ago" is brought up to date. */
const RELATIVE_TIME_REFRESH_MS = 30_000;
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);
// "Synced 2 min ago" keeps counting while the settings stay open.
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_REFRESH_MS);
return () => clearInterval(timer);
}, []);
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,
},
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 reviewDeletions = () => {
const resolve = (choice: 'delete' | 'restore') => {
void resolveDeletions(account.id, authority, choice)
.catch((err: unknown) => alertFailed(err instanceof Error ? err.message : String(err)))
.finally(() => { void refresh(); });
};
Alert.alert(
t('settings.device_sync.deletions_title', 'Delete them on the server too?'),
t(
'settings.device_sync.deletions_message',
'Many items were deleted on this device at once, so they were not deleted on the server. Delete them there as well, or bring them back on this device.',
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.device_sync.deletions_restore', 'Bring them back'), onPress: () => resolve('restore') },
{ text: t('settings.device_sync.deletions_delete', 'Delete on server'), style: 'destructive', onPress: () => resolve('delete') },
],
);
};
const runAction = (action: StatusAction) => {
if (action === 'signIn') navigation.navigate('AddAccount');
else if (action === 'grantAccess') void grantAccess();
else reviewDeletions();
};
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, plural, one {# of {total} syncs} other {# 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,70 @@
// 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,
onSyncCollectionsLoaded,
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]);
// The chooser loads its own, fresher list: the row's counts follow it.
React.useEffect(
() => onSyncCollectionsLoaded((id, auth, collections) => {
if (id === registryId && auth === authority) setState({ kind: 'loaded', collections });
}),
[registryId, authority],
);
const reload = React.useCallback(() => setAttempt((n) => n + 1), []);
return { state, reload };
}