// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import Intents /// There are two primary components in our system notification integration: /// /// 1. The `NotificationPresenter` 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 incomingCall case missedCallWithActions case missedCallWithoutActions case missedCallFromNoLongerVerifiedIdentity case internalError case incomingMessageGeneric case incomingGroupStoryReply case failedStorySend case transferRelaunch case deregistration } public enum AppNotificationAction: String, CaseIterable { case answerCall case callBack case declineCall 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 localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" 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 .incomingCall: return "Signal.AppNotificationCategory.incomingCall" 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 .incomingCall: return [.answerCall, .declineCall] 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 .answerCall: return "Signal.AppNotifications.Action.answerCall" case .callBack: return "Signal.AppNotifications.Action.callBack" case .declineCall: return "Signal.AppNotifications.Action.declineCall" 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: - extension UserNotificationPresenter { var hasReceivedSyncMessageRecently: Bool { return DependenciesBridge.shared.deviceManager.hasReceivedSyncMessage( inLastSeconds: 60 ) } } // MARK: - public class NotificationPresenter: NSObject, NotificationsProtocolSwift { private let presenter = UserNotificationPresenter(notifyQueue: NotificationPresenter.notificationQueue) public override init() { super.init() SwiftSingletons.register(self) } var previewType: NotificationType { return preferences.notificationPreviewType } var shouldShowActions: Bool { return previewType == .namePreview } // MARK: - Notifications Permissions public func registerNotificationSettings() -> Guarantee { return presenter.registerNotificationSettings() } // MARK: - Calls public func presentIncomingCall(_ call: CallNotificationInfo, caller: SignalServiceAddress) { let thread = call.thread let notificationTitle: String? let threadIdentifier: String? let callerNameForGroupCall: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil callerNameForGroupCall = nil case .nameNoPreview, .namePreview: (notificationTitle, callerNameForGroupCall) = databaseStorage.read { transaction in let threadName = contactsManager.displayName(for: thread, transaction: transaction) let callerNameForGroupCall: String? if thread.isGroupThread { callerNameForGroupCall = contactsManager.displayName(for: caller, tx: transaction).resolvedValue() } else { callerNameForGroupCall = nil } return (threadName, callerNameForGroupCall) } threadIdentifier = thread.uniqueId } let notificationBody: String if let callerNameForGroupCall = callerNameForGroupCall { notificationBody = String(format: NotificationStrings.incomingGroupCallBodyFormat, callerNameForGroupCall) } else if thread.isGroupThread { notificationBody = NotificationStrings.incomingGroupCallBodyAnonymous } else { switch call.offerMediaType { case .audio: notificationBody = NotificationStrings.incomingAudioCallBody case .video: notificationBody = NotificationStrings.incomingVideoCallBody } } let userInfo = [ AppNotificationUserInfoKey.threadId: thread.uniqueId, AppNotificationUserInfoKey.localCallId: call.localId.uuidString ] var interaction: INInteraction? if previewType != .noNameNoPreview, let intent = thread.generateIncomingCallIntent(callerAddress: caller) { let wrapper = INInteraction(intent: intent, response: nil) wrapper.direction = .incoming interaction = wrapper } performNotificationActionAsync { completion in self.presenter.notify(category: .incomingCall, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, interaction: interaction, sound: nil, replacingIdentifier: call.localId.uuidString, completion: completion) } } /// 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(_ call: CallNotificationInfo, caller: SignalServiceAddress, sentAt timestamp: Date) { let thread = call.thread let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: notificationTitle = databaseStorage.read { tx in contactsManager.displayName(for: thread, transaction: tx) } threadIdentifier = thread.uniqueId } 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 (call.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, remoteAddress: caller) let category: AppNotificationCategory = (shouldShowActions ? .missedCallWithActions : .missedCallWithoutActions) var interaction: INInteraction? if previewType != .noNameNoPreview, let intent = thread.generateIncomingCallIntent(callerAddress: caller) { 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: threadIdentifier, userInfo: userInfo, interaction: interaction, sound: sound, replacingIdentifier: call.localId.uuidString, completion: completion) } } public func presentMissedCallBecauseOfNoLongerVerifiedIdentity(call: CallNotificationInfo, caller: SignalServiceAddress) { let thread = call.thread let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: notificationTitle = databaseStorage.read { tx in contactsManager.displayName(for: thread, transaction: tx) } threadIdentifier = thread.uniqueId } 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: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, interaction: nil, sound: sound, replacingIdentifier: call.localId.uuidString, completion: completion) } } public func presentMissedCallBecauseOfNewIdentity(call: CallNotificationInfo, caller: SignalServiceAddress) { let thread = call.thread let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: notificationTitle = databaseStorage.read { tx in contactsManager.displayName(for: thread, transaction: tx) } threadIdentifier = thread.uniqueId } let notificationBody = NotificationStrings.missedCallBecauseOfIdentityChangeBody let userInfo = userInfoForMissedCall(thread: thread, remoteAddress: caller) let category: AppNotificationCategory = (shouldShowActions ? .missedCallWithActions : .missedCallWithoutActions) performNotificationActionAsync { completion in let sound = self.requestSound(thread: thread) self.presenter.notify(category: category, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, interaction: nil, sound: sound, replacingIdentifier: call.localId.uuidString, completion: completion) } } private func userInfoForMissedCall(thread: TSThread, remoteAddress: SignalServiceAddress) -> [String: Any] { var userInfo: [String: Any] = [ AppNotificationUserInfoKey.threadId: thread.uniqueId ] if let aci = remoteAddress.aci { userInfo[AppNotificationUserInfoKey.callBackAciString] = aci.serviceIdUppercaseString } if let phoneNumber = remoteAddress.phoneNumber { userInfo[AppNotificationUserInfoKey.callBackPhoneNumber] = phoneNumber } userInfo[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 = DependenciesBridge.shared.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 = DependenciesBridge.shared.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 { if DebugFlags.internalLogging { Logger.info("Not notifying.") } return } // While batch processing, some of the necessary changes have not been committed. let rawMessageText = incomingMessage.notificationPreviewText(transaction) let messageText = rawMessageText.filterStringForDisplay() let senderName = contactsManager.displayName(for: incomingMessage.authorAddress, tx: transaction).resolvedValue() 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 let identityManager = DependenciesBridge.shared.identityManager 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 !shouldShowActions { 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 guard previewType == .namePreview else { return } let senderName = contactsManager.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 message.hasBodyAttachments(transaction: transaction) { let mediaAttachments = message.mediaAttachments(transaction: transaction) let firstAttachment = mediaAttachments.first let firstAttachmentType = firstAttachment?.attachmentType(forContainingMessage: message, transaction: transaction) if mediaAttachments.count > 1 { notificationBody = String(format: NotificationStrings.incomingReactionAlbumMessageFormat, reaction.emoji) } else if let firstAttachmentStream = firstAttachment as? TSAttachmentStream, firstAttachmentStream.isAnimatedContent { notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) } else if firstAttachment?.isImageMimeType == true { notificationBody = String(format: NotificationStrings.incomingReactionPhotoMessageFormat, reaction.emoji) } else if let firstAttachment, let firstAttachmentType, firstAttachment.getAnimatedMimeType() == .animated || firstAttachment.isLoopingVideo(firstAttachmentType) { notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) } else if firstAttachment?.isVideoMimeType == true { notificationBody = String(format: NotificationStrings.incomingReactionVideoMessageFormat, reaction.emoji) } else if firstAttachmentType == .voiceMessage { notificationBody = String(format: NotificationStrings.incomingReactionVoiceMessageFormat, reaction.emoji) } else if firstAttachment?.isAudioMimeType == true { 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 let identityManager = DependenciesBridge.shared.identityManager 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 !shouldShowActions { category = .incomingMessageWithoutActions } 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 previewType != .noNameNoPreview, 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? switch previewType { case .noNameNoPreview: notificationTitle = nil case .nameNoPreview, .namePreview: notificationTitle = databaseStorage.read { tx in contactsManager.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? switch previewType { case .noNameNoPreview: notificationTitle = nil case .nameNoPreview, .namePreview: notificationTitle = databaseStorage.read { tx in contactsManager.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 notificationTitle: String? let threadIdentifier: String? switch self.previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .namePreview, .nameNoPreview: notificationTitle = contactsManager.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 = DependenciesBridge.shared.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.creatorAddress, let intent = thread.generateSendMessageIntent(context: .senderAddress(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 if #available(iOS 14, *) { sendMessageIntent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: storyName), conversationIdentifier: conversationIdentifier, serviceName: nil, sender: person ) } else { sendMessageIntent = INSendMessageIntent( recipients: nil, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: storyName), conversationIdentifier: conversationIdentifier, serviceName: nil, sender: person ) } 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() -> Promise { 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) } } public protocol CallNotificationInfo { var thread: TSThread { get } var localId: UUID { get } var offerMediaType: TSRecentCallOfferType { get } }