// // Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import Intents 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 UNNotificationAction( identifier: action.rawValue, title: CallStrings.callBackButtonTitle, options: .foreground, icon: UNNotificationActionIcon(systemImageName: "phone") ) case .markAsRead: return UNNotificationAction( identifier: action.rawValue, title: MessageStrings.markAsReadNotificationAction, icon: UNNotificationActionIcon(systemImageName: "message") ) case .reply: return UNTextInputNotificationAction( identifier: action.rawValue, title: MessageStrings.replyNotificationAction, icon: UNNotificationActionIcon(systemImageName: "arrowshape.turn.up.left"), textInputButtonTitle: MessageStrings.sendButton, textInputPlaceholder: "" ) case .showThread: return UNNotificationAction( identifier: action.rawValue, title: CallStrings.showThreadButtonTitle, icon: UNNotificationActionIcon(systemImageName: "bubble.left.and.bubble.right") ) case .reactWithThumbsUp: return UNNotificationAction( identifier: action.rawValue, title: MessageStrings.reactWithThumbsUpNotificationAction, icon: UNNotificationActionIcon(systemImageName: "hand.thumbsup") ) } } } // MARK: - public class UserNotificationPresenter { 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 public init() {} /// 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 hasReceivedSyncMessageRecentlyWithSneakyTransaction: Bool { let db = DependenciesBridge.shared.db let deviceManager = DependenciesBridge.shared.deviceManager return db.read { tx in return deviceManager.hasReceivedSyncMessage(inLastSeconds: 60, transaction: tx) } } // MARK: - Notify func notify( category: AppNotificationCategory, title: String?, body: String, threadIdentifier: String?, userInfo: AppNotificationUserInfo, interaction: INInteraction?, sound: Sound?, replacingIdentifier: String? = nil, forceBeforeRegistered: Bool = false, isMainAppAndActive: Bool, notificationSuppressionRule: NotificationSuppressionRule ) async { let tsAccountManager = DependenciesBridge.shared.tsAccountManager // TODO: It might make sense to have the callers check this instead. Further investigation is required. guard forceBeforeRegistered || tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { Logger.info("suppressing notification since user hasn't yet completed registration.") return } // TODO: It might make sense to have the callers check this instead. Further investigation is required. if case .incomingGroupStoryReply = category, !StoryManager.areStoriesEnabled { return } let content = UNMutableNotificationContent() content.categoryIdentifier = category.identifier content.userInfo = userInfo.build() if let sound, sound != .standard(.none) { content.sound = sound.notificationSound(isQuiet: isMainAppAndActive) } var notificationIdentifier: String = UUID().uuidString if let 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, !isMainAppAndActive, hasReceivedSyncMessageRecentlyWithSneakyTransaction { assert(userInfo.threadId != nil) trigger = UNTimeIntervalNotificationTrigger(timeInterval: kNotificationDelayForRemoteRead, repeats: false) } else if category == .newDeviceLinked { let db = DependenciesBridge.shared.db let deviceStore = DependenciesBridge.shared.deviceStore let linkedDeviceDetails = db.read { tx in deviceStore.mostRecentlyLinkedDeviceDetails(tx: tx) } let delay = { if let linkedDeviceDetails { return linkedDeviceDetails.notificationDelay } else { owsFailDebug("mostRecentlyLinkedDeviceDetails should be set before scheduling notification") return TimeInterval.random(in: .hour...(3 * .hour)) } }() trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false) } else if category == .backupsEnabled { let delay = TimeInterval.random(in: .hour...(3 * .hour)) trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false) } else { trigger = nil } if shouldPresentNotification(category: category, userInfo: userInfo, notificationSuppressionRule: notificationSuppressionRule) { 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 let interaction { do { try await interaction.donate() } catch { owsFailDebug("Failed to donate incoming message intent \(error)") } if let intent = interaction.intent as? UNNotificationContentProviding { do { contentToUse = try content.updating(from: intent) } catch { owsFailDebug("Failed to update UNNotificationContent for comm style notification") } } } let request = UNNotificationRequest(identifier: notificationIdentifier, content: contentToUse, trigger: trigger) do { try await Self.notificationCenter.add(request) } catch { owsFailDebug("Error presenting notification with identifier \(notificationIdentifier): \(error)") } } private func shouldPresentNotification( category: AppNotificationCategory, userInfo: AppNotificationUserInfo, notificationSuppressionRule: NotificationSuppressionRule ) -> Bool { switch category { case .incomingMessageFromNoLongerVerifiedIdentity, .missedCallWithActions, .missedCallWithoutActions, .missedCallFromNoLongerVerifiedIdentity, .transferRelaunch, .deregistration, .newDeviceLinked, .backupsEnabled: // 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, .pollEndNotification: // Don't show these notifications when the thread is visible. if let notificationThreadUniqueId = userInfo.threadId, case .messagesInThread(let suppressedThreadUniqueId) = notificationSuppressionRule, suppressedThreadUniqueId == notificationThreadUniqueId { return false } return true case .incomingGroupStoryReply: // Show notifications any time we're not currently showing the group reply sheet for that story if let notificationThreadUniqueId = userInfo.threadId, let notificationStoryTimestamp = userInfo.storyTimestamp, case .groupStoryReplies(let suppressedThreadUniqueId, let suppressedStoryTimestamp) = notificationSuppressionRule, suppressedThreadUniqueId == notificationThreadUniqueId, suppressedStoryTimestamp == notificationStoryTimestamp { return false } return true case .failedStorySend: if case .failedStorySends = notificationSuppressionRule { return false } return true } } // MARK: - Replacement func replaceNotification(messageId: String) async -> Bool { return self.cancelSync( notificationRequests: await getNotificationsRequests(), matching: .messageIds([messageId]) ) } // MARK: - Cancellation func cancelNotifications(threadId: String) async { await cancel(cancellation: .threadId(threadId)) } func cancelNotifications(messageIds: [String]) async { await cancel(cancellation: .messageIds(Set(messageIds))) } func cancelNotifications(reactionId: String) async { await cancel(cancellation: .reactionId(reactionId)) } func cancelNotificationsForMissedCalls(withThreadUniqueId threadId: String) async { await cancel(cancellation: .missedCalls(inThreadWithUniqueId: threadId)) } func cancelNotificationsForStoryMessage(withUniqueId storyMessageUniqueId: String) async { await cancel(cancellation: .storyMessage(storyMessageUniqueId)) } public func clearAllNotifications() { Logger.info("Clearing all notifications") Self.notificationCenter.removeAllPendingNotificationRequests() Self.notificationCenter.removeAllDeliveredNotifications() } public func clearAllNonScheduledNotifications() { Logger.info("Clearing all notifications except scheduled notifications") Task { let scheduledNotifications: Set = [ AppNotificationCategory.newDeviceLinked.identifier, AppNotificationCategory.backupsEnabled.identifier ] let pendingNotificationIDs = await Self.notificationCenter.pendingNotificationRequests() .filter { notificationRequest in scheduledNotifications.contains(notificationRequest.content.categoryIdentifier) == false } .map(\.identifier) let deliveredNotificationIDs = await Self.notificationCenter.deliveredNotifications() .filter { notification in scheduledNotifications.contains(notification.request.content.categoryIdentifier) == false } .map(\.request.identifier) Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: pendingNotificationIDs) Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: deliveredNotificationIDs) } } public func clearDeliveredNewLinkedDevicesNotifications() { Logger.info("Clearing delivered new linked device notifications") Task { let pendingNotificationRequestIDs = await Self.notificationCenter.deliveredNotifications() .filter { notification in notification.request.content.categoryIdentifier == AppNotificationCategory.newDeviceLinked.identifier } .map(\.request.identifier) Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: pendingNotificationRequestIDs) } } private enum CancellationType: Equatable, Hashable { case threadId(String) case messageIds(Set) case reactionId(String) case missedCalls(inThreadWithUniqueId: String) case storyMessage(String) } private func getNotificationsRequests() async -> [UNNotificationRequest] { return await ( Self.notificationCenter.deliveredNotifications().map({ $0.request }) + Self.notificationCenter.pendingNotificationRequests() ) } private func cancel(cancellation: CancellationType) async { self.cancelSync(notificationRequests: await getNotificationsRequests(), matching: cancellation) } @discardableResult private func cancelSync( notificationRequests: [UNNotificationRequest], matching cancellationType: CancellationType ) -> Bool { let requestMatchesPredicate: (UNNotificationRequest) -> Bool = { request in let userInfo = AppNotificationUserInfo(request.content.userInfo) switch cancellationType { case .threadId(let threadId): if let requestThreadId = userInfo.threadId, requestThreadId == threadId { return true } case .messageIds(let messageIds): if let requestMessageId = userInfo.messageId, messageIds.contains(requestMessageId) { return true } case .reactionId(let reactionId): if let requestReactionId = userInfo.reactionId, requestReactionId == reactionId { return true } case .missedCalls(let threadUniqueId): if userInfo.isMissedCall == true, let requestThreadId = userInfo.threadId, threadUniqueId == requestThreadId { return true } case .storyMessage(let storyMessageUniqueId): if let requestStoryMessageId = userInfo.storyMessageId, 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 } } public protocol LinkAndSyncProgressUI { var shouldSuppressNotifications: Bool { 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)) } }