Skip to content

Commit a6eee55

Browse files
authored
Merge pull request #460 from Iterable/MOB-1604-nse
[MOB-1604] revise service extension to be reactive
2 parents 493e64a + ae70b02 commit a6eee55

File tree

3 files changed

+248
-171
lines changed

3 files changed

+248
-171
lines changed

notification-extension/ITBNotificationServiceExtension.swift

+172-121
Original file line numberDiff line numberDiff line change
@@ -8,176 +8,227 @@ import UserNotifications
88
var contentHandler: ((UNNotificationContent) -> Void)?
99
var bestAttemptContent: UNMutableNotificationContent?
1010

11-
@objc override open func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
11+
@objc override open func didReceive(_ request: UNNotificationRequest,
12+
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
1213
self.contentHandler = contentHandler
1314
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
1415

15-
// IMPORTANT: need to add this to the documentation
16-
bestAttemptContent?.categoryIdentifier = getCategory(fromContent: request.content)
17-
18-
guard let itblDictionary = request.content.userInfo[JsonKey.Payload.metadata] as? [AnyHashable: Any] else {
19-
if let bestAttemptContent = bestAttemptContent {
20-
contentHandler(bestAttemptContent)
21-
}
22-
23-
return
24-
}
25-
26-
var contentHandlerCalled = false
27-
contentHandlerCalled = loadAttachment(itblDictionary: itblDictionary)
16+
getCategoryId(from: request.content)
17+
retrieveAttachment(from: request.content)
2818

29-
if !contentHandlerCalled {
30-
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
31-
if let bestAttemptContent = self.bestAttemptContent {
32-
contentHandler(bestAttemptContent)
33-
}
34-
}
35-
}
19+
checkPushCreationCompletion()
3620
}
3721

3822
@objc override open func serviceExtensionTimeWillExpire() {
3923
// Called just before the extension will be terminated by the system.
4024
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
41-
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
42-
contentHandler(bestAttemptContent)
43-
}
25+
26+
callContentHandler()
4427
}
4528

