NotificationService.swift 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import UserNotifications
  2. import CoreData
  3. /// This app extension is responsible for persisting the incoming notification to the data store (Core Data). It will eventually be the entity that
  4. /// fetches notification content from selfhosted servers (when a "poll request" is received). This is not implemented yet.
  5. ///
  6. /// 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,
  7. /// 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.
  8. class NotificationService: UNNotificationServiceExtension {
  9. private let tag = "NotificationService"
  10. private let actionsCategory = "ntfyActions" // It seems ok to re-use the same category
  11. var contentHandler: ((UNNotificationContent) -> Void)?
  12. var bestAttemptContent: UNMutableNotificationContent?
  13. override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
  14. self.contentHandler = contentHandler
  15. self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
  16. Log.d(tag, "Notification received (in service)") // Logs from extensions are not printed in Xcode!
  17. if let bestAttemptContent = bestAttemptContent {
  18. let store = Store.shared
  19. let userInfo = bestAttemptContent.userInfo
  20. guard let message = Message.from(userInfo: userInfo) else {
  21. Log.w(Store.tag, "Message cannot be parsed from userInfo", userInfo)
  22. contentHandler(request.content)
  23. return
  24. }
  25. if message.event != "message" {
  26. Log.w(tag, "Irrelevant message received", message)
  27. contentHandler(request.content)
  28. return
  29. }
  30. // Only handle "message" events
  31. let baseUrl = userInfo["base_url"] as? String ?? Config.appBaseUrl
  32. let topic = userInfo["topic"] as? String ?? ""
  33. guard let subscription = store.getSubscription(baseUrl: baseUrl, topic: topic) else {
  34. Log.w(tag, "Subscription for topic \(topic) unknown")
  35. contentHandler(request.content)
  36. return
  37. }
  38. // Set notification title to short URL if there is no title. The title is always set
  39. // by the server, but it may be empty.
  40. if let title = message.title, title == "" {
  41. bestAttemptContent.title = topicShortUrl(baseUrl: baseUrl, topic: topic)
  42. }
  43. // Emojify title or message
  44. let emojiTags = parseEmojiTags(message.tags)
  45. if !emojiTags.isEmpty {
  46. if let title = message.title, title != "" {
  47. bestAttemptContent.title = emojiTags.joined(separator: "") + " " + bestAttemptContent.title
  48. } else {
  49. bestAttemptContent.body = emojiTags.joined(separator: "") + " " + bestAttemptContent.body
  50. }
  51. }
  52. // Add custom actions
  53. //
  54. // We re-define the categories every time here, which is weird, but it works. When tapped, the action sets the
  55. // actionIdentifier in the application(didReceive) callback. This logic is handled in the AppDelegate. This approach
  56. // is described in a comment in https://stackoverflow.com/questions/30103867/changing-action-titles-in-interactive-notifications-at-run-time#comment122812568_30107065
  57. //
  58. // We also must set the .foreground flag, which brings the notification to the foreground and avoids an error about
  59. // permissions. This is described in https://stackoverflow.com/a/44580916/1440785
  60. if let actions = message.actions, !actions.isEmpty {
  61. bestAttemptContent.categoryIdentifier = actionsCategory
  62. let center = UNUserNotificationCenter.current()
  63. let notificationActions = actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: [.foreground]) }
  64. let category = UNNotificationCategory(identifier: actionsCategory, actions: notificationActions, intentIdentifiers: [])
  65. center.setNotificationCategories([category])
  66. }
  67. // Play a sound, and group by topic
  68. bestAttemptContent.sound = .default
  69. bestAttemptContent.threadIdentifier = topic
  70. // Map priorities to interruption level (light up screen, ...) and relevance (order)
  71. if #available(iOS 15.0, *) {
  72. switch message.priority {
  73. case 1:
  74. bestAttemptContent.interruptionLevel = .passive
  75. bestAttemptContent.relevanceScore = 0
  76. case 2:
  77. bestAttemptContent.interruptionLevel = .passive
  78. bestAttemptContent.relevanceScore = 0.25
  79. case 4:
  80. bestAttemptContent.interruptionLevel = .timeSensitive
  81. bestAttemptContent.relevanceScore = 0.75
  82. case 5:
  83. bestAttemptContent.interruptionLevel = .critical
  84. bestAttemptContent.relevanceScore = 1
  85. default:
  86. bestAttemptContent.interruptionLevel = .active
  87. bestAttemptContent.relevanceScore = 0.5
  88. }
  89. }
  90. // Save notification to store, and display it
  91. Store.shared.save(notificationFromMessage: message, withSubscription: subscription)
  92. contentHandler(bestAttemptContent)
  93. }
  94. }
  95. override func serviceExtensionTimeWillExpire() {
  96. // Called just before the extension will be terminated by the system.
  97. // Use this as an opportunity to deliver your "best attempt" at modified content,
  98. // otherwise the original push payload will be used.
  99. if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
  100. contentHandler(bestAttemptContent)
  101. }
  102. }
  103. }