diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index 99a45f6..89be20f 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -3,6 +3,7 @@ import SafariServices import UserNotifications import Firebase import FirebaseCore +import FirebaseMessaging import CoreData class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { @@ -86,6 +87,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { content.sound = .default content.userInfo = userInfo + // FIXME: Use logic in NotificationService here to build the same message + let request = UNNotificationRequest(identifier: message.id, content: content, trigger: nil /* now */) UNUserNotificationCenter.current().add(request) { (error) in if let error = error { @@ -114,15 +117,16 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo - let actionId = response.actionIdentifier - Log.d(tag, "Notification received via userNotificationCenter(didReceive)", userInfo) + guard let message = Message.from(userInfo: userInfo) else { + Log.w(tag, "Cannot convert userInfo to message", userInfo) + completionHandler() + return + } let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl let topic = userInfo["topic"] as? String ?? "" - let clickUrl = URL(string: userInfo["click"] as? String ?? "") - let actions = userInfo["actions"] as? String ?? "[]" - let action = findAction(id: actionId, actions: Actions.shared.parse(actions)) + let action = message.actions?.first { $0.id == response.actionIdentifier } // Show current topic if topic != "" { @@ -132,18 +136,13 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // Execute user action or click action (if any) if let action = action { handle(action: action) - } else if let clickUrl = clickUrl { - open(url: clickUrl) + } else if let click = message.click, let url = URL(string: click) { + open(url: url) } completionHandler() } - private func findAction(id: String, actions: [Action]?) -> Action? { - guard let actions = actions else { return nil } - return actions.first { $0.id == id } - } - private func handle(action: Action) { Log.d(tag, "Executing user action", action) switch action.action { diff --git a/ntfy/Persistence/Notification.swift b/ntfy/Persistence/Notification.swift index 387d8a6..b372f5c 100644 --- a/ntfy/Persistence/Notification.swift +++ b/ntfy/Persistence/Notification.swift @@ -70,16 +70,12 @@ struct Message: Decodable { var priority: Int16? var tags: [String]? var actions: [Action]? + var click: String? func toUserInfo() -> [AnyHashable: Any] { // This should mimic the way that the ntfy server encodes a message. // See server_firebase.go for more details. - var actionsStr: String? - if let actionsData = try? JSONEncoder().encode(actions) { - actionsStr = String(data: actionsData, encoding: .utf8) - } - return [ "id": id, "event": event, @@ -88,9 +84,37 @@ struct Message: Decodable { "title": title ?? "", "priority": String(priority ?? 3), "tags": tags?.joined(separator: ",") ?? "", - "actions": actionsStr ?? "" + "actions": Actions.shared.encode(actions), + "click": click ?? "" ] } + + static func from(userInfo: [AnyHashable: Any]) -> Message? { + guard let id = userInfo["id"] as? String, + let time = userInfo["time"] as? String, + let event = userInfo["event"] as? String, + let timeInt = Int64(time), + let message = userInfo["message"] as? String else { + Log.d(Store.tag, "Unknown or irrelevant message", userInfo) + return nil + } + let title = userInfo["title"] as? String + let priority = Int16(userInfo["priority"] as? String ?? "3") ?? 3 + let tags = (userInfo["tags"] as? String ?? "").components(separatedBy: ",") + let actions = userInfo["actions"] as? String + let click = userInfo["click"] as? String + return Message( + id: id, + time: timeInt, + event: event, + message: message, + title: title, + priority: priority, + tags: tags, + actions: Actions.shared.parse(actions), + click: click + ) + } } struct Action: Encodable, Decodable { diff --git a/ntfy/Persistence/Store.swift b/ntfy/Persistence/Store.swift index f4bdff9..6e91ea3 100644 --- a/ntfy/Persistence/Store.swift +++ b/ntfy/Persistence/Store.swift @@ -79,37 +79,6 @@ class Store: ObservableObject { try? context.save() } - func save(notificationFromUserInfo userInfo: [AnyHashable: Any]) { - 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) - return - } - let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl // Firebase messages all come from the main ntfy server - guard let subscription = getSubscription(baseUrl: baseUrl, topic: topic) else { - Log.d(Store.tag, "Subscription for topic \(topic) unknown") - return - } - let title = userInfo["title"] as? String ?? "" - let priority = Int16(userInfo["priority"] as? String ?? "3") ?? 3 - let tags = (userInfo["tags"] as? String ?? "").components(separatedBy: ",") - let m = Message( - id: id, - time: timeInt, - event: event, - message: message, - title: title, - priority: priority, - tags: tags, - actions: nil // TODO: Actions - ) - save(notificationFromMessage: m, withSubscription: subscription) - } - func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) { do { let notification = Notification(context: context) @@ -119,7 +88,8 @@ class Store: ObservableObject { notification.title = message.title ?? "" notification.priority = (message.priority != nil && message.priority != 0) ? message.priority! : 3 notification.tags = message.tags?.joined(separator: ",") ?? "" - // TODO: actions + notification.actions = Actions.shared.encode(message.actions) + notification.click = message.click ?? "" subscription.addToNotifications(notification) subscription.lastNotificationId = message.id try context.save() diff --git a/ntfy/Persistence/ntfy.xcdatamodeld/Model.xcdatamodel/contents b/ntfy/Persistence/ntfy.xcdatamodeld/Model.xcdatamodel/contents index 1141191..42e9c06 100644 --- a/ntfy/Persistence/ntfy.xcdatamodeld/Model.xcdatamodel/contents +++ b/ntfy/Persistence/ntfy.xcdatamodeld/Model.xcdatamodel/contents @@ -1,6 +1,8 @@ + + @@ -27,7 +29,7 @@ - + \ No newline at end of file diff --git a/ntfy/Utils/Actions.swift b/ntfy/Utils/Actions.swift index d75dcd2..a613864 100644 --- a/ntfy/Utils/Actions.swift +++ b/ntfy/Utils/Actions.swift @@ -17,6 +17,14 @@ struct Actions { } } + func encode(_ actions: [Action]?) -> String { + guard let actions = actions else { return "" } + if let actionsData = try? JSONEncoder().encode(actions) { + return String(data: actionsData, encoding: .utf8) ?? "" + } + return "" + } + func http(_ action: Action) { guard let actionUrl = action.url, let url = URL(string: actionUrl) else { Log.w(tag, "Unable to execute HTTP action, no or invalid URL", action) diff --git a/ntfy/Utils/Helpers.swift b/ntfy/Utils/Helpers.swift index 3a946d0..46f5315 100644 --- a/ntfy/Utils/Helpers.swift +++ b/ntfy/Utils/Helpers.swift @@ -12,12 +12,17 @@ func topicShortUrl(baseUrl: String, topic: String) -> String { func parseAllTags(_ tags: String?) -> [String] { return (tags?.components(separatedBy: ",") ?? []) - .filter { $0.trimmingCharacters(in: [" "]) != "" } + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } } func parseEmojiTags(_ tags: String?) -> [String] { + return parseEmojiTags(parseAllTags(tags)) +} + +func parseEmojiTags(_ tags: [String]?) -> [String] { + guard let tags = tags else { return [] } var emojiTags: [String] = [] - for tag in parseAllTags(tags) { + for tag in tags { if let emoji = EmojiManager.shared.getEmojiByAlias(alias: tag) { emojiTags.append(emoji.getUnicode()) } diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index f9d2883..f99752a 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -20,33 +20,38 @@ class NotificationService: UNNotificationServiceExtension { 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 - - // Get all the things - let event = userInfo["event"] as? String ?? "" - let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl - let topic = userInfo["topic"] as? String ?? "" - let title = userInfo["title"] as? String - let priority = userInfo["priority"] as? String ?? "3" - let tags = userInfo["tags"] as? String - let actions = userInfo["actions"] as? String ?? "[]" - - // Only handle "message" events - if event != "message" { + guard let message = Message.from(userInfo: userInfo) else { + Log.w(Store.tag, "Message cannot be parsed from userInfo", userInfo) + contentHandler(request.content) + return + } + if message.event != "message" { + Log.w(tag, "Irrelevant message received", message) contentHandler(request.content) return } + // Only handle "message" events + let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl + let topic = userInfo["topic"] as? String ?? "" + 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 = title, title == "" { + if let title = message.title, title == "" { bestAttemptContent.title = topicShortUrl(baseUrl: baseUrl, topic: topic) } // Emojify title or message - let emojiTags = parseEmojiTags(tags) + let emojiTags = parseEmojiTags(message.tags) if !emojiTags.isEmpty { - if let title = title, title != "" { + if let title = message.title, title != "" { bestAttemptContent.title = emojiTags.joined(separator: "") + " " + bestAttemptContent.title } else { bestAttemptContent.body = emojiTags.joined(separator: "") + " " + bestAttemptContent.body @@ -61,7 +66,7 @@ class NotificationService: UNNotificationServiceExtension { // // 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 = Actions.shared.parse(actions), !actions.isEmpty { + if let actions = message.actions, !actions.isEmpty { bestAttemptContent.categoryIdentifier = actionsCategory let center = UNUserNotificationCenter.current() @@ -76,17 +81,17 @@ class NotificationService: UNNotificationServiceExtension { // Map priorities to interruption level (light up screen, ...) and relevance (order) if #available(iOS 15.0, *) { - switch priority { - case "1": + switch message.priority { + case 1: bestAttemptContent.interruptionLevel = .passive bestAttemptContent.relevanceScore = 0 - case "2": + case 2: bestAttemptContent.interruptionLevel = .passive bestAttemptContent.relevanceScore = 0.25 - case "4": + case 4: bestAttemptContent.interruptionLevel = .timeSensitive bestAttemptContent.relevanceScore = 0.75 - case "5": + case 5: bestAttemptContent.interruptionLevel = .critical bestAttemptContent.relevanceScore = 1 default: @@ -96,7 +101,7 @@ class NotificationService: UNNotificationServiceExtension { } // Save notification to store, and display it - Store.shared.save(notificationFromUserInfo: userInfo) + Store.shared.save(notificationFromMessage: message, withSubscription: subscription) contentHandler(bestAttemptContent) } }