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 }],
|
||||
// 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,
|
||||
|
||||
Binary file not shown.
@@ -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<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.
|
||||
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<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
|
||||
// `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<typeof setTimeout> | null = null;
|
||||
let rescheduling: Promise<void> | null = null;
|
||||
let queued = false;
|
||||
@@ -68,25 +80,48 @@ async function ensurePermission(): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
@@ -119,6 +161,8 @@ async function scheduleOne(alert: ScheduledAlert, locale: string): Promise<void>
|
||||
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<void>
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user