Compare commits

...
Author SHA1 Message Date
binwiederhier 3e8787e70e WIP: Critical alerts 2026-06-08 20:22:50 -04:00
4 changed files with 73 additions and 7 deletions
+5
View File
@@ -492,6 +492,11 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
<figcaption>Detail view of priority notifications</figcaption> <figcaption>Detail view of priority notifications</figcaption>
</figure> </figure>
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 🥳 🎉 ## Tags & emojis 🥳 🎉
_Supported on:_ :material-android: :material-apple: :material-firefox: _Supported on:_ :material-android: :material-apple: :material-firefox:
+6
View File
@@ -1948,6 +1948,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## 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) ### 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 This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
+20 -7
View File
@@ -241,16 +241,29 @@ func createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging.
for k, v := range data { for k, v := range data {
apnsData[k] = v 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{ return &messaging.APNSConfig{
Headers: headers,
Payload: &messaging.APNSPayload{ Payload: &messaging.APNSPayload{
CustomData: apnsData, CustomData: apnsData,
Aps: &messaging.Aps{ Aps: aps,
MutableContent: true,
Alert: &messaging.ApsAlert{
Title: m.Title,
Body: maybeTruncateAPNSBodyMessage(m.Message),
},
},
}, },
} }
} }
+42
View File
@@ -165,6 +165,9 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
Priority: "high", Priority: "high",
}, fbm.Android) }, fbm.Android)
require.Equal(t, &messaging.APNSConfig{ require.Equal(t, &messaging.APNSConfig{
Headers: map[string]string{
"apns-push-type": "alert",
},
Payload: &messaging.APNSPayload{ Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{ Aps: &messaging.Aps{
MutableContent: true, MutableContent: true,
@@ -248,6 +251,42 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
}, fbm.Data) }, fbm.Data)
require.Equal(t, "", fbm.APNS.Payload.Aps.Alert.Title) require.Equal(t, "", fbm.APNS.Payload.Aps.Alert.Title)
require.Equal(t, "New message", fbm.APNS.Payload.Aps.Alert.Body) 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) { func TestToFirebaseMessage_PollRequest(t *testing.T) {
@@ -257,6 +296,9 @@ func TestToFirebaseMessage_PollRequest(t *testing.T) {
require.Equal(t, "mytopic", fbm.Topic) require.Equal(t, "mytopic", fbm.Topic)
require.Nil(t, fbm.Android) require.Nil(t, fbm.Android)
require.Equal(t, &messaging.APNSConfig{ require.Equal(t, &messaging.APNSConfig{
Headers: map[string]string{
"apns-push-type": "alert",
},
Payload: &messaging.APNSPayload{ Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{ Aps: &messaging.Aps{
MutableContent: true, MutableContent: true,