Support for user actions

This commit is contained in:
Philipp Heckel 2022-05-25 16:59:25 -04:00
parent ae6a212671
commit 72386665f9
8 changed files with 147 additions and 7 deletions

View file

@ -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 = "<group>"; };
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>"; };
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>"; };
@ -215,6 +218,7 @@
9474F211283327C200CDE4DD /* Helpers.swift */,
94A3F7C928386B2100C48E79 /* Config.swift */,
9474F216283531A200CDE4DD /* Log.swift */,
94867142283EC9950093C7A4 /* Actions.swift */,
);
path = Utils;
sourceTree = "<group>";
@ -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;

View file

@ -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 {

View file

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>AppBaseURL</key>
<string>$(APP_BASE_URL)</string>
<key>UIApplicationSceneManifest</key>

View file

@ -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?
}

47
ntfy/Utils/Actions.swift Normal file
View file

@ -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()
}
}

View file

@ -1,5 +1,8 @@
import Foundation
let helperFnTag = "Helpers"
let supportedActions = ["view", "http"]
func topicUrl(baseUrl: String, topic: String) -> String {
return "\(baseUrl)/\(topic)"
}

View file

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>AppBaseURL</key>
<string>$(APP_BASE_URL)</string>
<key>NSExtension</key>

View file

@ -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