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);