mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
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:
@@ -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': [],
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user