From 72386665f921470b38106c48e41a53b24bf6d1de Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 25 May 2022 16:59:25 -0400 Subject: [PATCH] Support for user actions --- ntfy.xcodeproj/project.pbxproj | 16 +++++++--- ntfy/App/AppDelegate.swift | 47 +++++++++++++++++++++++++++-- ntfy/Info.plist | 5 +++ ntfy/Persistence/Notification.swift | 12 ++++++++ ntfy/Utils/Actions.swift | 47 +++++++++++++++++++++++++++++ ntfy/Utils/Helpers.swift | 3 ++ ntfyNSE/Info.plist | 5 +++ ntfyNSE/NotificationService.swift | 19 ++++++++++++ 8 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 ntfy/Utils/Actions.swift diff --git a/ntfy.xcodeproj/project.pbxproj b/ntfy.xcodeproj/project.pbxproj index 1286d72..ed5e92e 100644 --- a/ntfy.xcodeproj/project.pbxproj +++ b/ntfy.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 9474F2142834755E00CDE4DD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1FE28316ACE00CDE4DD /* Subscription.swift */; }; 9474F2152834758700CDE4DD /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F211283327C200CDE4DD /* Helpers.swift */; }; 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 */; }; 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 */; }; @@ -91,6 +93,7 @@ 9474F20E283326C500CDE4DD /* ApiService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApiService.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -215,6 +218,7 @@ 9474F211283327C200CDE4DD /* Helpers.swift */, 94A3F7C928386B2100C48E79 /* Config.swift */, 9474F216283531A200CDE4DD /* Log.swift */, + 94867142283EC9950093C7A4 /* Actions.swift */, ); path = Utils; sourceTree = ""; @@ -354,6 +358,7 @@ 9474F1FF28316ACE00CDE4DD /* Subscription.swift in Sources */, 94CD196A283E666900973B93 /* EmojiManager.swift in Sources */, 9474F1C1282F2AA700CDE4DD /* AppMain.swift in Sources */, + 94867143283EC9960093C7A4 /* Actions.swift in Sources */, 9474F20F283326C500CDE4DD /* ApiService.swift in Sources */, 9474F1F72830830700CDE4DD /* ntfy.xcdatamodeld in Sources */, ); @@ -368,6 +373,7 @@ 9474F2152834758700CDE4DD /* Helpers.swift in Sources */, 9474F1E7282F3FFD00CDE4DD /* NotificationService.swift in Sources */, 94CD196B283E666900973B93 /* EmojiManager.swift in Sources */, + 94867144283ECD370093C7A4 /* Actions.swift in Sources */, 9474F2052831D51500CDE4DD /* Store.swift in Sources */, 9474F2062831D73C00CDE4DD /* ntfy.xcdatamodeld in Sources */, 94A3F7CB28386B2100C48E79 /* Config.swift in Sources */, @@ -516,6 +522,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ntfy/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ntfy; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -549,6 +556,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ntfy/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ntfy; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -573,7 +581,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ntfyNSE/ntfyNSE.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = YXQ4AMS4B4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ntfyNSE/Info.plist; @@ -585,7 +593,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = io.heckel.ntfy.ntfyNSE; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -600,7 +608,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ntfyNSE/ntfyNSE.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = YXQ4AMS4B4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ntfyNSE/Info.plist; @@ -612,7 +620,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = io.heckel.ntfy.ntfyNSE; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index 909a2e1..3b9f276 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -63,18 +63,59 @@ 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) let clickUrl = URL(string: userInfo["click"] as? String ?? "") let topic = userInfo["topic"] as? String ?? "" - if let clickUrl = clickUrl { - UIApplication.shared.open(clickUrl, options: [:], completionHandler: nil) + let action = findAction(id: actionId, actions: Actions.shared.parse(userInfo["actions"] as? String ?? "[]")) + + if let action = action { + handleAction(action) + } else if let clickUrl = clickUrl { + handleCustomClick(clickUrl) } else if topic != "" { - selectedBaseUrl = topicUrl(baseUrl: Config.appBaseUrl, topic: topic) + handleDefaultClick(topic: topic) } 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/Info.plist b/ntfy/Info.plist index 7b73644..ac9e0a1 100644 --- a/ntfy/Info.plist +++ b/ntfy/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + AppBaseURL $(APP_BASE_URL) UIApplicationSceneManifest diff --git a/ntfy/Persistence/Notification.swift b/ntfy/Persistence/Notification.swift index 5706ae7..7419886 100644 --- a/ntfy/Persistence/Notification.swift +++ b/ntfy/Persistence/Notification.swift @@ -68,4 +68,16 @@ struct Message: Decodable { var title: String? var priority: Int16? var tags: [String]? + var actions: [Action]? +} + +struct Action: Decodable { + var id: String + var action: String + var label: String + var url: String? + var method: String? + var headers: [String: String]? + var body: String? + var clear: Bool? } diff --git a/ntfy/Utils/Actions.swift b/ntfy/Utils/Actions.swift new file mode 100644 index 0000000..71326dc --- /dev/null +++ b/ntfy/Utils/Actions.swift @@ -0,0 +1,47 @@ +import Foundation + +struct Actions { + static let shared = Actions() + private let tag = "Actions" + + func parse(_ actions: String?) -> [Action]? { + guard let actions = actions, + let data = actions.data(using: .utf8) else { return nil } + do { + return try JSONDecoder().decode([Action].self, from: data) + .filter { supportedActions.contains($0.action) } + } catch { + Log.e(tag, "Unable to parse actions: \(error.localizedDescription)", error) + return nil + } + } + + 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() + } +} + + diff --git a/ntfy/Utils/Helpers.swift b/ntfy/Utils/Helpers.swift index 3a946d0..3fadfa6 100644 --- a/ntfy/Utils/Helpers.swift +++ b/ntfy/Utils/Helpers.swift @@ -1,5 +1,8 @@ import Foundation +let helperFnTag = "Helpers" +let supportedActions = ["view", "http"] + func topicUrl(baseUrl: String, topic: String) -> String { return "\(baseUrl)/\(topic)" } diff --git a/ntfyNSE/Info.plist b/ntfyNSE/Info.plist index a007794..5b43cab 100644 --- a/ntfyNSE/Info.plist +++ b/ntfyNSE/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + AppBaseURL $(APP_BASE_URL) NSExtension diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index a7b3819..1fd05da 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -8,6 +8,7 @@ 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 var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? @@ -26,6 +27,7 @@ class NotificationService: UNNotificationServiceExtension { 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. @@ -42,7 +44,24 @@ class NotificationService: UNNotificationServiceExtension { 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 = 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