Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@
"expo-notifications",
{
"color": "#1a5fa8",
"defaultChannel": "shift-alerts"
"defaultChannel": "shift-alerts-v2",
"sounds": ["./assets/sounds/shift_alarm.wav"]
}
],
[
Expand Down
Binary file added packages/mobile/assets/sounds/shift_alarm.wav
Binary file not shown.
40 changes: 37 additions & 3 deletions packages/mobile/src/lib/shift-alert-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -83,11 +97,29 @@ async function cancelTrackedNotifications(): Promise<void> {
*/
export async function ensureShiftAlertChannel(): Promise<void> {
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}`,
Expand Down Expand Up @@ -192,7 +224,9 @@ export async function fireDevTestShiftAlert(): Promise<string | null> {
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
},
Expand Down