diff --git a/.gitignore b/.gitignore index 7b74ec77..2efe55fc 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ expo-env.d.ts .mcp.json opencode.json /.dual-graph-pro +.DS_Store diff --git a/assets/audio/modernavailabilityalert.wav b/assets/audio/modernavailabilityalert.wav new file mode 100644 index 00000000..5331ae50 Binary files /dev/null and b/assets/audio/modernavailabilityalert.wav differ diff --git a/assets/audio/moderncalendar.wav b/assets/audio/moderncalendar.wav new file mode 100644 index 00000000..20227b4c Binary files /dev/null and b/assets/audio/moderncalendar.wav differ diff --git a/assets/audio/moderncallclosed.wav b/assets/audio/moderncallclosed.wav new file mode 100644 index 00000000..8ad4b4bd Binary files /dev/null and b/assets/audio/moderncallclosed.wav differ diff --git a/assets/audio/moderncallemergency.wav b/assets/audio/moderncallemergency.wav new file mode 100644 index 00000000..1e1ef4f2 Binary files /dev/null and b/assets/audio/moderncallemergency.wav differ diff --git a/assets/audio/moderncallhigh.wav b/assets/audio/moderncallhigh.wav new file mode 100644 index 00000000..b8af4619 Binary files /dev/null and b/assets/audio/moderncallhigh.wav differ diff --git a/assets/audio/moderncalllow.wav b/assets/audio/moderncalllow.wav new file mode 100644 index 00000000..cf5bd9b5 Binary files /dev/null and b/assets/audio/moderncalllow.wav differ diff --git a/assets/audio/moderncallmedium.wav b/assets/audio/moderncallmedium.wav new file mode 100644 index 00000000..7aba7804 Binary files /dev/null and b/assets/audio/moderncallmedium.wav differ diff --git a/assets/audio/moderncallupdated.wav b/assets/audio/moderncallupdated.wav new file mode 100644 index 00000000..68884d89 Binary files /dev/null and b/assets/audio/moderncallupdated.wav differ diff --git a/assets/audio/modernchat.wav b/assets/audio/modernchat.wav new file mode 100644 index 00000000..9bbc0b8b Binary files /dev/null and b/assets/audio/modernchat.wav differ diff --git a/assets/audio/modernmessage.wav b/assets/audio/modernmessage.wav new file mode 100644 index 00000000..68b22e64 Binary files /dev/null and b/assets/audio/modernmessage.wav differ diff --git a/assets/audio/modernnotification.wav b/assets/audio/modernnotification.wav new file mode 100644 index 00000000..54956cbc Binary files /dev/null and b/assets/audio/modernnotification.wav differ diff --git a/assets/audio/modernpersonnelstatus.wav b/assets/audio/modernpersonnelstatus.wav new file mode 100644 index 00000000..372eb3b4 Binary files /dev/null and b/assets/audio/modernpersonnelstatus.wav differ diff --git a/assets/audio/modernresourceorder.wav b/assets/audio/modernresourceorder.wav new file mode 100644 index 00000000..090abeed Binary files /dev/null and b/assets/audio/modernresourceorder.wav differ diff --git a/assets/audio/modernshift.wav b/assets/audio/modernshift.wav new file mode 100644 index 00000000..a09a8048 Binary files /dev/null and b/assets/audio/modernshift.wav differ diff --git a/assets/audio/modernstaffing.wav b/assets/audio/modernstaffing.wav new file mode 100644 index 00000000..f97044c1 Binary files /dev/null and b/assets/audio/modernstaffing.wav differ diff --git a/assets/audio/moderntraining.wav b/assets/audio/moderntraining.wav new file mode 100644 index 00000000..8ecf1c4b Binary files /dev/null and b/assets/audio/moderntraining.wav differ diff --git a/assets/audio/moderntroublealert.wav b/assets/audio/moderntroublealert.wav new file mode 100644 index 00000000..573bcac2 Binary files /dev/null and b/assets/audio/moderntroublealert.wav differ diff --git a/assets/audio/modernunitnotice.wav b/assets/audio/modernunitnotice.wav new file mode 100644 index 00000000..aeb0bb5d Binary files /dev/null and b/assets/audio/modernunitnotice.wav differ diff --git a/assets/audio/modernunitstatus.wav b/assets/audio/modernunitstatus.wav new file mode 100644 index 00000000..1305d1e6 Binary files /dev/null and b/assets/audio/modernunitstatus.wav differ diff --git a/assets/audio/modernweatheralert.wav b/assets/audio/modernweatheralert.wav new file mode 100644 index 00000000..a8a7e3f2 Binary files /dev/null and b/assets/audio/modernweatheralert.wav differ diff --git a/plugins/withNotificationSounds.js b/plugins/withNotificationSounds.js index 64a96836..a089fe95 100644 --- a/plugins/withNotificationSounds.js +++ b/plugins/withNotificationSounds.js @@ -26,6 +26,27 @@ const soundFiles = [ 'assets/audio/unitstatusupdated.wav', 'assets/audio/upcomingshift.wav', 'assets/audio/upcomingtraining.wav', + // Modern notification sound set + 'assets/audio/modernnotification.wav', + 'assets/audio/modernavailabilityalert.wav', + 'assets/audio/moderncalendar.wav', + 'assets/audio/moderncallclosed.wav', + 'assets/audio/moderncallemergency.wav', + 'assets/audio/moderncallhigh.wav', + 'assets/audio/moderncalllow.wav', + 'assets/audio/moderncallmedium.wav', + 'assets/audio/moderncallupdated.wav', + 'assets/audio/modernchat.wav', + 'assets/audio/modernmessage.wav', + 'assets/audio/modernpersonnelstatus.wav', + 'assets/audio/modernresourceorder.wav', + 'assets/audio/modernshift.wav', + 'assets/audio/modernstaffing.wav', + 'assets/audio/moderntraining.wav', + 'assets/audio/moderntroublealert.wav', + 'assets/audio/modernunitnotice.wav', + 'assets/audio/modernunitstatus.wav', + 'assets/audio/modernweatheralert.wav', 'assets/audio/custom/c1.wav', 'assets/audio/custom/c2.wav', 'assets/audio/custom/c3.wav', diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index e13655ac..426723d2 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -10,6 +10,7 @@ import { Item } from '@/components/settings/item'; import { KeepAliveItem } from '@/components/settings/keep-alive-item'; import { LanguageItem } from '@/components/settings/language-item'; import { LoginInfoBottomSheet } from '@/components/settings/login-info-bottom-sheet'; +import { ModernNotificationSoundsItem } from '@/components/settings/modern-notification-sounds-item'; import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { ThemeItem } from '@/components/settings/theme-item'; import { ToggleItem } from '@/components/settings/toggle-item'; @@ -131,6 +132,7 @@ export default function Settings() { + diff --git a/src/components/settings/__tests__/modern-notification-sounds-item.test.tsx b/src/components/settings/__tests__/modern-notification-sounds-item.test.tsx new file mode 100644 index 00000000..c99e80fa --- /dev/null +++ b/src/components/settings/__tests__/modern-notification-sounds-item.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { ModernNotificationSoundsItem } from '../modern-notification-sounds-item'; + +// Mock the translation hook +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'settings.modern_notification_sounds': 'Modern Notification Sounds', + 'settings.modern_notification_sounds_description': 'Use the new modern sound set for push notifications.', + }; + return translations[key] || key; + }, + }), +})); + +// Control the platform per test (component is Android-only). +let mockIsAndroid = true; +jest.mock('@/lib/platform', () => ({ + get isAndroid() { + return mockIsAndroid; + }, +})); + +// Control the preference hook. +const mockSetModernSoundsEnabled = jest.fn(); +let mockIsModernSoundsEnabled = true; +jest.mock('@/lib/hooks/use-modern-notification-sounds', () => ({ + useModernNotificationSounds: () => ({ + isModernSoundsEnabled: mockIsModernSoundsEnabled, + setModernSoundsEnabled: mockSetModernSoundsEnabled, + }), +})); + +describe('ModernNotificationSoundsItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsAndroid = true; + mockIsModernSoundsEnabled = true; + }); + + it('renders the label and description on Android', () => { + render(); + + expect(screen.getByText('Modern Notification Sounds')).toBeTruthy(); + expect(screen.getByText('Use the new modern sound set for push notifications.')).toBeTruthy(); + }); + + it('renders nothing on non-Android platforms', () => { + mockIsAndroid = false; + + render(); + + expect(screen.queryByText('Modern Notification Sounds')).toBeNull(); + }); + + it('reflects the enabled state on the switch', () => { + mockIsModernSoundsEnabled = true; + + render(); + + expect(screen.getByRole('switch').props.value).toBe(true); + }); + + it('calls the setter when toggled off', () => { + mockIsModernSoundsEnabled = true; + + render(); + + fireEvent(screen.getByRole('switch'), 'valueChange', false); + + expect(mockSetModernSoundsEnabled).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/settings/modern-notification-sounds-item.tsx b/src/components/settings/modern-notification-sounds-item.tsx new file mode 100644 index 00000000..4d40e011 --- /dev/null +++ b/src/components/settings/modern-notification-sounds-item.tsx @@ -0,0 +1,45 @@ +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useModernNotificationSounds } from '@/lib/hooks/use-modern-notification-sounds'; +import { isAndroid } from '@/lib/platform'; + +import { Switch } from '../ui/switch'; +import { Text } from '../ui/text'; +import { View } from '../ui/view'; +import { VStack } from '../ui/vstack'; + +export const ModernNotificationSoundsItem = () => { + const { isModernSoundsEnabled, setModernSoundsEnabled } = useModernNotificationSounds(); + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + + const handleToggle = React.useCallback( + (value: boolean) => { + setModernSoundsEnabled(value); + }, + [setModernSoundsEnabled] + ); + + // Notification channel sounds are an Android-only concept; hide on other platforms. + if (!isAndroid) { + return null; + } + + return ( + + + + {t('settings.modern_notification_sounds')} + + + + + + + {t('settings.modern_notification_sounds_description')} + + + ); +}; diff --git a/src/lib/hooks/use-modern-notification-sounds.ts b/src/lib/hooks/use-modern-notification-sounds.ts new file mode 100644 index 00000000..987e5dc1 --- /dev/null +++ b/src/lib/hooks/use-modern-notification-sounds.ts @@ -0,0 +1,33 @@ +import React from 'react'; +import { Platform } from 'react-native'; +import { useMMKVBoolean } from 'react-native-mmkv'; + +import { storage } from '@/lib/storage'; +import { MODERN_NOTIFICATION_SOUNDS_ENABLED } from '@/lib/storage/notification-prefs'; +import { pushNotificationService } from '@/services/push-notification'; + +/** + * Android-only hook for the "use modern notification sounds" preference. + * + * Defaults to enabled (modern sounds) and is persisted in MMKV alongside the + * other app settings. When toggled on Android, the notification channels are + * recreated so the new sound takes effect — a channel's sound is immutable + * after it is created, so it must be deleted and recreated to change it. + */ +export const useModernNotificationSounds = () => { + const [enabled, _setEnabled] = useMMKVBoolean(MODERN_NOTIFICATION_SOUNDS_ENABLED, storage); + + const setModernSoundsEnabled = React.useCallback( + async (value: boolean) => { + _setEnabled(value); + if (Platform.OS === 'android') { + await pushNotificationService.refreshAndroidNotificationChannels(); + } + }, + [_setEnabled] + ); + + // Default ON when the user has not set a preference. + const isModernSoundsEnabled = enabled ?? true; + return { isModernSoundsEnabled, setModernSoundsEnabled } as const; +}; diff --git a/src/lib/storage/notification-prefs.ts b/src/lib/storage/notification-prefs.ts new file mode 100644 index 00000000..5c9c22b2 --- /dev/null +++ b/src/lib/storage/notification-prefs.ts @@ -0,0 +1,36 @@ +import { storage } from '@/lib/storage'; + +/** + * MMKV key for the Android-only "use modern notification sounds" preference. + * Defaults to enabled (modern sounds) when the user has not set a preference. + */ +export const MODERN_NOTIFICATION_SOUNDS_ENABLED = 'MODERN_NOTIFICATION_SOUNDS_ENABLED'; + +/** + * MMKV key tracking which sound set the Android notification channels were last + * created with. Android notification channel sound is immutable after creation, + * so this marker lets us detect when channels need to be deleted and recreated. + */ +const NOTIFICATION_SOUND_MODE_APPLIED = 'NOTIFICATION_SOUND_MODE_APPLIED'; + +export type NotificationSoundMode = 'modern' | 'classic'; + +/** + * Whether modern notification sounds are enabled. Defaults to true (modern is + * the default) when the user has not made a choice. + */ +export const getModernNotificationSoundsEnabled = (): boolean => storage.getBoolean(MODERN_NOTIFICATION_SOUNDS_ENABLED) ?? true; + +/** + * The sound mode the Android channels were last created with, or undefined if + * they have never been created (fresh install or app upgrade). + */ +export const getAppliedNotificationSoundMode = (): NotificationSoundMode | undefined => { + const mode = storage.getString(NOTIFICATION_SOUND_MODE_APPLIED); + return mode === 'modern' || mode === 'classic' ? mode : undefined; +}; + +/** Persist the sound mode the Android channels were created with. */ +export const setAppliedNotificationSoundMode = (mode: NotificationSoundMode): void => { + storage.set(NOTIFICATION_SOUND_MODE_APPLIED, mode); +}; diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index a3c22b88..e141a6f0 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -95,9 +95,21 @@ jest.mock('expo-notifications', () => ({ AndroidNotificationVisibility: { PUBLIC: 1 }, })); +// Mock the modern-sounds preference module (controls Android channel sounds) +const mockGetModernNotificationSoundsEnabled = jest.fn((): boolean => true); +const mockGetAppliedNotificationSoundMode = jest.fn((): string | undefined => undefined); +const mockSetAppliedNotificationSoundMode = jest.fn(); + +jest.mock('@/lib/storage/notification-prefs', () => ({ + getModernNotificationSoundsEnabled: mockGetModernNotificationSoundsEnabled, + getAppliedNotificationSoundMode: mockGetAppliedNotificationSoundMode, + setAppliedNotificationSoundMode: mockSetAppliedNotificationSoundMode, +})); + // Mock Notifee (channels, categories, foreground/background events, check-in) const mockNotifeeForegroundUnsubscribe = jest.fn(); const mockCreateChannel = jest.fn(() => Promise.resolve()); +const mockDeleteChannel = jest.fn(() => Promise.resolve()); const mockSetNotificationCategories = jest.fn(() => Promise.resolve()); const mockNotifeeRequestPermission = jest.fn(() => Promise.resolve({ @@ -112,6 +124,7 @@ jest.mock('@notifee/react-native', () => ({ __esModule: true, default: { createChannel: mockCreateChannel, + deleteChannel: mockDeleteChannel, setNotificationCategories: mockSetNotificationCategories, requestPermission: mockNotifeeRequestPermission, displayNotification: mockDisplayNotification, @@ -554,5 +567,85 @@ describe('Push Notification Service Integration', () => { // Total: 32 channels expect(mockCreateChannel).toHaveBeenCalledTimes(32); }); + + it('should use modern sounds for the standard channels by default', async () => { + mockGetModernNotificationSoundsEnabled.mockReturnValue(true); + + await pushNotificationService.initialize(); + + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'calls', sound: 'modernnotification' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '0', sound: 'moderncallemergency' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '1', sound: 'moderncallhigh' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '2', sound: 'moderncallmedium' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '3', sound: 'moderncalllow' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'notif', sound: 'modernnotification' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'message', sound: 'modernmessage' })); + }); + + it('should use classic sounds when modern sounds are disabled', async () => { + mockGetModernNotificationSoundsEnabled.mockReturnValue(false); + + await pushNotificationService.initialize(); + + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'calls', sound: 'notification' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '0', sound: 'callemergency' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'notif', sound: 'notification' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'message', sound: 'newmessage' })); + // Never falls back to a modern sound when disabled. + expect(mockCreateChannel).not.toHaveBeenCalledWith(expect.objectContaining({ sound: 'moderncallemergency' })); + }); + + it('should leave custom call channels (c1-c25) unaffected by the setting', async () => { + mockGetModernNotificationSoundsEnabled.mockReturnValue(true); + + await pushNotificationService.initialize(); + + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1', sound: 'c1' })); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'c25', sound: 'c25' })); + }); + + it('should delete the standard sound channels before recreating them when the mode changes', async () => { + mockGetModernNotificationSoundsEnabled.mockReturnValue(true); + mockGetAppliedNotificationSoundMode.mockReturnValue('classic'); + + await pushNotificationService.initialize(); + + // The 7 standard sound channels are deleted so they can be recreated with the new sound. + expect(mockDeleteChannel).toHaveBeenCalledTimes(7); + expect(mockDeleteChannel).toHaveBeenCalledWith('0'); + expect(mockDeleteChannel).toHaveBeenCalledWith('notif'); + expect(mockDeleteChannel).not.toHaveBeenCalledWith('c1'); + }); + + it('should not delete channels when the sound mode is unchanged', async () => { + mockGetModernNotificationSoundsEnabled.mockReturnValue(true); + mockGetAppliedNotificationSoundMode.mockReturnValue('modern'); + + await pushNotificationService.initialize(); + + expect(mockDeleteChannel).not.toHaveBeenCalled(); + expect(mockCreateChannel).toHaveBeenCalledTimes(32); + }); + + it('should persist the applied sound mode after setup', async () => { + mockGetModernNotificationSoundsEnabled.mockReturnValue(true); + mockGetAppliedNotificationSoundMode.mockReturnValue('modern'); + + await pushNotificationService.initialize(); + + expect(mockSetAppliedNotificationSoundMode).toHaveBeenCalledWith('modern'); + }); + + it('refreshAndroidNotificationChannels recreates channels with the current setting', async () => { + mockGetModernNotificationSoundsEnabled.mockReturnValue(false); + mockGetAppliedNotificationSoundMode.mockReturnValue('modern'); + + await pushNotificationService.refreshAndroidNotificationChannels(); + + // Mode changed modern -> classic, so the standard channels are deleted and recreated. + expect(mockDeleteChannel).toHaveBeenCalledTimes(7); + expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '0', sound: 'callemergency' })); + expect(mockSetAppliedNotificationSoundMode).toHaveBeenCalledWith('classic'); + }); }); }); diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 6ff0034c..4efacfa4 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -7,6 +7,7 @@ import { Platform } from 'react-native'; import { registerUnitDevice } from '@/api/devices/push'; import { logger } from '@/lib/logging'; import { getDeviceUuid } from '@/lib/storage/app'; +import { getAppliedNotificationSoundMode, getModernNotificationSoundsEnabled, setAppliedNotificationSoundMode } from '@/lib/storage/notification-prefs'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; @@ -44,6 +45,29 @@ Notifications.setNotificationHandler({ }), }); +interface AndroidSoundChannel { + id: string; + name: string; + description: string; + vibration: boolean; + modernSound: string; + classicSound: string; +} + +// Android notification channels whose sound follows the "modern sounds" setting. +// Channel IDs are fixed because the backend targets them by id, so to change a +// channel's sound we delete and recreate it (a channel's sound is immutable once +// created). The custom-tone channels (c1-c25) are intentionally excluded. +const ANDROID_SOUND_CHANNELS: AndroidSoundChannel[] = [ + { id: 'calls', name: 'Generic Call', description: 'Generic Call', vibration: true, modernSound: 'modernnotification', classicSound: 'notification' }, + { id: '0', name: 'Emergency Call', description: 'Emergency Call', vibration: true, modernSound: 'moderncallemergency', classicSound: 'callemergency' }, + { id: '1', name: 'High Call', description: 'High Call', vibration: true, modernSound: 'moderncallhigh', classicSound: 'callhigh' }, + { id: '2', name: 'Medium Call', description: 'Medium Call', vibration: true, modernSound: 'moderncallmedium', classicSound: 'callmedium' }, + { id: '3', name: 'Low Call', description: 'Low Call', vibration: true, modernSound: 'moderncalllow', classicSound: 'calllow' }, + { id: 'notif', name: 'Notification', description: 'Notifications', vibration: false, modernSound: 'modernnotification', classicSound: 'notification' }, + { id: 'message', name: 'Message', description: 'Messages', vibration: false, modernSound: 'modernmessage', classicSound: 'newmessage' }, +]; + class PushNotificationService { private static instance: PushNotificationService; private pushToken: string | null = null; @@ -74,37 +98,60 @@ class PushNotificationService { } private async setupAndroidNotificationChannels(): Promise { - if (Platform.OS === 'android') { - try { - // Standard call channels - await this.createNotificationChannel('calls', 'Generic Call', 'Generic Call'); - await this.createNotificationChannel('0', 'Emergency Call', 'Emergency Call', 'callemergency'); - await this.createNotificationChannel('1', 'High Call', 'High Call', 'callhigh'); - await this.createNotificationChannel('2', 'Medium Call', 'Medium Call', 'callmedium'); - await this.createNotificationChannel('3', 'Low Call', 'Low Call', 'calllow'); - - // Message and notification channels - await this.createNotificationChannel('notif', 'Notification', 'Notifications', undefined, false); - await this.createNotificationChannel('message', 'Message', 'Messages', undefined, false); - - // Custom call channels (c1-c25) - for (let i = 1; i <= 25; i++) { - const channelId = `c${i}`; - await this.createNotificationChannel(channelId, `Custom Call ${i}`, `Custom Call Tone ${i}`, channelId); - } + if (Platform.OS !== 'android') { + return; + } - logger.info({ - message: 'Android notification channels setup completed', - }); - } catch (error) { - logger.error({ - message: 'Error setting up Android notification channels', - context: { error }, - }); + try { + // Modern sounds are the default; the user can opt back into the classic + // sounds via the settings toggle. + const useModernSounds = getModernNotificationSoundsEnabled(); + const desiredMode = useModernSounds ? 'modern' : 'classic'; + const appliedMode = getAppliedNotificationSoundMode(); + + // A channel's sound cannot be changed after creation, so when the sound + // mode changes (or on a fresh install / app upgrade) delete the channels + // first and let them be recreated below with the correct sound. + if (appliedMode !== desiredMode) { + await Promise.all(ANDROID_SOUND_CHANNELS.map((channel) => notifee.deleteChannel(channel.id))); } + + // Standard call/notification/message channels — sound follows the setting. + for (const channel of ANDROID_SOUND_CHANNELS) { + const sound = useModernSounds ? channel.modernSound : channel.classicSound; + await this.createNotificationChannel(channel.id, channel.name, channel.description, sound, channel.vibration); + } + + // Custom call channels (c1-c25) — user-selected tones, not affected by the setting. + for (let i = 1; i <= 25; i++) { + const channelId = `c${i}`; + await this.createNotificationChannel(channelId, `Custom Call ${i}`, `Custom Call Tone ${i}`, channelId); + } + + setAppliedNotificationSoundMode(desiredMode); + + logger.info({ + message: 'Android notification channels setup completed', + context: { soundMode: desiredMode }, + }); + } catch (error) { + logger.error({ + message: 'Error setting up Android notification channels', + context: { error }, + }); } } + /** + * Re-applies the Android notification channels using the current "modern + * sounds" preference. Call this after the user toggles the setting so the + * channel sounds update — the channels are deleted and recreated because a + * channel's sound is immutable once created. No-op on non-Android platforms. + */ + public async refreshAndroidNotificationChannels(): Promise { + await this.setupAndroidNotificationChannels(); + } + private async setupIOSNotificationCategories(): Promise { if (Platform.OS === 'ios') { try { diff --git a/src/translations/ar.json b/src/translations/ar.json index e937807b..359d439f 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -835,6 +835,8 @@ "logout": "تسجيل خروج", "logout_confirm_message": "هل أنت متأكد من رغبتك في تسجيل الخروج؟ سيؤدي هذا إلى مسح جميع بيانات التطبيق والقيم المخزنة مؤقتاً والإعدادات.", "logout_confirm_title": "تأكيد تسجيل الخروج", + "modern_notification_sounds": "أصوات إشعارات حديثة", + "modern_notification_sounds_description": "استخدم مجموعة الأصوات الحديثة الجديدة لإشعارات الدفع. أوقف التشغيل لاستخدام الأصوات الكلاسيكية.", "more": "المزيد", "no_units_available": "لا توجد وحدات متاحة", "none_selected": "لم يتم اختيار أي شيء", diff --git a/src/translations/de.json b/src/translations/de.json index 56025b10..ee4dc198 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -835,6 +835,8 @@ "logout": "Abmelden", "logout_confirm_message": "Sind Sie sicher, dass Sie sich abmelden möchten? Dadurch werden alle App-Daten, zwischengespeicherten Werte und Einstellungen gelöscht.", "logout_confirm_title": "Abmeldung bestätigen", + "modern_notification_sounds": "Moderne Benachrichtigungstöne", + "modern_notification_sounds_description": "Verwende das neue moderne Sound-Set für Push-Benachrichtigungen. Deaktiviere diese Option, um die klassischen Töne zu verwenden.", "more": "Mehr", "no_units_available": "Keine Einheiten verfügbar", "none_selected": "Keine ausgewählt", diff --git a/src/translations/en.json b/src/translations/en.json index bf448c37..b0918e92 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -835,6 +835,8 @@ "logout": "Logout", "logout_confirm_message": "Are you sure you want to logout? This will clear all app data, cached values, and settings.", "logout_confirm_title": "Confirm Logout", + "modern_notification_sounds": "Modern Notification Sounds", + "modern_notification_sounds_description": "Use the new modern sound set for push notifications. Turn this off to use the classic sounds.", "more": "More", "no_units_available": "No units available", "none_selected": "None Selected", diff --git a/src/translations/es.json b/src/translations/es.json index 7f180228..210e1f18 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -835,6 +835,8 @@ "logout": "Cerrar sesión", "logout_confirm_message": "¿Está seguro de que desea cerrar sesión? Esto borrará todos los datos de la aplicación, valores en caché y configuraciones.", "logout_confirm_title": "Confirmar cierre de sesión", + "modern_notification_sounds": "Sonidos de notificación modernos", + "modern_notification_sounds_description": "Usa el nuevo conjunto de sonidos modernos para las notificaciones push. Desactívalo para usar los sonidos clásicos.", "more": "Más", "no_units_available": "No hay unidades disponibles", "none_selected": "Ninguna seleccionada", diff --git a/src/translations/fr.json b/src/translations/fr.json index 9f45e00e..efa52866 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -835,6 +835,8 @@ "logout": "Déconnexion", "logout_confirm_message": "Êtes-vous sûr de vouloir vous déconnecter ? Cela effacera toutes les données de l'application, les valeurs mises en cache et les paramètres.", "logout_confirm_title": "Confirmer la déconnexion", + "modern_notification_sounds": "Sons de notification modernes", + "modern_notification_sounds_description": "Utiliser le nouvel ensemble de sons modernes pour les notifications push. Désactivez cette option pour utiliser les sons classiques.", "more": "Plus", "no_units_available": "Aucune unité disponible", "none_selected": "Aucune sélectionnée", diff --git a/src/translations/it.json b/src/translations/it.json index 1d061bc6..a3a31d58 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -835,6 +835,8 @@ "logout": "Esci", "logout_confirm_message": "Sei sicuro di voler uscire? Questo cancellerà tutti i dati dell'app, i valori memorizzati nella cache e le impostazioni.", "logout_confirm_title": "Conferma uscita", + "modern_notification_sounds": "Suoni di notifica moderni", + "modern_notification_sounds_description": "Usa il nuovo set di suoni moderni per le notifiche push. Disattiva questa opzione per usare i suoni classici.", "more": "Altro", "no_units_available": "Nessuna unità disponibile", "none_selected": "Nessuno selezionato", diff --git a/src/translations/pl.json b/src/translations/pl.json index c2767aed..ad329751 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -835,6 +835,8 @@ "logout": "Wyloguj", "logout_confirm_message": "Czy na pewno chcesz się wylogować? Spowoduje to wyczyszczenie wszystkich danych aplikacji, wartości w pamięci podręcznej i ustawień.", "logout_confirm_title": "Potwierdź wylogowanie", + "modern_notification_sounds": "Nowoczesne dźwięki powiadomień", + "modern_notification_sounds_description": "Używaj nowego, nowoczesnego zestawu dźwięków powiadomień push. Wyłącz, aby używać klasycznych dźwięków.", "more": "Więcej", "no_units_available": "Brak dostępnych jednostek", "none_selected": "Nic nie wybrano", diff --git a/src/translations/sv.json b/src/translations/sv.json index 773a0b0d..326bdde6 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -835,6 +835,8 @@ "logout": "Logga ut", "logout_confirm_message": "Är du säker på att du vill logga ut? Detta rensar all appdata, cachade värden och inställningar.", "logout_confirm_title": "Bekräfta utloggning", + "modern_notification_sounds": "Moderna aviseringsljud", + "modern_notification_sounds_description": "Använd den nya moderna ljuduppsättningen för push-aviseringar. Stäng av för att använda de klassiska ljuden.", "more": "Mer", "no_units_available": "Inga enheter tillgängliga", "none_selected": "Ingen vald", diff --git a/src/translations/uk.json b/src/translations/uk.json index 2c9ca185..3e1e2adc 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -835,6 +835,8 @@ "logout": "Вийти", "logout_confirm_message": "Ви впевнені, що хочете вийти? Це очистить усі дані додатку, кешовані значення та налаштування.", "logout_confirm_title": "Підтвердити вихід", + "modern_notification_sounds": "Сучасні звуки сповіщень", + "modern_notification_sounds_description": "Використовуйте новий сучасний набір звуків для push-сповіщень. Вимкніть, щоб використовувати класичні звуки.", "more": "Більше", "no_units_available": "Немає доступних підрозділів", "none_selected": "Нічого не вибрано",