// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import PromiseKit /// 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. /// /// The NotificationPresenter is driven by the adapter pattern to provide a unified interface to /// presenting notifications on iOS9, which uses UINotifications vs iOS10+ which supports /// UNUserNotifications. /// /// The `NotificationActionHandler`s also need slightly different integrations for UINotifications /// vs. UNUserNotifications, but because they are integrated at separate system defined callbacks, /// there is no need for an Adapter, 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 threadlessErrorMessage case incomingCall case missedCallWithActions case missedCallWithoutActions case missedCallFromNoLongerVerifiedIdentity case grdbMigration } public enum AppNotificationAction: String, CaseIterable { case answerCall case callBack case declineCall case markAsRead case reply case showThread case reactWithThumbsUp case showCallLobby } 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 callBackUuid = "Signal.AppNotificationsUserInfoKey.callBackUuid" public static let callBackPhoneNumber = "Signal.AppNotificationsUserInfoKey.callBackPhoneNumber" public static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" 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 .threadlessErrorMessage: return "Signal.AppNotificationCategory.threadlessErrorMessage" 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 .grdbMigration: return "Signal.AppNotificationCategory.grdbMigration" } } var actions: [AppNotificationAction] { switch self { case .incomingMessageWithActions_CanReply: if DebugFlags.reactWithThumbsUpFromLockscreen { return [.markAsRead, .reply, .reactWithThumbsUp] } else { return [.markAsRead, .reply] } case .incomingMessageWithActions_CannotReply: return [.markAsRead] case .incomingReactionWithActions_CanReply: return [.markAsRead, .reply] case .incomingReactionWithActions_CannotReply: return [.markAsRead] case .incomingMessageWithoutActions, .incomingMessageFromNoLongerVerifiedIdentity: return [] case .infoOrErrorMessage: return [] case .threadlessErrorMessage: return [] case .incomingCall: return [.answerCall, .declineCall] case .missedCallWithActions: return [.callBack, .showThread] case .missedCallWithoutActions: return [] case .missedCallFromNoLongerVerifiedIdentity: return [] case .grdbMigration: 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 .reactWithThumbsUp: return "Signal.AppNotifications.Action.reactWithThumbsUp" case .showCallLobby: return "Signal.AppNotifications.Action.showCallLobby" } } } // 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 let kAudioNotificationsThrottleCount = 2 let kAudioNotificationsThrottleInterval: TimeInterval = 5 protocol NotificationPresenterAdaptee: class { func registerNotificationSettings() -> Promise func notify(category: AppNotificationCategory, title: String?, body: String, threadIdentifier: String?, userInfo: [AnyHashable: Any], sound: OWSSound?) func notify(category: AppNotificationCategory, title: String?, body: String, threadIdentifier: String?, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?) func cancelNotifications(threadId: String) func cancelNotifications(messageId: String) func cancelNotifications(reactionId: String) func clearAllNotifications() func notifyUserForGRDBMigration() var hasReceivedSyncMessageRecently: Bool { get } } extension NotificationPresenterAdaptee { var hasReceivedSyncMessageRecently: Bool { return OWSDeviceManager.shared().hasReceivedSyncMessage(inLastSeconds: 60) } } @objc(OWSNotificationPresenter) public class NotificationPresenter: NSObject, NotificationsProtocol { private let adaptee: NotificationPresenterAdaptee @objc public override init() { self.adaptee = UserNotificationPresenterAdaptee() super.init() AppReadiness.runNowOrWhenAppDidBecomeReadySync { NotificationCenter.default.addObserver(self, selector: #selector(self.handleMessageRead), name: .incomingMessageMarkedAsRead, object: nil) } SwiftSingletons.register(self) } var previewType: NotificationType { return preferences.notificationPreviewType() } var shouldShowActions: Bool { return previewType == .namePreview } // MARK: - @objc public func handleMessageRead(notification: Notification) { AssertIsOnMainThread() switch notification.object { case let incomingMessage as TSIncomingMessage: if !DebugFlags.reduceLogChatter { Logger.debug("canceled notification for message: \(incomingMessage)") } cancelNotifications(messageId: incomingMessage.uniqueId) default: break } } // MARK: - Presenting Notifications public func registerNotificationSettings() -> Promise { return adaptee.registerNotificationSettings() } public func presentIncomingCall(_ call: IndividualCallNotificationInfo, callerName: String) { let remoteAddress = call.remoteAddress let thread = TSContactThread.getOrCreateThread(contactAddress: remoteAddress) let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: notificationTitle = callerName threadIdentifier = thread.uniqueId } let notificationBody: String switch call.offerMediaType { case .audio: notificationBody = NotificationStrings.incomingAudioCallBody case .video: notificationBody = NotificationStrings.incomingVideoCallBody } let userInfo = [ AppNotificationUserInfoKey.threadId: thread.uniqueId, AppNotificationUserInfoKey.localCallId: call.localId.uuidString ] DispatchQueue.main.async { self.adaptee.notify(category: .incomingCall, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, sound: nil, replacingIdentifier: call.localId.uuidString) } } public func presentMissedCall(_ call: IndividualCallNotificationInfo, callerName: String) { let remoteAddress = call.remoteAddress let thread = TSContactThread.getOrCreateThread(contactAddress: remoteAddress) let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: notificationTitle = callerName threadIdentifier = thread.uniqueId } let notificationBody: String switch call.offerMediaType { case .audio: notificationBody = NotificationStrings.missedAudioCallBody case .video: notificationBody = NotificationStrings.missedVideoCallBody } let userInfo = userInfoForMissedCall(thread: thread, remoteAddress: remoteAddress) let category: AppNotificationCategory = (shouldShowActions ? .missedCallWithActions : .missedCallWithoutActions) DispatchQueue.main.async { let sound = self.requestSound(thread: thread) self.adaptee.notify(category: category, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, sound: sound, replacingIdentifier: call.localId.uuidString) } } public func presentMissedCallBecauseOfNoLongerVerifiedIdentity(call: IndividualCallNotificationInfo, callerName: String) { let remoteAddress = call.remoteAddress let thread = TSContactThread.getOrCreateThread(contactAddress: remoteAddress) let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: notificationTitle = callerName threadIdentifier = thread.uniqueId } let notificationBody = NotificationStrings.missedCallBecauseOfIdentityChangeBody let userInfo = [ AppNotificationUserInfoKey.threadId: thread.uniqueId ] DispatchQueue.main.async { let sound = self.requestSound(thread: thread) self.adaptee.notify(category: .missedCallFromNoLongerVerifiedIdentity, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, sound: sound, replacingIdentifier: call.localId.uuidString) } } public func presentMissedCallBecauseOfNewIdentity(call: IndividualCallNotificationInfo, callerName: String) { let remoteAddress = call.remoteAddress let thread = TSContactThread.getOrCreateThread(contactAddress: remoteAddress) let notificationTitle: String? let threadIdentifier: String? switch previewType { case .noNameNoPreview: notificationTitle = nil threadIdentifier = nil case .nameNoPreview, .namePreview: notificationTitle = callerName threadIdentifier = thread.uniqueId } let notificationBody = NotificationStrings.missedCallBecauseOfIdentityChangeBody let userInfo = userInfoForMissedCall(thread: thread, remoteAddress: remoteAddress) let category: AppNotificationCategory = (shouldShowActions ? .missedCallWithActions : .missedCallWithoutActions) DispatchQueue.main.async { let sound = self.requestSound(thread: thread) self.adaptee.notify(category: category, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, sound: sound, replacingIdentifier: call.localId.uuidString) } } private func userInfoForMissedCall(thread: TSThread, remoteAddress: SignalServiceAddress) -> [String: Any] { var userInfo: [String: Any] = [ AppNotificationUserInfoKey.threadId: thread.uniqueId ] if let uuid = remoteAddress.uuid { userInfo[AppNotificationUserInfoKey.callBackUuid] = uuid.uuidString } if let phoneNumber = remoteAddress.phoneNumber { userInfo[AppNotificationUserInfoKey.callBackPhoneNumber] = phoneNumber } return userInfo } public func canNotify(for incomingMessage: TSIncomingMessage, thread: TSThread, transaction: SDSAnyReadTransaction) -> Bool { guard thread.isMuted else { return true } guard let localAddress = TSAccountManager.localAddress else { owsFailDebug("Missing local address") return false } let mentionedAddresses = MentionFinder.mentionedAddresses(for: incomingMessage, transaction: transaction.unwrapGrdbRead) guard mentionedAddresses.contains(localAddress) else { return false } switch thread.mentionNotificationMode { case .default, .always: return true case .never: return false } } public func notifyUser(for incomingMessage: 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 commited. let rawMessageText = incomingMessage.previewText(transaction: transaction) let messageText = rawMessageText.filterStringForDisplay() let senderName = contactsManager.displayName(for: incomingMessage.authorAddress, transaction: 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: NotificationStrings.incomingGroupMessageTitleFormat, senderName, groupThread.groupNameOrDefault) default: owsFailDebug("unexpected thread: \(thread.uniqueId)") return } threadIdentifier = thread.uniqueId } let notificationBody: String? switch previewType { case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody case .namePreview: notificationBody = messageText } assert((notificationBody ?? notificationTitle) != nil) // Don't reply from lockscreen if anyone in this conversation is // "no longer verified". var didIdentityChange = false for address in thread.recipientAddresses { if self.identityManager.verificationState(for: address, transaction: transaction) == .noLongerVerified { didIdentityChange = true break } } let category: AppNotificationCategory if didIdentityChange { category = .incomingMessageFromNoLongerVerifiedIdentity } else if !shouldShowActions { category = .incomingMessageWithoutActions } else { category = (thread.canSendToThread ? .incomingMessageWithActions_CanReply : .incomingMessageWithActions_CannotReply) } let userInfo = [ AppNotificationUserInfoKey.threadId: thread.uniqueId, AppNotificationUserInfoKey.messageId: incomingMessage.uniqueId ] DispatchQueue.main.async { let sound = self.requestSound(thread: thread) self.adaptee.notify(category: category, title: notificationTitle, body: notificationBody ?? "", threadIdentifier: threadIdentifier, userInfo: userInfo, sound: sound) } } public func notifyUser(for reaction: OWSReaction, on message: TSOutgoingMessage, thread: TSThread, transaction: SDSAnyReadTransaction) { guard !thread.isMuted 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, transaction: transaction) 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.plaintextBody(with: transaction.unwrapGrdbRead), !messageBody.isEmpty { 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.hasAttachments() { let mediaAttachments = message.mediaAttachments(with: transaction.unwrapGrdbRead) let firstAttachment = mediaAttachments.first if mediaAttachments.count > 1 { notificationBody = String(format: NotificationStrings.incomingReactionAlbumMessageFormat, reaction.emoji) } else if firstAttachment?.isImage == true { notificationBody = String(format: NotificationStrings.incomingReactionPhotoMessageFormat, reaction.emoji) } else if firstAttachment?.isAnimated == true || firstAttachment?.isLoopingVideo == true { notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) } else if firstAttachment?.isVideo == true { notificationBody = String(format: NotificationStrings.incomingReactionVideoMessageFormat, reaction.emoji) } else if firstAttachment?.isVoiceMessage == true { notificationBody = String(format: NotificationStrings.incomingReactionVoiceMessageFormat, reaction.emoji) } else if firstAttachment?.isAudio == 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 for address in thread.recipientAddresses { if self.identityManager.verificationState(for: address, transaction: transaction) == .noLongerVerified { didIdentityChange = true break } } let category: AppNotificationCategory if didIdentityChange { category = .incomingMessageFromNoLongerVerifiedIdentity } else if !shouldShowActions { category = .incomingMessageWithoutActions } else { category = (thread.canSendToThread ? .incomingReactionWithActions_CanReply : .incomingReactionWithActions_CannotReply) } let userInfo = [ AppNotificationUserInfoKey.threadId: thread.uniqueId, AppNotificationUserInfoKey.messageId: message.uniqueId, AppNotificationUserInfoKey.reactionId: reaction.uniqueId ] DispatchQueue.main.async { let sound = self.requestSound(thread: thread) self.adaptee.notify( category: category, title: notificationTitle, body: notificationBody, threadIdentifier: thread.uniqueId, userInfo: userInfo, sound: sound ) } } public func notifyForFailedSend(inThread thread: TSThread) { let notificationTitle: String? switch previewType { case .noNameNoPreview: notificationTitle = nil case .nameNoPreview, .namePreview: notificationTitle = contactsManager.displayNameWithSneakyTransaction(thread: thread) } let notificationBody = NotificationStrings.failedToSendBody let threadId = thread.uniqueId let userInfo = [ AppNotificationUserInfoKey.threadId: threadId ] DispatchQueue.main.async { let sound = self.requestSound(thread: thread) self.adaptee.notify(category: .infoOrErrorMessage, title: notificationTitle, body: notificationBody, threadIdentifier: nil, // show ungrouped userInfo: userInfo, sound: sound) } } public func notifyForGroupCallSafetyNumberChange(inThread thread: TSThread) { let notificationTitle: String? switch previewType { case .noNameNoPreview: notificationTitle = nil case .nameNoPreview, .namePreview: notificationTitle = contactsManager.displayNameWithSneakyTransaction(thread: thread) } let notificationBody = NotificationStrings.groupCallSafetyNumberChangeBody let threadId = thread.uniqueId let userInfo: [String: Any] = [ AppNotificationUserInfoKey.threadId: threadId, AppNotificationUserInfoKey.defaultAction: AppNotificationAction.showCallLobby.rawValue ] DispatchQueue.main.async { let sound = self.requestSound(thread: thread) self.adaptee.notify(category: .infoOrErrorMessage, title: notificationTitle, body: notificationBody, threadIdentifier: nil, // show ungrouped userInfo: userInfo, sound: sound) } } public func notifyUser(for errorMessage: TSErrorMessage, thread: TSThread, transaction: SDSAnyWriteTransaction) { switch errorMessage.errorType { case .noSession, .wrongTrustedIdentityKey, .invalidKeyException, .missingKeyId, .invalidMessage, .duplicateMessage, .invalidVersion, .nonBlockingIdentityChange, .unknownContactBlockOffer, .groupCreationFailed: return @unknown default: break } notifyUser(for: errorMessage as TSMessage, thread: thread, wantsSound: true, transaction: transaction) } public func notifyUser(for previewableInteraction: TSInteraction & OWSPreviewText, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction) { guard !thread.isMuted 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.incomingMessageBody case .namePreview: notificationBody = previewableInteraction.previewText(transaction: transaction) } let isGroupCallMessage = previewableInteraction is OWSGroupCallMessage let preferredDefaultAction: AppNotificationAction = isGroupCallMessage ? .showCallLobby : .showThread let threadId = thread.uniqueId let userInfo = [ AppNotificationUserInfoKey.threadId: threadId, AppNotificationUserInfoKey.messageId: previewableInteraction.uniqueId, AppNotificationUserInfoKey.defaultAction: preferredDefaultAction.rawValue ] transaction.addAsyncCompletion { let sound = wantsSound ? self.requestSound(thread: thread) : nil self.adaptee.notify(category: .infoOrErrorMessage, title: notificationTitle, body: notificationBody, threadIdentifier: threadIdentifier, userInfo: userInfo, sound: sound) } } public func notifyUser(for errorMessage: ThreadlessErrorMessage, transaction: SDSAnyWriteTransaction) { let notificationBody = errorMessage.previewText(transaction: transaction) transaction.addAsyncCompletion { let sound = self.checkIfShouldPlaySound() ? OWSSounds.globalNotificationSound() : nil self.adaptee.notify(category: .threadlessErrorMessage, title: nil, body: notificationBody, threadIdentifier: nil, userInfo: [:], sound: sound) } } @objc public func cancelNotifications(threadId: String) { adaptee.cancelNotifications(threadId: threadId) } @objc public func cancelNotifications(messageId: String) { adaptee.cancelNotifications(messageId: messageId) } @objc public func cancelNotifications(reactionId: String) { adaptee.cancelNotifications(reactionId: reactionId) } @objc public func clearAllNotifications() { adaptee.clearAllNotifications() } @objc public func notifyUserForGRDBMigration() { adaptee.notifyUserForGRDBMigration() } // MARK: - var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) private func requestSound(thread: TSThread) -> OWSSound? { guard checkIfShouldPlaySound() else { return nil } return OWSSounds.notificationSound(for: thread) } private func checkIfShouldPlaySound() -> Bool { AssertIsOnMainThread() guard CurrentAppContext().isMainAppAndActive else { return true } guard preferences.soundInForeground() else { return false } let now = NSDate.ows_millisecondTimeStamp() let recentThreshold = now - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) 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 IndividualCallNotificationInfo { var remoteAddress: SignalServiceAddress { get } var localId: UUID { get } var offerMediaType: TSRecentCallOfferType { get } }