// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import Intents import LibSignalClient import SignalCoreKit /// There are two primary components in our system notification integration: /// /// 1. The `NotificationPresenterImpl` shows system notifications to the user. /// 2. The `NotificationActionHandler` handles the users interactions with these /// notifications. /// /// Our `NotificationActionHandler`s need slightly different integrations for UINotifications (iOS 9) /// vs. UNUserNotifications (iOS 10+), but because they are integrated at separate system defined callbacks, /// there is no need for an adapter pattern, and instead the appropriate NotificationActionHandler is /// wired directly into the appropriate callback point. public enum AppNotificationCategory: CaseIterable { case incomingMessageWithActions_CanReply case incomingMessageWithActions_CannotReply case incomingMessageWithoutActions case incomingMessageFromNoLongerVerifiedIdentity case incomingReactionWithActions_CanReply case incomingReactionWithActions_CannotReply case infoOrErrorMessage case missedCallWithActions case missedCallWithoutActions case missedCallFromNoLongerVerifiedIdentity case internalError case incomingMessageGeneric case incomingGroupStoryReply case failedStorySend case transferRelaunch case deregistration } public enum AppNotificationAction: String, CaseIterable { case callBack case markAsRead case reply case showThread case showMyStories case reactWithThumbsUp case showCallLobby case submitDebugLogs case reregister case showChatList } public struct AppNotificationUserInfoKey { public static let threadId = "Signal.AppNotificationsUserInfoKey.threadId" public static let messageId = "Signal.AppNotificationsUserInfoKey.messageId" public static let reactionId = "Signal.AppNotificationsUserInfoKey.reactionId" public static let storyMessageId = "Signal.AppNotificationsUserInfoKey.storyMessageId" public static let storyTimestamp = "Signal.AppNotificationsUserInfoKey.storyTimestamp" public static let callBackAciString = "Signal.AppNotificationsUserInfoKey.callBackUuid" public static let callBackPhoneNumber = "Signal.AppNotificationsUserInfoKey.callBackPhoneNumber" public static let isMissedCall = "Signal.AppNotificationsUserInfoKey.isMissedCall" public static let defaultAction = "Signal.AppNotificationsUserInfoKey.defaultAction" } extension AppNotificationCategory { var identifier: String { switch self { case .incomingMessageWithActions_CanReply: return "Signal.AppNotificationCategory.incomingMessageWithActions" case .incomingMessageWithActions_CannotReply: return "Signal.AppNotificationCategory.incomingMessageWithActionsNoReply" case .incomingMessageWithoutActions: return "Signal.AppNotificationCategory.incomingMessage" case .incomingMessageFromNoLongerVerifiedIdentity: return "Signal.AppNotificationCategory.incomingMessageFromNoLongerVerifiedIdentity" case .incomingReactionWithActions_CanReply: return "Signal.AppNotificationCategory.incomingReactionWithActions" case .incomingReactionWithActions_CannotReply: return "Signal.AppNotificationCategory.incomingReactionWithActionsNoReply" case .infoOrErrorMessage: return "Signal.AppNotificationCategory.infoOrErrorMessage" case .missedCallWithActions: return "Signal.AppNotificationCategory.missedCallWithActions" case .missedCallWithoutActions: return "Signal.AppNotificationCategory.missedCall" case .missedCallFromNoLongerVerifiedIdentity: return "Signal.AppNotificationCategory.missedCallFromNoLongerVerifiedIdentity" case .internalError: return "Signal.AppNotificationCategory.internalError" case .incomingMessageGeneric: return "Signal.AppNotificationCategory.incomingMessageGeneric" case .incomingGroupStoryReply: return "Signal.AppNotificationCategory.incomingGroupStoryReply" case .failedStorySend: return "Signal.AppNotificationCategory.failedStorySend" case .transferRelaunch: return "Signal.AppNotificationCategory.transferRelaunch" case .deregistration: return "Signal.AppNotificationCategory.authErrorLogout" } } var actions: [AppNotificationAction] { switch self { case .incomingMessageWithActions_CanReply: return [.markAsRead, .reply, .reactWithThumbsUp] case .incomingMessageWithActions_CannotReply: return [.markAsRead] case .incomingReactionWithActions_CanReply: return [.markAsRead, .reply] case .incomingReactionWithActions_CannotReply: return [.markAsRead] case .incomingMessageWithoutActions, .incomingMessageFromNoLongerVerifiedIdentity: return [] case .infoOrErrorMessage: return [] case .missedCallWithActions: return [.callBack, .showThread] case .missedCallWithoutActions: return [] case .missedCallFromNoLongerVerifiedIdentity: return [] case .internalError: return [] case .incomingMessageGeneric: return [] case .incomingGroupStoryReply: return [.reply] case .failedStorySend: return [] case .transferRelaunch: return [] case .deregistration: return [] } } } extension AppNotificationAction { var identifier: String { switch self { case .callBack: return "Signal.AppNotifications.Action.callBack" case .markAsRead: return "Signal.AppNotifications.Action.markAsRead" case .reply: return "Signal.AppNotifications.Action.reply" case .showThread: return "Signal.AppNotifications.Action.showThread" case .showMyStories: return "Signal.AppNotifications.Action.showMyStories" case .reactWithThumbsUp: return "Signal.AppNotifications.Action.reactWithThumbsUp" case .showCallLobby: return "Signal.AppNotifications.Action.showCallLobby" case .submitDebugLogs: return "Signal.AppNotifications.Action.submitDebugLogs" case .reregister: return "Signal.AppNotifications.Action.reregister" case .showChatList: return "Signal.AppNotifications.Action.showChatList" } } } let kAudioNotificationsThrottleCount = 2 let kAudioNotificationsThrottleInterval: TimeInterval = 5 // MARK: - public class NotificationPresenterImpl: NotificationPresenter { private let presenter = UserNotificationPresenter(notifyQueue: NotificationPresenterImpl.notificationQueue) private var contactManager: any ContactManager { NSObject.contactsManager } private var databaseStorage: SDSDatabaseStorage { NSObject.databaseStorage } private var identityManager: any OWSIdentityManager { DependenciesBridge.shared.identityManager } private var preferences: Preferences { NSObject.preferences } private var tsAccountManager: any TSAccountManager { DependenciesBridge.shared.tsAccountManager } public init() { SwiftSingletons.register(self) } func previewType(tx: SDSAnyReadTransaction) -> NotificationType { return preferences.notificationPreviewType(tx: tx) } static func shouldShowActions(for previewType: NotificationType) -> Bool { return previewType == .namePreview } // MARK: - Notifications Permissions public func registerNotificationSettings() async { return await presenter.registerNotificationSettings() } // MARK: - Calls public struct CallNotificationInfo { /// Basically a per-call unique identifier. When posting multiple /// notifications with the same `groupingId`, only the latest notification /// will be shown. let groupingId: UUID /// The thread that was called. let thread: TSContactThread /// The user who called the thread. let caller: Aci public init(groupingId: UUID, thread: TSContactThread, caller: Aci) { self.groupingId = groupingId self.thread = thread self.caller = caller } } private struct CallPreview { let notificationTitle: String let threadIdentifier: String let shouldShowActions: Bool } private func fetchCallPreview(thread: TSThread, tx: SDSAnyReadTransaction) -> CallPreview? { let previewType = self.previewType(tx: tx) switch previewType { case .noNameNoPreview: return nil case .nameNoPreview, .namePreview: return CallPreview( notificationTitle: contactManager.displayName(for: thread, transaction: tx), threadIdentifier: thread.uniqueId, shouldShowActions: Self.shouldShowActions(for: previewType) ) } } /// Classifies a timestamp based on how it should be included in a notification. /// /// In particular, a notification already comes with its own timestamp, so any information we put in has to be /// relevant (different enough from the notification's own timestamp to be useful) and absolute (because if a /// thirty-minute-old notification says "five minutes ago", that's not great). private enum TimestampClassification { case lastFewMinutes case last24Hours case lastWeek case other init(_ timestamp: Date) { switch -timestamp.timeIntervalSinceNow { case ..<0: owsFailDebug("Formatting a notification for an event in the future") self = .other case ...(5 * kMinuteInterval): self = .lastFewMinutes case ...kDayInterval: self = .last24Hours case ...kWeekInterval: self = .lastWeek default: self = .other } } } public func presentMissedCall( notificationInfo: CallNotificationInfo, offerMediaType: TSRecentCallOfferType, sentAt timestamp: Date, tx: SDSAnyReadTransaction ) { let thread = notificationInfo.thread let callPreview = fetchCallPreview(thread: thread, tx: tx) let timestampClassification = TimestampClassification(timestamp) let timestampArgument: String switch timestampClassification { case .lastFewMinutes: // will be ignored timestampArgument = "" case .last24Hours: timestampArgument = DateUtil.formatDateAsTime(timestamp) case .lastWeek: timestampArgument = DateUtil.weekdayFormatter.string(from: timestamp) case .other: timestampArgument = DateUtil.monthAndDayFormatter.string(from: timestamp) } // We could build these localized string keys by interpolating the two pieces, // but then genstrings wouldn't pick them up. let notificationBodyFormat: String switch (offerMediaType, timestampClassification) { case (.audio, .lastFewMinutes): notificationBodyFormat = OWSLocalizedString( "CALL_AUDIO_MISSED_NOTIFICATION_BODY", comment: "notification body for a call that was just missed") case (.audio, .last24Hours): notificationBodyFormat = OWSLocalizedString( "CALL_AUDIO_MISSED_24_HOURS_NOTIFICATION_BODY_FORMAT", comment: "notification body for a missed call in the last 24 hours. Embeds {{time}}, e.g. '3:30 PM'.") case (.audio, .lastWeek): notificationBodyFormat = OWSLocalizedString( "CALL_AUDIO_MISSED_WEEK_NOTIFICATION_BODY_FORMAT", comment: "notification body for a missed call from the last week. Embeds {{weekday}}, e.g. 'Monday'.") case (.audio, .other): notificationBodyFormat = OWSLocalizedString( "CALL_AUDIO_MISSED_PAST_NOTIFICATION_BODY_FORMAT", comment: "notification body for a missed call from more than a week ago. Embeds {{short date}}, e.g. '6/28'.") case (.video, .lastFewMinutes): notificationBodyFormat = OWSLocalizedString( "CALL_VIDEO_MISSED_NOTIFICATION_BODY", comment: "notification body for a call that was just missed") case (.video, .last24Hours): notificationBodyFormat = OWSLocalizedString( "CALL_VIDEO_MISSED_24_HOURS_NOTIFICATION_BODY_FORMAT", comment: "notification body for a missed call in the last 24 hours. Embeds {{time}}, e.g. '3:30 PM'.") case (.video, .lastWeek): notificationBodyFormat = OWSLocalizedString( "CALL_VIDEO_MISSED_WEEK_NOTIFICATION_BODY_FORMAT", comment: "notification body for a missed call from the last week. Embeds {{weekday}}, e.g. 'Monday'.") case (.video, .other): notificationBodyFormat = OWSLocalizedString( "CALL_VIDEO_MISSED_PAST_NOTIFICATION_BODY_FORMAT", comment: "notification body for a missed call from more than a week ago. Embeds {{short date}}, e.g. '6/28'.") } let notificationBody = String(format: notificationBodyFormat, timestampArgument) let userInfo = userInfoForMissedCall(thread: thread, remoteAci: notificationInfo.caller) let category: AppNotificationCategory = ( callPreview?.shouldShowActions == true ? .missedCallWithActions : .missedCallWithoutActions ) var interaction: INInteraction? if callPreview != nil, let intent = thread.generateIncomingCallIntent(callerAci: notificationInfo.caller, tx: tx) { let wrapper = INInteraction(intent: intent, response: nil) wrapper.direction = .incoming interaction = wrapper } performNotificationActionAsync { completion in let sound = self.requestSound(thread: thread) self.presenter.notify( category: category, title: callPreview?.notificationTitle, body: notificationBody, threadIdentifier: callPreview?.threadIdentifier, userInfo: userInfo, interaction: interaction, sound: sound, replacingIdentifier: notificationInfo.groupingId.uuidString, completion: completion ) } } public func presentMissedCallBecauseOfNoLongerVerifiedIdentity( notificationInfo: CallNotificationInfo, tx: SDSAnyReadTransaction ) { let thread = notificationInfo.thread let callPreview = fetchCallPreview(thread: thread, tx: tx) let notificationBody = NotificationStrings.missedCallBecauseOfIdentityChangeBody let userInfo = [ AppNotificationUserInfoKey.threadId: thread.uniqueId ] performNotificationActionAsync { completion in let sound = self.requestSound(thread: thread) self.presenter.notify( category: .missedCallFromNoLongerVerifiedIdentity, title: callPreview?.notificationTitle, body: notificationBody, threadIdentifier: callPreview?.threadIdentifier, userInfo: userInfo, interaction: nil, sound: sound, replacingIdentifier: notificationInfo.groupingId.uuidString, completion: completion ) } } public func presentMissedCallBecauseOfNewIdentity( notificationInfo: CallNotificationInfo, tx: SDSAnyReadTransaction ) { let thread = notificationInfo.thread let callPreview = fetchCallPreview(thread: thread, tx: tx) let notificationBody = NotificationStrings.missedCallBecauseOfIdentityChangeBody let userInfo = userInfoForMissedCall(thread: thread, remoteAci: notificationInfo.caller) let category: AppNotificationCategory = ( callPreview?.shouldShowActions == true ? .missedCallWithActions : .missedCallWithoutActions ) performNotificationActionAsync { completion in let sound = self.requestSound(thread: thread) self.presenter.notify( category: category, title: callPreview?.notificationTitle, body: notificationBody, threadIdentifier: callPreview?.threadIdentifier, userInfo: userInfo, interaction: nil, sound: sound, replacingIdentifier: notificationInfo.groupingId.uuidString, completion: completion ) } } private func userInfoForMissedCall(thread: TSThread, remoteAci: Aci) -> [String: Any] { let userInfo: [String: Any] = [ AppNotificationUserInfoKey.threadId: thread.uniqueId, AppNotificationUserInfoKey.callBackAciString: remoteAci.serviceIdUppercaseString, AppNotificationUserInfoKey.isMissedCall: true, ] return userInfo } // MARK: - Notify public func isThreadMuted(_ thread: TSThread, transaction: SDSAnyReadTransaction) -> Bool { ThreadAssociatedData.fetchOrDefault(for: thread, transaction: transaction).isMuted } public func canNotify( for incomingMessage: TSIncomingMessage, thread: TSThread, transaction: SDSAnyReadTransaction ) -> Bool { guard isThreadMuted(thread, transaction: transaction) else { guard incomingMessage.isGroupStoryReply else { return true } guard let storyTimestamp = incomingMessage.storyTimestamp?.uint64Value, let storyAuthorAci = incomingMessage.storyAuthorAci?.wrappedAciValue else { return false } let localAci = tsAccountManager.localIdentifiers(tx: transaction.asV2Read)?.aci // Always notify for replies to group stories you sent if storyAuthorAci == localAci { return true } // Always notify if you have been @mentioned if let mentionedAcis = incomingMessage.bodyRanges?.mentions.values, mentionedAcis.contains(where: { $0 == localAci }) { return true } // Notify people who did not author the story if they've previously replied to it return InteractionFinder.hasLocalUserReplied( storyTimestamp: storyTimestamp, storyAuthorAci: storyAuthorAci, transaction: transaction ) } guard thread.isGroupThread else { return false } guard let localAddress = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress else { owsFailDebug("Missing local address") return false } let mentionedAddresses = MentionFinder.mentionedAddresses(for: incomingMessage, transaction: transaction.unwrapGrdbRead) let localUserIsQuoted = incomingMessage.quotedMessage?.authorAddress.isEqualToAddress(localAddress) ?? false guard mentionedAddresses.contains(localAddress) || localUserIsQuoted else { return false } switch thread.mentionNotificationMode { case .default, .always: return true case .never: return false } } public func notifyUser( forIncomingMessage incomingMessage: TSIncomingMessage, thread: TSThread, transaction: SDSAnyReadTransaction ) { notifyUserInternal( forIncomingMessage: incomingMessage, editTarget: nil, thread: thread, transaction: transaction ) } public func notifyUser( forIncomingMessage incomingMessage: TSIncomingMessage, editTarget: TSIncomingMessage, thread: TSThread, transaction: SDSAnyReadTransaction ) { notifyUserInternal( forIncomingMessage: incomingMessage, editTarget: editTarget, thread: thread, transaction: transaction ) } private func notifyUserInternal( forIncomingMessage incomingMessage: TSIncomingMessage, editTarget: TSIncomingMessage?, thread: TSThread, transaction: SDSAnyReadTransaction ) { guard canNotify(for: incomingMessage, thread: thread, transaction: transaction) else { return } // While batch processing, some of the necessary changes have not been committed. let rawMessageText = incomingMessage.notificationPreviewText(transaction) let messageText = rawMessageText.filterStringForDisplay() let senderName = contactManager.displayName(for: incomingMessage.authorAddress, tx: transaction).resolvedValue() let previewType = self.previewType(tx: transaction) let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: switch thread { case is TSContactThread: notificationTitle = senderName case let groupThread as TSGroupThread: notificationTitle = String( format: incomingMessage.isGroupStoryReply ? NotificationStrings.incomingGroupStoryReplyTitleFormat : NotificationStrings.incomingGroupMessageTitleFormat, senderName, groupThread.groupNameOrDefault ) default: owsFailDebug("Invalid thread: \(thread.uniqueId)") return } threadIdentifier = thread.uniqueId } let notificationBody: String = { if thread.hasPendingMessageRequest(transaction: transaction) { return NotificationStrings.incomingMessageRequestNotification } switch previewType { case .noNameNoPreview, .nameNoPreview: return NotificationStrings.genericIncomingMessageNotification case .namePreview: return messageText } }() // Don't reply from lockscreen if anyone in this conversation is // "no longer verified". var didIdentityChange = false for address in thread.recipientAddresses(with: transaction) { if identityManager.verificationState(for: address, tx: transaction.asV2Read) == .noLongerVerified { didIdentityChange = true break } } let category: AppNotificationCategory if didIdentityChange { category = .incomingMessageFromNoLongerVerifiedIdentity } else if !Self.shouldShowActions(for: previewType) { category = .incomingMessageWithoutActions } else if incomingMessage.isGroupStoryReply { category = .incomingGroupStoryReply } else { category = ( thread.canSendChatMessagesToThread() ? .incomingMessageWithActions_CanReply : .incomingMessageWithActions_CannotReply ) } var userInfo: [AnyHashable: Any] = [ AppNotificationUserInfoKey.threadId: thread.uniqueId, AppNotificationUserInfoKey.messageId: incomingMessage.uniqueId ] if let storyTimestamp = incomingMessage.storyTimestamp?.uint64Value { userInfo[AppNotificationUserInfoKey.storyTimestamp] = storyTimestamp } var interaction: INInteraction? if previewType != .noNameNoPreview, let intent = thread.generateSendMessageIntent(context: .incomingMessage(incomingMessage), transaction: transaction) { let wrapper = INInteraction(intent: intent, response: nil) wrapper.direction = .incoming interaction = wrapper } performNotificationActionAsync { completion in let sound = (editTarget != nil) ? nil : self.requestSound(thread: thread) let notify = { self.presenter.notify( category: category, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, interaction: interaction, sound: sound, completion: completion ) } if let editTarget { self.presenter.replaceNotification(messageId: editTarget.uniqueId) { didReplaceNotification in guard didReplaceNotification else { completion() return } Self.notificationQueue.async { notify() } } } else { notify() } } } public func notifyUser( forReaction reaction: OWSReaction, onOutgoingMessage message: TSOutgoingMessage, thread: TSThread, transaction: SDSAnyReadTransaction ) { guard !isThreadMuted(thread, transaction: transaction) else { return } // Reaction notifications only get displayed if we can include the reaction // details, otherwise we don't disturb the user for a non-message let previewType = self.previewType(tx: transaction) guard previewType == .namePreview else { return } owsAssert(Self.shouldShowActions(for: previewType)) let senderName = contactManager.displayName(for: reaction.reactor, tx: transaction).resolvedValue() let notificationTitle: String switch thread { case is TSContactThread: notificationTitle = senderName case let groupThread as TSGroupThread: notificationTitle = String( format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, groupThread.groupNameOrDefault ) default: owsFailDebug("unexpected thread: \(thread.uniqueId)") return } let notificationBody: String if let bodyDescription: String = { if let messageBody = message.notificationPreviewText(transaction).nilIfEmpty { return messageBody } else { return nil } }() { notificationBody = String(format: NotificationStrings.incomingReactionTextMessageFormat, reaction.emoji, bodyDescription) } else if message.isViewOnceMessage { notificationBody = String(format: NotificationStrings.incomingReactionViewOnceMessageFormat, reaction.emoji) } else if message.messageSticker != nil { notificationBody = String(format: NotificationStrings.incomingReactionStickerMessageFormat, reaction.emoji) } else if message.contactShare != nil { notificationBody = String(format: NotificationStrings.incomingReactionContactShareMessageFormat, reaction.emoji) } else if let mediaAttachments = DependenciesBridge.shared.tsResourceStore .referencedBodyMediaAttachments(for: message, tx: transaction.asV2Read) .nilIfEmpty, let firstAttachment = mediaAttachments.first { let firstRenderingFlag = firstAttachment.reference.renderingFlag // Mime type is spoofable by the sender but for the purpose of showing notifications, // trust the sender (if they _intended_ to send an image, say they sent an image). let firstMimeType = firstAttachment.attachment.mimeType if mediaAttachments.count > 1 { notificationBody = String(format: NotificationStrings.incomingReactionAlbumMessageFormat, reaction.emoji) } else if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(firstMimeType) { notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) } else if MimeTypeUtil.isSupportedImageMimeType(firstMimeType) { notificationBody = String(format: NotificationStrings.incomingReactionPhotoMessageFormat, reaction.emoji) } else if MimeTypeUtil.isSupportedVideoMimeType(firstMimeType), firstRenderingFlag == .shouldLoop { notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) } else if MimeTypeUtil.isSupportedVideoMimeType(firstMimeType) { notificationBody = String(format: NotificationStrings.incomingReactionVideoMessageFormat, reaction.emoji) } else if firstRenderingFlag == .voiceMessage { notificationBody = String(format: NotificationStrings.incomingReactionVoiceMessageFormat, reaction.emoji) } else if MimeTypeUtil.isSupportedAudioMimeType(firstMimeType) { notificationBody = String(format: NotificationStrings.incomingReactionAudioMessageFormat, reaction.emoji) } else { notificationBody = String(format: NotificationStrings.incomingReactionFileMessageFormat, reaction.emoji) } } else { notificationBody = String(format: NotificationStrings.incomingReactionFormat, reaction.emoji) } // Don't reply from lockscreen if anyone in this conversation is // "no longer verified". var didIdentityChange = false for address in thread.recipientAddresses(with: transaction) { if identityManager.verificationState(for: address, tx: transaction.asV2Read) == .noLongerVerified { didIdentityChange = true break } } let category: AppNotificationCategory if didIdentityChange { category = .incomingMessageFromNoLongerVerifiedIdentity } else { category = ( thread.canSendChatMessagesToThread() ? .incomingReactionWithActions_CanReply : .incomingReactionWithActions_CannotReply ) } let userInfo = [ AppNotificationUserInfoKey.threadId: thread.uniqueId, AppNotificationUserInfoKey.messageId: message.uniqueId, AppNotificationUserInfoKey.reactionId: reaction.uniqueId ] var interaction: INInteraction? if let intent = thread.generateSendMessageIntent(context: .senderAddress(reaction.reactor), transaction: transaction) { let wrapper = INInteraction(intent: intent, response: nil) wrapper.direction = .incoming interaction = wrapper } performNotificationActionAsync { completion in let sound = self.requestSound(thread: thread) self.presenter.notify( category: category, title: notificationTitle, body: notificationBody, threadIdentifier: thread.uniqueId, userInfo: userInfo, interaction: interaction, sound: sound, completion: completion ) } } public func notifyForFailedSend(inThread thread: TSThread) { let notificationTitle: String? = databaseStorage.read { tx in switch self.previewType(tx: tx) { case .noNameNoPreview: return nil case .nameNoPreview, .namePreview: return contactManager.displayName(for: thread, transaction: tx) } } let notificationBody = NotificationStrings.failedToSendBody let threadId = thread.uniqueId let userInfo = [ AppNotificationUserInfoKey.threadId: threadId ] performNotificationActionAsync { completion in let sound = self.requestSound(thread: thread) self.presenter.notify( category: .infoOrErrorMessage, title: notificationTitle, body: notificationBody, threadIdentifier: nil, // show ungrouped userInfo: userInfo, interaction: nil, sound: sound, completion: completion ) } } public func notifyTestPopulation(ofErrorMessage errorString: String) { // Fail debug on all devices. External devices should still log the error string. owsFailDebug("Fatal error occurred: \(errorString).") guard DebugFlags.testPopulationErrorAlerts else { return } let title = OWSLocalizedString( "ERROR_NOTIFICATION_TITLE", comment: "Format string for an error alert notification title." ) let messageFormat = OWSLocalizedString( "ERROR_NOTIFICATION_MESSAGE_FORMAT", comment: "Format string for an error alert notification message. Embeds {{ error string }}" ) let message = String(format: messageFormat, errorString) performNotificationActionAsync { completion in self.presenter.notify( category: .internalError, title: title, body: message, threadIdentifier: nil, userInfo: [ AppNotificationUserInfoKey.defaultAction: AppNotificationAction.submitDebugLogs.rawValue ], interaction: nil, sound: self.requestGlobalSound(), completion: completion ) } } public func notifyForGroupCallSafetyNumberChange(inThread thread: TSThread, presentAtJoin: Bool) { let notificationTitle: String? = databaseStorage.read { tx in switch previewType(tx: tx) { case .noNameNoPreview: return nil case .nameNoPreview, .namePreview: return contactManager.displayName(for: thread, transaction: tx) } } let notificationBody = ( presentAtJoin ? NotificationStrings.groupCallSafetyNumberChangeAtJoinBody : NotificationStrings.groupCallSafetyNumberChangeBody ) let threadId = thread.uniqueId let userInfo: [String: Any] = [ AppNotificationUserInfoKey.threadId: threadId, AppNotificationUserInfoKey.defaultAction: AppNotificationAction.showCallLobby.rawValue ] performNotificationActionAsync { completion in let sound = self.requestSound(thread: thread) self.presenter.notify( category: .infoOrErrorMessage, title: notificationTitle, body: notificationBody, threadIdentifier: nil, // show ungrouped userInfo: userInfo, interaction: nil, sound: sound, completion: completion ) } } public func notifyUser( forErrorMessage errorMessage: TSErrorMessage, thread: TSThread, transaction: SDSAnyWriteTransaction ) { guard (errorMessage is OWSRecoverableDecryptionPlaceholder) == false else { return } switch errorMessage.errorType { case .noSession, .wrongTrustedIdentityKey, .invalidKeyException, .missingKeyId, .invalidMessage, .duplicateMessage, .invalidVersion, .nonBlockingIdentityChange, .unknownContactBlockOffer, .decryptionFailure, .groupCreationFailed: return case .sessionRefresh: notifyUser( forTSMessage: errorMessage as TSMessage, thread: thread, wantsSound: true, transaction: transaction ) } } public func notifyUser( forTSMessage message: TSMessage, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction ) { notifyUser( tsInteraction: message, previewProvider: { tx in return message.notificationPreviewText(tx) }, thread: thread, wantsSound: wantsSound, transaction: transaction ) } public func notifyUser( forPreviewableInteraction previewableInteraction: TSInteraction & OWSPreviewText, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction ) { notifyUser( tsInteraction: previewableInteraction, previewProvider: { tx in return previewableInteraction.previewText(transaction: tx) }, thread: thread, wantsSound: wantsSound, transaction: transaction ) } private func notifyUser( tsInteraction: TSInteraction, previewProvider: (SDSAnyWriteTransaction) -> String, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction ) { guard !isThreadMuted(thread, transaction: transaction) else { return } let previewType = self.previewType(tx: transaction) let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .namePreview, .nameNoPreview: notificationTitle = contactManager.displayName(for: thread, transaction: transaction) threadIdentifier = thread.uniqueId } let notificationBody: String switch previewType { case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.genericIncomingMessageNotification case .namePreview: notificationBody = previewProvider(transaction) } let isGroupCallMessage = tsInteraction is OWSGroupCallMessage let preferredDefaultAction: AppNotificationAction = isGroupCallMessage ? .showCallLobby : .showThread let threadId = thread.uniqueId let userInfo = [ AppNotificationUserInfoKey.threadId: threadId, AppNotificationUserInfoKey.messageId: tsInteraction.uniqueId, AppNotificationUserInfoKey.defaultAction: preferredDefaultAction.rawValue ] // Some types of generic messages (locally generated notifications) have a defacto // "sender". If so, generate an interaction so the notification renders as if it // is from that user. var interaction: INInteraction? if previewType != .noNameNoPreview { func wrapIntent(_ intent: INIntent) { let wrapper = INInteraction(intent: intent, response: nil) wrapper.direction = .incoming interaction = wrapper } if let infoMessage = tsInteraction as? TSInfoMessage { guard let localIdentifiers = tsAccountManager.localIdentifiers( tx: transaction.asV2Read ) else { owsFailDebug("Missing local identifiers!") return } switch infoMessage.messageType { case .typeGroupUpdate: let groupUpdateAuthor: SignalServiceAddress? switch infoMessage.groupUpdateMetadata(localIdentifiers: localIdentifiers) { case .legacyRawString, .nonGroupUpdate: groupUpdateAuthor = nil case .newGroup(_, let updateMetadata), .modelDiff(_, _, let updateMetadata): switch updateMetadata.source { case .unknown, .localUser: groupUpdateAuthor = nil case .legacyE164(let e164): groupUpdateAuthor = .legacyAddress(serviceId: nil, phoneNumber: e164.stringValue) case .aci(let aci): groupUpdateAuthor = .init(aci) case .rejectedInviteToPni(let pni): groupUpdateAuthor = .init(pni) } case .precomputed(let persistableGroupUpdateItemsWrapper): groupUpdateAuthor = persistableGroupUpdateItemsWrapper .asSingleUpdateItem?.senderForNotification } if let groupUpdateAuthor, let intent = thread.generateSendMessageIntent(context: .senderAddress(groupUpdateAuthor), transaction: transaction) { wrapIntent(intent) } case .userJoinedSignal: if let thread = thread as? TSContactThread, let intent = thread.generateSendMessageIntent(context: .senderAddress(thread.contactAddress), transaction: transaction) { wrapIntent(intent) } default: break } } else if let callMessage = tsInteraction as? OWSGroupCallMessage, let callCreator = callMessage.creatorAci?.wrappedAciValue, let intent = thread.generateSendMessageIntent(context: .senderAddress(SignalServiceAddress(callCreator)), transaction: transaction) { wrapIntent(intent) } } performNotificationActionInAsyncCompletion(transaction: transaction) { completion in let sound = wantsSound ? self.requestSound(thread: thread) : nil self.presenter.notify( category: .infoOrErrorMessage, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, interaction: interaction, sound: sound, completion: completion ) } } public func notifyUser( forFailedStorySend storyMessage: StoryMessage, to thread: TSThread, transaction: SDSAnyWriteTransaction ) { let storyName = StoryManager.storyName(for: thread) let conversationIdentifier = thread.uniqueId + "_failedStorySend" let handle = INPersonHandle(value: nil, type: .unknown) let image = thread.intentStoryAvatarImage(tx: transaction) let person: INPerson = { if #available(iOS 15, *) { return INPerson( personHandle: handle, nameComponents: nil, displayName: storyName, image: image, contactIdentifier: nil, customIdentifier: nil, isMe: false, suggestionType: .none ) } else { return INPerson( personHandle: handle, nameComponents: nil, displayName: storyName, image: image, contactIdentifier: nil, customIdentifier: nil, isMe: false ) } }() let sendMessageIntent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: storyName), conversationIdentifier: conversationIdentifier, serviceName: nil, sender: person, attachments: nil ) let interaction = INInteraction(intent: sendMessageIntent, response: nil) interaction.direction = .outgoing let notificationTitle = storyName let notificationBody = OWSLocalizedString( "STORY_SEND_FAILED_NOTIFICATION_BODY", comment: "Body for notification shown when a story fails to send." ) let threadIdentifier = thread.uniqueId let storyMessageId = storyMessage.uniqueId performNotificationActionInAsyncCompletion(transaction: transaction) { completion in self.presenter.notify( category: .failedStorySend, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: [ AppNotificationUserInfoKey.defaultAction: AppNotificationAction.showMyStories.rawValue, AppNotificationUserInfoKey.storyMessageId: storyMessageId ], interaction: interaction, sound: self.requestGlobalSound(), completion: completion ) } } public func notifyUserToRelaunchAfterTransfer(completion: (() -> Void)? = nil) { let notificationBody = OWSLocalizedString( "TRANSFER_RELAUNCH_NOTIFICATION", comment: "Notification prompting the user to relaunch Signal after a device transfer completed." ) performNotificationActionAsync { innerCompletion in self.presenter.notify( category: .transferRelaunch, title: nil, body: notificationBody, threadIdentifier: nil, userInfo: [ AppNotificationUserInfoKey.defaultAction: AppNotificationAction.showChatList.rawValue ], interaction: nil, // Use a default sound so we don't read from // the db (which doesn't work until we relaunch) sound: .standard(.note), forceBeforeRegistered: true, completion: { innerCompletion() completion?() } ) } } public func notifyUserOfDeregistration(tx: DBWriteTransaction) { notifyUserOfDeregistration(transaction: SDSDB.shimOnlyBridge(tx)) } public func notifyUserOfDeregistration(transaction: SDSAnyWriteTransaction) { let notificationBody = OWSLocalizedString( "DEREGISTRATION_NOTIFICATION", comment: "Notification warning the user that they have been de-registered." ) performNotificationActionInAsyncCompletion(transaction: transaction) { completion in self.presenter.notify( category: .deregistration, title: nil, body: notificationBody, threadIdentifier: nil, userInfo: [ AppNotificationUserInfoKey.defaultAction: AppNotificationAction.reregister.rawValue ], interaction: nil, sound: self.requestGlobalSound(), completion: completion ) } } /// Note that this method is not serialized with other notifications /// actions. public func postGenericIncomingMessageNotification() async { await presenter.postGenericIncomingMessageNotification() } // MARK: - Cancellation public func cancelNotifications(threadId: String) { performNotificationActionAsync { completion in self.presenter.cancelNotifications(threadId: threadId, completion: completion) } } public func cancelNotifications(messageIds: [String]) { performNotificationActionAsync { completion in self.presenter.cancelNotifications(messageIds: messageIds, completion: completion) } } public func cancelNotifications(reactionId: String) { performNotificationActionAsync { completion in self.presenter.cancelNotifications(reactionId: reactionId, completion: completion) } } public func cancelNotificationsForMissedCalls(threadUniqueId: String) { performNotificationActionAsync { completion in self.presenter.cancelNotificationsForMissedCalls(withThreadUniqueId: threadUniqueId, completion: completion) } } public func cancelNotifications(for storyMessage: StoryMessage) { let storyMessageId = storyMessage.uniqueId performNotificationActionAsync { completion in self.presenter.cancelNotificationsForStoryMessage(withUniqueId: storyMessageId, completion: completion) } } public func clearAllNotifications() { presenter.clearAllNotifications() } // MARK: - Serialization private static let serialQueue = DispatchQueue(label: "org.signal.notifications.action") private static var notificationQueue: DispatchQueue { // The NSE can safely post notifications off the main thread, but the // main app cannot. if CurrentAppContext().isNSE { return serialQueue } return .main } private var notificationQueue: DispatchQueue { Self.notificationQueue } private static let pendingTasks = PendingTasks(label: "Notifications") public static func pendingNotificationsPromise() -> Promise { // This promise blocks on all pending notifications already in flight, // but will not block on new notifications enqueued after this promise // is created. That's intentional to ensure that NotificationService // instances complete in a timely way. pendingTasks.pendingTasksPromise() } private func performNotificationActionAsync( _ block: @escaping (@escaping UserNotificationPresenter.NotificationActionCompletion) -> Void ) { let pendingTask = Self.pendingTasks.buildPendingTask(label: "NotificationAction") notificationQueue.async { block { pendingTask.complete() } } } private func performNotificationActionInAsyncCompletion( transaction: SDSAnyWriteTransaction, _ block: @escaping (@escaping UserNotificationPresenter.NotificationActionCompletion) -> Void ) { let pendingTask = Self.pendingTasks.buildPendingTask(label: "NotificationAction") transaction.addAsyncCompletion(queue: notificationQueue) { block { pendingTask.complete() } } } // MARK: - private let unfairLock = UnfairLock() private var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) private func requestSound(thread: TSThread) -> Sound? { checkIfShouldPlaySound() ? Sounds.notificationSoundForThread(thread) : nil } private func requestGlobalSound() -> Sound? { checkIfShouldPlaySound() ? Sounds.globalNotificationSound : nil } // This method is thread-safe. private func checkIfShouldPlaySound() -> Bool { guard CurrentAppContext().isMainAppAndActive else { return true } guard preferences.soundInForeground else { return false } let now = NSDate.ows_millisecondTimeStamp() let recentThreshold = now - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) return unfairLock.withLock { let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } guard recentNotifications.count < kAudioNotificationsThrottleCount else { return false } mostRecentNotifications.append(now) return true } } } struct TruncatedList { let maxLength: Int private var contents: [Element] = [] init(maxLength: Int) { self.maxLength = maxLength } mutating func append(_ newElement: Element) { var newElements = self.contents newElements.append(newElement) self.contents = Array(newElements.suffix(maxLength)) } } extension TruncatedList: Collection { typealias Index = Int var startIndex: Index { return contents.startIndex } var endIndex: Index { return contents.endIndex } subscript (position: Index) -> Element { return contents[position] } func index(after i: Index) -> Index { return contents.index(after: i) } }