fix: keep reminding of synced events while the phone's calendar can't sync #34

With calendar sync on and the calendar app reminding, Bulwark skipped
its own reminders for the synced calendars whenever sync was on in the
app, even when the phone's calendar received nothing: its calendar sync
paused in Android's account settings, Android's auto-sync off, or the
calendar permission revoked. Events created on the server meanwhile
never reached the phone, and nobody reminded of them.

The reminder filter now keeps what Android last said about each
account (the permission, `getMasterSyncAutomatically` and the
calendar's `getSyncAutomatically`) and stays quiet only while the
phone's calendar receives events. Auto-sync off counts as not
receiving: Android then runs manual syncs only, and the periodic,
pushed and app-triggered syncs are not manual. Android's side is read
when the scheduler subscribes and again whenever the filter is asked
for; a changed answer makes the scheduler reschedule. Until it was
read, Bulwark reminds: a double reminder is better than a missed one.
This commit is contained in:
Linus Rath
2026-09-27 10:20:55 +02:00
parent 217bb59d57
commit 0664a31f8b
2 changed files with 117 additions and 6 deletions
@@ -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<string, unknown> = {}): 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<void> {
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();
+67 -4
View File
@@ -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<string>();
const androidListeners = new Set<() => void>();
let reads = 0;
async function calendarReceives(accountName: string | undefined): Promise<boolean> {
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<void> {
const read = ++reads;
await waitForDeviceSyncHydration();
const owners = Object.entries(useDeviceSyncStore.getState().accounts).filter(([, entry]) => deviceOwnsReminders(entry));
const next = new Set<string>();
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, AccountDeviceSync>): 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);
};
}