oncall-mobile-ios/ntfyNSE/NotificationService.swift

152 lines
7.5 KiB
Swift
Raw Normal View History

2022-05-14 19:01:40 -04:00
import UserNotifications
2022-05-15 20:17:07 -04:00
import CoreData
2022-05-27 20:45:17 -04:00
import CryptoKit
2022-05-14 19:01:40 -04:00
/// 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.
///
/// Note that the app extension does not run as part of the main app, so log messages are not printed in the main Xcode window. To debug,
/// 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.
2022-05-15 20:17:07 -04:00
class NotificationService: UNNotificationServiceExtension {
2022-05-24 22:27:04 -04:00
private let tag = "NotificationService"
2022-05-25 16:59:25 -04:00
private let actionsCategory = "ntfyActions" // It seems ok to re-use the same category
2022-05-14 19:01:40 -04:00
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
2022-05-20 09:53:10 -04:00
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
2022-05-14 19:01:40 -04:00
self.contentHandler = contentHandler
2022-05-20 09:53:10 -04:00
self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
Log.d(tag, "Notification received (in service)") // Logs from extensions are not printed in Xcode!
2022-05-14 19:01:40 -04:00
if let bestAttemptContent = bestAttemptContent {
2022-05-26 12:56:25 -04:00
let store = Store.shared
2022-05-15 20:17:07 -04:00
let userInfo = bestAttemptContent.userInfo
2022-05-27 20:45:17 -04:00
let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl
let topic = userInfo["topic"] as? String ?? ""
2022-05-26 12:56:25 -04:00
guard let message = Message.from(userInfo: userInfo) else {
Log.w(Store.tag, "Message cannot be parsed from userInfo", userInfo)
contentHandler(request.content)
return
}
2022-05-27 20:45:17 -04:00
if message.event == "poll_request" {
let subscription = store.getSubscriptions()?.first { $0.urlHash() == topic }
guard let subscription = subscription, let pollId = message.pollId else {
Log.w(tag, "Cannot find subscription", message)
contentHandler(request.content)
return
}
//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
}
bestAttemptContent.title = message.title ?? subscription.urlString()
bestAttemptContent.body = message.message ?? ""
contentHandler(bestAttemptContent)
//semaphore.signal()
}
//semaphore.wait(timeout: .distantFuture)
Thread.sleep(forTimeInterval: 5)
return
}
2022-05-26 12:56:25 -04:00
if message.event != "message" {
Log.w(tag, "Irrelevant message received", message)
contentHandler(request.content)
return
}
2022-05-18 20:16:30 -04:00
2022-05-26 12:56:25 -04:00
// Only handle "message" events
guard let subscription = store.getSubscription(baseUrl: baseUrl, topic: topic) else {
Log.w(tag, "Subscription for topic \(topic) unknown")
2022-05-25 20:25:58 -04:00
contentHandler(request.content)
return
}
2022-05-26 12:56:25 -04:00
// Set notification title to short URL if there is no title. The title is always set
// by the server, but it may be empty.
2022-05-26 12:56:25 -04:00
if let title = message.title, title == "" {
bestAttemptContent.title = topicShortUrl(baseUrl: baseUrl, topic: topic)
2022-05-25 11:26:23 -04:00
}
// Emojify title or message
2022-05-26 12:56:25 -04:00
let emojiTags = parseEmojiTags(message.tags)
2022-05-25 11:26:23 -04:00
if !emojiTags.isEmpty {
2022-05-26 12:56:25 -04:00
if let title = message.title, title != "" {
2022-05-25 11:26:23 -04:00
bestAttemptContent.title = emojiTags.joined(separator: "") + " " + bestAttemptContent.title
} else {
bestAttemptContent.body = emojiTags.joined(separator: "") + " " + bestAttemptContent.body
}
}
2022-05-25 16:59:25 -04:00
// 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
2022-05-26 12:56:25 -04:00
if let actions = message.actions, !actions.isEmpty {
2022-05-25 16:59:25 -04:00
bestAttemptContent.categoryIdentifier = actionsCategory
2022-05-25 16:59:25 -04:00
let center = UNUserNotificationCenter.current()
let notificationActions = actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: [.foreground]) }
2022-05-25 16:59:25 -04:00
let category = UNNotificationCategory(identifier: actionsCategory, actions: notificationActions, intentIdentifiers: [])
center.setNotificationCategories([category])
}
// Play a sound, and group by topic
bestAttemptContent.sound = .default
2022-05-25 11:26:23 -04:00
bestAttemptContent.threadIdentifier = topic
2022-05-24 22:27:04 -04:00
// Map priorities to interruption level (light up screen, ...) and relevance (order)
2022-05-25 14:42:45 -04:00
if #available(iOS 15.0, *) {
2022-05-26 12:56:25 -04:00
switch message.priority {
case 1:
2022-05-25 14:42:45 -04:00
bestAttemptContent.interruptionLevel = .passive
bestAttemptContent.relevanceScore = 0
2022-05-26 12:56:25 -04:00
case 2:
2022-05-25 14:42:45 -04:00
bestAttemptContent.interruptionLevel = .passive
bestAttemptContent.relevanceScore = 0.25
2022-05-26 12:56:25 -04:00
case 4:
2022-05-25 14:42:45 -04:00
bestAttemptContent.interruptionLevel = .timeSensitive
bestAttemptContent.relevanceScore = 0.75
2022-05-26 12:56:25 -04:00
case 5:
2022-05-25 14:42:45 -04:00
bestAttemptContent.interruptionLevel = .critical
bestAttemptContent.relevanceScore = 1
default:
bestAttemptContent.interruptionLevel = .active
bestAttemptContent.relevanceScore = 0.5
}
2022-05-24 22:27:04 -04:00
}
// Save notification to store, and display it
2022-05-26 12:56:25 -04:00
Store.shared.save(notificationFromMessage: message, withSubscription: subscription)
2022-05-14 19:01:40 -04:00
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
2022-05-20 09:53:10 -04:00
// Use this as an opportunity to deliver your "best attempt" at modified content,
// otherwise the original push payload will be used.
2022-05-14 19:01:40 -04:00
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
2022-05-27 20:45:17 -04:00
func handleMessage() {
}
func handlePollRequest() {
}
2022-05-14 19:01:40 -04:00
}