mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			511 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			511 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import Foundation
 | |
| import AVFAudio
 | |
| import Combine
 | |
| import GRDB
 | |
| import CallKit
 | |
| import UserNotifications
 | |
| import BackgroundTasks
 | |
| import SessionMessagingKit
 | |
| import SignalUtilitiesKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| public final class NotificationServiceExtension: UNNotificationServiceExtension {
 | |
|     private let dependencies: Dependencies = Dependencies()
 | |
|     private var didPerformSetup = false
 | |
|     private var contentHandler: ((UNNotificationContent) -> Void)?
 | |
|     private var request: UNNotificationRequest?
 | |
|     private var hasCompleted: Atomic<Bool> = Atomic(false)
 | |
| 
 | |
|     public static let isFromRemoteKey = "remote"                                                                   // stringlint:disable
 | |
|     public static let threadIdKey = "Signal.AppNotificationsUserInfoKey.threadId"                                  // stringlint:disable
 | |
|     public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw"                     // stringlint:disable
 | |
|     public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter"  // stringlint:disable
 | |
|     private static let callPreOfferLargeNotificationSupressionDuration: TimeInterval = 30
 | |
| 
 | |
|     // MARK: Did receive a remote push notification request
 | |
|     
 | |
|     override public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
 | |
|         self.contentHandler = contentHandler
 | |
|         self.request = request
 | |
|         
 | |
|         // It's technically possible for 'completeSilently' to be called twice due to the NSE timeout so
 | |
|         self.hasCompleted.mutate { $0 = false }
 | |
|         
 | |
|         // Abort if the main app is running
 | |
|         guard !(UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else {
 | |
|             Log.info("didReceive called while main app running.")
 | |
|             return self.completeSilenty(handledNotification: false, isMainAppAndActive: true)
 | |
|         }
 | |
|         
 | |
|         guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
 | |
|             Log.info("didReceive called with no content.")
 | |
|             return self.completeSilenty(handledNotification: false)
 | |
|         }
 | |
|         
 | |
|         Log.info("didReceive called.")
 | |
|         
 | |
|         /// Create the context if we don't have it (needed before _any_ interaction with the database)
 | |
|         if !Singleton.hasAppContext {
 | |
|             Singleton.setup(appContext: NotificationServiceExtensionContext())
 | |
|         }
 | |
|         
 | |
|         // Perform main setup
 | |
|         Storage.resumeDatabaseAccess(using: dependencies)
 | |
|         DispatchQueue.main.sync {
 | |
|             self.setUpIfNecessary() { [weak self] in
 | |
|                 self?.handleNotification(notificationContent, isPerformingResetup: false)
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func handleNotification(_ notificationContent: UNMutableNotificationContent, isPerformingResetup: Bool) {
 | |
|         let (maybeData, metadata, result) = PushNotificationAPI.processNotification(
 | |
|             notificationContent: notificationContent,
 | |
|             using: dependencies
 | |
|         )
 | |
|         
 | |
|         guard metadata.accountId == getUserHexEncodedPublicKey(using: dependencies) else {
 | |
|             guard !isPerformingResetup else {
 | |
|                 Log.error("Received notification for an accountId that isn't the current user, resetup failed.")
 | |
|                 return self.completeSilenty(handledNotification: false)
 | |
|             }
 | |
|             
 | |
|             Log.warn("Received notification for an accountId that isn't the current user, attempting to resetup.")
 | |
|             return self.forceResetup(notificationContent)
 | |
|         }
 | |
|         
 | |
|         guard
 | |
|             (result == .success || result == .legacySuccess),
 | |
|             let data: Data = maybeData
 | |
|         else {
 | |
|             switch result {
 | |
|                 // If we got an explicit failure, or we got a success but no content then show
 | |
|                 // the fallback notification
 | |
|                 case .success, .legacySuccess, .failure, .legacyFailure:
 | |
|                     return self.handleFailure(for: notificationContent, error: .processing(result))
 | |
|                     
 | |
|                 // Just log if the notification was too long (a ~2k message should be able to fit so
 | |
|                 // these will most commonly be call or config messages)
 | |
|                 case .successTooLong:
 | |
|                     Log.info("Received too long notification for namespace: \(metadata.namespace), dataLength: \(metadata.dataLength).")
 | |
|                     return self.completeSilenty(handledNotification: false)
 | |
|                     
 | |
|                 case .legacyForceSilent:
 | |
|                     Log.info("Ignoring non-group legacy notification.")
 | |
|                     return self.completeSilenty(handledNotification: false)
 | |
|                     
 | |
|                 case .failureNoContent:
 | |
|                     Log.warn("Failed due to missing notification content.")
 | |
|                     return self.completeSilenty(handledNotification: false)
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         let isCallOngoing: Bool = (UserDefaults.sharedLokiProject?[.isCallOngoing])
 | |
|             .defaulting(to: false)
 | |
|         
 | |
|         // HACK: It is important to use write synchronously here to avoid a race condition
 | |
|         // where the completeSilenty() is called before the local notification request
 | |
|         // is added to notification center
 | |
|         dependencies.storage.write { [weak self, dependencies] db in
 | |
|             do {
 | |
|                 guard let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification(db, data: data, metadata: metadata, using: dependencies) else {
 | |
|                     throw NotificationError.messageProcessing
 | |
|                 }
 | |
|                 
 | |
|                 switch processedMessage {
 | |
|                     /// Custom handle config messages (as they don't get handled by the normal `MessageReceiver.handle` call
 | |
|                     case .config(let publicKey, let namespace, let serverHash, let serverTimestampMs, let data):
 | |
|                         try LibSession.handleConfigMessages(
 | |
|                             db,
 | |
|                             messages: [
 | |
|                                 ConfigMessageReceiveJob.Details.MessageInfo(
 | |
|                                     namespace: namespace,
 | |
|                                     serverHash: serverHash,
 | |
|                                     serverTimestampMs: serverTimestampMs,
 | |
|                                     data: data
 | |
|                                 )
 | |
|                             ],
 | |
|                             publicKey: publicKey,
 | |
|                             using: dependencies
 | |
|                         )
 | |
|                         
 | |
|                     /// Due to the way the `CallMessage` works we need to custom handle it's behaviour within the notification
 | |
|                     /// extension, for all other message types we want to just use the standard `MessageReceiver.handle` call
 | |
|                     case .standard(let threadId, let threadVariant, _, let messageInfo) where messageInfo.message is CallMessage:
 | |
|                         guard let callMessage = messageInfo.message as? CallMessage else {
 | |
|                             throw NotificationError.ignorableMessage
 | |
|                         }
 | |
|                         
 | |
|                         // Throw if the message is outdated and shouldn't be processed
 | |
|                         try MessageReceiver.throwIfMessageOutdated(
 | |
|                             db,
 | |
|                             message: messageInfo.message,
 | |
|                             threadId: threadId,
 | |
|                             threadVariant: threadVariant,
 | |
|                             using: dependencies
 | |
|                         )
 | |
|                         
 | |
|                         try MessageReceiver.handleCallMessage(
 | |
|                             db,
 | |
|                             threadId: threadId,
 | |
|                             threadVariant: threadVariant,
 | |
|                             message: callMessage,
 | |
|                             using: dependencies
 | |
|                         )
 | |
|                         
 | |
|                         guard case .preOffer = callMessage.kind else {
 | |
|                             throw NotificationError.ignorableMessage
 | |
|                         }
 | |
|                         
 | |
|                         let hasMicrophonePermission: Bool = (AVAudioSession.sharedInstance().recordPermission == .granted)
 | |
|                         switch ((db[.areCallsEnabled] && hasMicrophonePermission), isCallOngoing) {
 | |
|                             case (false, _):
 | |
|                                 if
 | |
|                                     let sender: String = callMessage.sender,
 | |
|                                     let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(
 | |
|                                         db,
 | |
|                                         for: callMessage,
 | |
|                                         state: (db[.areCallsEnabled] ? .permissionDeniedMicrophone : .permissionDenied),
 | |
|                                         using: dependencies
 | |
|                                     )
 | |
|                                 {
 | |
|                                     let thread: SessionThread = try SessionThread
 | |
|                                         .fetchOrCreate(
 | |
|                                             db,
 | |
|                                             id: sender,
 | |
|                                             variant: .contact,
 | |
|                                             shouldBeVisible: nil
 | |
|                                         )
 | |
| 
 | |
|                                     // Notify the user if the call message wasn't already read
 | |
|                                     if !interaction.wasRead {
 | |
|                                         SessionEnvironment.shared?.notificationsManager.wrappedValue?
 | |
|                                             .notifyUser(
 | |
|                                                 db,
 | |
|                                                 forIncomingCall: interaction,
 | |
|                                                 in: thread,
 | |
|                                                 applicationState: .background
 | |
|                                             )
 | |
|                                     }
 | |
|                                 }
 | |
|                                 
 | |
|                             case (true, true):
 | |
|                                 try MessageReceiver.handleIncomingCallOfferInBusyState(
 | |
|                                     db,
 | |
|                                     message: callMessage
 | |
|                                 )
 | |
|                                 
 | |
|                             case (true, false):
 | |
|                                 try MessageReceiver.insertCallInfoMessage(db, for: callMessage, using: dependencies)
 | |
|                                 
 | |
|                                 // Perform any required post-handling logic
 | |
|                                 try MessageReceiver.postHandleMessage(
 | |
|                                     db,
 | |
|                                     threadId: threadId,
 | |
|                                     threadVariant: threadVariant,
 | |
|                                     message: messageInfo.message
 | |
|                                 )
 | |
|                                 
 | |
|                                 return self?.handleSuccessForIncomingCall(db, for: callMessage)
 | |
|                         }
 | |
|                         
 | |
|                         // Perform any required post-handling logic
 | |
|                         try MessageReceiver.postHandleMessage(
 | |
|                             db,
 | |
|                             threadId: threadId,
 | |
|                             threadVariant: threadVariant,
 | |
|                             message: messageInfo.message
 | |
|                         )
 | |
|                         
 | |
|                     case .standard(let threadId, let threadVariant, let proto, let messageInfo):
 | |
|                         try MessageReceiver.handle(
 | |
|                             db,
 | |
|                             threadId: threadId,
 | |
|                             threadVariant: threadVariant,
 | |
|                             message: messageInfo.message,
 | |
|                             serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
 | |
|                             associatedWithProto: proto,
 | |
|                             using: dependencies
 | |
|                         )
 | |
|                 }
 | |
|                 
 | |
|                 db.afterNextTransaction(
 | |
|                     onCommit: { _ in self?.completeSilenty(handledNotification: true) },
 | |
|                     onRollback: { _ in self?.completeSilenty(handledNotification: false) }
 | |
|                 )
 | |
|             }
 | |
|             catch {
 | |
|                 // If an error occurred we want to rollback the transaction (by throwing) and then handle
 | |
|                 // the error outside of the database
 | |
|                 let handleError = {
 | |
|                     switch error {
 | |
|                         case MessageReceiverError.noGroupKeyPair:
 | |
|                             Log.warn("Failed due to having no legacy group decryption keys.")
 | |
|                             self?.completeSilenty(handledNotification: false)
 | |
|                             
 | |
|                         case MessageReceiverError.outdatedMessage:
 | |
|                             Log.info("Ignoring notification for already seen message.")
 | |
|                             self?.completeSilenty(handledNotification: false)
 | |
|                             
 | |
|                         case NotificationError.ignorableMessage:
 | |
|                             Log.info("Ignoring message which requires no notification.")
 | |
|                             self?.completeSilenty(handledNotification: false)
 | |
|                             
 | |
|                         case MessageReceiverError.duplicateMessage, MessageReceiverError.duplicateControlMessage,
 | |
|                             MessageReceiverError.duplicateMessageNewSnode:
 | |
|                             Log.info("Ignoring duplicate message (probably received it just before going to the background).")
 | |
|                             self?.completeSilenty(handledNotification: false)
 | |
|                             
 | |
|                         case NotificationError.messageProcessing:
 | |
|                             self?.handleFailure(for: notificationContent, error: .messageProcessing)
 | |
|                             
 | |
|                         case let msgError as MessageReceiverError:
 | |
|                             self?.handleFailure(for: notificationContent, error: .messageHandling(msgError))
 | |
|                             
 | |
|                         default: self?.handleFailure(for: notificationContent, error: .other(error))
 | |
|                     }
 | |
|                 }
 | |
|                 
 | |
|                 db.afterNextTransaction(
 | |
|                     onCommit: { _ in  handleError() },
 | |
|                     onRollback: { _ in handleError() }
 | |
|                 )
 | |
|                 throw error
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: Setup
 | |
| 
 | |
|     private func setUpIfNecessary(completion: @escaping () -> Void) {
 | |
|         Log.assertOnMainThread()
 | |
| 
 | |
|         // The NSE will often re-use the same process, so if we're
 | |
|         // already set up we want to do nothing; we're already ready
 | |
|         // to process new messages.
 | |
|         guard !didPerformSetup else { return completion() }
 | |
| 
 | |
|         Log.info("Performing setup.")
 | |
|         didPerformSetup = true
 | |
| 
 | |
|         _ = AppVersion.shared
 | |
| 
 | |
|         AppSetup.setupEnvironment(
 | |
|             retrySetupIfDatabaseInvalid: true,
 | |
|             appSpecificBlock: {
 | |
|                 Log.setup(with: Logger(
 | |
|                     primaryPrefix: "NotificationServiceExtension",                                               // stringlint:disable
 | |
|                     level: .info,
 | |
|                     customDirectory: "\(FileManager.default.appSharedDataDirectoryPath)/Logs/NotificationExtension", // stringlint:disable
 | |
|                     forceNSLog: true
 | |
|                 ))
 | |
|                 
 | |
|                 SessionEnvironment.shared?.notificationsManager.mutate {
 | |
|                     $0 = NSENotificationPresenter()
 | |
|                 }
 | |
|                 
 | |
|                 // Setup LibSession
 | |
|                 LibSession.addLogger()
 | |
|             },
 | |
|             migrationsCompletion: { [weak self] result, needsConfigSync in
 | |
|                 switch result {
 | |
|                     case .failure(let error):
 | |
|                         Log.error("Failed to complete migrations: \(error).")
 | |
|                         self?.completeSilenty(handledNotification: false)
 | |
|                         
 | |
|                     case .success:
 | |
|                         // We should never receive a non-voip notification on an app that doesn't support
 | |
|                         // app extensions since we have to inform the service we wanted these, so in theory
 | |
|                         // this path should never occur. However, the service does have our push token
 | |
|                         // so it is possible that could change in the future. If it does, do nothing
 | |
|                         // and don't disturb the user. Messages will be processed when they open the app.
 | |
|                         guard Storage.shared[.isReadyForAppExtensions] else {
 | |
|                             Log.error("Not ready for extensions.")
 | |
|                             self?.completeSilenty(handledNotification: false)
 | |
|                             return
 | |
|                         }
 | |
|                         
 | |
|                         DispatchQueue.main.async {
 | |
|                             self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync, completion: completion)
 | |
|                         }
 | |
|                 }
 | |
|             },
 | |
|             using: dependencies
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     private func versionMigrationsDidComplete(needsConfigSync: Bool, completion: @escaping () -> Void) {
 | |
|         Log.assertOnMainThread()
 | |
| 
 | |
|         // If we need a config sync then trigger it now
 | |
|         if needsConfigSync {
 | |
|             Storage.shared.write { db in
 | |
|                 ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // App isn't ready until storage is ready AND all version migrations are complete.
 | |
|         guard Storage.shared.isValid else {
 | |
|             Log.error("Storage invalid.")
 | |
|             return self.completeSilenty(handledNotification: false)
 | |
|         }
 | |
|         
 | |
|         // If the app wasn't ready then mark it as ready now
 | |
|         if !Singleton.appReadiness.isAppReady {
 | |
|             // Note that this does much more than set a flag; it will also run all deferred blocks.
 | |
|             Singleton.appReadiness.setAppReady()
 | |
|         }
 | |
|         
 | |
|         completion()
 | |
|     }
 | |
|     
 | |
|     /// It's possible for the NotificationExtension to still have some kind of cached data from the old database after it's been deleted
 | |
|     /// when a new account is created shortly after, this results in weird errors when receiving PNs for the new account
 | |
|     ///
 | |
|     /// In order to avoid this situation we check to see whether the received PN is targetting the current user and, if not, we call this
 | |
|     /// method to force a resetup of the notification extension
 | |
|     ///
 | |
|     /// **Note:** We need to reconfigure the database here because if the database was deleted it's possible for the NotificationExtension
 | |
|     /// to somehow still have some form of access to the old one
 | |
|     private func forceResetup(_ notificationContent: UNMutableNotificationContent) {
 | |
|         Storage.reconfigureDatabase()
 | |
|         LibSession.clearMemoryState(using: dependencies)
 | |
|         dependencies.caches.mutate(cache: .general) { $0.clearCachedUserPublicKey() }
 | |
|         
 | |
|         self.setUpIfNecessary() { [weak self, dependencies] in
 | |
|             // If we had already done a setup then `libSession` won't have been re-setup so
 | |
|             // we need to do so now (this ensures it has the correct user keys as well)
 | |
|             Storage.shared.read { db in
 | |
|                 LibSession.loadState(
 | |
|                     db,
 | |
|                     userPublicKey: getUserHexEncodedPublicKey(db),
 | |
|                     ed25519SecretKey: Identity.fetchUserEd25519KeyPair(db)?.secretKey,
 | |
|                     using: dependencies
 | |
|                 )
 | |
|             }
 | |
|             
 | |
|             self?.handleNotification(notificationContent, isPerformingResetup: true)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: Handle completion
 | |
|     
 | |
|     override public func serviceExtensionTimeWillExpire() {
 | |
|         // Called just before the extension will be terminated by the system.
 | |
|         // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
 | |
|         Log.warn("Execution time expired.")
 | |
|         completeSilenty(handledNotification: false)
 | |
|     }
 | |
|     
 | |
|     private func completeSilenty(handledNotification: Bool, isMainAppAndActive: Bool = false) {
 | |
|         // Ensure we only run this once
 | |
|         guard
 | |
|             hasCompleted.mutate({ hasCompleted in
 | |
|                 let wasCompleted: Bool = hasCompleted
 | |
|                 hasCompleted = true
 | |
|                 return wasCompleted
 | |
|             }) == false
 | |
|         else { return }
 | |
|         
 | |
|         let silentContent: UNMutableNotificationContent = UNMutableNotificationContent()
 | |
|         silentContent.badge = Storage.shared
 | |
|             .read { db in try Interaction.fetchUnreadCount(db) }
 | |
|             .map { NSNumber(value: $0) }
 | |
|             .defaulting(to: NSNumber(value: 0))
 | |
|         
 | |
|         Log.info(handledNotification ? "Completed after handling notification." : "Completed silently.")
 | |
|         if !isMainAppAndActive {
 | |
|             Storage.suspendDatabaseAccess(using: dependencies)
 | |
|         }
 | |
|         Log.flush()
 | |
|         
 | |
|         self.contentHandler!(silentContent)
 | |
|     }
 | |
|     
 | |
|     private func handleSuccessForIncomingCall(_ db: Database, for callMessage: CallMessage) {
 | |
|         if #available(iOSApplicationExtension 14.5, *), Preferences.isCallKitSupported {
 | |
|             guard let caller: String = callMessage.sender, let timestamp = callMessage.sentTimestamp else { return }
 | |
|             
 | |
|             let reportCall: () -> () = { [weak self] in
 | |
|                 let payload: JSON = [
 | |
|                     "uuid": callMessage.uuid,   // stringlint:disable
 | |
|                     "caller": caller,           // stringlint:disable
 | |
|                     "timestamp": timestamp      // stringlint:disable
 | |
|                 ]
 | |
|                 
 | |
|                 CXProvider.reportNewIncomingVoIPPushPayload(payload) { error in
 | |
|                     if let error = error {
 | |
|                         Log.error("Failed to notify main app of call message: \(error).")
 | |
|                         Storage.shared.read { db in
 | |
|                             self?.handleFailureForVoIP(db, for: callMessage)
 | |
|                         }
 | |
|                     }
 | |
|                     else {
 | |
|                         Log.info("Successfully notified main app of call message.")
 | |
|                         UserDefaults.sharedLokiProject?[.lastCallPreOffer] = Date()
 | |
|                         self?.completeSilenty(handledNotification: true)
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|             
 | |
|             db.afterNextTransaction(
 | |
|                 onCommit: { _ in reportCall() },
 | |
|                 onRollback: { _ in reportCall() }
 | |
|             )
 | |
|         }
 | |
|         else {
 | |
|             self.handleFailureForVoIP(db, for: callMessage)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func handleFailureForVoIP(_ db: Database, for callMessage: CallMessage) {
 | |
|         let notificationContent = UNMutableNotificationContent()
 | |
|         notificationContent.userInfo = [ NotificationServiceExtension.isFromRemoteKey : true ]
 | |
|         notificationContent.title = "Session"
 | |
|         notificationContent.badge = (try? Interaction.fetchUnreadCount(db))
 | |
|             .map { NSNumber(value: $0) }
 | |
|             .defaulting(to: NSNumber(value: 0))
 | |
|         
 | |
|         if let sender: String = callMessage.sender {
 | |
|             let senderDisplayName: String = Profile.displayName(db, id: sender, threadVariant: .contact)
 | |
|             notificationContent.body = "\(senderDisplayName) is calling..."
 | |
|         }
 | |
|         else {
 | |
|             notificationContent.body = "Incoming call..."
 | |
|         }
 | |
|         
 | |
|         let identifier = self.request?.identifier ?? UUID().uuidString
 | |
|         let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: nil)
 | |
|         let semaphore = DispatchSemaphore(value: 0)
 | |
|         
 | |
|         UNUserNotificationCenter.current().add(request) { error in
 | |
|             if let error = error {
 | |
|                 Log.error("Failed to add notification request due to error: \(error).")
 | |
|             }
 | |
|             semaphore.signal()
 | |
|         }
 | |
|         semaphore.wait()
 | |
|         Log.info("Add remote notification request.")
 | |
|         
 | |
|         db.afterNextTransaction(
 | |
|             onCommit: { [weak self] _ in self?.completeSilenty(handledNotification: true) },
 | |
|             onRollback: { [weak self] _ in self?.completeSilenty(handledNotification: false) }
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     private func handleFailure(for content: UNMutableNotificationContent, error: NotificationError) {
 | |
|         Log.error("Show generic failure message due to error: \(error).")
 | |
|         Storage.suspendDatabaseAccess(using: dependencies)
 | |
|         Log.flush()
 | |
|         
 | |
|         content.title = "Session"
 | |
|         content.body = "APN_Message".localized()
 | |
|         let userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ]
 | |
|         content.userInfo = userInfo
 | |
|         contentHandler!(content)
 | |
|         hasCompleted.mutate { $0 = true }
 | |
|     }
 | |
| }
 |