Philipp Heckel 2 лет назад
Родитель
Сommit
dd61467f6d

+ 6 - 0
ntfy.xcodeproj/project.pbxproj

@@ -35,6 +35,8 @@
 		94867143283EC9960093C7A4 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94867142283EC9950093C7A4 /* Actions.swift */; };
 		94867144283ECD370093C7A4 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94867142283EC9950093C7A4 /* Actions.swift */; };
 		94867145284058C60093C7A4 /* ApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F20E283326C500CDE4DD /* ApiService.swift */; };
+		948671472841B0B20093C7A4 /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948671462841B0B20093C7A4 /* NotificationContent.swift */; };
+		948671482841B1430093C7A4 /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948671462841B0B20093C7A4 /* NotificationContent.swift */; };
 		94A3F7C8283734D900C48E79 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A3F7C7283734D900C48E79 /* SubscriptionManager.swift */; };
 		94A3F7CA28386B2100C48E79 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A3F7C928386B2100C48E79 /* Config.swift */; };
 		94A3F7CB28386B2100C48E79 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A3F7C928386B2100C48E79 /* Config.swift */; };
@@ -95,6 +97,7 @@
 		9474F211283327C200CDE4DD /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = "<group>"; };
 		9474F216283531A200CDE4DD /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
 		94867142283EC9950093C7A4 /* Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Actions.swift; sourceTree = "<group>"; };
+		948671462841B0B20093C7A4 /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = "<group>"; };
 		94A3F7C7283734D900C48E79 /* SubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = "<group>"; };
 		94A3F7C928386B2100C48E79 /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
 		94CD1965283E662900973B93 /* emojis.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = emojis.json; sourceTree = "<group>"; };
@@ -220,6 +223,7 @@
 				94A3F7C928386B2100C48E79 /* Config.swift */,
 				9474F216283531A200CDE4DD /* Log.swift */,
 				94867142283EC9950093C7A4 /* Actions.swift */,
+				948671462841B0B20093C7A4 /* NotificationContent.swift */,
 			);
 			path = Utils;
 			sourceTree = "<group>";
@@ -345,6 +349,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				02024E60283D7CBB0064224A /* Extensions.swift in Sources */,
+				948671472841B0B20093C7A4 /* NotificationContent.swift in Sources */,
 				9474F1F92830835400CDE4DD /* Store.swift in Sources */,
 				9474F212283327C200CDE4DD /* Helpers.swift in Sources */,
 				9474F217283531A300CDE4DD /* Log.swift in Sources */,
@@ -379,6 +384,7 @@
 				9474F2052831D51500CDE4DD /* Store.swift in Sources */,
 				9474F2062831D73C00CDE4DD /* ntfy.xcdatamodeld in Sources */,
 				94A3F7CB28386B2100C48E79 /* Config.swift in Sources */,
