831 lines
32 KiB
Swift
831 lines
32 KiB
Swift
//
|
|
// Copyright (c) 2020 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 = 5
|
|
|
|
let kAudioNotificationsThrottleCount = 2
|
|
let kAudioNotificationsThrottleInterval: TimeInterval = 5
|
|
|
|
protocol NotificationPresenterAdaptee: class {
|
|
|
|
func registerNotificationSettings() -> Promise<Void>
|
|
|
|
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)
|
|
}
|
|
|
|
// MARK: - Dependencies
|
|
|
|
var contactsManager: OWSContactsManager {
|
|
return Environment.shared.contactsManager
|
|
}
|
|
|
|
var identityManager: OWSIdentityManager {
|
|
return OWSIdentityManager.shared()
|
|
}
|
|
|
|
var preferences: OWSPreferences {
|
|
return Environment.shared.preferences
|
|
}
|
|
|
|
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:
|
|
Logger.debug("canceled notification for message: \(incomingMessage)")
|
|
cancelNotifications(messageId: incomingMessage.uniqueId)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Presenting Notifications
|
|
|
|
public func registerNotificationSettings() -> Promise<Void> {
|
|
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?.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 if firstAttachment?.isAnimated == true {
|
|
notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, 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<UInt64>(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<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)
|
|
}
|
|
}
|
|
|
|
public protocol IndividualCallNotificationInfo {
|
|
var remoteAddress: SignalServiceAddress { get }
|
|
var localId: UUID { get }
|
|
var offerMediaType: TSRecentCallOfferType { get }
|
|
}
|