diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index f05b3e8..99a45f6 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -49,25 +49,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { return } - // Poll and display new messages + // Poll and show new messages as notifications let store = Store.shared - let center = UNUserNotificationCenter.current() let subscriptionManager = SubscriptionManager(store: store) - store.getSubscriptions()?.forEach { subscription in subscriptionManager.poll(subscription) { messages in messages.forEach { message in - let content = UNMutableNotificationContent() - content.title = message.title ?? "" - content.body = message.message ?? "" - content.sound = .default - - let request = UNNotificationRequest(identifier: message.id, content: content, trigger: nil /* now */) - center.add(request) { (error) in - if let error = error { - Log.e(self.tag, "Unable to create notification", error) - } - } + self.showNotification(subscription, message) } } } @@ -83,6 +71,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { Log.e(tag, "Failed to register for remote notifications", error) } + + /// 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 + + let content = UNMutableNotificationContent() + content.title = message.title ?? "" + content.body = message.message ?? "" // FIXME: This needs to be truncated! + content.sound = .default + content.userInfo = userInfo + + let request = UNNotificationRequest(identifier: message.id, content: content, trigger: nil /* now */) + UNUserNotificationCenter.current().add(request) { (error) in + if let error = error { + Log.e(self.tag, "Unable to create notification", error) + } + } + } } extension AppDelegate: UNUserNotificationCenterDelegate { @@ -108,21 +118,22 @@ extension AppDelegate: UNUserNotificationCenterDelegate { Log.d(tag, "Notification received via userNotificationCenter(didReceive)", userInfo) - let clickUrl = URL(string: userInfo["click"] as? String ?? "") + 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)) // Show current topic if topic != "" { - selectedBaseUrl = topicUrl(baseUrl: Config.appBaseUrl, topic: topic) + selectedBaseUrl = topicUrl(baseUrl: baseUrl, topic: topic) } // Execute user action or click action (if any) if let action = action { - handleAction(action) + handle(action: action) } else if let clickUrl = clickUrl { - handleCustomClick(clickUrl) + open(url: clickUrl) } completionHandler() @@ -133,12 +144,12 @@ extension AppDelegate: UNUserNotificationCenterDelegate { return actions.first { $0.id == id } } - private func handleAction(_ action: Action) { + private func handle(action: Action) { Log.d(tag, "Executing user action", action) switch action.action { case "view": if let url = URL(string: action.url ?? "") { - openUrl(url) + open(url: url) } else { Log.w(tag, "Unable to parse action URL", action) } @@ -149,16 +160,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } } - private func handleCustomClick(_ url: URL) { - openUrl(url) - } - - private func handleDefaultClick(topic: String) { - Log.d(tag, "Selecting topic \(topic)") - selectedBaseUrl = topicUrl(baseUrl: Config.appBaseUrl, topic: topic) - } - - private func openUrl(_ url: URL) { + private func open(url: URL) { Log.d(tag, "Opening URL \(url)") UIApplication.shared.open(url, options: [:], completionHandler: nil) } diff --git a/ntfy/Persistence/Notification.swift b/ntfy/Persistence/Notification.swift index 7419886..387d8a6 100644 --- a/ntfy/Persistence/Notification.swift +++ b/ntfy/Persistence/Notification.swift @@ -64,14 +64,36 @@ extension Notification { struct Message: Decodable { var id: String var time: Int64 + var event: String var message: String? var title: String? var priority: Int16? var tags: [String]? var actions: [Action]? + + 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, + "time": String(time), + "message": message ?? "", + "title": title ?? "", + "priority": String(priority ?? 3), + "tags": tags?.joined(separator: ",") ?? "", + "actions": actionsStr ?? "" + ] + } } -struct Action: Decodable { +struct Action: Encodable, Decodable { var id: String var action: String var label: String diff --git a/ntfy/Persistence/Store.swift b/ntfy/Persistence/Store.swift index d5c36f0..f4bdff9 100644 --- a/ntfy/Persistence/Store.swift +++ b/ntfy/Persistence/Store.swift @@ -81,14 +81,15 @@ class Store: ObservableObject { func save(notificationFromUserInfo userInfo: [AnyHashable: Any]) { guard let id = userInfo["id"] as? String, - let topic = userInfo["topic"] 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 = Config.appBaseUrl // Firebase messages all come from the main ntfy server + 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 @@ -99,10 +100,12 @@ class Store: ObservableObject { let m = Message( id: id, time: timeInt, + event: event, message: message, title: title, priority: priority, - tags: tags + tags: tags, + actions: nil // TODO: Actions ) save(notificationFromMessage: m, withSubscription: subscription) } @@ -116,6 +119,7 @@ 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 subscription.addToNotifications(notification) subscription.lastNotificationId = message.id try context.save() @@ -181,9 +185,10 @@ class Store: ObservableObject { extension Store { static let sampleData = [ "stats": [ - Message(id: "1", time: 1653048956, 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"]), - Message(id: "2", time: 1653058956, message: "201 users/h\n80 IPs", title: "This is a title", priority: 1, tags: []), - Message(id: "3", time: 1643058956, 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"]) + // 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) ], "backups": [], "announcements": [], diff --git a/ntfy/Views/SubscriptionAddView.swift b/ntfy/Views/SubscriptionAddView.swift index e693631..21e3f9c 100644 --- a/ntfy/Views/SubscriptionAddView.swift +++ b/ntfy/Views/SubscriptionAddView.swift @@ -26,7 +26,10 @@ struct SubscriptionAddView: View { .disableAutocapitalization() .disableAutocorrection(true) } - Section { + Section( + footer: + (useAnother) ? Text("Support for self-hosted servers is currently very limited. Delivery of messages is significantly delayed and not guaranteed. This is actively being developed.") : Text("") + ) { Toggle("Use another server", isOn: $useAnother) if useAnother { TextField("Server URL, e.g. https://ntfy.example.com", text: $baseUrl) @@ -48,32 +51,30 @@ struct SubscriptionAddView: View { Button(action: subscribeAction) { Text("Subscribe") } - .disabled(!isValid(topic: topic)) + .disabled(!isValid()) } } } } - private func sanitize(topic: String) -> String { - return topic.trimmingCharacters(in: [" "]) + private var sanitizedTopic: String { + return topic.trimmingCharacters(in: .whitespaces) } - private func isValid(topic: String) -> Bool { - let sanitizedTopic = sanitize(topic: topic) + private func isValid() -> Bool { if sanitizedTopic.isEmpty { return false } else if sanitizedTopic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) == nil { return false - } else if store.getSubscription(baseUrl: Config.appBaseUrl, topic: topic) != nil { + } else if store.getSubscription(baseUrl: selectedBaseUrl, topic: topic) != nil { return false } return true } private func subscribeAction() { - let baseUrl = (useAnother) ? baseUrl : Config.appBaseUrl DispatchQueue.global(qos: .background).async { - subscriptionManager.subscribe(baseUrl: baseUrl, topic: sanitize(topic: topic)) + subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic) } isShowing = false } @@ -81,6 +82,10 @@ struct SubscriptionAddView: View { private func cancelAction() { isShowing = false } + + private var selectedBaseUrl: String { + return (useAnother) ? baseUrl : Config.appBaseUrl + } } diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index 6dd7b52..f9d2883 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -24,6 +24,7 @@ class NotificationService: UNNotificationServiceExtension { // 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" @@ -39,7 +40,7 @@ class NotificationService: UNNotificationServiceExtension { // 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 == "" { - bestAttemptContent.title = topicShortUrl(baseUrl: Config.appBaseUrl, topic: topic) + bestAttemptContent.title = topicShortUrl(baseUrl: baseUrl, topic: topic) } // Emojify title or message