|  |  |  | // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | import Foundation | 
					
						
							|  |  |  | import Combine | 
					
						
							|  |  |  | import GRDB | 
					
						
							|  |  |  | import SessionMessagingKit | 
					
						
							|  |  |  | import SignalUtilitiesKit | 
					
						
							|  |  |  | import SignalCoreKit | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | /// 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. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | enum AppNotificationCategory: CaseIterable { | 
					
						
							|  |  |  |     case incomingMessage | 
					
						
							|  |  |  |     case incomingMessageFromNoLongerVerifiedIdentity | 
					
						
							|  |  |  |     case errorMessage | 
					
						
							|  |  |  |     case threadlessErrorMessage | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | enum AppNotificationAction: CaseIterable { | 
					
						
							|  |  |  |     case markAsRead | 
					
						
							|  |  |  |     case reply | 
					
						
							|  |  |  |     case showThread | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | struct AppNotificationUserInfoKey { | 
					
						
							|  |  |  |     static let threadId = "Signal.AppNotificationsUserInfoKey.threadId" | 
					
						
							|  |  |  |     static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" | 
					
						
							|  |  |  |     static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber" | 
					
						
							|  |  |  |     static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" | 
					
						
							|  |  |  |     static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | extension AppNotificationCategory { | 
					
						
							|  |  |  |     var identifier: String { | 
					
						
							|  |  |  |         switch self { | 
					
						
							|  |  |  |         case .incomingMessage: | 
					
						
							|  |  |  |             return "Signal.AppNotificationCategory.incomingMessage" | 
					
						
							|  |  |  |         case .incomingMessageFromNoLongerVerifiedIdentity: | 
					
						
							|  |  |  |             return "Signal.AppNotificationCategory.incomingMessageFromNoLongerVerifiedIdentity" | 
					
						
							|  |  |  |         case .errorMessage: | 
					
						
							|  |  |  |             return "Signal.AppNotificationCategory.errorMessage" | 
					
						
							|  |  |  |         case .threadlessErrorMessage: | 
					
						
							|  |  |  |             return "Signal.AppNotificationCategory.threadlessErrorMessage" | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     var actions: [AppNotificationAction] { | 
					
						
							|  |  |  |         switch self { | 
					
						
							|  |  |  |         case .incomingMessage: | 
					
						
							|  |  |  |             return [.markAsRead, .reply] | 
					
						
							|  |  |  |         case .incomingMessageFromNoLongerVerifiedIdentity: | 
					
						
							|  |  |  |             return [.markAsRead, .showThread] | 
					
						
							|  |  |  |         case .errorMessage: | 
					
						
							|  |  |  |             return [.showThread] | 
					
						
							|  |  |  |         case .threadlessErrorMessage: | 
					
						
							|  |  |  |             return [] | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | extension AppNotificationAction { | 
					
						
							|  |  |  |     var identifier: String { | 
					
						
							|  |  |  |         switch self { | 
					
						
							|  |  |  |         case .markAsRead: | 
					
						
							|  |  |  |             return "Signal.AppNotifications.Action.markAsRead" | 
					
						
							|  |  |  |         case .reply: | 
					
						
							|  |  |  |             return "Signal.AppNotifications.Action.reply" | 
					
						
							|  |  |  |         case .showThread: | 
					
						
							|  |  |  |             return "Signal.AppNotifications.Action.showThread" | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | let kAudioNotificationsThrottleCount = 2 | 
					
						
							|  |  |  | let kAudioNotificationsThrottleInterval: TimeInterval = 5 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | protocol NotificationPresenterAdaptee: AnyObject { | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     func registerNotificationSettings() -> AnyPublisher<Void, Never> | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     func notify( | 
					
						
							|  |  |  |         category: AppNotificationCategory, | 
					
						
							|  |  |  |         title: String?, | 
					
						
							|  |  |  |         body: String, | 
					
						
							|  |  |  |         userInfo: [AnyHashable: Any], | 
					
						
							|  |  |  |         previewType: Preferences.NotificationPreviewType, | 
					
						
							|  |  |  |         sound: Preferences.Sound?, | 
					
						
							|  |  |  |         threadVariant: SessionThread.Variant, | 
					
						
							|  |  |  |         threadName: String, | 
					
						
							|  |  |  |         applicationState: UIApplication.State, | 
					
						
							|  |  |  |         replacingIdentifier: String? | 
					
						
							|  |  |  |     ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     func cancelNotifications(threadId: String) | 
					
						
							|  |  |  |     func cancelNotifications(identifiers: [String]) | 
					
						
							|  |  |  |     func clearAllNotifications() | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | extension NotificationPresenterAdaptee { | 
					
						
							|  |  |  |     func notify( | 
					
						
							|  |  |  |         category: AppNotificationCategory, | 
					
						
							|  |  |  |         title: String?, | 
					
						
							|  |  |  |         body: String, | 
					
						
							|  |  |  |         userInfo: [AnyHashable: Any], | 
					
						
							|  |  |  |         previewType: Preferences.NotificationPreviewType, | 
					
						
							|  |  |  |         sound: Preferences.Sound?, | 
					
						
							|  |  |  |         threadVariant: SessionThread.Variant, | 
					
						
							|  |  |  |         threadName: String, | 
					
						
							|  |  |  |         applicationState: UIApplication.State | 
					
						
							|  |  |  |     ) { | 
					
						
							|  |  |  |         notify( | 
					
						
							|  |  |  |             category: category, | 
					
						
							|  |  |  |             title: title, | 
					
						
							|  |  |  |             body: body, | 
					
						
							|  |  |  |             userInfo: userInfo, | 
					
						
							|  |  |  |             previewType: previewType, | 
					
						
							|  |  |  |             sound: sound, | 
					
						
							|  |  |  |             threadVariant: threadVariant, | 
					
						
							|  |  |  |             threadName: threadName, | 
					
						
							|  |  |  |             applicationState: applicationState, | 
					
						
							|  |  |  |             replacingIdentifier: nil | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | public class NotificationPresenter: NotificationsProtocol { | 
					
						
							|  |  |  |     private let adaptee: NotificationPresenterAdaptee = UserNotificationPresenterAdaptee() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public init() { | 
					
						
							|  |  |  |         SwiftSingletons.register(self) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     // MARK: - Presenting Notifications | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     func registerNotificationSettings() -> AnyPublisher<Void, Never> { | 
					
						
							|  |  |  |         return adaptee.registerNotificationSettings() | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public func notifyUser( | 
					
						
							|  |  |  |         _ db: Database, | 
					
						
							|  |  |  |         for interaction: Interaction, | 
					
						
							|  |  |  |         in thread: SessionThread, | 
					
						
							|  |  |  |         applicationState: UIApplication.State | 
					
						
							|  |  |  |     ) { | 
					
						
							|  |  |  |         let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         // Ensure we should be showing a notification for the thread | 
					
						
							|  |  |  |         guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else { | 
					
						
							|  |  |  |             return | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         // Try to group notifications for interactions from open groups | 
					
						
							|  |  |  |         let identifier: String = interaction.notificationIdentifier( | 
					
						
							|  |  |  |             shouldGroupMessagesForThread: (thread.variant == .community) | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         // While batch processing, some of the necessary changes have not been commited. | 
					
						
							|  |  |  |         let rawMessageText = interaction.previewText(db) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         // iOS strips anything that looks like a printf formatting character from | 
					
						
							|  |  |  |         // the notification body, so if we want to dispay a literal "%" in a notification | 
					
						
							|  |  |  |         // it must be escaped. | 
					
						
							|  |  |  |         // see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody | 
					
						
							|  |  |  |         // for more details. | 
					
						
							|  |  |  |         let messageText: String? = String.filterNotificationText(rawMessageText) | 
					
						
							|  |  |  |         let notificationTitle: String? | 
					
						
							|  |  |  |         var notificationBody: String? | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) | 
					
						
							|  |  |  |         let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] | 
					
						
							|  |  |  |             .defaulting(to: .defaultPreviewType) | 
					
						
							|  |  |  |         let groupName: String = SessionThread.displayName( | 
					
						
							|  |  |  |             threadId: thread.id, | 
					
						
							|  |  |  |             variant: thread.variant, | 
					
						
							|  |  |  |             closedGroupName: try? thread.closedGroup | 
					
						
							|  |  |  |                 .select(.name) | 
					
						
							|  |  |  |                 .asRequest(of: String.self) | 
					
						
							|  |  |  |                 .fetchOne(db), | 
					
						
							|  |  |  |             openGroupName: try? thread.openGroup | 
					
						
							|  |  |  |                 .select(.name) | 
					
						
							|  |  |  |                 .asRequest(of: String.self) | 
					
						
							|  |  |  |                 .fetchOne(db) | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         switch previewType { | 
					
						
							|  |  |  |             case .noNameNoPreview: | 
					
						
							|  |  |  |                 notificationTitle = "Session" | 
					
						
							|  |  |  |                  | 
					
						
							|  |  |  |             case .nameNoPreview, .nameAndPreview: | 
					
						
							|  |  |  |                 switch thread.variant { | 
					
						
							|  |  |  |                     case .contact: | 
					
						
							|  |  |  |                         notificationTitle = (isMessageRequest ? "Session" : senderName) | 
					
						
							|  |  |  |                          | 
					
						
							|  |  |  |                     case .legacyGroup, .group, .community: | 
					
						
							|  |  |  |                         notificationTitle = String( | 
					
						
							|  |  |  |                             format: NotificationStrings.incomingGroupMessageTitleFormat, | 
					
						
							|  |  |  |                             senderName, | 
					
						
							|  |  |  |                             groupName | 
					
						
							|  |  |  |                         ) | 
					
						
							|  |  |  |                 } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         switch previewType { | 
					
						
							|  |  |  |             case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody | 
					
						
							|  |  |  |             case .nameAndPreview: notificationBody = messageText | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         // If it's a message request then overwrite the body to be something generic (only show a notification | 
					
						
							|  |  |  |         // when receiving a new message request if there aren't any others or the user had hidden them) | 
					
						
							|  |  |  |         if isMessageRequest { | 
					
						
							|  |  |  |             notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized() | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         guard notificationBody != nil || notificationTitle != nil else { | 
					
						
							|  |  |  |             SNLog("AppNotifications error: No notification content") | 
					
						
							|  |  |  |             return | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         // Don't reply from lockscreen if anyone in this conversation is | 
					
						
							|  |  |  |         // "no longer verified". | 
					
						
							|  |  |  |         let category = AppNotificationCategory.incomingMessage | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         let userInfo: [AnyHashable: Any] = [ | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadId: thread.id, | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue | 
					
						
							|  |  |  |         ] | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let userPublicKey: String = getUserHexEncodedPublicKey(db) | 
					
						
							|  |  |  |         let userBlinded15Key: String? = SessionThread.getUserHexEncodedBlindedKey( | 
					
						
							|  |  |  |             db, | 
					
						
							|  |  |  |             threadId: thread.id, | 
					
						
							|  |  |  |             threadVariant: thread.variant, | 
					
						
							|  |  |  |             blindingPrefix: .blinded15 | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |         let userBlinded25Key: String? = SessionThread.getUserHexEncodedBlindedKey( | 
					
						
							|  |  |  |             db, | 
					
						
							|  |  |  |             threadId: thread.id, | 
					
						
							|  |  |  |             threadVariant: thread.variant, | 
					
						
							|  |  |  |             blindingPrefix: .blinded25 | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |         let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] | 
					
						
							|  |  |  |             .defaulting(to: Preferences.Sound.defaultNotificationSound) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         let sound: Preferences.Sound? = requestSound( | 
					
						
							|  |  |  |             thread: thread, | 
					
						
							|  |  |  |             fallbackSound: fallbackSound, | 
					
						
							|  |  |  |             applicationState: applicationState | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         notificationBody = MentionUtilities.highlightMentionsNoAttributes( | 
					
						
							|  |  |  |             in: (notificationBody ?? ""), | 
					
						
							|  |  |  |             threadVariant: thread.variant, | 
					
						
							|  |  |  |             currentUserPublicKey: userPublicKey, | 
					
						
							|  |  |  |             currentUserBlinded15PublicKey: userBlinded15Key, | 
					
						
							|  |  |  |             currentUserBlinded25PublicKey: userBlinded25Key | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         self.adaptee.notify( | 
					
						
							|  |  |  |             category: category, | 
					
						
							|  |  |  |             title: notificationTitle, | 
					
						
							|  |  |  |             body: (notificationBody ?? ""), | 
					
						
							|  |  |  |             userInfo: userInfo, | 
					
						
							|  |  |  |             previewType: previewType, | 
					
						
							|  |  |  |             sound: sound, | 
					
						
							|  |  |  |             threadVariant: thread.variant, | 
					
						
							|  |  |  |             threadName: groupName, | 
					
						
							|  |  |  |             applicationState: applicationState, | 
					
						
							|  |  |  |             replacingIdentifier: identifier | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     public func notifyUser( | 
					
						
							|  |  |  |         _ db: Database, | 
					
						
							|  |  |  |         forIncomingCall interaction: Interaction, | 
					
						
							|  |  |  |         in thread: SessionThread, | 
					
						
							|  |  |  |         applicationState: UIApplication.State | 
					
						
							|  |  |  |     ) { | 
					
						
							|  |  |  |         // No call notifications for muted or group threads | 
					
						
							|  |  |  |         guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } | 
					
						
							|  |  |  |         guard | 
					
						
							|  |  |  |             thread.variant != .legacyGroup && | 
					
						
							|  |  |  |             thread.variant != .group && | 
					
						
							|  |  |  |             thread.variant != .community | 
					
						
							|  |  |  |         else { return } | 
					
						
							|  |  |  |         guard | 
					
						
							|  |  |  |             interaction.variant == .infoCall, | 
					
						
							|  |  |  |             let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), | 
					
						
							|  |  |  |             let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( | 
					
						
							|  |  |  |                 CallMessage.MessageInfo.self, | 
					
						
							|  |  |  |                 from: infoMessageData | 
					
						
							|  |  |  |             ) | 
					
						
							|  |  |  |         else { return } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         // Only notify missed calls | 
					
						
							|  |  |  |         guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let category = AppNotificationCategory.errorMessage | 
					
						
							|  |  |  |         let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] | 
					
						
							|  |  |  |             .defaulting(to: .nameAndPreview) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let userInfo: [AnyHashable: Any] = [ | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadId: thread.id, | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue | 
					
						
							|  |  |  |         ] | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let notificationTitle: String = "Session" | 
					
						
							|  |  |  |         let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) | 
					
						
							|  |  |  |         let notificationBody: String? = { | 
					
						
							|  |  |  |             switch messageInfo.state { | 
					
						
							|  |  |  |                 case .permissionDenied: | 
					
						
							|  |  |  |                     return String( | 
					
						
							|  |  |  |                         format: "modal_call_missed_tips_explanation".localized(), | 
					
						
							|  |  |  |                         senderName | 
					
						
							|  |  |  |                     ) | 
					
						
							|  |  |  |                 case .missed: | 
					
						
							|  |  |  |                     return String( | 
					
						
							|  |  |  |                         format: "call_missed".localized(), | 
					
						
							|  |  |  |                         senderName | 
					
						
							|  |  |  |                     ) | 
					
						
							|  |  |  |                 default: | 
					
						
							|  |  |  |                     return nil | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         }() | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] | 
					
						
							|  |  |  |             .defaulting(to: Preferences.Sound.defaultNotificationSound) | 
					
						
							|  |  |  |         let sound = self.requestSound( | 
					
						
							|  |  |  |             thread: thread, | 
					
						
							|  |  |  |             fallbackSound: fallbackSound, | 
					
						
							|  |  |  |             applicationState: applicationState | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         self.adaptee.notify( | 
					
						
							|  |  |  |             category: category, | 
					
						
							|  |  |  |             title: notificationTitle, | 
					
						
							|  |  |  |             body: (notificationBody ?? ""), | 
					
						
							|  |  |  |             userInfo: userInfo, | 
					
						
							|  |  |  |             previewType: previewType, | 
					
						
							|  |  |  |             sound: sound, | 
					
						
							|  |  |  |             threadVariant: thread.variant, | 
					
						
							|  |  |  |             threadName: senderName, | 
					
						
							|  |  |  |             applicationState: applicationState, | 
					
						
							|  |  |  |             replacingIdentifier: UUID().uuidString | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     public func notifyUser( | 
					
						
							|  |  |  |         _ db: Database, | 
					
						
							|  |  |  |         forReaction reaction: Reaction, | 
					
						
							|  |  |  |         in thread: SessionThread, | 
					
						
							|  |  |  |         applicationState: UIApplication.State | 
					
						
							|  |  |  |     ) { | 
					
						
							|  |  |  |         let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         // No reaction notifications for muted, group threads or message requests | 
					
						
							|  |  |  |         guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } | 
					
						
							|  |  |  |         guard | 
					
						
							|  |  |  |             thread.variant != .legacyGroup && | 
					
						
							|  |  |  |             thread.variant != .group && | 
					
						
							|  |  |  |             thread.variant != .community | 
					
						
							|  |  |  |         else { return } | 
					
						
							|  |  |  |         guard !isMessageRequest else { return } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant) | 
					
						
							|  |  |  |         let notificationTitle = "Session" | 
					
						
							|  |  |  |         var notificationBody = String(format: "EMOJI_REACTS_NOTIFICATION".localized(), senderName, reaction.emoji) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         // Title & body | 
					
						
							|  |  |  |         let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] | 
					
						
							|  |  |  |             .defaulting(to: .nameAndPreview) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         switch previewType { | 
					
						
							|  |  |  |             case .nameAndPreview: break | 
					
						
							|  |  |  |             default: notificationBody = NotificationStrings.incomingMessageBody | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let category = AppNotificationCategory.incomingMessage | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         let userInfo: [AnyHashable: Any] = [ | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadId: thread.id, | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue | 
					
						
							|  |  |  |         ] | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         let threadName: String = SessionThread.displayName( | 
					
						
							|  |  |  |             threadId: thread.id, | 
					
						
							|  |  |  |             variant: thread.variant, | 
					
						
							|  |  |  |             closedGroupName: nil,       // Not supported | 
					
						
							|  |  |  |             openGroupName: nil          // Not supported | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |         let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] | 
					
						
							|  |  |  |             .defaulting(to: Preferences.Sound.defaultNotificationSound) | 
					
						
							|  |  |  |         let sound = self.requestSound( | 
					
						
							|  |  |  |             thread: thread, | 
					
						
							|  |  |  |             fallbackSound: fallbackSound, | 
					
						
							|  |  |  |             applicationState: applicationState | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         self.adaptee.notify( | 
					
						
							|  |  |  |             category: category, | 
					
						
							|  |  |  |             title: notificationTitle, | 
					
						
							|  |  |  |             body: notificationBody, | 
					
						
							|  |  |  |             userInfo: userInfo, | 
					
						
							|  |  |  |             previewType: previewType, | 
					
						
							|  |  |  |             sound: sound, | 
					
						
							|  |  |  |             threadVariant: thread.variant, | 
					
						
							|  |  |  |             threadName: threadName, | 
					
						
							|  |  |  |             applicationState: applicationState, | 
					
						
							|  |  |  |             replacingIdentifier: UUID().uuidString | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public func notifyForFailedSend( | 
					
						
							|  |  |  |         _ db: Database, | 
					
						
							|  |  |  |         in thread: SessionThread, | 
					
						
							|  |  |  |         applicationState: UIApplication.State | 
					
						
							|  |  |  |     ) { | 
					
						
							|  |  |  |         let notificationTitle: String? | 
					
						
							|  |  |  |         let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] | 
					
						
							|  |  |  |             .defaulting(to: .defaultPreviewType) | 
					
						
							|  |  |  |         let threadName: String = SessionThread.displayName( | 
					
						
							|  |  |  |             threadId: thread.id, | 
					
						
							|  |  |  |             variant: thread.variant, | 
					
						
							|  |  |  |             closedGroupName: try? thread.closedGroup | 
					
						
							|  |  |  |                 .select(.name) | 
					
						
							|  |  |  |                 .asRequest(of: String.self) | 
					
						
							|  |  |  |                 .fetchOne(db), | 
					
						
							|  |  |  |             openGroupName: try? thread.openGroup | 
					
						
							|  |  |  |                 .select(.name) | 
					
						
							|  |  |  |                 .asRequest(of: String.self) | 
					
						
							|  |  |  |                 .fetchOne(db), | 
					
						
							|  |  |  |             isNoteToSelf: (thread.isNoteToSelf(db) == true), | 
					
						
							|  |  |  |             profile: try? Profile.fetchOne(db, id: thread.id) | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         switch previewType { | 
					
						
							|  |  |  |             case .noNameNoPreview: notificationTitle = nil | 
					
						
							|  |  |  |             case .nameNoPreview, .nameAndPreview: notificationTitle = threadName | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         let notificationBody = NotificationStrings.failedToSendBody | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         let userInfo: [AnyHashable: Any] = [ | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadId: thread.id, | 
					
						
							|  |  |  |             AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue | 
					
						
							|  |  |  |         ] | 
					
						
							|  |  |  |         let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] | 
					
						
							|  |  |  |             .defaulting(to: Preferences.Sound.defaultNotificationSound) | 
					
						
							|  |  |  |         let sound: Preferences.Sound? = self.requestSound( | 
					
						
							|  |  |  |             thread: thread, | 
					
						
							|  |  |  |             fallbackSound: fallbackSound, | 
					
						
							|  |  |  |             applicationState: applicationState | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         self.adaptee.notify( | 
					
						
							|  |  |  |             category: .errorMessage, | 
					
						
							|  |  |  |             title: notificationTitle, | 
					
						
							|  |  |  |             body: notificationBody, | 
					
						
							|  |  |  |             userInfo: userInfo, | 
					
						
							|  |  |  |             previewType: previewType, | 
					
						
							|  |  |  |             sound: sound, | 
					
						
							|  |  |  |             threadVariant: thread.variant, | 
					
						
							|  |  |  |             threadName: threadName, | 
					
						
							|  |  |  |             applicationState: applicationState | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     @objc | 
					
						
							|  |  |  |     public func cancelNotifications(identifiers: [String]) { | 
					
						
							|  |  |  |         DispatchQueue.main.async { | 
					
						
							|  |  |  |             self.adaptee.cancelNotifications(identifiers: identifiers) | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     @objc | 
					
						
							|  |  |  |     public func cancelNotifications(threadId: String) { | 
					
						
							|  |  |  |         self.adaptee.cancelNotifications(threadId: threadId) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     @objc | 
					
						
							|  |  |  |     public func clearAllNotifications() { | 
					
						
							|  |  |  |         adaptee.clearAllNotifications() | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     // MARK: - | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     var mostRecentNotifications: Atomic<TruncatedList<UInt64>> = Atomic(TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     private func requestSound( | 
					
						
							|  |  |  |         thread: SessionThread, | 
					
						
							|  |  |  |         fallbackSound: Preferences.Sound, | 
					
						
							|  |  |  |         applicationState: UIApplication.State | 
					
						
							|  |  |  |     ) -> Preferences.Sound? { | 
					
						
							|  |  |  |         guard checkIfShouldPlaySound(applicationState: applicationState) else { return nil } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         return (thread.notificationSound ?? fallbackSound) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     private func checkIfShouldPlaySound(applicationState: UIApplication.State) -> Bool { | 
					
						
							|  |  |  |         guard applicationState == .active else { return true } | 
					
						
							|  |  |  |         guard Storage.shared[.playNotificationSoundInForeground] else { return false } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) | 
					
						
							|  |  |  |         let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         let recentNotifications = mostRecentNotifications.wrappedValue.filter { $0 > recentThreshold } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         guard recentNotifications.count < kAudioNotificationsThrottleCount else { return false } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         mostRecentNotifications.mutate { $0.append(nowMs) } | 
					
						
							|  |  |  |         return true | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | class NotificationActionHandler { | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     static let shared: NotificationActionHandler = NotificationActionHandler() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     // MARK: - Dependencies | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     var notificationPresenter: NotificationPresenter { | 
					
						
							|  |  |  |         return AppEnvironment.shared.notificationPresenter | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     // MARK: - | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     func markAsRead(userInfo: [AnyHashable: Any]) -> AnyPublisher<Void, Error> { | 
					
						
							|  |  |  |         guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else { | 
					
						
							|  |  |  |             return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) | 
					
						
							|  |  |  |                 .eraseToAnyPublisher() | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         guard Storage.shared.read({ db in try SessionThread.exists(db, id: threadId) }) == true else { | 
					
						
							|  |  |  |             return Fail(error: NotificationError.failDebug("unable to find thread with id: \(threadId)")) | 
					
						
							|  |  |  |                 .eraseToAnyPublisher() | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return markAsRead(threadId: threadId) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     func reply( | 
					
						
							|  |  |  |         userInfo: [AnyHashable: Any], | 
					
						
							|  |  |  |         replyText: String, | 
					
						
							|  |  |  |         applicationState: UIApplication.State | 
					
						
							|  |  |  |     ) -> AnyPublisher<Void, Error> { | 
					
						
							|  |  |  |         guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { | 
					
						
							|  |  |  |             return Fail<Void, Error>(error: NotificationError.failDebug("threadId was unexpectedly nil")) | 
					
						
							|  |  |  |                 .eraseToAnyPublisher() | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { | 
					
						
							|  |  |  |             return Fail<Void, Error>(error: NotificationError.failDebug("unable to find thread with id: \(threadId)")) | 
					
						
							|  |  |  |                 .eraseToAnyPublisher() | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         return Storage.shared | 
					
						
							|  |  |  |             .writePublisher { db in | 
					
						
							|  |  |  |                 let interaction: Interaction = try Interaction( | 
					
						
							|  |  |  |                     threadId: threadId, | 
					
						
							|  |  |  |                     authorId: getUserHexEncodedPublicKey(db), | 
					
						
							|  |  |  |                     variant: .standardOutgoing, | 
					
						
							|  |  |  |                     body: replyText, | 
					
						
							|  |  |  |                     timestampMs: SnodeAPI.currentOffsetTimestampMs(), | 
					
						
							|  |  |  |                     hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText), | 
					
						
							|  |  |  |                     expiresInSeconds: try? DisappearingMessagesConfiguration | 
					
						
							|  |  |  |                         .select(.durationSeconds) | 
					
						
							|  |  |  |                         .filter(id: threadId) | 
					
						
							|  |  |  |                         .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) | 
					
						
							|  |  |  |                         .asRequest(of: TimeInterval.self) | 
					
						
							|  |  |  |                         .fetchOne(db) | 
					
						
							|  |  |  |                 ).inserted(db) | 
					
						
							|  |  |  |                  | 
					
						
							|  |  |  |                 try Interaction.markAsRead( | 
					
						
							|  |  |  |                     db, | 
					
						
							|  |  |  |                     interactionId: interaction.id, | 
					
						
							|  |  |  |                     threadId: threadId, | 
					
						
							|  |  |  |                     threadVariant: thread.variant, | 
					
						
							|  |  |  |                     includingOlder: true, | 
					
						
							|  |  |  |                     trySendReadReceipt: try SessionThread.canSendReadReceipt( | 
					
						
							|  |  |  |                         db, | 
					
						
							|  |  |  |                         threadId: threadId, | 
					
						
							|  |  |  |                         threadVariant: thread.variant | 
					
						
							|  |  |  |                     ) | 
					
						
							|  |  |  |                 ) | 
					
						
							|  |  |  |                  | 
					
						
							|  |  |  |                 return try MessageSender.preparedSendData( | 
					
						
							|  |  |  |                     db, | 
					
						
							|  |  |  |                     interaction: interaction, | 
					
						
							|  |  |  |                     threadId: threadId, | 
					
						
							|  |  |  |                     threadVariant: thread.variant | 
					
						
							|  |  |  |                 ) | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |             .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } | 
					
						
							|  |  |  |             .handleEvents( | 
					
						
							|  |  |  |                 receiveCompletion: { result in | 
					
						
							|  |  |  |                     switch result { | 
					
						
							|  |  |  |                         case .finished: break | 
					
						
							|  |  |  |                         case .failure: | 
					
						
							|  |  |  |                             Storage.shared.read { [weak self] db in | 
					
						
							|  |  |  |                                 self?.notificationPresenter.notifyForFailedSend( | 
					
						
							|  |  |  |                                     db, | 
					
						
							|  |  |  |                                     in: thread, | 
					
						
							|  |  |  |                                     applicationState: applicationState | 
					
						
							|  |  |  |                                 ) | 
					
						
							|  |  |  |                             } | 
					
						
							|  |  |  |                     } | 
					
						
							|  |  |  |                 } | 
					
						
							|  |  |  |             ) | 
					
						
							|  |  |  |             .eraseToAnyPublisher() | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher<Void, Never> { | 
					
						
							|  |  |  |         guard | 
					
						
							|  |  |  |             let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String, | 
					
						
							|  |  |  |             let threadVariantRaw = userInfo[AppNotificationUserInfoKey.threadVariantRaw] as? Int, | 
					
						
							|  |  |  |             let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: threadVariantRaw) | 
					
						
							|  |  |  |         else { return showHomeVC() } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         // If this happens when the the app is not, visible we skip the animation so the thread | 
					
						
							|  |  |  |         // can be visible to the user immediately upon opening the app, rather than having to watch | 
					
						
							|  |  |  |         // it animate in from the homescreen. | 
					
						
							|  |  |  |         SessionApp.presentConversationCreatingIfNeeded( | 
					
						
							|  |  |  |             for: threadId, | 
					
						
							|  |  |  |             variant: threadVariant, | 
					
						
							|  |  |  |             dismissing: nil, | 
					
						
							|  |  |  |             animated: (UIApplication.shared.applicationState == .active) | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         return Just(()) | 
					
						
							|  |  |  |             .eraseToAnyPublisher() | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     func showHomeVC() -> AnyPublisher<Void, Never> { | 
					
						
							|  |  |  |         SessionApp.showHomeView() | 
					
						
							|  |  |  |         return Just(()) | 
					
						
							|  |  |  |             .eraseToAnyPublisher() | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     private func markAsRead(threadId: String) -> AnyPublisher<Void, Error> { | 
					
						
							|  |  |  |         return Storage.shared | 
					
						
							|  |  |  |             .writePublisher { db in | 
					
						
							|  |  |  |                 guard | 
					
						
							|  |  |  |                     let threadVariant: SessionThread.Variant = try SessionThread | 
					
						
							|  |  |  |                         .filter(id: threadId) | 
					
						
							|  |  |  |                         .select(.variant) | 
					
						
							|  |  |  |                         .asRequest(of: SessionThread.Variant.self) | 
					
						
							|  |  |  |                         .fetchOne(db), | 
					
						
							|  |  |  |                     let lastInteractionId: Int64 = try Interaction | 
					
						
							|  |  |  |                         .select(.id) | 
					
						
							|  |  |  |                         .filter(Interaction.Columns.threadId == threadId) | 
					
						
							|  |  |  |                         .order(Interaction.Columns.timestampMs.desc) | 
					
						
							|  |  |  |                         .asRequest(of: Int64.self) | 
					
						
							|  |  |  |                         .fetchOne(db) | 
					
						
							|  |  |  |                 else { throw NotificationError.failDebug("unable to required thread info: \(threadId)") } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |                 try Interaction.markAsRead( | 
					
						
							|  |  |  |                     db, | 
					
						
							|  |  |  |                     interactionId: lastInteractionId, | 
					
						
							|  |  |  |                     threadId: threadId, | 
					
						
							|  |  |  |                     threadVariant: threadVariant, | 
					
						
							|  |  |  |                     includingOlder: true, | 
					
						
							|  |  |  |                     trySendReadReceipt: try SessionThread.canSendReadReceipt( | 
					
						
							|  |  |  |                         db, | 
					
						
							|  |  |  |                         threadId: threadId, | 
					
						
							|  |  |  |                         threadVariant: threadVariant | 
					
						
							|  |  |  |                     ) | 
					
						
							|  |  |  |                 ) | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |             .eraseToAnyPublisher() | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | enum NotificationError: Error { | 
					
						
							|  |  |  |     case assertionError(description: String) | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | extension NotificationError { | 
					
						
							|  |  |  |     static func failDebug(_ description: String) -> NotificationError { | 
					
						
							|  |  |  |         owsFailDebug(description) | 
					
						
							|  |  |  |         return NotificationError.assertionError(description: description) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | struct TruncatedList<Element> { | 
					
						
							|  |  |  |     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) | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } |