diff --git a/docs/publish.md b/docs/publish.md index 9ac600e9..355e99c1 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -492,6 +492,11 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
Detail view of priority notifications
+On **iOS**, max priority (`5`) messages are delivered as [critical alerts](https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/critical), +which break through silent mode and Do Not Disturb and play a sound at full volume. You must grant ntfy +permission to send critical alerts (the app asks for it on first launch; it can also be toggled in +**iOS Settings > Notifications > ntfy**). + ## Tags & emojis 🥳 🎉 _Supported on:_ :material-android: :material-apple: :material-firefox: diff --git a/docs/releases.md b/docs/releases.md index 5af903d7..a446ba81 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1948,6 +1948,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.25.0 (UNRELEASED) + +**Features:** + +* Send priority 5 (max/urgent) messages as iOS critical alerts (APNs critical sound + `interruption-level`), so they bypass silent mode and Do Not Disturb ([ntfy-ios#44](https://github.com/binwiederhier/ntfy-ios/pull/44), thanks to [@am7590](https://github.com/am7590) for the iOS app contribution) + ### ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out diff --git a/server/server_firebase.go b/server/server_firebase.go index 0f1bb9b1..73c1f88e 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -241,16 +241,29 @@ func createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging. for k, v := range data { apnsData[k] = v } + aps := &messaging.Aps{ + MutableContent: true, + Alert: &messaging.ApsAlert{ + Title: m.Title, + Body: maybeTruncateAPNSBodyMessage(m.Message), + }, + } + headers := map[string]string{"apns-push-type": "alert"} + + // Critical alerts (iOS): max priority messages bypass silent mode / Do Not Disturb. The iOS + // Notification Service Extension re-applies the critical sound based on priority, but we also + // flag the raw payload as critical (sound dict + interruption-level), so it stays critical even + // if the NSE never runs (e.g. when it exceeds its time budget or is dropped under memory pressure). + if m.Priority >= 5 { + aps.CriticalSound = &messaging.CriticalSound{Critical: true, Name: "default", Volume: 1.0} + aps.CustomData = map[string]any{"interruption-level": "critical"} + headers["apns-priority"] = "10" + } return &messaging.APNSConfig{ + Headers: headers, Payload: &messaging.APNSPayload{ CustomData: apnsData, - Aps: &messaging.Aps{ - MutableContent: true, - Alert: &messaging.ApsAlert{ - Title: m.Title, - Body: maybeTruncateAPNSBodyMessage(m.Message), - }, - }, + Aps: aps, }, } } diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index 40509fb9..1bd7526e 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -165,6 +165,9 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) { Priority: "high", }, fbm.Android) require.Equal(t, &messaging.APNSConfig{ + Headers: map[string]string{ + "apns-push-type": "alert", + }, Payload: &messaging.APNSPayload{ Aps: &messaging.Aps{ MutableContent: true, @@ -248,6 +251,42 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) { }, fbm.Data) require.Equal(t, "", fbm.APNS.Payload.Aps.Alert.Title) require.Equal(t, "New message", fbm.APNS.Payload.Aps.Alert.Body) + // Priority is kept when downgrading to a poll request (see toPollRequest), so a priority 5 + // message still wakes iOS as a critical alert to poll for the message. + require.Equal(t, "10", fbm.APNS.Headers["apns-priority"]) + require.True(t, fbm.APNS.Payload.Aps.CriticalSound.Critical) + require.Equal(t, "critical", fbm.APNS.Payload.Aps.CustomData["interruption-level"]) +} + +func TestToFirebaseMessage_Message_Critical(t *testing.T) { + m := model.NewDefaultMessage("mytopic", "this is urgent") + m.Priority = 5 + m.Title = "wake up" + fbm, err := toFirebaseMessage(m, &testAuther{Allow: true}) + require.Nil(t, err) + + // Critical alerts use apns-priority 10, a critical sound dict, and interruption-level "critical" + // so the iOS device treats the message as critical (bypassing silent mode / Do Not Disturb). + require.Equal(t, "alert", fbm.APNS.Headers["apns-push-type"]) + require.Equal(t, "10", fbm.APNS.Headers["apns-priority"]) + require.NotNil(t, fbm.APNS.Payload.Aps.CriticalSound) + require.True(t, fbm.APNS.Payload.Aps.CriticalSound.Critical) + require.Equal(t, "default", fbm.APNS.Payload.Aps.CriticalSound.Name) + require.Equal(t, 1.0, fbm.APNS.Payload.Aps.CriticalSound.Volume) + require.Equal(t, "critical", fbm.APNS.Payload.Aps.CustomData["interruption-level"]) +} + +func TestToFirebaseMessage_Message_NotCritical(t *testing.T) { + m := model.NewDefaultMessage("mytopic", "this is normal") + m.Priority = 4 + fbm, err := toFirebaseMessage(m, &testAuther{Allow: true}) + require.Nil(t, err) + + // Priority < 5 is a regular alert: no critical sound and no interruption-level. + require.Equal(t, "alert", fbm.APNS.Headers["apns-push-type"]) + require.Empty(t, fbm.APNS.Headers["apns-priority"]) + require.Nil(t, fbm.APNS.Payload.Aps.CriticalSound) + require.Nil(t, fbm.APNS.Payload.Aps.CustomData) } func TestToFirebaseMessage_PollRequest(t *testing.T) { @@ -257,6 +296,9 @@ func TestToFirebaseMessage_PollRequest(t *testing.T) { require.Equal(t, "mytopic", fbm.Topic) require.Nil(t, fbm.Android) require.Equal(t, &messaging.APNSConfig{ + Headers: map[string]string{ + "apns-push-type": "alert", + }, Payload: &messaging.APNSPayload{ Aps: &messaging.Aps{ MutableContent: true,