From af76a2606d8e72810a761b468db8537baf0ce04d Mon Sep 17 00:00:00 2001
From: Philipp Heckel <philipp.heckel@gmail.com>
Date: Wed, 25 May 2022 21:39:46 -0400
Subject: [PATCH] Support for Firebase ~poll keepalive topic that wakes up iOS
 devices every 20 minutes

---
 docs/releases.md          |  4 ++++
 server/config.go          |  5 ++++-
 server/server.go          |  8 +++++++-
 server/server_firebase.go | 18 ++++++++++++++++++
 4 files changed, 33 insertions(+), 2 deletions(-)

diff --git a/docs/releases.md b/docs/releases.md
index 19db7a01..29c3deb9 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -38,6 +38,10 @@ some known issues, which will be addressed in follow-up releases).
 
 ## ntfy server v1.24.0 (UNRELEASED)
 
+**Features:**
+
+* Regularly send Firebase keepalive messages to ~poll topic to support self-hosted servers (no ticket)
+
 **Bugs:**
 
 * Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall))
diff --git a/server/config.go b/server/config.go
index ea34c6af..d36d5c66 100644
--- a/server/config.go
+++ b/server/config.go
@@ -13,7 +13,8 @@ const (
 	DefaultAtSenderInterval          = 10 * time.Second
 	DefaultMinDelay                  = 10 * time.Second
 	DefaultMaxDelay                  = 3 * 24 * time.Hour
-	DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
+	DefaultFirebaseKeepaliveInterval = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery
+	DefaultFirebasePollInterval      = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
 )
 
 // Defines all global and per-visitor limits
@@ -67,6 +68,7 @@ type Config struct {
 	WebRootIsApp                         bool
 	AtSenderInterval                     time.Duration
 	FirebaseKeepaliveInterval            time.Duration
+	FirebasePollInterval                 time.Duration
 	SMTPSenderAddr                       string
 	SMTPSenderUser                       string
 	SMTPSenderPass                       string
@@ -117,6 +119,7 @@ func NewConfig() *Config {
 		MaxDelay:                             DefaultMaxDelay,
 		AtSenderInterval:                     DefaultAtSenderInterval,
 		FirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval,
+		FirebasePollInterval:                 DefaultFirebasePollInterval,
 		TotalTopicLimit:                      DefaultTotalTopicLimit,
 		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit,
 		VisitorAttachmentTotalSizeLimit:      DefaultVisitorAttachmentTotalSizeLimit,
diff --git a/server/server.go b/server/server.go
index 1a643c23..55562d37 100644
--- a/server/server.go
+++ b/server/server.go
@@ -91,6 +91,7 @@ var (
 
 const (
 	firebaseControlTopic     = "~control"                // See Android if changed
+	firebasePollTopic        = "~poll"                   // See iOS if changed
 	emptyMessageBody         = "triggered"               // Used if message body is empty
 	defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
 	encodingBase64           = "base64"
@@ -1074,7 +1075,12 @@ func (s *Server) runFirebaseKeepaliver() {
 		select {
 		case <-time.After(s.config.FirebaseKeepaliveInterval):
 			if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
-				log.Printf("error sending Firebase keepalive message: %s", err.Error())
+				log.Printf("error sending Firebase keepalive message to %s: %s", firebaseControlTopic, err.Error())
+			}
+		case <-time.After(s.config.FirebasePollInterval):
+			log.Printf("Sending to timer topic %s", firebasePollTopic)
+			if err := s.firebase(newKeepaliveMessage(firebasePollTopic)); err != nil {
+				log.Printf("error sending Firebase keepalive message to %s: %s", firebasePollTopic, err.Error())
 			}
 		case <-s.closeChan:
 			return
diff --git a/server/server_firebase.go b/server/server_firebase.go
index 4bcbfd27..ad0da0e2 100644
--- a/server/server_firebase.go
+++ b/server/server_firebase.go
@@ -80,6 +80,24 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
 			"event": m.Event,
 			"topic": m.Topic,
 		}
+		// Silent notification; only 2-3 per hour are allowed; delivery not guaranteed
+		// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
+		apnsData := make(map[string]interface{})
+		for k, v := range data {
+			apnsData[k] = v
+		}
+		apnsConfig = &messaging.APNSConfig{
+			Headers: map[string]string{
+				"apns-push-type": "background",
+				"apns-priority":  "5",
+			},
+			Payload: &messaging.APNSPayload{
+				Aps: &messaging.Aps{
+					ContentAvailable: true,
+				},
+				CustomData: apnsData,
+			},
+		}
 	case messageEvent:
 		allowForward := true
 		if auther != nil {