diff --git a/android/app/src/main/res/raw/involved_notification.mp3 b/android/app/src/main/res/raw/involved_notification.mp3
new file mode 100644
index 00000000..22bd26f8
Binary files /dev/null and b/android/app/src/main/res/raw/involved_notification.mp3 differ
diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml
new file mode 100644
index 00000000..865f0412
--- /dev/null
+++ b/android/app/src/main/res/raw/keep.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app.config.js b/app.config.js
index bf40cb11..ee306356 100644
--- a/app.config.js
+++ b/app.config.js
@@ -106,6 +106,15 @@ module.exports = {
},
],
['./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: {
commit: COMMIT,
diff --git a/assets/sounds/involved_notification.wav b/assets/sounds/involved_notification.wav
new file mode 100644
index 00000000..284e743b
Binary files /dev/null and b/assets/sounds/involved_notification.wav differ
diff --git a/src/lib/__tests__/calendar-notifications.test.ts b/src/lib/__tests__/calendar-notifications.test.ts
index 4b995773..41a73b18 100644
--- a/src/lib/__tests__/calendar-notifications.test.ts
+++ b/src/lib/__tests__/calendar-notifications.test.ts
@@ -1,4 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
type Listener = (state: Record, prev: Record) => void;
@@ -24,6 +26,8 @@ const h = vi.hoisted(() => ({
// Device sync (#34): which events the device's calendar app reminds of.
onDevice: null as ((event: { id: string }) => boolean) | 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', () => ({
@@ -35,6 +39,8 @@ vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(async () => ({ ...h.permission })),
requestPermissionsAsync: vi.fn(async () => ({ granted: true })),
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),
cancelScheduledNotificationAsync: vi.fn(async () => undefined),
scheduleNotificationAsync: vi.fn(async () => 'id'),
@@ -46,7 +52,7 @@ vi.mock('expo-notifications', () => ({
return { remove: vi.fn() };
}),
DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT',
- AndroidImportance: { HIGH: 4 },
+ AndroidImportance: { NONE: 2, DEFAULT: 5, HIGH: 6 },
IosAuthorizationStatus: { PROVISIONAL: 3 },
SchedulableTriggerInputTypes: { DATE: 'date' },
}));
@@ -91,7 +97,7 @@ const upcoming = {
const upcomingKey = `ev1:a1:${new Date('2026-09-24T09:45:00Z').getTime()}`;
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');
@@ -115,6 +121,7 @@ beforeEach(async () => {
h.calendarListener = null;
h.onDevice = null;
h.reminderListener = null;
+ h.legacyChannel = null;
h.loadEventsInRange.mockResolvedValue([upcoming]);
mod = await import('../calendar-notifications');
N = await import('expo-notifications');
@@ -322,6 +329,7 @@ describe('tapping a calendar reminder', () => {
tag: TAG,
key: expect.any(String),
locale: 'en',
+ delivery: 'calendar-reminders-involved',
eventId: 'acc-2:ev1:2026-09-24T10:00:00Z',
kind: 'event',
serverId: 'ev1',
@@ -366,3 +374,55 @@ describe('tapping a calendar reminder', () => {
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 } }];
+ 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']");
+ });
+});
diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts
index fd7aada6..835b9246 100644
--- a/src/lib/calendar-notifications.ts
+++ b/src/lib/calendar-notifications.ts
@@ -22,7 +22,17 @@ import { startCalendarEventNotificationSync } from './calendar-event-notificatio
// don't depend on the Calendar tab being open. Honours the
// `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 HORIZON_MS = 7 * DAY_MS;
// The events loaded for the horizon: from a day back (an end-relative alert
@@ -42,6 +52,8 @@ let permissionRequested = false;
let mayAskPermission = false;
// The language the Android channel was last named in; null until created.
let channelLocale: string | null = null;
+// The channel reminders go to; decided once per run.
+let activeChannelId: string | null = null;
let rescheduleTimer: ReturnType | null = null;
let rescheduling: Promise | null = null;
let queued = false;
@@ -68,25 +80,48 @@ async function ensurePermission(): Promise {
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
// (re)named in the app language; setting an existing channel only renames it.
+// The old channel is never touched.
async function ensureChannel(): Promise {
if (Platform.OS !== 'android') return;
const { locale, t } = useLocaleStore.getState();
if (channelLocale === locale) return;
try {
- await Notifications.setNotificationChannelAsync(CHANNEL_ID, {
- name: t('calendar.notifications.channel_name', 'Calendar reminders'),
- importance: Notifications.AndroidImportance.HIGH,
- sound: 'default',
- vibrationPattern: [0, 250, 250, 250],
- });
+ if (activeChannelId === null) {
+ const legacy = await Notifications.getNotificationChannelAsync(LEGACY_CHANNEL_ID).catch(() => null);
+ activeChannelId = reminderChannelFor(legacy);
+ }
+ 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 {
// Channel creation failing only degrades to the default channel.
}
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 {
const data = request.content.data as { tag?: string; key?: string } | undefined;
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;
}
+// 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. */
export async function cancelAllCalendarNotifications(): Promise {
try {
@@ -119,6 +161,8 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise
key: alert.key,
// The language the title and body are in, to reword it on a switch.
locale,
+ // The channel (Android) or sound (iOS) it goes out with.
+ delivery: deliveryTag(),
eventId: alert.eventId,
kind: alert.kind,
// Enough to find the event again when the reminder is tapped.
@@ -133,13 +177,15 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise
content: {
title: alert.title,
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,
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
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 {
for (const request of pending) {
const key = alertKeyOf(request);
if (key === null) continue;
- if (wantedByKey.has(key) && !isStaleLanguage(request, locale)) {
+ if (wantedByKey.has(key) && !isStaleLanguage(request, locale) && !isStaleDelivery(request)) {
present.add(key);
} else {
await Notifications.cancelScheduledNotificationAsync(request.identifier);