46-
private func loadAttachment(itblDictionary: [AnyHashable: Any]) -> Bool {
47-
guard let attachmentUrlString = itblDictionary[JsonKey.Payload.attachmentUrl] as? String else { return false }
48-
guard let url = URL(string: attachmentUrlString) else { return false }
29+
// MARK: - Private
30+
31+
private func retrieveAttachment(from content: UNNotificationContent) {
32+
guard let metadata = content.userInfo[JsonKey.Payload.metadata] as? [AnyHashable: Any],
33+
let attachmentUrlString = metadata[JsonKey.Payload.attachmentUrl] as? String,
34+
let url = URL(string: attachmentUrlString) else {
35+
attachmentRetrievalFinished = true
36+
return
37+
}
4938

50-
let downloadTask = URLSession.shared.downloadTask(with: url, completionHandler: { [weak self] location, response, error in
51-
guard let strongSelf = self else { return }
52-
53-
if error == nil, let response = response, let responseUrl = response.url, let location = location {
54-
let tempDirectoryUrl = FileManager.default.temporaryDirectory
55-
var attachmentIdString = UUID().uuidString + responseUrl.lastPathComponent
56-
if let suggestedFilename = response.suggestedFilename {
57-
attachmentIdString = UUID().uuidString + suggestedFilename
58-
}
59-
60-
var attachment: UNNotificationAttachment?
61-
let tempFileUrl = tempDirectoryUrl.appendingPathComponent(attachmentIdString)
62-
do {
63-
try FileManager.default.moveItem(at: location, to: tempFileUrl)
64-
attachment = try UNNotificationAttachment(identifier: attachmentIdString, url: tempFileUrl, options: nil)
65-
} catch { /* TODO: FileManager or attachment error */ }
66-
67-
if let attachment = attachment, let bestAttemptContent = strongSelf.bestAttemptContent, let contentHandler = strongSelf.contentHandler {
68-
bestAttemptContent.attachments.append(attachment)
69-
contentHandler(bestAttemptContent)
70-
}
71-
} else { /* TODO: handle download error */ }
72-
})
73-
74-
downloadTask.resume()
75-
return true
76-
}
77-
78-
private func getCategory(fromContent content: UNNotificationContent) -> String {
79-
if content.categoryIdentifier.count == 0 {
80-
guard let itblDictionary = content.userInfo[JsonKey.Payload.metadata] as? [AnyHashable: Any] else {
81-
return ""
39+
stopCurrentAttachmentDownloadTask()
40+
41+
attachmentDownloadTask = createAttachmentDownloadTask(url: url)
42+
attachmentDownloadTask?.resume()
43+
}
44+
45+
private func createAttachmentDownloadTask(url: URL) -> URLSessionDownloadTask {
46+
return URLSession.shared.downloadTask(with: url) { [weak self] location, response, error in
47+
guard let strongSelf = self, error == nil, let response = response, let responseUrl = response.url, let location = location else {
48+
self?.attachmentRetrievalFinished = true
49+
return
8250
}
8351

84-
guard let messageId = itblDictionary[JsonKey.Payload.messageId] as? String else {
85-
return ""
86-
}
52+
let attachmentId = UUID().uuidString + ITBNotificationServiceExtension.getAttachmentIdSuffix(response: response,
53+
responseUrl: responseUrl)
54+
let tempFileUrl = FileManager.default.temporaryDirectory.appendingPathComponent(attachmentId)
8755

88-
var actionButtons: [[AnyHashable: Any]] = []
89-
if let actionButtonsFromITBLPayload = itblDictionary[JsonKey.Payload.actionButtons] as? [[AnyHashable: Any]] {
90-
actionButtons = actionButtonsFromITBLPayload
91-
} else {
92-
#if DEBUG
93-
if let actionButtonsFromUserInfo = content.userInfo[JsonKey.Payload.actionButtons] as? [[AnyHashable: Any]] {
94-
actionButtons = actionButtonsFromUserInfo
95-
}
96-
#endif
97-
}
56+
var attachment: UNNotificationAttachment?
9857

99-
var notificationActions = [UNNotificationAction]()
100-
for actionButton in actionButtons {
101-
if let notificationAction = createNotificationActionButton(buttonDictionary: actionButton) {
102-
notificationActions.append(notificationAction)
103-
}
58+
do {
59+
try FileManager.default.moveItem(at: location, to: tempFileUrl)
60+
attachment = try UNNotificationAttachment(identifier: attachmentId, url: tempFileUrl, options: nil)
61+
} catch {
62+
self?.attachmentRetrievalFinished = true
63+
return
10464
}
10565

106-
messageCategory = UNNotificationCategory(identifier: messageId, actions: notificationActions, intentIdentifiers: [], options: [])
107-
if let messageCategory = messageCategory {
108-
UNUserNotificationCenter.current().getNotificationCategories { categories in
109-
var newCategories = categories
110-
newCategories.insert(messageCategory)
111-
UNUserNotificationCenter.current().setNotificationCategories(newCategories)
112-
}
66+
if let attachment = attachment, let content = strongSelf.bestAttemptContent, let handler = strongSelf.contentHandler {
67+
content.attachments.append(attachment)
68+
handler(content)
69+
} else {
70+
self?.attachmentRetrievalFinished = true
71+
return
72+
}
73+
}
74+
}
75+
76+
private func stopCurrentAttachmentDownloadTask() {
77+
attachmentDownloadTask?.cancel()
78+
attachmentDownloadTask = nil
79+
}
80+
81+
private func getCategoryId(from content: UNNotificationContent) {
82+
guard content.categoryIdentifier.count == 0 else {
83+
setCategoryId(id: content.categoryIdentifier)
84+
return
85+
}
86+
87+
guard let metadata = content.userInfo[JsonKey.Payload.metadata] as? [AnyHashable: Any],
88+
let messageId = metadata[JsonKey.Payload.messageId] as? String else {
89+
setCategoryId(id: "")
90+
return
91+
}
92+
93+
messageCategory = UNNotificationCategory(identifier: messageId,
94+
actions: getNotificationActions(metadata: metadata,
95+
content: content),
96+
intentIdentifiers: [],
97+
options: [])
98+
99+
if let messageCategory = messageCategory {
100+
UNUserNotificationCenter.current().getNotificationCategories { [weak self] categories in
101+
var newCategories = categories
102+
newCategories.insert(messageCategory)
103+
UNUserNotificationCenter.current().setNotificationCategories(newCategories)
104+
self?.setCategoryId(id: messageId)
113105
}
114-
115-
return messageId
116106
} else {
117-
return content.categoryIdentifier
107+
setCategoryId(id: messageId)
118108
}
119109
}
120110

121-
private func createNotificationActionButton(buttonDictionary: [AnyHashable: Any]) -> UNNotificationAction? {
122-
guard let identifier = buttonDictionary[JsonKey.ActionButton.identifier] as? String else {
123-
return nil
111+
private func setCategoryId(id: String) {
112+
// IMPORTANT: need to add this to the documentation
113+
bestAttemptContent?.categoryIdentifier = id
114+
115+
// for some reason, the check needs to be put into this dispatch
116+
// to function properly for rich pushes with buttons but no image
117+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
118+
self.getCategoryIdFinished = true
124119
}
120+
}
121+
122+
private func getNotificationActions(metadata: [AnyHashable: Any], content: UNNotificationContent) -> [UNNotificationAction] {
123+
var actionButtons: [[AnyHashable: Any]] = []
125124

126-
guard let title = buttonDictionary[JsonKey.ActionButton.title] as? String else {
127-
return nil
125+
if let actionButtonsFromMetadata = metadata[JsonKey.Payload.actionButtons] as? [[AnyHashable: Any]] {
126+
actionButtons = actionButtonsFromMetadata
127+
} else {
128+
#if DEBUG
129+
if let actionButtonsFromUserInfo = content.userInfo[JsonKey.Payload.actionButtons] as? [[AnyHashable: Any]] {
130+
actionButtons = actionButtonsFromUserInfo
131+
}
132+
#endif
128133
}
129134

130-
let buttonType = getButtonType(buttonDictionary: buttonDictionary)
131-
var openApp = true
132-
if let openAppFromDict = buttonDictionary[JsonKey.ActionButton.openApp] as? NSNumber {
133-
openApp = openAppFromDict.boolValue
135+
return actionButtons.compactMap { createNotificationActionButton(info: $0) }
136+
}
137+
138+
private func createNotificationActionButton(info: [AnyHashable: Any]) -> UNNotificationAction? {
139+
guard let identifier = info[JsonKey.ActionButton.identifier] as? String else { return nil }
140+
guard let title = info[JsonKey.ActionButton.title] as? String else { return nil }
141+
142+
let buttonType = getButtonType(info: info)
143+
let openApp = getBoolValue(info[JsonKey.ActionButton.openApp]) ?? true
144+
let requiresUnlock = getBoolValue(info[JsonKey.ActionButton.requiresUnlock]) ?? false
145+
146+
let options = getActionButtonOptions(buttonType: buttonType,
147+
openApp: openApp,
148+
requiresUnlock: requiresUnlock)
149+
150+
guard buttonType == IterableButtonTypeTextInput else {
151+
return UNNotificationAction(identifier: identifier, title: title, options: options)
134152
}
135153

136-
var requiresUnlock = false
137-
if let requiresUnlockFromDict = buttonDictionary[JsonKey.ActionButton.requiresUnlock] as? NSNumber {
138-
requiresUnlock = requiresUnlockFromDict.boolValue
154+
let inputTitle = info[JsonKey.ActionButton.inputTitle] as? String ?? ""
155+
let inputPlaceholder = info[JsonKey.ActionButton.inputPlaceholder] as? String ?? ""
156+
157+
return UNTextInputNotificationAction(identifier: identifier,
158+
title: title,
159+
options: options,
160+
textInputButtonTitle: inputTitle,
161+
textInputPlaceholder: inputPlaceholder)
162+
}
163+
164+
private func getButtonType(info: [AnyHashable: Any]) -> String {
165+
if let buttonType = info[JsonKey.ActionButton.buttonType] as? String {
166+
if buttonType == IterableButtonTypeTextInput || buttonType == IterableButtonTypeDestructive {
167+
return buttonType
168+
}
139169
}
140170

141-
var actionOptions: UNNotificationActionOptions = []
171+
return IterableButtonTypeDefault
172+
}
173+
174+
private func getBoolValue(_ value: Any?) -> Bool? {
175+
return (value as? NSNumber)?.boolValue
176+
}
177+
178+
private func getActionButtonOptions(buttonType: String, openApp: Bool, requiresUnlock: Bool) -> UNNotificationActionOptions {
179+
var options: UNNotificationActionOptions = []
180+
142181
if buttonType == IterableButtonTypeDestructive {
143-
actionOptions.insert(.destructive)
182+
options.insert(.destructive)
144183
}
145184

146185
if openApp {
147-
actionOptions.insert(.foreground)
186+
options.insert(.foreground)
148187
}
149188

150189
if requiresUnlock || openApp {
151-
actionOptions.insert(.authenticationRequired)
190+
options.insert(.authenticationRequired)
152191
}
153192

154-
if buttonType == IterableButtonTypeTextInput {
155-
let inputTitle = buttonDictionary[JsonKey.ActionButton.inputTitle] as? String ?? ""
156-
let inputPlaceholder = buttonDictionary[JsonKey.ActionButton.inputPlaceholder] as? String ?? ""
157-
158-
return UNTextInputNotificationAction(identifier: identifier,
159-
title: title,
160-
options: actionOptions,
161-
textInputButtonTitle: inputTitle,
162-
textInputPlaceholder: inputPlaceholder)
163-
} else {
164-
return UNNotificationAction(identifier: identifier, title: title, options: actionOptions)
193+
return options
194+
}
195+
196+
private func checkPushCreationCompletion() {
197+
if getCategoryIdFinished && attachmentRetrievalFinished {
198+
callContentHandler()
199+
}
200+
}
201+
202+
private func callContentHandler() {
203+
stopCurrentAttachmentDownloadTask()
204+
205+
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
206+
contentHandler(bestAttemptContent)
165207
}
166208
}
167209

168-
private func getButtonType(buttonDictionary: [AnyHashable: Any]) -> String {
169-
guard let buttonType = buttonDictionary[JsonKey.ActionButton.buttonType] as? String else {
170-
return IterableButtonTypeDefault
210+
private static func getAttachmentIdSuffix(response: URLResponse, responseUrl: URL) -> String {
211+
if let suggestedFilename = response.suggestedFilename {
212+
return suggestedFilename
171213
}
172214

173-
if buttonType == IterableButtonTypeTextInput || buttonType == IterableButtonTypeDestructive {
174-
return buttonType
175-
} else {
176-
return IterableButtonTypeDefault
215+
return responseUrl.lastPathComponent
216+
}
217+
218+
private var getCategoryIdFinished: Bool = false {
219+
didSet {
220+
checkPushCreationCompletion()
221+
}
222+
}
223+
224+
private var attachmentRetrievalFinished: Bool = false {
225+
didSet {
226+
checkPushCreationCompletion()
177227
}
178228
}
179229

180230
private var messageCategory: UNNotificationCategory?
231+
private var attachmentDownloadTask: URLSessionDownloadTask?
181232
private let IterableButtonTypeDefault = "default"
182233
private let IterableButtonTypeDestructive = "destructive"
183234
private let IterableButtonTypeTextInput = "textInput"

0 commit comments

Comments
 (0)