diff --git a/src/device-sync/__tests__/app/reminders.test.ts b/src/device-sync/__tests__/app/reminders.test.ts index 744081c4..ed1b5915 100644 --- a/src/device-sync/__tests__/app/reminders.test.ts +++ b/src/device-sync/__tests__/app/reminders.test.ts @@ -1,9 +1,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const h = vi.hoisted(() => ({ available: true })); +// Android's side: the device's auto-sync, the account's calendar sync and the calendar permission. +const h = vi.hoisted(() => ({ available: true, master: true, automatic: true, permitted: true })); vi.mock('../../native', () => ({ isDeviceSyncAvailable: () => h.available, + getSyncSettings: vi.fn(async () => ({ + masterAutomatic: h.master, + authorities: { + 'com.android.contacts': { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: false }, + 'com.android.calendar': { syncable: 1, automatic: h.automatic, periodicSeconds: 3600, active: false, pending: false }, + }, + })), +})); + +vi.mock('../../app/permissions', () => ({ + hasSyncPermissions: vi.fn(async () => h.permitted), })); import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types'; @@ -34,10 +46,16 @@ function calendarSync(extra: Record = {}): void { }); } +/** Waits until the calendar app reminds of Alice's synced events (Android's side is read when the filter is asked for). */ +async function remindedByCalendarApp(): Promise { + await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).not.toBeNull()); +} + beforeEach(async () => { await waitForDeviceSyncHydration(); - h.available = true; + Object.assign(h, { available: true, master: true, automatic: true, permitted: true }); calendarSync(); + await remindedByCalendarApp(); }); describe('remindedOnDevice', () => { @@ -82,6 +100,36 @@ describe('remindedOnDevice', () => { }); }); +describe("remindedOnDevice while the device calendar can't receive events", () => { + it('leaves the reminders to Bulwark while calendar sync is paused in Android settings', async () => { + h.automatic = false; + await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull()); + }); + + it("leaves them to Bulwark while Android's auto-sync is off: Android then runs manual syncs only", async () => { + h.master = false; + await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull()); + }); + + it('leaves them to Bulwark while the calendar permission is revoked', async () => { + h.permitted = false; + await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull()); + }); + + it('hands them back to the calendar app once it syncs again, and tells the scheduler both times', async () => { + const listener = vi.fn(); + const off = onDeviceRemindersChange(listener); + h.automatic = false; + await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(1)); + expect(remindedOnDevice(ALICE, 'c')).toBeNull(); + + h.automatic = true; + await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(2)); + expect(remindedOnDevice(ALICE, 'c')).not.toBeNull(); + off(); + }); +}); + describe('onDeviceRemindersChange', () => { it('fires for the owner and the calendar selection, not for sync bookkeeping', () => { const listener = vi.fn(); diff --git a/src/device-sync/app/reminders.ts b/src/device-sync/app/reminders.ts index 023bac16..bb45c209 100644 --- a/src/device-sync/app/reminders.ts +++ b/src/device-sync/app/reminders.ts @@ -4,18 +4,28 @@ * app owns the reminders, the device fires them from its Reminders rows, so * Bulwark's own scheduler skips the events of those calendars. Tasks are not * synced and keep Bulwark's reminders. + * + * Only while the device calendar receives the server's events, as Android + * last said: not while the account's calendar sync is paused in Android's + * settings, the device's auto-sync is off (Android then runs manual syncs + * only, and the periodic, pushed and app-triggered ones are not manual), or + * the calendar permission is revoked. Bulwark reminds then, and until + * Android's side was read: a double reminder is better than a missed one. */ import type { CalendarEvent } from '../../api/types'; +import { getSyncSettings } from '../native'; import { isCollectionSelected, selectionFor, syncOnInApp, useDeviceSyncStore, + waitForDeviceSyncHydration, type AccountDeviceSync, } from '../../stores/device-sync-store'; import { collectionKey } from '../common/ids'; import { CALENDAR_AUTHORITY } from '../types'; import { deviceSyncAvailable } from './available'; +import { hasSyncPermissions } from './permissions'; /** Whether the device's calendar app fires the reminders of an account's synced events. */ function deviceOwnsReminders(entry: AccountDeviceSync | undefined): entry is AccountDeviceSync { @@ -23,11 +33,53 @@ function deviceOwnsReminders(entry: AccountDeviceSync | undefined): entry is Acc return !!entry && syncOnInApp(entry, CALENDAR_AUTHORITY) && (entry.reminderOwner ?? 'device') === 'device'; } +// ─── What Android says ───────────────────────────────── + +/** App accounts whose device calendar receives the server's events, as last read from Android. */ +let receiving = new Set(); +const androidListeners = new Set<() => void>(); +let reads = 0; + +async function calendarReceives(accountName: string | undefined): Promise { + if (!accountName) return false; + try { + const settings = await getSyncSettings(accountName); + return settings.masterAutomatic !== false && !!settings.authorities?.[CALENDAR_AUTHORITY]?.automatic; + } catch { + return false; + } +} + +/** + * Reads from Android whose device calendars receive the server's events; + * a change calls the `onDeviceRemindersChange` listeners. Only the latest + * read lands. + */ +async function readAndroid(): Promise { + const read = ++reads; + await waitForDeviceSyncHydration(); + const owners = Object.entries(useDeviceSyncStore.getState().accounts).filter(([, entry]) => deviceOwnsReminders(entry)); + const next = new Set(); + if (owners.length > 0 && (await hasSyncPermissions(CALENDAR_AUTHORITY))) { + for (const [registryId, entry] of owners) { + if (await calendarReceives(entry.androidAccountName)) next.add(registryId); + } + } + if (read !== reads) return; + if (next.size === receiving.size && [...next].every((id) => receiving.has(id))) return; + receiving = next; + for (const listener of [...androidListeners]) listener(); +} + +// ─── The filter ──────────────────────────────────────── + /** * A test for the events whose reminders the device's calendar app fires for * this app account, so Bulwark schedules none; null when there are none. * `primaryJmapAccountId`: the JMAP account the app's calendar store loads - * events from when they carry no `accountId` (the user's own). + * events from when they carry no `accountId` (the user's own). Android's side + * is read again each time, for the next time: a change calls the + * `onDeviceRemindersChange` listeners. */ export function remindedOnDevice( registryId: string | null | undefined, @@ -36,6 +88,8 @@ export function remindedOnDevice( if (!registryId || !deviceSyncAvailable()) return null; const entry = useDeviceSyncStore.getState().accounts[registryId]; if (!deviceOwnsReminders(entry)) return null; + void readAndroid(); + if (!receiving.has(registryId)) return null; const selection = selectionFor(entry, CALENDAR_AUTHORITY); const personal = entry.primaryJmapAccounts?.[CALENDAR_AUTHORITY] ?? primaryJmapAccountId ?? null; return (event) => { @@ -65,15 +119,24 @@ function reminderSignature(accounts: Record): string /** * Calls `listener` whenever the set of events the device reminds of may have - * changed (reminder owner, calendar sync on or off, the calendar selection). - * Returns the unsubscribe. + * changed (reminder owner, calendar sync on or off, the calendar selection, + * whether Android lets the device calendar sync). Android's side is read + * right away, so the first reminders are scheduled with it known. Returns + * the unsubscribe. */ export function onDeviceRemindersChange(listener: () => void): () => void { let last = reminderSignature(useDeviceSyncStore.getState().accounts); - return useDeviceSyncStore.subscribe((state) => { + const unsubscribe = useDeviceSyncStore.subscribe((state) => { const next = reminderSignature(state.accounts); if (next === last) return; last = next; listener(); }); + const android = () => listener(); + androidListeners.add(android); + if (deviceSyncAvailable()) void readAndroid(); + return () => { + unsubscribe(); + androidListeners.delete(android); + }; }