mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: play the Involved sound for calendar reminders
This commit is contained in:
Binary file not shown.
@@ -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" />
|
||||||
@@ -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']");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user