Support for user actions
This commit is contained in:
parent
ae6a212671
commit
72386665f9
8 changed files with 147 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
47
ntfy/Utils/Actions.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
let helperFnTag = "Helpers"
|
||||
let supportedActions = ["view", "http"]
|
||||
|
||||
func topicUrl(baseUrl: String, topic: String) -> String {
|
||||
return "\(baseUrl)/\(topic)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue