feat: play the Involved sound for calendar reminders

This commit is contained in:
Linus Rath
2026-10-31 14:49:56 +01:00
parent f1297b6be8
commit b4f1082915
6 changed files with 133 additions and 12 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The calendar reminder channel names its sound by resource name from JS
(src/lib/calendar-notifications.ts), which the release resource shrinker
can't see. -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@raw/involved_notification" />
+9
View File
@@ -106,6 +106,15 @@ module.exports = {
}, },
], ],
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }], ['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
// Bundles the Involved sound calendar reminders play on iOS
// (src/lib/calendar-notifications.ts). Android has it in res/raw.
[
'expo-notifications',
{
mode: APS_ENVIRONMENT,
sounds: ['./assets/sounds/involved_notification.wav'],
},
],
], ],
extra: { extra: {
commit: COMMIT, commit: COMMIT,
Binary file not shown.
@@ -1,4 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
type Listener = (state: Record<string, unknown>, prev: Record<string, unknown>) => void; type Listener = (state: Record<string, unknown>, prev: Record<string, unknown>) => void;
@@ -24,6 +26,8 @@ const h = vi.hoisted(() => ({
// Device sync (#34): which events the device's calendar app reminds of. // Device sync (#34): which events the device's calendar app reminds of.
onDevice: null as ((event: { id: string }) => boolean) | null, onDevice: null as ((event: { id: string }) => boolean) | null,
reminderListener: null as (() => void) | null, reminderListener: null as (() => void) | null,
// The reminder channel earlier versions created, if any.
legacyChannel: null as { id: string; importance: number } | null,
})); }));
vi.mock('react-native', () => ({ vi.mock('react-native', () => ({
@@ -35,6 +39,8 @@ vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(async () => ({ ...h.permission })), getPermissionsAsync: vi.fn(async () => ({ ...h.permission })),
requestPermissionsAsync: vi.fn(async () => ({ granted: true })), requestPermissionsAsync: vi.fn(async () => ({ granted: true })),
setNotificationChannelAsync: vi.fn(async () => undefined), setNotificationChannelAsync: vi.fn(async () => undefined),
getNotificationChannelAsync: vi.fn(async (id: string) => (h.legacyChannel?.id === id ? h.legacyChannel : null)),
deleteNotificationChannelAsync: vi.fn(async () => undefined),
getAllScheduledNotificationsAsync: vi.fn(async () => h.pending), getAllScheduledNotificationsAsync: vi.fn(async () => h.pending),
cancelScheduledNotificationAsync: vi.fn(async () => undefined), cancelScheduledNotificationAsync: vi.fn(async () => undefined),
scheduleNotificationAsync: vi.fn(async () => 'id'), scheduleNotificationAsync: vi.fn(async () => 'id'),
@@ -46,7 +52,7 @@ vi.mock('expo-notifications', () => ({
return { remove: vi.fn() }; return { remove: vi.fn() };
}), }),
DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT', DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT',
AndroidImportance: { HIGH: 4 }, AndroidImportance: { NONE: 2, DEFAULT: 5, HIGH: 6 },
IosAuthorizationStatus: { PROVISIONAL: 3 }, IosAuthorizationStatus: { PROVISIONAL: 3 },
SchedulableTriggerInputTypes: { DATE: 'date' }, SchedulableTriggerInputTypes: { DATE: 'date' },
})); }));
@@ -91,7 +97,7 @@ const upcoming = {
const upcomingKey = `ev1:a1:${new Date('2026-09-24T09:45:00Z').getTime()}`; const upcomingKey = `ev1:a1:${new Date('2026-09-24T09:45:00Z').getTime()}`;
function reminder(identifier: string, key: string) { function reminder(identifier: string, key: string) {
return { identifier, content: { data: { tag: TAG, key } } }; return { identifier, content: { data: { tag: TAG, key, delivery: 'calendar-reminders-involved' } } };
} }
let mod: typeof import('../calendar-notifications'); let mod: typeof import('../calendar-notifications');
@@ -115,6 +121,7 @@ beforeEach(async () => {
h.calendarListener = null; h.calendarListener = null;
h.onDevice = null; h.onDevice = null;
h.reminderListener = null; h.reminderListener = null;
h.legacyChannel = null;
h.loadEventsInRange.mockResolvedValue([upcoming]); h.loadEventsInRange.mockResolvedValue([upcoming]);
mod = await import('../calendar-notifications'); mod = await import('../calendar-notifications');
N = await import('expo-notifications'); N = await import('expo-notifications');
@@ -322,6 +329,7 @@ describe('tapping a calendar reminder', () => {
tag: TAG, tag: TAG,
key: expect.any(String), key: expect.any(String),
locale: 'en', locale: 'en',
delivery: 'calendar-reminders-involved',
eventId: 'acc-2:ev1:2026-09-24T10:00:00Z', eventId: 'acc-2:ev1:2026-09-24T10:00:00Z',
kind: 'event', kind: 'event',
serverId: 'ev1', serverId: 'ev1',
@@ -366,3 +374,55 @@ describe('tapping a calendar reminder', () => {
expect(open).toHaveBeenCalledTimes(1); expect(open).toHaveBeenCalledTimes(1);
}); });
}); });
describe('the Involved sound on calendar reminders', () => {
it('schedules reminders on a channel of its own that plays the Involved sound', async () => {
await mod.rescheduleCalendarNotifications();
expect(N.setNotificationChannelAsync).toHaveBeenCalledWith('calendar-reminders-involved', expect.objectContaining({
sound: 'involved_notification.mp3',
}));
const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0];
expect(request.trigger).toMatchObject({ channelId: 'calendar-reminders-involved' });
expect(request.content.sound).toBe('involved_notification.mp3');
});
it('leaves the channel of earlier versions alone', async () => {
h.legacyChannel = { id: 'calendar-reminders', importance: 6 };
await mod.rescheduleCalendarNotifications();
const ids = vi.mocked(N.setNotificationChannelAsync).mock.calls.map((call) => call[0]);
expect(ids).toEqual(['calendar-reminders-involved']);
expect(N.deleteNotificationChannelAsync).not.toHaveBeenCalled();
expect(vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0].trigger).toMatchObject({ channelId: 'calendar-reminders-involved' });
});
it('keeps reminders off for someone who switched the old channel off', async () => {
h.legacyChannel = { id: 'calendar-reminders', importance: 2 };
await mod.rescheduleCalendarNotifications();
expect(N.setNotificationChannelAsync).not.toHaveBeenCalled();
expect(vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0].trigger).toMatchObject({ channelId: 'calendar-reminders' });
});
it('schedules again a reminder pending from before, so it plays the new sound', async () => {
h.pending = [{ identifier: 'old', content: { data: { tag: TAG, key: upcomingKey, locale: 'en' } } }];
await mod.rescheduleCalendarNotifications();
expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('old');
const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0];
expect(request.content.data).toMatchObject({ key: upcomingKey, delivery: 'calendar-reminders-involved' });
// Once moved, it stays.
h.pending = [{ identifier: 'new', content: { data: request.content.data as Record<string, unknown> } }];
vi.mocked(N.scheduleNotificationAsync).mockClear();
vi.mocked(N.cancelScheduledNotificationAsync).mockClear();
await mod.rescheduleCalendarNotifications();
expect(N.cancelScheduledNotificationAsync).not.toHaveBeenCalled();
expect(N.scheduleNotificationAsync).not.toHaveBeenCalled();
});
it('ships the sound for Android, kept from the resource shrinker, and for iOS', () => {
const root = path.resolve(__dirname, '../../..');
expect(fs.existsSync(path.join(root, 'android/app/src/main/res/raw/involved_notification.mp3'))).toBe(true);
expect(fs.readFileSync(path.join(root, 'android/app/src/main/res/raw/keep.xml'), 'utf8')).toContain('@raw/involved_notification');
expect(fs.existsSync(path.join(root, 'assets/sounds/involved_notification.wav'))).toBe(true);
expect(fs.readFileSync(path.join(root, 'app.config.js'), 'utf8')).toContain("sounds: ['./assets/sounds/involved_notification.wav']");
});
});
+56 -10
View File
@@ -22,7 +22,17 @@ import { startCalendarEventNotificationSync } from './calendar-event-notificatio
// don't depend on the Calendar tab being open. Honours the // don't depend on the Calendar tab being open. Honours the
// `calendarNotificationsEnabled` setting. // `calendarNotificationsEnabled` setting.
const CHANNEL_ID = 'calendar-reminders'; // Reminders play the webmail's default notification sound, Involved
// (webmail lib/notification-sound.ts, stores/settings-store.ts
// notificationSoundChoice). An Android channel keeps the sound it was created
// with, so the Involved channel has an id of its own; the channel earlier
// versions created stays as the user left it. Its sound file ships as
// android/app/src/main/res/raw/involved_notification.mp3 and, for iOS,
// assets/sounds/involved_notification.wav.
export const CHANNEL_ID = 'calendar-reminders-involved';
export const LEGACY_CHANNEL_ID = 'calendar-reminders';
const ANDROID_SOUND = 'involved_notification.mp3';
const IOS_SOUND = 'involved_notification.wav';
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const HORIZON_MS = 7 * DAY_MS; const HORIZON_MS = 7 * DAY_MS;
// The events loaded for the horizon: from a day back (an end-relative alert // The events loaded for the horizon: from a day back (an end-relative alert
@@ -42,6 +52,8 @@ let permissionRequested = false;
let mayAskPermission = false; let mayAskPermission = false;
// The language the Android channel was last named in; null until created. // The language the Android channel was last named in; null until created.
let channelLocale: string | null = null; let channelLocale: string | null = null;
// The channel reminders go to; decided once per run.
let activeChannelId: string | null = null;
let rescheduleTimer: ReturnType<typeof setTimeout> | null = null; let rescheduleTimer: ReturnType<typeof setTimeout> | null = null;
let rescheduling: Promise<void> | null = null; let rescheduling: Promise<void> | null = null;
let queued = false; let queued = false;
@@ -68,25 +80,48 @@ async function ensurePermission(): Promise<boolean> {
return permissionGranted; return permissionGranted;
} }
/**
* The channel reminders go to, given the channel earlier versions created
* (null when there is none). Someone who switched those reminders off in the
* system settings keeps them off: they stay on that channel. Everyone else
* gets the Involved channel.
*/
export function reminderChannelFor(legacy: { importance?: number } | null): string {
if (legacy && legacy.importance === Notifications.AndroidImportance.NONE) return LEGACY_CHANNEL_ID;
return CHANNEL_ID;
}
// The channel's name shows in the system notification settings, so it is // The channel's name shows in the system notification settings, so it is
// (re)named in the app language; setting an existing channel only renames it. // (re)named in the app language; setting an existing channel only renames it.
// The old channel is never touched.
async function ensureChannel(): Promise<void> { async function ensureChannel(): Promise<void> {
if (Platform.OS !== 'android') return; if (Platform.OS !== 'android') return;
const { locale, t } = useLocaleStore.getState(); const { locale, t } = useLocaleStore.getState();
if (channelLocale === locale) return; if (channelLocale === locale) return;
try { try {
await Notifications.setNotificationChannelAsync(CHANNEL_ID, { if (activeChannelId === null) {
name: t('calendar.notifications.channel_name', 'Calendar reminders'), const legacy = await Notifications.getNotificationChannelAsync(LEGACY_CHANNEL_ID).catch(() => null);
importance: Notifications.AndroidImportance.HIGH, activeChannelId = reminderChannelFor(legacy);
sound: 'default', }
vibrationPattern: [0, 250, 250, 250], if (activeChannelId === CHANNEL_ID) {
}); await Notifications.setNotificationChannelAsync(CHANNEL_ID, {
name: t('calendar.notifications.channel_name', 'Calendar reminders'),
importance: Notifications.AndroidImportance.HIGH,
sound: ANDROID_SOUND,
vibrationPattern: [0, 250, 250, 250],
});
}
} catch { } catch {
// Channel creation failing only degrades to the default channel. // Channel creation failing only degrades to the default channel.
} }
channelLocale = locale; channelLocale = locale;
} }
/** What a reminder is scheduled with: its channel on Android, its sound on iOS. */
function deliveryTag(): string {
return Platform.OS === 'android' ? activeChannelId ?? CHANNEL_ID : IOS_SOUND;
}
function alertKeyOf(request: Notifications.NotificationRequest): string | null { function alertKeyOf(request: Notifications.NotificationRequest): string | null {
const data = request.content.data as { tag?: string; key?: string } | undefined; const data = request.content.data as { tag?: string; key?: string } | undefined;
return data?.tag === DATA_TAG && typeof data.key === 'string' ? data.key : null; return data?.tag === DATA_TAG && typeof data.key === 'string' ? data.key : null;
@@ -99,6 +134,13 @@ function isStaleLanguage(request: Notifications.NotificationRequest, locale: str
return typeof data?.locale === 'string' && data.locale !== locale; return typeof data?.locale === 'string' && data.locale !== locale;
} }
// A reminder scheduled for another channel or sound, such as the default one
// before the Involved sound: scheduled again, so it plays the new sound.
function isStaleDelivery(request: Notifications.NotificationRequest): boolean {
const data = request.content.data as { delivery?: unknown } | undefined;
return data?.delivery !== deliveryTag();
}
/** Cancel every calendar reminder this app scheduled. */ /** Cancel every calendar reminder this app scheduled. */
export async function cancelAllCalendarNotifications(): Promise<void> { export async function cancelAllCalendarNotifications(): Promise<void> {
try { try {
@@ -119,6 +161,8 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise<void>
key: alert.key, key: alert.key,
// The language the title and body are in, to reword it on a switch. // The language the title and body are in, to reword it on a switch.
locale, locale,
// The channel (Android) or sound (iOS) it goes out with.
delivery: deliveryTag(),
eventId: alert.eventId, eventId: alert.eventId,
kind: alert.kind, kind: alert.kind,
// Enough to find the event again when the reminder is tapped. // Enough to find the event again when the reminder is tapped.
@@ -133,13 +177,15 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise<void>
content: { content: {
title: alert.title, title: alert.title,
body: alert.body, body: alert.body,
sound: 'default', // iOS plays the bundled file, or the default sound without it. Android
// takes the channel's sound.
sound: Platform.OS === 'ios' ? IOS_SOUND : ANDROID_SOUND,
data, data,
}, },
trigger: { trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE, type: Notifications.SchedulableTriggerInputTypes.DATE,
date: new Date(alert.fireTimeMs), date: new Date(alert.fireTimeMs),
...(Platform.OS === 'android' ? { channelId: CHANNEL_ID } : {}), ...(Platform.OS === 'android' ? { channelId: activeChannelId ?? CHANNEL_ID } : {}),
}, },
}); });
} }
@@ -251,7 +297,7 @@ export async function rescheduleCalendarNotifications(): Promise<void> {
for (const request of pending) { for (const request of pending) {
const key = alertKeyOf(request); const key = alertKeyOf(request);
if (key === null) continue; if (key === null) continue;
if (wantedByKey.has(key) && !isStaleLanguage(request, locale)) { if (wantedByKey.has(key) && !isStaleLanguage(request, locale) && !isStaleDelivery(request)) {
present.add(key); present.add(key);
} else { } else {
await Notifications.cancelScheduledNotificationAsync(request.identifier); await Notifications.cancelScheduledNotificationAsync(request.identifier);