diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 36009b2b..074e43bf 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -89,7 +89,15 @@ timestamp are silently skipped. - **Background / locked / app suspended:** the OS-scheduled local notification fires with `vibrate: [0, 500, 200, 500, 200, 500]`, the - `shift-alerts` Android channel (MAX importance), and the default sound. + `shift-alerts-v2` Android channel (MAX importance), and the bundled + `shift_alarm.wav` chime: a soft alarm that rings for ~28 seconds (iOS caps + notification sounds at 30s). On Android the channel routes the sound + through the ALARM audio stream, so it plays at alarm volume even when + notification volume is low. Channels are immutable after creation, which + is why the sound change shipped as a `-v2` channel; the old `shift-alerts` + channel is deleted on the next `ensureShiftAlertChannel()` run. The custom + sound requires a real build (the expo-notifications config plugin bundles + it); Expo Go falls back to the default notification sound. - **Foreground:** scheduled notifications can be suppressed by the runtime, so the dashboard also runs a 5-second interval that fires `Haptics.notificationAsync(Warning)` when any assignment lands in the diff --git a/packages/mobile/app.json b/packages/mobile/app.json index c0b667b0..a0ca02e3 100644 --- a/packages/mobile/app.json +++ b/packages/mobile/app.json @@ -102,7 +102,8 @@ "expo-notifications", { "color": "#1a5fa8", - "defaultChannel": "shift-alerts" + "defaultChannel": "shift-alerts-v2", + "sounds": ["./assets/sounds/shift_alarm.wav"] } ], [ diff --git a/packages/mobile/assets/sounds/shift_alarm.wav b/packages/mobile/assets/sounds/shift_alarm.wav new file mode 100644 index 00000000..565d87fe Binary files /dev/null and b/packages/mobile/assets/sounds/shift_alarm.wav differ diff --git a/packages/mobile/src/lib/shift-alert-scheduler.ts b/packages/mobile/src/lib/shift-alert-scheduler.ts index e6d5e706..7c915d6b 100644 --- a/packages/mobile/src/lib/shift-alert-scheduler.ts +++ b/packages/mobile/src/lib/shift-alert-scheduler.ts @@ -18,7 +18,21 @@ import { Platform } from 'react-native'; */ const SCHEDULED_IDS_KEY = 'rayhealth_scheduled_shift_alerts_v1'; -export const SHIFT_ALERT_CHANNEL_ID = 'shift-alerts'; +// v2: Android channels are immutable after creation, so switching the sound +// from the default ping to the bundled alarm chime (and moving playback onto +// the ALARM audio stream) requires a new channel ID. The v1 channel is +// deleted in ensureShiftAlertChannel so users don't see a stale duplicate +// under the app's notification settings. +export const SHIFT_ALERT_CHANNEL_ID = 'shift-alerts-v2'; +const LEGACY_CHANNEL_IDS = ['shift-alerts']; +/** + * Bundled via the expo-notifications config plugin ("sounds" in app.json). + * A soft 28 second chime: iOS caps notification sounds at 30s, so this rings + * for roughly the whole lead-in window instead of a single ping. In Expo Go + * (where the config plugin doesn't run) the OS silently falls back to the + * default notification sound. + */ +export const SHIFT_ALARM_SOUND = 'shift_alarm.wav'; /** Lead time before the shift's start at which the notification fires. */ const LEAD_TIME_MS = 30 * 1000; @@ -83,11 +97,29 @@ async function cancelTrackedNotifications(): Promise { */ export async function ensureShiftAlertChannel(): Promise { if (Platform.OS !== 'android') return; + // Remove superseded channels first so the app's notification settings only + // list the live one. Deleting a nonexistent channel is a no-op. + await Promise.all( + LEGACY_CHANNEL_IDS.map(async (id) => { + try { + await Notifications.deleteNotificationChannelAsync(id); + } catch { + // Best effort: the alert still works if a stale channel lingers. + } + }) + ); await Notifications.setNotificationChannelAsync(SHIFT_ALERT_CHANNEL_ID, { name: 'Shift alerts', importance: Notifications.AndroidImportance.MAX, vibrationPattern: VIBRATION_PATTERN, - sound: 'default', + sound: SHIFT_ALARM_SOUND, + // Play on the ALARM stream, not NOTIFICATION: the chime rings at the + // user's alarm volume (like their morning alarm) even when notification + // volume is turned down. Silent/DND behavior still follows OS rules. + audioAttributes: { + usage: Notifications.AndroidAudioUsage.ALARM, + contentType: Notifications.AndroidAudioContentType.SONIFICATION + }, enableVibrate: true, enableLights: true, // PRIVATE, not PUBLIC: the notification body is `${clientName} ยท ${code}`, @@ -192,7 +224,9 @@ export async function fireDevTestShiftAlert(): Promise { scheduledTime: new Date(Date.now() + 35_000).toISOString(), serviceCode: 'TEST' }, - sound: 'default', + // iOS reads the sound from the content; Android 8+ takes it from the + // channel, so this only matters on iOS (and pre-8 Android). + sound: SHIFT_ALARM_SOUND, vibrate: VIBRATION_PATTERN, priority: Notifications.AndroidNotificationPriority.MAX },