diff --git a/apps/api/src/app/notifications/notifications.service.spec.ts b/apps/api/src/app/notifications/notifications.service.spec.ts index 547561c0..25c035b6 100644 --- a/apps/api/src/app/notifications/notifications.service.spec.ts +++ b/apps/api/src/app/notifications/notifications.service.spec.ts @@ -26,6 +26,17 @@ describe('The NotificationsService class', () => { beforeEach(() => { mockPreferencesRepository = mock>(); + mockPreferencesRepository.findOne.mockResolvedValue({ + alert_sound: 'sentry_siren.wav', + telegram_enabled: true, + userId: fakeUserId, + } as NotificationPreferences); + mockPreferencesRepository.create.mockReturnValue({ + alert_sound: 'sentry_siren.wav', + telegram_enabled: true, + userId: fakeUserId, + } as NotificationPreferences); + mockPreferencesRepository.save.mockImplementation((pref) => Promise.resolve(pref as NotificationPreferences)); mockPushDeviceTokenRepository = mock>(); mockPushDeviceTokenRepository.find.mockResolvedValue([createDevice()]); fetchMock = jest.fn().mockResolvedValue({ @@ -278,4 +289,38 @@ describe('The NotificationsService class', () => { }); }); }); + + describe('The getPreferences() method', () => { + describe('When preferences exist for the user', () => { + it('should return the alert_sound from preferences', async () => { + mockPreferencesRepository.findOne.mockResolvedValue({ + alert_sound: 'tesla_horn.wav', + telegram_enabled: true, + userId: fakeUserId, + } as NotificationPreferences); + + const result = await service.getPreferences(fakeUserId); + + expect(result.alert_sound).toBe('tesla_horn.wav'); + }); + }); + }); + + describe('The updatePreferences() method', () => { + describe('When updating the alert sound', () => { + it('should persist the new alert_sound', async () => { + mockPreferencesRepository.findOne.mockResolvedValue({ + alert_sound: 'sentry_siren.wav', + telegram_enabled: true, + userId: fakeUserId, + } as NotificationPreferences); + + await service.updatePreferences(fakeUserId, { alert_sound: 'cyber_pulse.wav' }); + + expect(mockPreferencesRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ alert_sound: 'cyber_pulse.wav' }) + ); + }); + }); + }); }); diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts index 726934c2..3d01c588 100644 --- a/apps/api/src/app/notifications/notifications.service.ts +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -10,6 +10,7 @@ import { NOTIFICATION_REQUEST_TIMEOUT_MS } from '../../config/notification-timeo import { withTimeout } from '../../common/utils/with-timeout.util'; export interface NotificationPreferencesDto { + alert_sound?: string; critical_alerts_enabled: boolean; critical_only: boolean; push_enabled: boolean; @@ -103,9 +104,12 @@ export class NotificationsService { return false; } + const preferences = await this.findOrCreatePreferences(userId); + const alertSound = preferences.alert_sound || 'sentry_siren.wav'; + this.logger.log(`[EXPO_PUSH][${correlationId || 'none'}] Sending push to ${eligibleDevices.length} device(s) for user: ${userId}`); - await this.dispatchPushToDevices(eligibleDevices, severity, type, userId, userLanguage, correlationId); + await this.dispatchPushToDevices(eligibleDevices, severity, type, userId, userLanguage, alertSound, correlationId); return true; } @@ -121,12 +125,13 @@ export class NotificationsService { type: AlertEventType, userId: string, userLanguage: 'en' | 'fr', + alertSound: string, correlationId?: string ): Promise { const { body, title } = this.resolveAlertTexts(type, userLanguage); const results = await Promise.allSettled( devices.map((device) => - this.sendExpoPush(device, title, body, severity, type, device.critical_alerts_enabled, userId, userLanguage, correlationId) + this.sendExpoPush(device, title, body, severity, type, device.critical_alerts_enabled, userId, userLanguage, alertSound, correlationId) ) ); @@ -199,6 +204,7 @@ export class NotificationsService { private pickGlobalPreferenceUpdates(preferences: Partial): Partial { return { + ...(preferences.alert_sound !== undefined ? { alert_sound: preferences.alert_sound } : {}), ...(preferences.telegram_enabled !== undefined ? { telegram_enabled: preferences.telegram_enabled } : {}), }; } @@ -213,6 +219,7 @@ export class NotificationsService { private toDto(preferences: NotificationPreferences, device: PushDeviceToken | null): NotificationPreferencesDto { return { + alert_sound: preferences.alert_sound ?? 'sentry_siren.wav', critical_alerts_enabled: device?.critical_alerts_enabled ?? false, critical_only: device?.critical_only ?? false, push_enabled: device?.push_enabled ?? false, @@ -233,13 +240,14 @@ export class NotificationsService { criticalAlertsEnabled: boolean, userId: string, userLanguage: 'en' | 'fr', + alertSound: string, correlationId?: string ): Promise { try { const pushStart = Date.now(); const response = await withTimeout( (signal) => fetch('https://exp.host/--/api/v2/push/send', { - body: JSON.stringify(this.buildExpoPushBody(device.token, title, body, severity, type, criticalAlertsEnabled, userId, userLanguage)), + body: JSON.stringify(this.buildExpoPushBody(device.token, title, body, severity, type, criticalAlertsEnabled, userId, userLanguage, alertSound)), headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, method: 'POST', signal, @@ -266,15 +274,18 @@ export class NotificationsService { type: AlertEventType, criticalAlertsEnabled: boolean, userId: string, - userLanguage: 'en' | 'fr' + userLanguage: 'en' | 'fr', + alertSound: string ): object { const isPriorityAlert = criticalAlertsEnabled && this.shouldUsePriorityChannel(severity, type); - const channelId = isPriorityAlert ? 'sentryguard-critical-alerts-v5' : 'sentryguard-alerts'; + const soundBase = alertSound.replace('.wav', ''); + const channelId = isPriorityAlert ? `sentryguard-critical-${soundBase}` : `sentryguard-alerts-${soundBase}`; - return { + const pushMessage: Record = { body, channelId, data: { + alertSound, channelId, criticalAlertsEnabled, isCriticalAlert: isPriorityAlert, @@ -287,10 +298,21 @@ export class NotificationsService { title, to: token, }; + + const isIosCritical = criticalAlertsEnabled && severity === AlertEventSeverity.Critical; + + if (isIosCritical) { + pushMessage.interruptionLevel = 'critical'; + pushMessage.sound = { critical: true, name: alertSound, volume: 1.0 }; + } else { + pushMessage.sound = alertSound; + } + + return pushMessage; } private shouldUsePriorityChannel(severity: AlertEventSeverity, type: AlertEventType): boolean { - return severity === AlertEventSeverity.Critical || type === AlertEventType.Sentry; + return severity === AlertEventSeverity.Critical; } private buildTeslaRedirectUrl(userId: string, userLanguage: 'en' | 'fr'): string { diff --git a/apps/api/src/entities/notification-preferences.entity.ts b/apps/api/src/entities/notification-preferences.entity.ts index 726e516a..7a8266c6 100644 --- a/apps/api/src/entities/notification-preferences.entity.ts +++ b/apps/api/src/entities/notification-preferences.entity.ts @@ -8,6 +8,9 @@ export class NotificationPreferences { @Column({ type: 'boolean', default: true }) telegram_enabled!: boolean; + @Column({ type: 'varchar', length: 64, default: 'sentry_siren.wav' }) + alert_sound!: string; + @CreateDateColumn() created_at!: Date; diff --git a/apps/api/src/migrations/1784000000000-AddAlertSoundToNotificationPreferences.ts b/apps/api/src/migrations/1784000000000-AddAlertSoundToNotificationPreferences.ts new file mode 100644 index 00000000..f76cd0e6 --- /dev/null +++ b/apps/api/src/migrations/1784000000000-AddAlertSoundToNotificationPreferences.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAlertSoundToNotificationPreferences1784000000000 implements MigrationInterface { + name = 'AddAlertSoundToNotificationPreferences1784000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification_preferences" ADD "alert_sound" character varying(64) NOT NULL DEFAULT 'sentry_siren.wav'` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification_preferences" DROP COLUMN "alert_sound"` + ); + } +} diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 5e31d4a1..cb486254 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -12,6 +12,9 @@ "icon": "./assets/icon.png", "supportsTablet": true, "bundleIdentifier": "com.sentryguard.mobile", + "entitlements": { + "com.apple.developer.usernotifications.critical-alerts": true + }, "infoPlist": { "ITSAppUsesNonExemptEncryption": false }, @@ -65,7 +68,14 @@ "expo-notifications", { "icon": "./assets/notification-icon.png", - "color": "#dc2626" + "color": "#dc2626", + "sounds": [ + "./assets/sounds/sentry_siren.wav", + "./assets/sounds/cyber_pulse.wav", + "./assets/sounds/tesla_horn.wav", + "./assets/sounds/danger_sonar.wav", + "./assets/sounds/klaxon_alarm.wav" + ] } ], [ diff --git a/apps/mobile/assets/sounds/cyber_pulse.wav b/apps/mobile/assets/sounds/cyber_pulse.wav new file mode 100644 index 00000000..51c3a87e Binary files /dev/null and b/apps/mobile/assets/sounds/cyber_pulse.wav differ diff --git a/apps/mobile/assets/sounds/danger_sonar.wav b/apps/mobile/assets/sounds/danger_sonar.wav new file mode 100644 index 00000000..3245fbed Binary files /dev/null and b/apps/mobile/assets/sounds/danger_sonar.wav differ diff --git a/apps/mobile/assets/sounds/klaxon_alarm.wav b/apps/mobile/assets/sounds/klaxon_alarm.wav new file mode 100644 index 00000000..fbfd7cfa Binary files /dev/null and b/apps/mobile/assets/sounds/klaxon_alarm.wav differ diff --git a/apps/mobile/assets/sounds/sentry_siren.wav b/apps/mobile/assets/sounds/sentry_siren.wav new file mode 100644 index 00000000..a4b3ae7f Binary files /dev/null and b/apps/mobile/assets/sounds/sentry_siren.wav differ diff --git a/apps/mobile/assets/sounds/tesla_horn.wav b/apps/mobile/assets/sounds/tesla_horn.wav new file mode 100644 index 00000000..31f4ae0e Binary files /dev/null and b/apps/mobile/assets/sounds/tesla_horn.wav differ diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index 80279756..0557632a 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -4,6 +4,9 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], testMatch: ['/src/**/*(*.)@(spec|test).[jt]s?(x)'], coverageDirectory: 'test-output/jest/coverage', + moduleNameMapper: { + '\\.(wav|mp3|ogg|caf|aiff|png|jpg|jpeg|gif|svg)$': '/src/testing/file-mock.js', + }, transform: { '^.+\\.[tj]sx?$': ['ts-jest', { isolatedModules: true, tsconfig: { jsx: 'react-jsx' } }], }, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 80beb7a4..1e4f0e87 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -16,6 +16,8 @@ "@tanstack/react-query": "^5.100.6", "enhanced-resolve": "*", "expo": "~54.0.35", + "expo-asset": "~12.0.13", + "expo-audio": "~1.1.1", "expo-blur": "~15.0.8", "expo-constants": "~18.0.13", "expo-device": "~8.0.10", diff --git a/apps/mobile/src/core/hooks/useSoundPlayer.ts b/apps/mobile/src/core/hooks/useSoundPlayer.ts new file mode 100644 index 00000000..c2c0324b --- /dev/null +++ b/apps/mobile/src/core/hooks/useSoundPlayer.ts @@ -0,0 +1,68 @@ +import { AudioPlayer, createAudioPlayer, setAudioModeAsync } from 'expo-audio'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface SoundPlayerRequirements { + isPlaying: (soundId: string) => boolean; + play: (soundId: string, asset: number) => void; + stop: () => void; +} + +export function useSoundPlayer(): SoundPlayerRequirements { + const [playingSoundId, setPlayingSoundId] = useState(null); + const playerRef = useRef(null); + + const cleanupCurrentPlayer = useCallback((): void => { + if (!playerRef.current) { + return; + } + try { + playerRef.current.pause(); + playerRef.current.remove(); + } catch { + playerRef.current = null; + } + playerRef.current = null; + }, []); + + const stop = useCallback((): void => { + cleanupCurrentPlayer(); + setPlayingSoundId(null); + }, [cleanupCurrentPlayer]); + + const attachFinishListener = useCallback( + (player: AudioPlayer): void => { + player.addListener('playbackStatusUpdate', (status) => { + if (status.didJustFinish) { + stop(); + } + }); + }, + [stop] + ); + + const play = useCallback( + (soundId: string, asset: number): void => { + stop(); + try { + void setAudioModeAsync({ playsInSilentMode: true }); + const player = createAudioPlayer(asset); + playerRef.current = player; + setPlayingSoundId(soundId); + attachFinishListener(player); + player.play(); + } catch { + stop(); + } + }, + [attachFinishListener, stop] + ); + + useEffect(() => () => stop(), [stop]); + + const isPlaying = useCallback( + (soundId: string): boolean => playingSoundId === soundId, + [playingSoundId] + ); + + return { isPlaying, play, stop }; +} diff --git a/apps/mobile/src/core/ui/Icon.tsx b/apps/mobile/src/core/ui/Icon.tsx index 327be5d8..6bea7aba 100644 --- a/apps/mobile/src/core/ui/Icon.tsx +++ b/apps/mobile/src/core/ui/Icon.tsx @@ -49,6 +49,11 @@ const ioniconsFallback: Record = { 'link': 'link', 'eye': 'eye-outline', 'eye.slash': 'eye-off-outline', + 'play.fill': 'play', + 'stop.fill': 'stop', + 'speaker.wave.2.fill': 'volume-high', + 'checkmark': 'checkmark', + 'checkmark.circle.fill': 'checkmark-circle', }; const useNativeSymbols = Platform.OS === 'ios'; diff --git a/apps/mobile/src/features/notifications/domain/alert-sounds.test.ts b/apps/mobile/src/features/notifications/domain/alert-sounds.test.ts new file mode 100644 index 00000000..b9af6868 --- /dev/null +++ b/apps/mobile/src/features/notifications/domain/alert-sounds.test.ts @@ -0,0 +1,31 @@ +import { ALERT_SOUNDS, DEFAULT_ALERT_SOUND_ID, resolveAlertSound } from './alert-sounds'; + +describe('The resolveAlertSound() function', () => { + describe('When a valid sound id is provided', () => { + it('should return the matching alert sound', () => { + const sound = resolveAlertSound('tesla_horn.wav'); + expect(sound.id).toBe('tesla_horn.wav'); + expect(sound.labelKey).toBe('settings.soundTeslaHorn'); + }); + }); + + describe('When an unknown sound id is provided', () => { + it('should fallback to the default alert sound', () => { + const sound = resolveAlertSound('unknown.wav'); + expect(sound.id).toBe(DEFAULT_ALERT_SOUND_ID); + }); + }); + + describe('When undefined is provided', () => { + it('should fallback to the default alert sound', () => { + const sound = resolveAlertSound(undefined); + expect(sound.id).toBe(DEFAULT_ALERT_SOUND_ID); + }); + }); + + describe('The ALERT_SOUNDS constant', () => { + it('should contain 5 alert sounds', () => { + expect(ALERT_SOUNDS).toHaveLength(5); + }); + }); +}); diff --git a/apps/mobile/src/features/notifications/domain/alert-sounds.ts b/apps/mobile/src/features/notifications/domain/alert-sounds.ts new file mode 100644 index 00000000..86cef324 --- /dev/null +++ b/apps/mobile/src/features/notifications/domain/alert-sounds.ts @@ -0,0 +1,40 @@ +export interface AlertSoundItem { + id: string; + labelKey: string; + asset: number; +} + +export const DEFAULT_ALERT_SOUND_ID = 'sentry_siren.wav'; + +export const ALERT_SOUNDS: readonly AlertSoundItem[] = [ + { + asset: require('../../../../assets/sounds/sentry_siren.wav'), + id: 'sentry_siren.wav', + labelKey: 'settings.soundSentrySiren', + }, + { + asset: require('../../../../assets/sounds/cyber_pulse.wav'), + id: 'cyber_pulse.wav', + labelKey: 'settings.soundCyberPulse', + }, + { + asset: require('../../../../assets/sounds/tesla_horn.wav'), + id: 'tesla_horn.wav', + labelKey: 'settings.soundTeslaHorn', + }, + { + asset: require('../../../../assets/sounds/danger_sonar.wav'), + id: 'danger_sonar.wav', + labelKey: 'settings.soundDangerSonar', + }, + { + asset: require('../../../../assets/sounds/klaxon_alarm.wav'), + id: 'klaxon_alarm.wav', + labelKey: 'settings.soundKlaxonAlarm', + }, +] as const; + +export function resolveAlertSound(id?: string): AlertSoundItem { + const found = ALERT_SOUNDS.find((sound) => sound.id === id); + return found ?? ALERT_SOUNDS[0]; +} diff --git a/apps/mobile/src/features/notifications/domain/entities.ts b/apps/mobile/src/features/notifications/domain/entities.ts index c2ee5986..ef8742fa 100644 --- a/apps/mobile/src/features/notifications/domain/entities.ts +++ b/apps/mobile/src/features/notifications/domain/entities.ts @@ -1,4 +1,5 @@ export interface NotificationPreferences { + alert_sound?: string; critical_alerts_enabled: boolean; critical_only: boolean; push_enabled: boolean; diff --git a/apps/mobile/src/features/notifications/infrastructure/push-notification.service.ts b/apps/mobile/src/features/notifications/infrastructure/push-notification.service.ts index 1d1c616b..c5997d1f 100644 --- a/apps/mobile/src/features/notifications/infrastructure/push-notification.service.ts +++ b/apps/mobile/src/features/notifications/infrastructure/push-notification.service.ts @@ -6,6 +6,7 @@ import { Platform } from 'react-native'; import { i18n } from '../../../core/i18n'; import { lightColors } from '../../../core/theme'; +import { ALERT_SOUNDS } from '../domain/alert-sounds'; import { DndPolicyAccessRequirements } from './dnd-policy-access'; export interface PushNotificationServiceRequirements { @@ -122,6 +123,13 @@ export class PushNotificationService implements PushNotificationServiceRequireme } private async configureAndroidChannels(): Promise { + await Promise.all([ + this.configureDefaultAndroidChannels(), + this.configureSoundAndroidChannels(), + ]); + } + + private async configureDefaultAndroidChannels(): Promise { await Promise.all([ Notifications.setNotificationChannelAsync(this.notificationChannelId, { importance: Notifications.AndroidImportance.HIGH, @@ -133,6 +141,34 @@ export class PushNotificationService implements PushNotificationServiceRequireme ]); } + private async configureSoundAndroidChannels(): Promise { + await Promise.all([ + ...this.buildStandardSoundChannels(), + ...this.buildCriticalSoundChannels(), + ]); + } + + private buildStandardSoundChannels(): Promise[] { + return ALERT_SOUNDS.map((sound) => + Notifications.setNotificationChannelAsync(`sentryguard-alerts-${sound.id.replace('.wav', '')}`, { + importance: Notifications.AndroidImportance.HIGH, + lightColor: lightColors.systemGreen, + name: `${i18n.t('notifications.channelName')} (${i18n.t(sound.labelKey)})`, + sound: sound.id, + vibrationPattern: [0, 250, 250, 250], + }) + ); + } + + private buildCriticalSoundChannels(): Promise[] { + return ALERT_SOUNDS.map((sound) => + this.dndPolicyAccess.ensureCriticalNotificationChannel( + `sentryguard-critical-${sound.id.replace('.wav', '')}`, + `${i18n.t('notifications.criticalChannelName')} (${i18n.t(sound.labelKey)})` + ) + ); + } + private async configureCriticalNotificationChannel(): Promise { if (!(await this.dndPolicyAccess.isNotificationPolicyAccessGranted())) { return; @@ -146,11 +182,21 @@ export class PushNotificationService implements PushNotificationServiceRequireme private async resolvePermissionStatus(): Promise { const permissions = await Notifications.getPermissionsAsync(); - const requestedPermissions = permissions.granted - ? permissions - : await Notifications.requestPermissionsAsync(); + const needsCriticalPrompt = Platform.OS === 'ios' && !permissions.ios?.allowsCriticalAlerts; + + if (!permissions.granted || needsCriticalPrompt) { + const requestedPermissions = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: true, + allowBadge: true, + allowSound: true, + allowCriticalAlerts: true, + }, + }); + return requestedPermissions.status; + } - return requestedPermissions.status; + return permissions.status; } private async getExpoPushToken(): Promise { diff --git a/apps/mobile/src/locales/en.json b/apps/mobile/src/locales/en.json index c4031e49..774284a5 100644 --- a/apps/mobile/src/locales/en.json +++ b/apps/mobile/src/locales/en.json @@ -50,10 +50,13 @@ "common.back": "Back", "common.beta": "Beta", "common.cancel": "Cancel", + "common.done": "Done", "common.inactive": "Inactive", "common.loading": "Loading...", "common.notProvided": "Not provided", + "common.play": "Play", "common.protected": "Protected", + "common.stop": "Stop", "common.toConfigure": "To configure", "common.vehicleFallback": "Tesla", "api.error.forbidden": "Access denied. Check consent or beta account status.", @@ -86,6 +89,13 @@ "dashboard.pushBanner.enable": "Enable on this device", "dashboard.pushBanner.dismiss": "Dismiss", "settings.account": "Account", + "settings.alertSound": "Alert Tone", + "settings.alertSoundDescription": "Choose the sound tone used for intrusion and security alerts.", + "settings.soundSentrySiren": "Sentry Siren", + "settings.soundCyberPulse": "Cyber Pulse", + "settings.soundTeslaHorn": "Tesla Horn", + "settings.soundDangerSonar": "Danger Sonar", + "settings.soundKlaxonAlarm": "Klaxon Alarm", "settings.beta": "Beta", "settings.betaFooter": "Beta program member", "settings.criticalOnly": "Critical only", diff --git a/apps/mobile/src/locales/fr.json b/apps/mobile/src/locales/fr.json index 6fbc4ba5..cba0e113 100644 --- a/apps/mobile/src/locales/fr.json +++ b/apps/mobile/src/locales/fr.json @@ -50,10 +50,13 @@ "common.back": "Retour", "common.beta": "Beta", "common.cancel": "Annuler", + "common.done": "Terminé", "common.inactive": "Inactive", "common.loading": "Chargement...", "common.notProvided": "Non renseigné", + "common.play": "Écouter", "common.protected": "Protégé", + "common.stop": "Arrêter", "common.toConfigure": "À configurer", "common.vehicleFallback": "Tesla", "api.error.forbidden": "Accès refusé. Vérifie le consentement ou le statut beta du compte.", @@ -86,6 +89,13 @@ "dashboard.pushBanner.enable": "Activer sur cet appareil", "dashboard.pushBanner.dismiss": "Ignorer", "settings.account": "Compte", + "settings.alertSound": "Sonnerie d'alerte", + "settings.alertSoundDescription": "Choisissez la tonalité utilisée pour les notifications et alertes d'intrusion.", + "settings.soundSentrySiren": "Sirène Sentry", + "settings.soundCyberPulse": "Impulsion Cyber", + "settings.soundTeslaHorn": "Klaxon Tesla", + "settings.soundDangerSonar": "Sonar Danger", + "settings.soundKlaxonAlarm": "Alarme Klaxon", "settings.beta": "Beta", "settings.betaFooter": "Membre du programme bêta", "settings.criticalOnly": "Critiques uniquement", diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 39dd8e70..a9ec1a03 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -12,6 +12,7 @@ import { ThemeMode, useTheme } from '../core/theme'; import { AppSwitch, AppText, GlassButton, GlassButtonVariant, ListRow, ListSection, SegmentedControl, Surface } from '../core/ui'; import { UserLanguage } from '../features/user/domain/entities'; import { resolveTelegramStatusKey } from './telegram-settings/telegram-settings.helpers'; +import { resolveAlertSound } from '../features/notifications/domain/alert-sounds'; import { clearDebugLogs, openAndroidDoNotDisturbAccessSettings, @@ -28,6 +29,7 @@ import { resolveSupportEmail, shareDebugLogs, } from './settings/settings.helpers'; +import { SoundSelectorModal } from './settings/SoundSelectorModal'; import { useSettings } from './settings/use-settings'; interface SettingsScreenProps { @@ -40,6 +42,7 @@ export function SettingsScreen({ onLogout }: SettingsScreenProps): JSX.Element { const topInset = useScreenTopInset(); const { isDndAccessModalOpen, + isSoundModalOpen, isTelegramLinked, languageMutation, languageQuery, @@ -49,9 +52,11 @@ export function SettingsScreen({ onLogout }: SettingsScreenProps): JSX.Element { preferencesQuery, profile, setIsDndAccessModalOpen, + setIsSoundModalOpen, updatePreference, } = useSettings(); + const selectedSound = resolveAlertSound(preferences.alert_sound); const isBusy = preferencesMutation.isPending; const language = languageQuery.data?.language ?? UserLanguage.French; const faqUrl = resolveFaqUrl(); @@ -124,6 +129,28 @@ export function SettingsScreen({ onLogout }: SettingsScreenProps): JSX.Element { title={t('settings.push')} accessory={ void updatePreference({ push_enabled: value })} />} /> + {preferences.push_enabled ? ( + <> + void updatePreference({ critical_alerts_enabled: value })} + /> + } + /> + setIsSoundModalOpen(true)} + /> + + ) : null} @@ -223,6 +250,15 @@ export function SettingsScreen({ onLogout }: SettingsScreenProps): JSX.Element { + + setIsSoundModalOpen(false)} + selectedSoundId={preferences.alert_sound} + onSelectSound={(soundId) => { + void updatePreference({ alert_sound: soundId }); + }} + /> ); } diff --git a/apps/mobile/src/screens/settings/SoundSelectorModal.tsx b/apps/mobile/src/screens/settings/SoundSelectorModal.tsx new file mode 100644 index 00000000..b6c40017 --- /dev/null +++ b/apps/mobile/src/screens/settings/SoundSelectorModal.tsx @@ -0,0 +1,167 @@ +import { Modal, Pressable, StyleSheet, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { radius, spacing } from '../../core/design/metrics'; +import { TextVariant } from '../../core/design/typography'; +import { useTheme } from '../../core/theme'; +import { AppText, GlassButton, GlassButtonVariant, Icon, Surface } from '../../core/ui'; +import { ALERT_SOUNDS, AlertSoundItem, DEFAULT_ALERT_SOUND_ID } from '../../features/notifications/domain/alert-sounds'; +import { useSoundPlayer } from '../../core/hooks/useSoundPlayer'; + +interface SoundSelectorModalProps { + isOpen: boolean; + onClose: () => void; + onSelectSound: (soundId: string) => void; + selectedSoundId?: string; +} + +interface SoundItemRowProps { + isSelected: boolean; + isPlaying: boolean; + onPlay: () => void; + onSelect: () => void; + sound: AlertSoundItem; +} + +function SoundItemRow({ isSelected, isPlaying, onPlay, onSelect, sound }: SoundItemRowProps) { + const { colors } = useTheme(); + const { t } = useTranslation(); + + return ( + + + + + {t(sound.labelKey)} + + + + + + + + ); +} + +export function SoundSelectorModal({ + isOpen, + onClose, + onSelectSound, + selectedSoundId = DEFAULT_ALERT_SOUND_ID, +}: SoundSelectorModalProps) { + const { colors } = useTheme(); + const { t } = useTranslation(); + const { isPlaying, play, stop } = useSoundPlayer(); + + const handleClose = () => { + stop(); + onClose(); + }; + + const handleSelect = (soundId: string) => { + onSelectSound(soundId); + }; + + const handleTogglePlayback = (sound: AlertSoundItem) => { + if (isPlaying(sound.id)) { + stop(); + return; + } + play(sound.id, sound.asset); + }; + + return ( + + + + + {t('settings.alertSound')} + + {t('settings.alertSoundDescription')} + + + + + {ALERT_SOUNDS.map((sound) => ( + handleSelect(sound.id)} + onPlay={() => handleTogglePlayback(sound)} + /> + ))} + + + + + + + ); +} + +const styles = StyleSheet.create({ + header: { + gap: spacing.xs, + }, + modalBackdrop: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: spacing.xxl, + }, + modalCard: { + gap: spacing.lg, + maxWidth: 380, + width: '100%', + }, + playButton: { + alignItems: 'center', + borderRadius: 16, + height: 32, + justifyContent: 'center', + width: 32, + }, + soundInfo: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.sm, + }, + soundList: { + gap: spacing.sm, + }, + soundRow: { + alignItems: 'center', + borderRadius: radius.control, + borderWidth: 1, + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + }, +}); diff --git a/apps/mobile/src/screens/settings/settings.helpers.ts b/apps/mobile/src/screens/settings/settings.helpers.ts index 07cbf1c4..994ac57e 100644 --- a/apps/mobile/src/screens/settings/settings.helpers.ts +++ b/apps/mobile/src/screens/settings/settings.helpers.ts @@ -10,6 +10,7 @@ import { dndPolicyAccess, pushNotificationService, registerPushTokenUseCase } fr import { NotificationPreferences } from '../../features/notifications/domain/entities'; export const defaultPreferences: NotificationPreferences = { + alert_sound: 'sentry_siren.wav', critical_alerts_enabled: false, critical_only: false, push_enabled: false, diff --git a/apps/mobile/src/screens/settings/use-settings.ts b/apps/mobile/src/screens/settings/use-settings.ts index a3b8a90b..15f0d548 100644 --- a/apps/mobile/src/screens/settings/use-settings.ts +++ b/apps/mobile/src/screens/settings/use-settings.ts @@ -31,6 +31,7 @@ export function useSettings() { const { i18n, t } = useTranslation(); const [preferenceMessage, setPreferenceMessage] = useState(null); const [isDndAccessModalOpen, setIsDndAccessModalOpen] = useState(false); + const [isSoundModalOpen, setIsSoundModalOpen] = useState(false); const { isTokenResolved, pushToken, setPushToken } = usePushToken(); useTelegramStatusSync(); const hasRegisteredPushToken = useRef(false); @@ -162,6 +163,7 @@ export function useSettings() { return { isDndAccessModalOpen, + isSoundModalOpen, isTelegramLinked: telegramStatusQuery.data?.linked === true, languageMutation, languageQuery, @@ -171,6 +173,7 @@ export function useSettings() { preferencesQuery, profile: profileQuery.data?.profile, setIsDndAccessModalOpen, + setIsSoundModalOpen, updatePreference, }; } diff --git a/apps/mobile/src/testing/file-mock.js b/apps/mobile/src/testing/file-mock.js new file mode 100644 index 00000000..bd816eab --- /dev/null +++ b/apps/mobile/src/testing/file-mock.js @@ -0,0 +1 @@ +module.exports = 1; diff --git a/nx.json b/nx.json index 906d84c6..e09550cf 100644 --- a/nx.json +++ b/nx.json @@ -113,5 +113,6 @@ }, "sync": { "applyChanges": true - } + }, + "analytics": false } diff --git a/yarn.lock b/yarn.lock index 477f397f..8f67be49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9145,6 +9145,8 @@ __metadata: babel-preset-expo: "npm:~54.0.7" enhanced-resolve: "npm:*" expo: "npm:~54.0.35" + expo-asset: "npm:~12.0.13" + expo-audio: "npm:~1.1.1" expo-blur: "npm:~15.0.8" expo-constants: "npm:~18.0.13" expo-device: "npm:~8.0.10" @@ -15586,6 +15588,18 @@ __metadata: languageName: node linkType: hard +"expo-audio@npm:~1.1.1": + version: 1.1.1 + resolution: "expo-audio@npm:1.1.1" + peerDependencies: + expo: "*" + expo-asset: "*" + react: "*" + react-native: "*" + checksum: 10c0/3ec687ca239b816097336808c9bd69995149166faf8dd74cb5fe732878843436178cbd1d901642779148df397562ffd36d207d217c801f16c300ab7df24d2208 + languageName: node + linkType: hard + "expo-blur@npm:~15.0.8": version: 15.0.8 resolution: "expo-blur@npm:15.0.8"