// // Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import Intents import SignalCoreKit import UserNotifications public class UserNotificationConfig { class var allNotificationCategories: Set { let categories = AppNotificationCategory.allCases.map { notificationCategory($0) } return Set(categories) } class func notificationActions(for category: AppNotificationCategory) -> [UNNotificationAction] { return category.actions.compactMap { notificationAction($0) } } class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory { return UNNotificationCategory( identifier: category.identifier, actions: notificationActions(for: category), intentIdentifiers: [], options: [] ) } class func notificationAction(_ action: AppNotificationAction) -> UNNotificationAction? { switch action { case .callBack: return notificationActionWithIdentifier( action.identifier, title: CallStrings.callBackButtonTitle, options: [.foreground], systemImage: "phone" ) case .markAsRead: return notificationActionWithIdentifier( action.identifier, title: MessageStrings.markAsReadNotificationAction, options: [], systemImage: "message" ) case .reply: return textInputNotificationActionWithIdentifier( action.identifier, title: MessageStrings.replyNotificationAction, options: [], textInputButtonTitle: MessageStrings.sendButton, textInputPlaceholder: "", systemImage: "arrowshape.turn.up.left" ) case .showThread: return notificationActionWithIdentifier( action.identifier, title: CallStrings.showThreadButtonTitle, options: [], systemImage: "bubble.left.and.bubble.right" ) case .showMyStories: // Currently, .showMyStories is only used as a default action. owsFailDebug("Show my stories not supported as a UNNotificationAction") return nil case .reactWithThumbsUp: return notificationActionWithIdentifier( action.identifier, title: MessageStrings.reactWithThumbsUpNotificationAction, options: [], systemImage: "hand.thumbsup" ) case .showCallLobby: // Currently, .showCallLobby is only used as a default action. owsFailDebug("Show call lobby not supported as a UNNotificationAction") return nil case .submitDebugLogs: // Currently, .submitDebugLogs is only used as a default action. owsFailDebug("Show submit debug logs not supported as a UNNotificationAction") return nil case .reregister: // Currently, .reregister is only used as a default action. owsFailDebug("Reregister is not supported as a UNNotificationAction") return nil case .showChatList: // Currently, .showChatList is only used as a default action. owsFailDebug("ShowChatList is not supported as a UNNotificationAction") return nil } } private class func notificationActionWithIdentifier( _ identifier: String, title: String, options: UNNotificationActionOptions, systemImage: String? ) -> UNNotificationAction { if #available(iOS 15, *), let systemImage = systemImage { let actionIcon = UNNotificationActionIcon(systemImageName: systemImage) return UNNotificationAction( identifier: identifier, title: title, options: options, icon: actionIcon ) } else { return UNNotificationAction(identifier: identifier, title: title, options: options) } } private class func textInputNotificationActionWithIdentifier( _ identifier: String, title: String, options: UNNotificationActionOptions, textInputButtonTitle: String, textInputPlaceholder: String, systemImage: String? ) -> UNNotificationAction { if #available(iOS 15, *), let systemImage = systemImage { let actionIcon = UNNotificationActionIcon(systemImageName: systemImage) return UNTextInputNotificationAction( identifier: identifier, title: title, options: options, icon: actionIcon, textInputButtonTitle: textInputButtonTitle, textInputPlaceholder: textInputPlaceholder ) } else { return UNTextInputNotificationAction( identifier: identifier, title: title, options: options, textInputButtonTitle: textInputButtonTitle, textInputPlaceholder: textInputPlaceholder ) } } public class func action(identifier: String) -> AppNotificationAction? { return AppNotificationAction.allCases.first { notificationAction($0)?.identifier == identifier } } } // MARK: - class UserNotificationPresenter: Dependencies { typealias NotificationActionCompletion = () -> Void typealias NotificationReplaceCompletion = (Bool) -> Void private static var notificationCenter: UNUserNotificationCenter { UNUserNotificationCenter.current() } // Delay notification of incoming messages when it's likely to be read by a linked device to // avoid notifying a user on their phone while a conversation is actively happening on desktop. let kNotificationDelayForRemoteRead: TimeInterval = 20 private let notifyQueue: DispatchQueue init(notifyQueue: DispatchQueue) { self.notifyQueue = notifyQueue SwiftSingletons.register(self) } /// Request notification permissions. func registerNotificationSettings() async { do { let granted = try await Self.notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) Logger.info("Notification permission? \(granted)") } catch { owsFailDebug("Notification permission request failed with error: \(error)") } Self.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories) } var hasReceivedSyncMessageRecently: Bool { return DependenciesBridge.shared.deviceManager.hasReceivedSyncMessage( inLastSeconds: 60 ) } // MARK: - Notify func notify( category: AppNotificationCategory, title: String?, body: String, threadIdentifier: String?, userInfo: [AnyHashable: Any], interaction: INInteraction?, sound: Sound?, replacingIdentifier: String? = nil, forceBeforeRegistered: Bool = false, completion: NotificationActionCompletion? ) { dispatchPrecondition(condition: .onQueue(notifyQueue)) guard forceBeforeRegistered || DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { Logger.info("suppressing notification since user hasn't yet completed registration.") completion?() return } let content = UNMutableNotificationContent() content.categoryIdentifier = category.identifier content.userInfo = userInfo let isAppActive = CurrentAppContext().isMainAppAndActive if let sound, sound != .standard(.none) { content.sound = sound.notificationSound(isQuiet: isAppActive) } var notificationIdentifier: String = UUID().uuidString if let replacingIdentifier = replacingIdentifier { notificationIdentifier = replacingIdentifier Logger.debug("replacing notification with identifier: \(notificationIdentifier)") cancelNotificationSync(identifier: notificationIdentifier) } let trigger: UNNotificationTrigger? let checkForCancel = ( category == .incomingMessageWithActions_CanReply || category == .incomingMessageWithActions_CannotReply || category == .incomingMessageWithoutActions || category == .incomingReactionWithActions_CanReply || category == .incomingReactionWithActions_CannotReply ) if checkForCancel && hasReceivedSyncMessageRecently { assert(userInfo[AppNotificationUserInfoKey.threadId] != nil) trigger = UNTimeIntervalNotificationTrigger(timeInterval: kNotificationDelayForRemoteRead, repeats: false) } else { trigger = nil } if shouldPresentNotification(category: category, userInfo: userInfo) { if let displayableTitle = title?.filterForDisplay { content.title = displayableTitle } content.body = body.filterForDisplay } else { // Play sound and vibrate, but without a `body` no banner will show. } if let threadIdentifier = threadIdentifier { content.threadIdentifier = threadIdentifier } var contentToUse: UNNotificationContent = content if #available(iOS 15, *), let interaction = interaction { interaction.donate(completion: { error in if let error = error { owsFailDebug("Failed to donate incoming message intent \(error)") return } }) if let intent = interaction.intent as? UNNotificationContentProviding { do { try contentToUse = content.updating(from: intent) } catch { owsFailDebug("Failed to update UNNotificationContent for comm style notification") } } } let request = UNNotificationRequest(identifier: notificationIdentifier, content: contentToUse, trigger: trigger) Self.notificationCenter.add(request) { (error: Error?) in if let error = error { owsFailDebug("Error presenting notification with identifier \(notificationIdentifier): \(error)") } completion?() } } // This method is thread-safe. func postGenericIncomingMessageNotification() async { let content = UNMutableNotificationContent() content.categoryIdentifier = AppNotificationCategory.incomingMessageGeneric.identifier content.userInfo = [:] // We use a fixed identifier so that if we post multiple "generic" // notifications, they replace each other. let notificationIdentifier = "org.signal.genericIncomingMessageNotification" content.body = NotificationStrings.genericIncomingMessageNotification let request = UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: nil) Logger.info("Presenting generic incoming message notification with identifier \(notificationIdentifier)") do { try await Self.notificationCenter.add(request) } catch { owsFailDebug("Error presenting generic incoming message notification with identifier \(notificationIdentifier): \(error)") } Logger.info("Presented notification with identifier \(notificationIdentifier)") } private func shouldPresentNotification(category: AppNotificationCategory, userInfo: [AnyHashable: Any]) -> Bool { switch category { case .incomingMessageFromNoLongerVerifiedIdentity, .missedCallWithActions, .missedCallWithoutActions, .missedCallFromNoLongerVerifiedIdentity, .transferRelaunch, .deregistration: // Always show these notifications return true case .internalError: // Only show errors alerts on builds run by a test population (beta, internal, etc.) return DebugFlags.testPopulationErrorAlerts case .incomingMessageWithActions_CanReply, .incomingMessageWithActions_CannotReply, .incomingMessageWithoutActions, .incomingReactionWithActions_CanReply, .incomingReactionWithActions_CannotReply, .infoOrErrorMessage: // Only show these notification if: // - The app is not foreground // - The app is foreground, but the corresponding conversation is not open guard CurrentAppContext().isMainAppAndActive else { return true } guard let notificationThreadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { owsFailDebug("threadId was unexpectedly nil") return true } guard let conversationSplitVC = CurrentAppContext().frontmostViewController() as? ConversationSplit else { return true } // Show notifications for any *other* thread than the currently selected thread return conversationSplitVC.visibleThread?.uniqueId != notificationThreadId case .incomingGroupStoryReply: guard StoryManager.areStoriesEnabled else { return false } guard CurrentAppContext().isMainAppAndActive else { return true } guard let notificationThreadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { owsFailDebug("threadId was unexpectedly nil") return true } guard let notificationStoryTimestamp = userInfo[AppNotificationUserInfoKey.storyTimestamp] as? UInt64 else { owsFailDebug("storyTimestamp was unexpectedly nil") return true } guard let storyGroupReply = CurrentAppContext().frontmostViewController() as? StoryGroupReplier else { return true } // Show notifications any time we're not currently showing the group reply sheet for that story return notificationStoryTimestamp != storyGroupReply.storyMessage.timestamp || notificationThreadId != storyGroupReply.threadUniqueId case .failedStorySend: guard StoryManager.areStoriesEnabled else { return false } guard CurrentAppContext().isMainAppAndActive else { return true } // Show notifications any time we're not currently showing the my stories screen. return !(CurrentAppContext().frontmostViewController() is FailedStorySendDisplayController) case .incomingMessageGeneric: owsFailDebug(".incomingMessageGeneric should never check shouldPresentNotification().") return true } } // MARK: - Replacement func replaceNotification(messageId: String, completion: @escaping NotificationReplaceCompletion) { getNotificationsRequests { requests in let didFindNotification = self.cancelSync( notificationRequests: requests, matching: .messageIds([messageId]) ) completion(didFindNotification) } } // MARK: - Cancellation func cancelNotifications(threadId: String, completion: @escaping NotificationActionCompletion) { cancel(cancellation: .threadId(threadId), completion: completion) } func cancelNotifications(messageIds: [String], completion: @escaping NotificationActionCompletion) { cancel(cancellation: .messageIds(Set(messageIds)), completion: completion) } func cancelNotifications(reactionId: String, completion: @escaping NotificationActionCompletion) { cancel(cancellation: .reactionId(reactionId), completion: completion) } func cancelNotificationsForMissedCalls(withThreadUniqueId threadId: String, completion: @escaping NotificationActionCompletion) { cancel(cancellation: .missedCalls(inThreadWithUniqueId: threadId), completion: completion) } func cancelNotificationsForStoryMessage(withUniqueId storyMessageUniqueId: String, completion: @escaping NotificationActionCompletion) { cancel(cancellation: .storyMessage(storyMessageUniqueId), completion: completion) } func clearAllNotifications() { Logger.info("Clearing all notifications") Self.notificationCenter.removeAllPendingNotificationRequests() Self.notificationCenter.removeAllDeliveredNotifications() } private enum CancellationType: Equatable, Hashable { case threadId(String) case messageIds(Set) case reactionId(String) case missedCalls(inThreadWithUniqueId: String) case storyMessage(String) } private func getNotificationsRequests(completion: @escaping ([UNNotificationRequest]) -> Void) { Self.notificationCenter.getDeliveredNotifications { delivered in Self.notificationCenter.getPendingNotificationRequests { pending in completion(delivered.map { $0.request } + pending) } } } private func cancel( cancellation: CancellationType, completion: @escaping NotificationActionCompletion ) { getNotificationsRequests { requests in self.cancelSync(notificationRequests: requests, matching: cancellation) completion() } } @discardableResult private func cancelSync( notificationRequests: [UNNotificationRequest], matching cancellationType: CancellationType ) -> Bool { let requestMatchesPredicate: (UNNotificationRequest) -> Bool = { request in switch cancellationType { case .threadId(let threadId): if let requestThreadId = request.content.userInfo[AppNotificationUserInfoKey.threadId] as? String, requestThreadId == threadId { return true } case .messageIds(let messageIds): if let requestMessageId = request.content.userInfo[AppNotificationUserInfoKey.messageId] as? String, messageIds.contains(requestMessageId) { return true } case .reactionId(let reactionId): if let requestReactionId = request.content.userInfo[AppNotificationUserInfoKey.reactionId] as? String, requestReactionId == reactionId { return true } case .missedCalls(let threadUniqueId): if (request.content.userInfo[AppNotificationUserInfoKey.isMissedCall] as? Bool) == true, let requestThreadId = request.content.userInfo[AppNotificationUserInfoKey.threadId] as? String, threadUniqueId == requestThreadId { return true } case .storyMessage(let storyMessageUniqueId): if let requestStoryMessageId = request.content.userInfo[AppNotificationUserInfoKey.storyMessageId] as? String, requestStoryMessageId == storyMessageUniqueId { return true } } return false } let identifiersToCancel: [String] = { notificationRequests.compactMap { request in if requestMatchesPredicate(request) { return request.identifier } return nil } }() guard !identifiersToCancel.isEmpty else { return false } Logger.info("Removing delivered/pending notifications with identifiers: \(identifiersToCancel)") Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToCancel) Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToCancel) return true } // This method is thread-safe. private func cancelNotificationSync(identifier: String) { Logger.warn("Canceling notification for identifier: \(identifier)") Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier]) Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier]) } } public protocol ConversationSplit { var visibleThread: TSThread? { get } } public protocol StoryGroupReplier: UIViewController { var storyMessage: StoryMessage { get } var threadUniqueId: String? { get } } extension Sound { func notificationSound(isQuiet: Bool) -> UNNotificationSound { guard let filename = filename(quiet: isQuiet) else { owsFailDebug("[Notification Sounds] sound filename was unexpectedly nil") return UNNotificationSound.default } if !FileManager.default.fileExists(atPath: (Sounds.soundsDirectory as NSString).appendingPathComponent(filename)) && !FileManager.default.fileExists(atPath: (Bundle.main.bundlePath as NSString).appendingPathComponent(filename)) { Logger.info("[Notification Sounds] sound file doesn't exist!") } return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename)) } } extension UNAuthorizationStatus: CustomStringConvertible { public var description: String { switch self { case .notDetermined: return "Not Determined" case .denied: return "Denied" case .authorized: return "Authorized" case .provisional: return "Provisional" case .ephemeral: return "Ephemeral" @unknown default: owsFailDebug("New case! Please update the method") return "Raw value: \(rawValue)" } } }