The NSE should only run on iOS 13.3 or later where the "filtering" entitlement is available since our notifications don't contain any content and will often not trigger any user visible content. We control this by setting the deployment target to iOS 13.3. This does not handle calls as it's currently impossible to wake the main app or launch CallKit from within the NSE. Should we reach a point where we need to use this extension in production the service will need to be able to differentiate between call and non-call messages and deliver them as VOIP or Vanilla pushes as appropriate. Alternatively, Apple may introduce some way for us to signal the main app that a call message has been received. This does not currently address the potential for the NSE and the main app to be running and trying to process messages at the same time. As long as the websocket is connected and the main app is processing messages in a timely fashion the NSE will never be called since the service will not send pushes for these messages, but censorship circumvention users and users where the websocket is disconnected for some reason will legitimately receive pushes and we will want to process those messages. How we will handle these cases requires further thought since just terminating the NSE when the app launches is not sufficient. We could potentially do something like terminate the NSE everytime the main app runs the message fetcher job which should only happen if either the user pulls-to-refresh on the conversation list or the websocket is actively connected and receiving messsages. I plan to address this in a follow-up pull request. Currently this code will not ever be run since the service never sends vanilla push notifications. Eventually we will need to add logic into the main app to: a) detect we're on iOS 13.3 or later and b) update a flag on the service telling it to stop using VOIP pushes for non-call messages. The API for requesting this from the service does not yet exist.
704 lines
26 KiB
Swift
704 lines
26 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
|
|
case incomingMessageWithoutActions
|
|
case incomingMessageFromNoLongerVerifiedIdentity
|
|
case infoOrErrorMessage
|
|
case threadlessErrorMessage
|
|
case incomingCall
|
|
case missedCallWithActions
|
|
case missedCallWithoutActions
|
|
case missedCallFromNoLongerVerifiedIdentity
|
|
case grdbMigration
|
|
}
|
|
|
|
public enum AppNotificationAction: CaseIterable {
|
|
case answerCall
|
|
case callBack
|
|
case declineCall
|
|
case markAsRead
|
|
case reply
|
|
case showThread
|
|
}
|
|
|
|
public struct AppNotificationUserInfoKey {
|
|
public static let threadId = "Signal.AppNotificationsUserInfoKey.threadId"
|
|
public static let callBackUuid = "Signal.AppNotificationsUserInfoKey.callBackUuid"
|
|
public static let callBackPhoneNumber = "Signal.AppNotificationsUserInfoKey.callBackPhoneNumber"
|
|
public static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId"
|
|
}
|
|
|
|
extension AppNotificationCategory {
|
|
var identifier: String {
|
|
switch self {
|
|
case .incomingMessageWithActions:
|
|
return "Signal.AppNotificationCategory.incomingMessageWithActions"
|
|
case .incomingMessageWithoutActions:
|
|
return "Signal.AppNotificationCategory.incomingMessage"
|
|
case .incomingMessageFromNoLongerVerifiedIdentity:
|
|
return "Signal.AppNotificationCategory.incomingMessageFromNoLongerVerifiedIdentity"
|
|
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:
|
|
return [.markAsRead, .reply]
|
|
case .incomingMessageWithoutActions:
|
|
return []
|
|
case .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"
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 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.runNowOrWhenAppDidBecomeReady {
|
|
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(threadId: incomingMessage.uniqueThreadId)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Presenting Notifications
|
|
|
|
public func registerNotificationSettings() -> Promise<Void> {
|
|
return adaptee.registerNotificationSettings()
|
|
}
|
|
|
|
public func presentIncomingCall(_ call: SignalCall, 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.incomingCallBody
|
|
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: .defaultiOSIncomingRingtone,
|
|
replacingIdentifier: call.localId.uuidString)
|
|
}
|
|
}
|
|
|
|
public func presentMissedCall(_ call: SignalCall, 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.missedCallBody
|
|
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: SignalCall, 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: SignalCall, 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 notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: SDSAnyReadTransaction) {
|
|
|
|
guard !thread.isMuted else {
|
|
return
|
|
}
|
|
|
|
// While batch processing, some of the necessary changes have not been commited.
|
|
let rawMessageText = incomingMessage.previewText(with: 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)")
|
|
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) == .noLongerVerified {
|
|
didIdentityChange = true
|
|
break
|
|
}
|
|
}
|
|
|
|
let category: AppNotificationCategory
|
|
if didIdentityChange {
|
|
category = .incomingMessageFromNoLongerVerifiedIdentity
|
|
} else if !shouldShowActions {
|
|
category = .incomingMessageWithoutActions
|
|
} else {
|
|
category = .incomingMessageWithActions
|
|
}
|
|
let userInfo = [
|
|
AppNotificationUserInfoKey.threadId: thread.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, in 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)")
|
|
return
|
|
}
|
|
|
|
let notificationBody: String
|
|
if let bodyDescription: String = {
|
|
if let messageBody = message.body, !messageBody.isEmpty {
|
|
return messageBody.filterStringForDisplay()
|
|
} else if let oversizeText = message.oversizeText(with: transaction), !oversizeText.isEmpty {
|
|
return oversizeText.filterStringForDisplay()
|
|
} 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)
|
|
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) == .noLongerVerified {
|
|
didIdentityChange = true
|
|
break
|
|
}
|
|
}
|
|
|
|
let category: AppNotificationCategory
|
|
if didIdentityChange {
|
|
category = .incomingMessageFromNoLongerVerifiedIdentity
|
|
} else if !shouldShowActions {
|
|
category = .incomingMessageWithoutActions
|
|
} else {
|
|
category = .incomingMessageWithActions
|
|
}
|
|
let userInfo = [
|
|
AppNotificationUserInfoKey.threadId: thread.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 notifyUser(for errorMessage: TSErrorMessage, thread: TSThread, transaction: SDSAnyWriteTransaction) {
|
|
notifyUser(for: errorMessage as TSMessage, thread: thread, wantsSound: true, transaction: transaction)
|
|
}
|
|
|
|
public func notifyUser(for infoMessage: TSInfoMessage, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction) {
|
|
notifyUser(for: infoMessage as TSMessage, thread: thread, wantsSound: wantsSound, transaction: transaction)
|
|
}
|
|
|
|
private func notifyUser(for infoOrErrorMessage: TSMessage, thread: TSThread, wantsSound: Bool, transaction: SDSAnyWriteTransaction) {
|
|
|
|
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 = infoOrErrorMessage.previewText(with: transaction)
|
|
}
|
|
|
|
let threadId = thread.uniqueId
|
|
let userInfo = [
|
|
AppNotificationUserInfoKey.threadId: threadId
|
|
]
|
|
|
|
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(with: 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 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)
|
|
}
|
|
}
|