diff --git a/.gitignore b/.gitignore index b71c2c4..3e8f0a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store GoogleService-Info.plist ntfy.xcodeproj/xcuserdata +ntfy.xcodeproj/project.xcworkspace/xcuserdata/ diff --git a/ntfy.xcodeproj/project.pbxproj b/ntfy.xcodeproj/project.pbxproj index ed5e92e..7d78538 100644 --- a/ntfy.xcodeproj/project.pbxproj +++ b/ntfy.xcodeproj/project.pbxproj @@ -34,6 +34,10 @@ 9474F217283531A300CDE4DD /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F216283531A200CDE4DD /* Log.swift */; }; 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 */; }; + 9486714A2841D0CE0093C7A4 /* ActionExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948671492841D0CE0093C7A4 /* ActionExecutor.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 */; }; @@ -94,6 +98,8 @@ 9474F211283327C200CDE4DD /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; 9474F216283531A200CDE4DD /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 94867142283EC9950093C7A4 /* Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Actions.swift; sourceTree = ""; }; + 948671462841B0B20093C7A4 /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; + 948671492841D0CE0093C7A4 /* ActionExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionExecutor.swift; sourceTree = ""; }; 94A3F7C7283734D900C48E79 /* SubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = ""; }; 94A3F7C928386B2100C48E79 /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 94CD1965283E662900973B93 /* emojis.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = emojis.json; sourceTree = ""; }; @@ -219,6 +225,8 @@ 94A3F7C928386B2100C48E79 /* Config.swift */, 9474F216283531A200CDE4DD /* Log.swift */, 94867142283EC9950093C7A4 /* Actions.swift */, + 948671462841B0B20093C7A4 /* NotificationContent.swift */, + 948671492841D0CE0093C7A4 /* ActionExecutor.swift */, ); path = Utils; sourceTree = ""; @@ -344,6 +352,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 */, @@ -354,6 +363,7 @@ 9474F1C3282F2AA700CDE4DD /* ContentView.swift in Sources */, 9474F20C283321C300CDE4DD /* Notification.swift in Sources */, 9474F1F22830825600CDE4DD /* SubscriptionListView.swift in Sources */, + 9486714A2841D0CE0093C7A4 /* ActionExecutor.swift in Sources */, 9474F1FD2831311A00CDE4DD /* SubscriptionAddView.swift in Sources */, 9474F1FF28316ACE00CDE4DD /* Subscription.swift in Sources */, 94CD196A283E666900973B93 /* EmojiManager.swift in Sources */, @@ -374,9 +384,11 @@ 9474F1E7282F3FFD00CDE4DD /* NotificationService.swift in Sources */, 94CD196B283E666900973B93 /* EmojiManager.swift in Sources */, 94867144283ECD370093C7A4 /* Actions.swift in Sources */, + 94867145284058C60093C7A4 /* ApiService.swift in Sources */, 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; diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index 7bd3577..c0265a5 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -3,10 +3,12 @@ import SafariServices import UserNotifications import Firebase import FirebaseCore +import FirebaseMessaging import CoreData class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { - let tag = "AppDelegate" + private let tag = "AppDelegate" + 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 @@ -30,9 +32,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { // Set self as messaging delegate Messaging.messaging().delegate = self + // Register to "~poll" topic + Messaging.messaging().subscribe(toTopic: pollTopic) + return true } + /// 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 != pollTopic { + completionHandler(.noData) + return + } + + // Poll and show new messages as notifications + let store = Store.shared + let subscriptionManager = SubscriptionManager(store: store) + store.getSubscriptions()?.forEach { subscription in + subscriptionManager.poll(subscription) { messages in + messages.forEach { message in + self.showNotification(subscription, message) + } + } + } + completionHandler(.newData) + } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { data in String(format: "%02.2hhx", data) }.joined() Messaging.messaging().apnsToken = deviceToken @@ -42,6 +72,21 @@ 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. + private func showNotification(_ subscription: Subscription, _ message: Message) { + let content = UNMutableNotificationContent() + 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 { + Log.e(self.tag, "Unable to create notification", error) + } + } + } } extension AppDelegate: UNUserNotificationCenterDelegate { @@ -63,64 +108,30 @@ 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 clickUrl = URL(string: userInfo["click"] as? String ?? "") - let topic = userInfo["topic"] as? String ?? "" - let actions = userInfo["actions"] as? String ?? "[]" - let action = findAction(id: actionId, actions: Actions.shared.parse(actions)) + let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl + let action = message.actions?.first { $0.id == response.actionIdentifier } // Show current topic - if topic != "" { - selectedBaseUrl = topicUrl(baseUrl: Config.appBaseUrl, topic: topic) + if message.topic != "" { + selectedBaseUrl = topicUrl(baseUrl: baseUrl, topic: message.topic) } // Execute user action or click action (if any) if let action = action { - handleAction(action) - } else if let clickUrl = clickUrl { - handleCustomClick(clickUrl) + ActionExecutor.execute(action) + } else if let click = message.click, click != "", let url = URL(string: click) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) } completionHandler() } - - private func findAction(id: String, actions: [Action]?) -> Action? { - guard let actions = actions else { return nil } - return actions.first { $0.id == id } - } - - private func handleAction(_ action: Action) { - Log.d(tag, "Executing user action", action) - switch action.action { - case "view": - if let url = URL(string: action.url ?? "") { - openUrl(url) - } else { - Log.w(tag, "Unable to parse action URL", action) - } - case "http": - Actions.shared.http(action) - default: - Log.w(tag, "Action \(action.action) not supported", action) - } - } - - 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) { - Log.d(tag, "Opening URL \(url)") - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } } extension AppDelegate: MessagingDelegate { diff --git a/ntfy/Persistence/Notification.swift b/ntfy/Persistence/Notification.swift index 7419886..d16a56f 100644 --- a/ntfy/Persistence/Notification.swift +++ b/ntfy/Persistence/Notification.swift @@ -58,20 +58,78 @@ extension Notification { func nonEmojiTags() -> [String] { return parseNonEmojiTags(tags) } + + func actionsList() -> [Action] { + return Actions.shared.parse(actions) ?? [] + } } /// This is the "on the wire" message as it is received from the ntfy server struct Message: Decodable { var id: String var time: Int64 + var event: String + var topic: String var message: String? var title: String? var priority: Int16? var tags: [String]? var actions: [Action]? + var click: String? + var pollId: String? + + func toUserInfo() -> [AnyHashable: Any] { + // This should mimic the way that the ntfy server encodes a message. + // See server_firebase.go for more details. + + return [ + "id": id, + "time": String(time), + "event": event, + "topic": topic, + "message": message ?? "", + "title": title ?? "", + "priority": String(priority ?? 3), + "tags": tags?.joined(separator: ",") ?? "", + "actions": Actions.shared.encode(actions), + "click": click ?? "", + "poll_id": pollId ?? "" + ] + } + + 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 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 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 + let pollId = userInfo["poll_id"] as? String + return Message( + id: id, + time: timeInt, + event: event, + topic: topic, + message: message, + title: title, + priority: priority, + tags: tags, + actions: Actions.shared.parse(actions), + click: click, + pollId: pollId + ) + } } -struct Action: Decodable { +struct Action: Encodable, Decodable, Identifiable { var id: String var action: String var label: String diff --git a/ntfy/Persistence/Store.swift b/ntfy/Persistence/Store.swift index 93408b7..f9a0380 100644 --- a/ntfy/Persistence/Store.swift +++ b/ntfy/Persistence/Store.swift @@ -70,39 +70,15 @@ class Store: ObservableObject { return try? context.fetch(fetchRequest).first } + func getSubscriptions() -> [Subscription]? { + return try? context.fetch(Subscription.fetchRequest()) + } + func delete(subscription: Subscription) { context.delete(subscription) try? context.save() } - 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 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 - 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, - message: message, - title: title, - priority: priority, - tags: tags - ) - save(notificationFromMessage: m, withSubscription: subscription) - } - func save(notificationFromMessage message: Message, withSubscription subscription: Subscription) { do { let notification = Notification(context: context) @@ -112,6 +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: ",") ?? "" + notification.actions = Actions.shared.encode(message.actions) + notification.click = message.click ?? "" subscription.addToNotifications(notification) subscription.lastNotificationId = message.id try context.save() @@ -177,9 +155,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", 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": [], diff --git a/ntfy/Persistence/Subscription.swift b/ntfy/Persistence/Subscription.swift index 819ca7d..317ff6e 100644 --- a/ntfy/Persistence/Subscription.swift +++ b/ntfy/Persistence/Subscription.swift @@ -10,7 +10,11 @@ extension Subscription { } func topicName() -> String { - return topic ?? "" + return topic ?? "?" + } + + func urlHash() -> String { + return topicHash(baseUrl: baseUrl ?? "?", topic: topic ?? "?") } func notificationCount() -> Int { diff --git a/ntfy/Persistence/SubscriptionManager.swift b/ntfy/Persistence/SubscriptionManager.swift index 154bb9a..ba0ce60 100644 --- a/ntfy/Persistence/SubscriptionManager.swift +++ b/ntfy/Persistence/SubscriptionManager.swift @@ -9,7 +9,11 @@ struct SubscriptionManager { func subscribe(baseUrl: String, topic: String) { Log.d(tag, "Subscribing to \(topicUrl(baseUrl: baseUrl, topic: topic))") - Messaging.messaging().subscribe(toTopic: topic) + if baseUrl == Config.appBaseUrl { + Messaging.messaging().subscribe(toTopic: topic) + } else { + Messaging.messaging().subscribe(toTopic: topicHash(baseUrl: baseUrl, topic: topic)) + } let subscription = store.saveSubscription(baseUrl: baseUrl, topic: topic) poll(subscription) } @@ -17,28 +21,38 @@ struct SubscriptionManager { func unsubscribe(_ subscription: Subscription) { Log.d(tag, "Unsubscribing from \(subscription.urlString())") DispatchQueue.main.async { - if let topic = subscription.topic { - Messaging.messaging().unsubscribe(fromTopic: topic) + if let baseUrl = subscription.baseUrl, let topic = subscription.topic { + if baseUrl == Config.appBaseUrl { + Messaging.messaging().unsubscribe(fromTopic: topic) + } else { + Messaging.messaging().unsubscribe(fromTopic: topicHash(baseUrl: baseUrl, topic: topic)) + } } store.delete(subscription: subscription) } } func poll(_ subscription: Subscription) { + poll(subscription) { _ in } + } + + func poll(_ subscription: Subscription, completionHandler: @escaping ([Message]) -> Void) { Log.d(tag, "Polling from \(subscription.urlString())") ApiService.shared.poll(subscription: subscription) { messages, error in guard let messages = messages else { Log.e(tag, "Polling failed", error) + completionHandler([]) return } Log.d(tag, "Polling success, \(messages.count) new message(s)", messages) if !messages.isEmpty { - DispatchQueue.main.async { + DispatchQueue.main.sync { for message in messages { store.save(notificationFromMessage: message, withSubscription: subscription) } } } + completionHandler(messages) } } } 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/ActionExecutor.swift b/ntfy/Utils/ActionExecutor.swift new file mode 100644 index 0000000..0ed1447 --- /dev/null +++ b/ntfy/Utils/ActionExecutor.swift @@ -0,0 +1,56 @@ +import Foundation +import UIKit + +struct ActionExecutor { + private static let tag = "ActionExecutor" + + static func execute(_ action: Action) { + Log.d(tag, "Executing user action", action) + switch action.action { + case "view": + if let url = URL(string: action.url ?? "") { + open(url: url) + } else { + Log.w(tag, "Unable to parse action URL", action) + } + case "http": + http(action) + default: + Log.w(tag, "Action \(action.action) not supported", action) + } + } + + private static 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) + return + } + let method = action.method ?? "POST" // POST is the default!! + let body = action.body ?? "" + + Log.d(tag, "Performing HTTP \(method) \(url)") + + var request = URLRequest(url: url) + request.httpMethod = method + action.headers?.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + if !["GET", "HEAD"].contains(method) { + request.httpBody = body.data(using: .utf8) + } + URLSession.shared.dataTask(with: request) { (data, response, error) in + guard error == nil else { + Log.e(self.tag, "Error performing HTTP \(method)", error!) + return + } + Log.d(self.tag, "HTTP \(method) succeeded", response) + }.resume() + } + + private static func open(url: URL) { + Log.d(tag, "Opening URL \(url)") + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } +} + + diff --git a/ntfy/Utils/Actions.swift b/ntfy/Utils/Actions.swift index d75dcd2..d8422f5 100644 --- a/ntfy/Utils/Actions.swift +++ b/ntfy/Utils/Actions.swift @@ -6,7 +6,7 @@ struct Actions { private let supportedActions = ["view", "http"] func parse(_ actions: String?) -> [Action]? { - guard let actions = actions, + guard let actions = actions, actions != "", let data = actions.data(using: .utf8) else { return nil } do { return try JSONDecoder().decode([Action].self, from: data) @@ -17,31 +17,12 @@ struct Actions { } } - 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) - return + func encode(_ actions: [Action]?) -> String { + guard let actions = actions else { return "" } + if let actionsData = try? JSONEncoder().encode(actions) { + return String(data: actionsData, encoding: .utf8) ?? "" } - let method = action.method ?? "POST" // POST is the default!! - let body = action.body ?? "" - - Log.d(tag, "Performing HTTP \(method) \(url)") - - var request = URLRequest(url: url) - request.httpMethod = method - action.headers?.forEach { key, value in - request.setValue(value, forHTTPHeaderField: key) - } - if !["GET", "HEAD"].contains(method) { - request.httpBody = body.data(using: .utf8) - } - URLSession.shared.dataTask(with: request) { (data, response, error) in - guard error == nil else { - Log.e(self.tag, "Error performing HTTP \(method)", error!) - return - } - Log.d(self.tag, "HTTP \(method) succeeded", response) - }.resume() + return "" } } diff --git a/ntfy/Utils/ApiService.swift b/ntfy/Utils/ApiService.swift index cde771b..469c591 100644 --- a/ntfy/Utils/ApiService.swift +++ b/ntfy/Utils/ApiService.swift @@ -5,13 +5,34 @@ class ApiService { static let shared = ApiService() func poll(subscription: Subscription, completionHandler: @escaping ([Message]?, Error?) -> Void) { - guard let url = URL(string: subscription.urlString()) else { return } + guard let url = URL(string: subscription.urlString()) else { + // FIXME + return + } let since = subscription.lastNotificationId ?? "all" let urlString = "\(url)/json?poll=1&since=\(since)" Log.d(tag, "Polling from \(urlString)") fetchJsonData(urlString: urlString, completionHandler: completionHandler) } + + func poll(subscription: Subscription, messageId: String, completionHandler: @escaping (Message?, Error?) -> Void) { + let url = URL(string: "\(subscription.urlString())/json?poll=1&id=\(messageId)")! + Log.d(tag, "Polling single message from \(url)") + + URLSession.shared.dataTask(with: URLRequest(url: url)) { (data, response, error) in + if let error = error { + completionHandler(nil, error) + return + } + do { + let message = try JSONDecoder().decode(Message.self, from: data!) + completionHandler(message, nil) + } catch { + completionHandler(nil, error) + } + }.resume() + } func publish( subscription: Subscription, diff --git a/ntfy/Utils/Helpers.swift b/ntfy/Utils/Helpers.swift index 3a946d0..34c2377 100644 --- a/ntfy/Utils/Helpers.swift +++ b/ntfy/Utils/Helpers.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit func topicUrl(baseUrl: String, topic: String) -> String { return "\(baseUrl)/\(topic)" @@ -10,14 +11,25 @@ func topicShortUrl(baseUrl: String, topic: String) -> String { .replacingOccurrences(of: "https://", with: "") } +func topicHash(baseUrl: String, topic: String) -> String { + let data = Data(topicUrl(baseUrl: baseUrl, topic: topic).utf8) + let digest = SHA256.hash(data: data) + return digest.compactMap { String(format: "%02x", $0)}.joined() +} + 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()) } @@ -29,3 +41,4 @@ func parseNonEmojiTags(_ tags: String?) -> [String] { return parseAllTags(tags) .filter { EmojiManager.shared.getEmojiByAlias(alias: $0) == nil } } + diff --git a/ntfy/Utils/NotificationContent.swift b/ntfy/Utils/NotificationContent.swift new file mode 100644 index 0000000..c212795 --- /dev/null +++ b/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 + } +} diff --git a/ntfy/Views/NotificationListView.swift b/ntfy/Views/NotificationListView.swift index 5f145fd..3b701e4 100644 --- a/ntfy/Views/NotificationListView.swift +++ b/ntfy/Views/NotificationListView.swift @@ -56,7 +56,7 @@ struct NotificationListView: View { }){ Image(systemName: "chevron.left") } - .padding([.top, .bottom, .trailing], 20) + .padding([.top, .bottom, .trailing], 40) } } ToolbarItem(placement: .principal) { @@ -90,7 +90,7 @@ struct NotificationListView: View { } } label: { Image(systemName: "ellipsis.circle") - .padding([.leading], 20) + .padding([.leading], 40) } } } @@ -145,7 +145,7 @@ struct NotificationListView: View { .foregroundColor(.gray) .multilineTextAlignment(.center) .padding(.bottom) - + if #available(iOS 15.0, *) { Text("To send notifications to this topic, simply PUT or POST to the topic URL.\n\nExample:\n`$ curl -d \"hi\" ntfy.sh/\(subscription.topicName())`\n\nDetailed instructions are available on [ntfy.sh](https://ntfy.sh) and [in the docs](https://ntfy.sh/docs).") .foregroundColor(.gray) @@ -269,10 +269,12 @@ struct NotificationRowView: View { .frame(width: 16, height: 16) } } + .padding([.bottom], 2) if let title = notification.formatTitle(), title != "" { Text(title) .font(.headline) .bold() + .padding([.bottom], 2) } Text(notification.formatMessage()) .font(.body) @@ -280,6 +282,24 @@ struct NotificationRowView: View { Text("Tags: " + notification.nonEmojiTags().joined(separator: ", ")) .font(.subheadline) .foregroundColor(.gray) + .padding([.top], 2) + } + if !notification.actionsList().isEmpty { + HStack { + ForEach(notification.actionsList()) { action in + if #available(iOS 15, *) { + Button(action.label) { + ActionExecutor.execute(action) + } + .buttonStyle(.bordered) + } else { + Button(action.label) { + ActionExecutor.execute(action) + } + } + } + } + .padding([.top], 5) } } .padding(.all, 4) @@ -305,3 +325,4 @@ struct NotificationListView_Previews: PreviewProvider { } } } + diff --git a/ntfy/Views/SubscriptionAddView.swift b/ntfy/Views/SubscriptionAddView.swift index 692e2e6..c67fe35 100644 --- a/ntfy/Views/SubscriptionAddView.swift +++ b/ntfy/Views/SubscriptionAddView.swift @@ -7,6 +7,8 @@ struct SubscriptionAddView: View { @EnvironmentObject private var store: Store @State private var topic: String = "" + @State private var useAnother: Bool = false + @State private var baseUrl: String = "" private var subscriptionManager: SubscriptionManager { return SubscriptionManager(store: store) @@ -18,12 +20,23 @@ struct SubscriptionAddView: View { Form { Section( footer: - Text("Topics may not be password protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications") + Text("Topics are not password protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications") ) { TextField("Topic name, e.g. phil_alerts", text: $topic) .disableAutocapitalization() .disableAutocorrection(true) } + Section( + footer: + (useAnother) ? Text("Support for self-hosted servers is currently limited. To ensure instant delivery, be sure to set upstream-base-url in your server's config, otherwise messages may arrive with significant delay. Auth is not yet supported.") : Text("") + ) { + Toggle("Use another server", isOn: $useAnother) + if useAnother { + TextField("Server URL, e.g. https://ntfy.example.com", text: $baseUrl) + .disableAutocapitalization() + .disableAutocorrection(true) + } + } } } .navigationTitle("Add subscription") @@ -38,23 +51,22 @@ 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 @@ -62,7 +74,7 @@ struct SubscriptionAddView: View { private func subscribeAction() { DispatchQueue.global(qos: .background).async { - subscriptionManager.subscribe(baseUrl: Config.appBaseUrl, topic: sanitize(topic: topic)) + subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic) } isShowing = false } @@ -70,4 +82,19 @@ struct SubscriptionAddView: View { private func cancelAction() { isShowing = false } + + private var selectedBaseUrl: String { + return (useAnother) ? baseUrl : Config.appBaseUrl + } +} + + +struct SubscriptionAddView_Previews: PreviewProvider { + @State static var isShowing = true + + static var previews: some View { + let store = Store.preview + SubscriptionAddView(isShowing: $isShowing) + .environmentObject(store) + } } diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index a549eaa..0a53655 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -1,5 +1,6 @@ import UserNotifications import CoreData +import CryptoKit /// This app extension is responsible for persisting the incoming notification to the data store (Core Data). It will eventually be the entity that /// fetches notification content from selfhosted servers (when a "poll request" is received). This is not implemented yet. @@ -8,12 +9,13 @@ import CoreData /// 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) @@ -21,75 +23,21 @@ class NotificationService: UNNotificationServiceExtension { if let bestAttemptContent = bestAttemptContent { let userInfo = bestAttemptContent.userInfo - - // Get all the things - 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 ?? "[]" - - // 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) - } - - // Emojify title or message - let emojiTags = parseEmojiTags(tags) - if !emojiTags.isEmpty { - if let title = title, title != "" { - bestAttemptContent.title = emojiTags.joined(separator: "") + " " + bestAttemptContent.title - } else { - bestAttemptContent.body = emojiTags.joined(separator: "") + " " + bestAttemptContent.body - } + let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl + guard let message = Message.from(userInfo: userInfo) else { + Log.w(tag, "Message cannot be parsed from userInfo", userInfo) + contentHandler(request.content) + return } - - // 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 = Actions.shared.parse(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 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 - } + 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) } - - // Save notification to store, and display it - Store.shared.save(notificationFromUserInfo: userInfo) - contentHandler(bestAttemptContent) } } @@ -102,4 +50,50 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } } + + 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) + } + + 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 + } }