+				948671482841B1430093C7A4 /* NotificationContent.swift in Sources */,
 				9474F2142834755E00CDE4DD /* Subscription.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;

+ 11 - 21
ntfy/App/AppDelegate.swift

@@ -8,7 +8,7 @@ import CoreData
 
 class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
     private let tag = "AppDelegate"
-    private let pollTimerTopic = "~poll" // See ntfy server if ever changed
+    private let pollTopic = "~poll" // See ntfy server if ever changed
     
     // Implements navigation from notifications, see https://stackoverflow.com/a/70731861/1440785
     @Published var selectedBaseUrl: String? = nil
@@ -32,20 +32,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
         // Set self as messaging delegate
         Messaging.messaging().delegate = self
         
-        // Register to timerkeeper topic
-        Messaging.messaging().subscribe(toTopic: pollTimerTopic)
+        // Register to "~poll" topic
+        Messaging.messaging().subscribe(toTopic: pollTopic)
         
         return true
     }
     
-    /// Executed when a background notification arrives. This is used to trigger polling of local topics.
+    /// Executed when a background notification arrives on the "~poll" topic. This is used to trigger polling of local topics.
     /// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
     func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
         Log.d(tag, "Background notification received", userInfo)
         
         // Exit out early if this message is not expected
         let topic = userInfo["topic"] as? String ?? ""
-        if topic != pollTimerTopic {
+        if topic != pollTopic {
             completionHandler(.noData)
             return
         }
@@ -76,19 +76,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
     /// Create a local notification manually (as opposed to a remote notification being generated by Firebase). We need to make the
     /// local notification look exactly like the remote one (same userInfo), so that when we tap it, the userNotificationCenter(didReceive) function
     /// has the same information available.
-    func showNotification(_ subscription: Subscription, _ message: Message) {
-        var userInfo = message.toUserInfo()
-        userInfo["base_url"] = subscription.baseUrl
-        userInfo["topic"] = subscription.topic
-        
+    private func showNotification(_ subscription: Subscription, _ message: Message) {
         let content = UNMutableNotificationContent()
-        content.title = message.title ?? ""
-        content.body = message.message ?? "" // FIXME: This needs to be truncated!
-        content.sound = .default
-        content.userInfo = userInfo
-        
-        // FIXME: Use logic in NotificationService here to build the same message
-        
+        content.modify(message: message, baseUrl: subscription.baseUrl ?? "?")
+    
         let request = UNNotificationRequest(identifier: message.id, content: content, trigger: nil /* now */)
         UNUserNotificationCenter.current().add(request) { (error) in
             if let error = error {
@@ -125,18 +116,17 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
         }
         
         let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl
-        let topic = userInfo["topic"] as? String ?? ""
         let action = message.actions?.first { $0.id == response.actionIdentifier }
 
         // Show current topic
-        if topic != "" {
-            selectedBaseUrl = topicUrl(baseUrl: baseUrl, topic: topic)
+        if message.topic != "" {
+            selectedBaseUrl = topicUrl(baseUrl: baseUrl, topic: message.topic)
         }
         
         // Execute user action or click action (if any)
         if let action = action {
             handle(action: action)
-        } else if let click = message.click, let url = URL(string: click) {
+        } else if let click = message.click, click != "", let url = URL(string: click) {
             open(url: url)
         }
     

+ 5 - 1
ntfy/Persistence/Notification.swift

@@ -65,6 +65,7 @@ struct Message: Decodable {
     var id: String
     var time: Int64
     var event: String
+    var topic: String
     var message: String?
     var title: String?
     var priority: Int16?
@@ -79,8 +80,9 @@ struct Message: Decodable {
         
         return [
             "id": id,
-            "event": event,
             "time": String(time),
+            "event": event,
+            "topic": topic,
             "message": message ?? "",
             "title": title ?? "",
             "priority": String(priority ?? 3),
@@ -95,6 +97,7 @@ struct Message: Decodable {
         guard let id = userInfo["id"] as? String,
               let time = userInfo["time"] as? String,
               let event = userInfo["event"] as? String,
+              let topic = userInfo["topic"] as? String,
               let timeInt = Int64(time),
               let message = userInfo["message"] as? String else {
             Log.d(Store.tag, "Unknown or irrelevant message", userInfo)
@@ -110,6 +113,7 @@ struct Message: Decodable {
             id: id,
             time: timeInt,
             event: event,
+            topic: topic,
             message: message,
             title: title,
             priority: priority,

+ 3 - 3
ntfy/Persistence/Store.swift

@@ -156,9 +156,9 @@ extension Store {
     static let sampleData = [
         "stats": [
             // TODO: Message with action
-            Message(id: "1", time: 1653048956, event: "message", message: "In the last 24 hours, hyou had 5,000 users across 13 countries visit your website", title: "Record visitor numbers", priority: 4, tags: ["smile", "server123", "de"], actions: nil),
-            Message(id: "2", time: 1653058956, event: "message", message: "201 users/h\n80 IPs", title: "This is a title", priority: 1, tags: [], actions: nil),
-            Message(id: "3", time: 1643058956, event: "message", message: "This message does not have a title, but is instead super long. Like really really long. It can't be any longer I think. I mean, there is s 4,000 byte limit of the message, so I guess I have to make this 4,000 bytes long. Or do I? 😁 I don't know. It's quite tedious to come up with something so long, so I'll stop now. Bye!", title: nil, priority: 5, tags: ["facepalm"], actions: nil)
+            Message(id: "1", time: 1653048956, event: "message", topic: "stats", message: "In the last 24 hours, hyou had 5,000 users across 13 countries visit your website", title: "Record visitor numbers", priority: 4, tags: ["smile", "server123", "de"], actions: nil),
+            Message(id: "2", time: 1653058956, event: "message", topic: "stats", message: "201 users/h\n80 IPs", title: "This is a title", priority: 1, tags: [], actions: nil),
+            Message(id: "3", time: 1643058956, event: "message", topic: "stats", message: "This message does not have a title, but is instead super long. Like really really long. It can't be any longer I think. I mean, there is s 4,000 byte limit of the message, so I guess I have to make this 4,000 bytes long. Or do I? 😁 I don't know. It's quite tedious to come up with something so long, so I'll stop now. Bye!", title: nil, priority: 5, tags: ["facepalm"], actions: nil)
         ],
         "backups": [],
         "announcements": [],

+ 1 - 0
ntfy/Utils/Helpers.swift

@@ -41,3 +41,4 @@ func parseNonEmojiTags(_ tags: String?) -> [String] {
     return parseAllTags(tags)
         .filter { EmojiManager.shared.getEmojiByAlias(alias: $0) == nil }
 }
+

+ 78 - 0
ntfy/Utils/NotificationContent.swift

@@ -0,0 +1,78 @@
+import Foundation
+import UserNotifications
+
+private let actionsCategory = "ntfyActions" // It seems ok to re-use the same category
+
+extension UNMutableNotificationContent {
+    func modify(message: Message, baseUrl: String) {
+        // Body and title
+        if let body = message.message {
+            self.body = body
+        }
+        
+        // Set notification title to short URL if there is no title. The title is always set
+        // by the server, but it may be empty.
+        if let title = message.title, title != "" {
+            self.title = title
+        } else {
+            self.title = topicShortUrl(baseUrl: baseUrl, topic: message.topic)
+        }
+        
+        // Emojify title or message
+        let emojiTags = parseEmojiTags(message.tags)
+        if !emojiTags.isEmpty {
+            if let title = message.title, title != "" {
+                self.title = emojiTags.joined(separator: "") + " " + self.title
+            } else {
+                self.body = emojiTags.joined(separator: "") + " " + self.body
+            }
+        }
+        
+        // Add custom actions
+        //
+        // We re-define the categories every time here, which is weird, but it works. When tapped, the action sets the
+        // actionIdentifier in the application(didReceive) callback. This logic is handled in the AppDelegate. This approach
+        // is described in a comment in https://stackoverflow.com/questions/30103867/changing-action-titles-in-interactive-notifications-at-run-time#comment122812568_30107065
+        //
+        // We also must set the .foreground flag, which brings the notification to the foreground and avoids an error about
+        // permissions. This is described in https://stackoverflow.com/a/44580916/1440785
+        if let actions = message.actions, !actions.isEmpty {
+            self.categoryIdentifier = actionsCategory
+            
+            let center = UNUserNotificationCenter.current()
+            let notificationActions = actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: [.foreground]) }
+            let category = UNNotificationCategory(identifier: actionsCategory, actions: notificationActions, intentIdentifiers: [])
+            center.setNotificationCategories([category])
+        }
+        
+        // Play a sound, and group by topic
+        self.sound = .default
+        self.threadIdentifier = topicUrl(baseUrl: baseUrl, topic: message.topic)
+        
+        // Map priorities to interruption level (light up screen, ...) and relevance (order)
+        if #available(iOS 15.0, *) {
+            switch message.priority {
+            case 1:
+                self.interruptionLevel = .passive
+                self.relevanceScore = 0
+            case 2:
+                self.interruptionLevel = .passive
+                self.relevanceScore = 0.25
+            case 4:
+                self.interruptionLevel = .timeSensitive
+                self.relevanceScore = 0.75
+            case 5:
+                self.interruptionLevel = .critical
+                self.relevanceScore = 1
+            default:
+                self.interruptionLevel = .active
+                self.relevanceScore = 0.5
+            }
+        }
+        
+        // Make sure the userInfo matches, so that when the notification is tapped, the AppDelegate
+        // can properly navigate to the right topic and re-assemble the message.
+        self.userInfo = message.toUserInfo()
+        self.userInfo["base_url"] = baseUrl
+    }
+}

+ 49 - 101
ntfyNSE/NotificationService.swift

@@ -9,125 +9,35 @@ import CryptoKit
 /// select Debug -> Attach to Process by PID or Name, and select the extension. Don't forget to set a breakpoint, or you're not gonna have a good time.
 class NotificationService: UNNotificationServiceExtension {
     private let tag = "NotificationService"
-    private let actionsCategory = "ntfyActions" // It seems ok to re-use the same category
+    private var store: Store?
     
     var contentHandler: ((UNNotificationContent) -> Void)?
     var bestAttemptContent: UNMutableNotificationContent?
     
     override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+        self.store = Store.shared
         self.contentHandler = contentHandler
         self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
         
         Log.d(tag, "Notification received (in service)") // Logs from extensions are not printed in Xcode!
 
         if let bestAttemptContent = bestAttemptContent {
-            let store = Store.shared
             let userInfo = bestAttemptContent.userInfo
             let baseUrl = userInfo["base_url"]  as? String ?? Config.appBaseUrl
-            let topic = userInfo["topic"]  as? String ?? ""
             guard let message = Message.from(userInfo: userInfo) else {
-                Log.w(Store.tag, "Message cannot be parsed from userInfo", userInfo)
+                Log.w(tag, "Message cannot be parsed from userInfo", userInfo)
                 contentHandler(request.content)
                 return
             }
-            if message.event == "poll_request" {
-                let subscription = store.getSubscriptions()?.first { $0.urlHash() == topic }
-                guard let subscription = subscription, let pollId = message.pollId else {
-                    Log.w(tag, "Cannot find subscription", message)
-                    contentHandler(request.content)
-                    return
-                }
-                //let semaphore = DispatchSemaphore(value: 0)
-                ApiService.shared.poll(subscription: subscription, messageId: pollId) { message, error in
-                    guard let message = message else {
-                        Log.w(self.tag, "Error fetching message", error)
-                        contentHandler(request.content)
-                        return
-                    }
-                    bestAttemptContent.title = message.title ?? subscription.urlString()
-                    bestAttemptContent.body = message.message ?? ""
-                    contentHandler(bestAttemptContent)
-                    //semaphore.signal()
-                }
-                //semaphore.wait(timeout: .distantFuture)
-                Thread.sleep(forTimeInterval: 5)
-                return
-            }
-            
-            if message.event != "message" {
+            switch message.event {
+            case "poll_request":
+                handlePollRequest(request, bestAttemptContent, message, contentHandler)
+            case "message":
+                handleMessage(request, bestAttemptContent, baseUrl, message, contentHandler)
+            default:
                 Log.w(tag, "Irrelevant message received", message)
                 contentHandler(request.content)
-                return
-            }
-            
-            // Only handle "message" events
-            guard let subscription = store.getSubscription(baseUrl: baseUrl, topic: topic) else {
-                Log.w(tag, "Subscription for topic \(topic) unknown")
-                contentHandler(request.content)
-                return
-            }
-
-            // Set notification title to short URL if there is no title. The title is always set
-            // by the server, but it may be empty.
-            if let title = message.title, title == "" {
-                bestAttemptContent.title = topicShortUrl(baseUrl: baseUrl, topic: topic)
-            }
-            
-            // Emojify title or message
-            let emojiTags = parseEmojiTags(message.tags)
-            if !emojiTags.isEmpty {
-                if let title = message.title, title != "" {
-                    bestAttemptContent.title = emojiTags.joined(separator: "") + " " + bestAttemptContent.title
-                } else {
-                    bestAttemptContent.body = emojiTags.joined(separator: "") + " " + bestAttemptContent.body
-                }
-            }
-            
-            // Add custom actions
-            //
-            // We re-define the categories every time here, which is weird, but it works. When tapped, the action sets the
-            // actionIdentifier in the application(didReceive) callback. This logic is handled in the AppDelegate. This approach
-            // is described in a comment in https://stackoverflow.com/questions/30103867/changing-action-titles-in-interactive-notifications-at-run-time#comment122812568_30107065
-            //
-            // We also must set the .foreground flag, which brings the notification to the foreground and avoids an error about
-            // permissions. This is described in https://stackoverflow.com/a/44580916/1440785
-            if let actions = message.actions, !actions.isEmpty {
-                bestAttemptContent.categoryIdentifier = actionsCategory
-
-                let center = UNUserNotificationCenter.current()
-                let notificationActions = actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: [.foreground]) }
-                let category = UNNotificationCategory(identifier: actionsCategory, actions: notificationActions, intentIdentifiers: [])
-                center.setNotificationCategories([category])
             }
-                        
-            // Play a sound, and group by topic
-            bestAttemptContent.sound = .default
-            bestAttemptContent.threadIdentifier = topic
-
-            // Map priorities to interruption level (light up screen, ...) and relevance (order)
-            if #available(iOS 15.0, *) {
-                switch message.priority {
-                case 1:
-                    bestAttemptContent.interruptionLevel = .passive
-                    bestAttemptContent.relevanceScore = 0
-                case 2:
-                    bestAttemptContent.interruptionLevel = .passive
-                    bestAttemptContent.relevanceScore = 0.25
-                case 4:
-                    bestAttemptContent.interruptionLevel = .timeSensitive
-                    bestAttemptContent.relevanceScore = 0.75
-                case 5:
-                    bestAttemptContent.interruptionLevel = .critical
-                    bestAttemptContent.relevanceScore = 1
-                default:
-                    bestAttemptContent.interruptionLevel = .active
-                    bestAttemptContent.relevanceScore = 0.5
-                }
-            }
-            
-            // Save notification to store, and display it
-            Store.shared.save(notificationFromMessage: message, withSubscription: subscription)
-            contentHandler(bestAttemptContent)
         }
     }
     
@@ -141,11 +51,49 @@ class NotificationService: UNNotificationServiceExtension {
         }
     }
     
-    func handleMessage() {
+    private func handleMessage(_ request: UNNotificationRequest, _ content: UNMutableNotificationContent, _ baseUrl: String, _ message: Message, _ contentHandler: @escaping (UNNotificationContent) -> Void) {
+        // Modify notification based on message
+        content.modify(message: message, baseUrl: baseUrl)
         
+        // Save notification to store, and display it
+        guard let subscription = store?.getSubscription(baseUrl: baseUrl, topic: message.topic) else {
+            Log.w(tag, "Subscription \(topicUrl(baseUrl: baseUrl, topic: message.topic)) unknown")
+            contentHandler(request.content)
+            return
+        }
+        Store.shared.save(notificationFromMessage: message, withSubscription: subscription)
+        contentHandler(content)
     }
     
-    func handlePollRequest() {
+    private func handlePollRequest(_ request: UNNotificationRequest, _ content: UNMutableNotificationContent, _ pollRequest: Message, _ contentHandler: @escaping (UNNotificationContent) -> Void) {
+        let subscription = store?.getSubscriptions()?.first { $0.urlHash() == pollRequest.topic }
+        let baseUrl = subscription?.baseUrl
+        guard
+            let subscription = subscription,
+            let pollId = pollRequest.pollId,
+            let baseUrl = baseUrl
+        else {
+            Log.w(tag, "Cannot find subscription", pollRequest)
+            contentHandler(request.content)
+            return
+        }
+        
+        // Poll original server
+        let semaphore = DispatchSemaphore(value: 0)
+        ApiService.shared.poll(subscription: subscription, messageId: pollId) { message, error in
+            guard let message = message else {
+                Log.w(self.tag, "Error fetching message", error)
+                contentHandler(request.content)
+                return
+            }
+            self.handleMessage(request, content, baseUrl, message, contentHandler)
+            semaphore.signal()
+        }
+        
+        // Note: If notifications only show up as "New message", it may be because the "return" statement
+        // happens before the contentHandler() is called. We add this semaphore here to synchronize the threads.
+        // I don't know if this is necessary, but it feels like the right thing to do.
         
+        _ = semaphore.wait(timeout: DispatchTime.now() + 25) // 30 seconds is the max for the entire extension
     }
 }