2019-01-18 18:54:09 +01:00
|
|
|
//
|
|
|
|
// Copyright (c) 2019 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.
|
|
|
|
|
|
|
|
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 callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber"
|
|
|
|
static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId"
|
|
|
|
}
|
|
|
|
|
|
|
|
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"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
2019-01-30 23:27:53 +01:00
|
|
|
let kAudioNotificationsThrottleCount = 2
|
|
|
|
let kAudioNotificationsThrottleInterval: TimeInterval = 5
|
|
|
|
|
2019-01-18 18:54:09 +01:00
|
|
|
protocol NotificationPresenterAdaptee: class {
|
|
|
|
|
|
|
|
func registerNotificationSettings() -> Promise<Void>
|
|
|
|
|
2019-01-31 03:11:56 +01:00
|
|
|
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?)
|
|
|
|
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?)
|
2019-01-18 18:54:09 +01:00
|
|
|
|
|
|
|
func cancelNotifications(threadId: String)
|
2021-08-02 06:03:46 +02:00
|
|
|
func cancelNotification(identifier: String)
|
2019-01-18 18:54:09 +01:00
|
|
|
func clearAllNotifications()
|
|
|
|
}
|
|
|
|
|
|
|
|
@objc(OWSNotificationPresenter)
|
|
|
|
public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|
|
|
|
|
|
|
private let adaptee: NotificationPresenterAdaptee
|
|
|
|
|
|
|
|
@objc
|
|
|
|
public override init() {
|
2021-02-23 05:38:55 +01:00
|
|
|
self.adaptee = UserNotificationPresenterAdaptee()
|
2019-01-18 18:54:09 +01:00
|
|
|
|
|
|
|
super.init()
|
|
|
|
|
|
|
|
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(self.handleMessageRead), name: .incomingMessageMarkedAsRead, object: nil)
|
|
|
|
}
|
|
|
|
SwiftSingletons.register(self)
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Dependencies
|
|
|
|
|
|
|
|
var identityManager: OWSIdentityManager {
|
|
|
|
return OWSIdentityManager.shared()
|
|
|
|
}
|
|
|
|
|
2019-01-30 23:27:53 +01:00
|
|
|
var preferences: OWSPreferences {
|
|
|
|
return Environment.shared.preferences
|
|
|
|
}
|
|
|
|
|
2019-01-18 18:54:09 +01:00
|
|
|
var previewType: NotificationType {
|
2019-01-30 23:27:53 +01:00
|
|
|
return preferences.notificationPreviewType()
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: -
|
|
|
|
|
|
|
|
@objc
|
|
|
|
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
|
|
|
|
|
|
|
|
func registerNotificationSettings() -> Promise<Void> {
|
|
|
|
return adaptee.registerNotificationSettings()
|
|
|
|
}
|
|
|
|
|
2019-01-30 23:43:40 +01:00
|
|
|
public func notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) {
|
2019-01-18 18:54:09 +01:00
|
|
|
|
|
|
|
guard !thread.isMuted else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// While batch processing, some of the necessary changes have not been commited.
|
|
|
|
let rawMessageText = incomingMessage.previewText(with: transaction)
|
|
|
|
|
|
|
|
// 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.
|
2020-08-14 05:34:57 +02:00
|
|
|
let messageText = DisplayableText.filterNotificationText(rawMessageText)
|
2021-07-26 07:43:03 +02:00
|
|
|
|
2021-07-29 02:14:06 +02:00
|
|
|
// Don't fire the notification if the current user isn't mentioned
|
|
|
|
// and isOnlyNotifyingForMentions is on.
|
2021-07-30 01:43:05 +02:00
|
|
|
if let groupThread = thread as? TSGroupThread, groupThread.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned {
|
2021-07-26 07:43:03 +02:00
|
|
|
return
|
|
|
|
}
|
2019-01-18 18:54:09 +01:00
|
|
|
|
2021-02-26 05:56:41 +01:00
|
|
|
let context = Contact.context(for: thread)
|
|
|
|
let senderName = Storage.shared.getContact(with: incomingMessage.authorId)?.displayName(for: context) ?? incomingMessage.authorId
|
2019-01-18 18:54:09 +01:00
|
|
|
|
2019-01-31 03:11:56 +01:00
|
|
|
let notificationTitle: String?
|
2019-01-18 18:54:09 +01:00
|
|
|
switch previewType {
|
|
|
|
case .noNameNoPreview:
|
2019-01-31 03:11:56 +01:00
|
|
|
notificationTitle = nil
|
|
|
|
case .nameNoPreview, .namePreview:
|
2019-01-18 18:54:09 +01:00
|
|
|
switch thread {
|
|
|
|
case is TSContactThread:
|
2019-01-31 03:11:56 +01:00
|
|
|
notificationTitle = senderName
|
2019-01-18 18:54:09 +01:00
|
|
|
case is TSGroupThread:
|
|
|
|
var groupName = thread.name()
|
|
|
|
if groupName.count < 1 {
|
|
|
|
groupName = MessageStrings.newGroupDefaultTitle
|
|
|
|
}
|
2019-01-31 03:11:56 +01:00
|
|
|
notificationTitle = String(format: NotificationStrings.incomingGroupMessageTitleFormat,
|
|
|
|
senderName,
|
|
|
|
groupName)
|
2019-01-18 18:54:09 +01:00
|
|
|
default:
|
|
|
|
owsFailDebug("unexpected thread: \(thread)")
|
|
|
|
return
|
|
|
|
}
|
2019-01-31 03:11:56 +01:00
|
|
|
}
|
2019-01-18 18:54:09 +01:00
|
|
|
|
2020-08-14 05:34:57 +02:00
|
|
|
var notificationBody: String?
|
2019-01-31 03:11:56 +01:00
|
|
|
switch previewType {
|
|
|
|
case .noNameNoPreview, .nameNoPreview:
|
|
|
|
notificationBody = NotificationStrings.incomingMessageBody
|
|
|
|
case .namePreview:
|
|
|
|
notificationBody = messageText
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
guard let threadId = thread.uniqueId else {
|
|
|
|
owsFailDebug("threadId was unexpectedly nil")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2019-01-31 03:11:56 +01:00
|
|
|
assert((notificationBody ?? notificationTitle) != nil)
|
|
|
|
|
2019-01-18 18:54:09 +01:00
|
|
|
// Don't reply from lockscreen if anyone in this conversation is
|
|
|
|
// "no longer verified".
|
|
|
|
var category = AppNotificationCategory.incomingMessage
|
|
|
|
|
|
|
|
let userInfo = [
|
|
|
|
AppNotificationUserInfoKey.threadId: threadId
|
|
|
|
]
|
2021-08-02 06:03:46 +02:00
|
|
|
|
2021-08-03 02:42:09 +02:00
|
|
|
let identifier: String = incomingMessage.notificationIdentifier ?? UUID().uuidString
|
2019-01-18 18:54:09 +01:00
|
|
|
|
|
|
|
DispatchQueue.main.async {
|
2020-08-14 05:34:57 +02:00
|
|
|
notificationBody = MentionUtilities.highlightMentions(in: notificationBody!, threadID: thread.uniqueId!)
|
2019-01-30 23:27:53 +01:00
|
|
|
let sound = self.requestSound(thread: thread)
|
2019-01-31 03:11:56 +01:00
|
|
|
self.adaptee.notify(category: category,
|
|
|
|
title: notificationTitle,
|
|
|
|
body: notificationBody ?? "",
|
|
|
|
userInfo: userInfo,
|
2021-08-02 06:03:46 +02:00
|
|
|
sound: sound,
|
|
|
|
replacingIdentifier: identifier)
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public func notifyForFailedSend(inThread thread: TSThread) {
|
2019-01-31 03:11:56 +01:00
|
|
|
let notificationTitle: String?
|
|
|
|
switch previewType {
|
|
|
|
case .noNameNoPreview:
|
|
|
|
notificationTitle = nil
|
|
|
|
case .nameNoPreview, .namePreview:
|
|
|
|
notificationTitle = thread.name()
|
|
|
|
}
|
|
|
|
|
|
|
|
let notificationBody = NotificationStrings.failedToSendBody
|
2019-01-18 18:54:09 +01:00
|
|
|
|
|
|
|
guard let threadId = thread.uniqueId else {
|
|
|
|
owsFailDebug("threadId was unexpectedly nil")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let userInfo = [
|
|
|
|
AppNotificationUserInfoKey.threadId: threadId
|
|
|
|
]
|
|
|
|
|
|
|
|
DispatchQueue.main.async {
|
2019-01-30 23:27:53 +01:00
|
|
|
let sound = self.requestSound(thread: thread)
|
2019-01-31 03:11:56 +01:00
|
|
|
self.adaptee.notify(category: .errorMessage,
|
|
|
|
title: notificationTitle,
|
|
|
|
body: notificationBody,
|
|
|
|
userInfo: userInfo,
|
|
|
|
sound: sound)
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
}
|
2021-08-02 06:03:46 +02:00
|
|
|
|
|
|
|
@objc
|
|
|
|
public func cancelNotification(_ identifier: String) {
|
2021-08-02 07:24:12 +02:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.adaptee.cancelNotification(identifier: identifier)
|
|
|
|
}
|
2021-08-02 06:03:46 +02:00
|
|
|
}
|
2019-01-18 18:54:09 +01:00
|
|
|
|
2019-04-09 17:59:09 +02:00
|
|
|
@objc
|
2019-01-18 18:54:09 +01:00
|
|
|
public func cancelNotifications(threadId: String) {
|
|
|
|
self.adaptee.cancelNotifications(threadId: threadId)
|
|
|
|
}
|
|
|
|
|
2019-04-09 17:59:09 +02:00
|
|
|
@objc
|
2019-01-18 18:54:09 +01:00
|
|
|
public func clearAllNotifications() {
|
|
|
|
adaptee.clearAllNotifications()
|
|
|
|
}
|
|
|
|
|
2019-01-30 23:27:53 +01:00
|
|
|
// 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 UIApplication.shared.applicationState == .active 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
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class NotificationActionHandler {
|
|
|
|
|
|
|
|
static let shared: NotificationActionHandler = NotificationActionHandler()
|
|
|
|
|
|
|
|
// MARK: - Dependencies
|
|
|
|
|
|
|
|
var signalApp: SignalApp {
|
|
|
|
return SignalApp.shared()
|
|
|
|
}
|
|
|
|
|
|
|
|
var notificationPresenter: NotificationPresenter {
|
|
|
|
return AppEnvironment.shared.notificationPresenter
|
|
|
|
}
|
|
|
|
|
|
|
|
var dbConnection: YapDatabaseConnection {
|
|
|
|
return OWSPrimaryStorage.shared().dbReadWriteConnection
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: -
|
|
|
|
|
|
|
|
func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
|
|
|
|
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
|
|
|
throw NotificationError.failDebug("threadId was unexpectedly nil")
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let thread = TSThread.fetch(uniqueId: threadId) else {
|
|
|
|
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
|
|
|
|
}
|
|
|
|
|
2019-02-15 02:36:45 +01:00
|
|
|
return markAsRead(thread: thread)
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
func reply(userInfo: [AnyHashable: Any], replyText: String) throws -> Promise<Void> {
|
|
|
|
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
|
|
|
throw NotificationError.failDebug("threadId was unexpectedly nil")
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let thread = TSThread.fetch(uniqueId: threadId) else {
|
|
|
|
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
|
|
|
|
}
|
|
|
|
|
2019-02-15 02:36:45 +01:00
|
|
|
return markAsRead(thread: thread).then { () -> Promise<Void> in
|
2020-11-26 04:01:24 +01:00
|
|
|
let message = VisibleMessage()
|
|
|
|
message.sentTimestamp = NSDate.millisecondTimestamp()
|
|
|
|
message.text = replyText
|
|
|
|
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
|
|
|
|
Storage.write { transaction in
|
|
|
|
tsMessage.save(with: transaction)
|
|
|
|
}
|
|
|
|
var promise: Promise<Void>!
|
|
|
|
Storage.writeSync { transaction in
|
|
|
|
promise = MessageSender.sendNonDurably(message, in: thread, using: transaction)
|
|
|
|
}
|
|
|
|
promise.catch { [weak self] error in
|
|
|
|
self?.notificationPresenter.notifyForFailedSend(inThread: thread)
|
|
|
|
}
|
|
|
|
return promise
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func showThread(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
|
|
|
|
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
2020-03-27 05:13:24 +01:00
|
|
|
return showHomeVC()
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
let shouldAnimate = UIApplication.shared.applicationState == .active
|
2019-03-28 23:01:49 +01:00
|
|
|
signalApp.presentConversationAndScrollToFirstUnreadMessage(forThreadId: threadId, animated: shouldAnimate)
|
2019-01-18 18:54:09 +01:00
|
|
|
return Promise.value(())
|
|
|
|
}
|
2020-03-25 01:49:43 +01:00
|
|
|
|
2020-03-27 05:13:24 +01:00
|
|
|
func showHomeVC() -> Promise<Void> {
|
2020-03-25 01:49:43 +01:00
|
|
|
signalApp.showHomeView()
|
|
|
|
return Promise.value(())
|
|
|
|
}
|
2019-02-15 02:36:45 +01:00
|
|
|
|
|
|
|
private func markAsRead(thread: TSThread) -> Promise<Void> {
|
2020-06-11 04:23:06 +02:00
|
|
|
return Storage.write { transaction in
|
2019-02-15 02:36:45 +01:00
|
|
|
thread.markAllAsRead(with: transaction)
|
|
|
|
}
|
|
|
|
}
|
2019-01-18 18:54:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
enum NotificationError: Error {
|
|
|
|
case assertionError(description: String)
|
|
|
|
}
|
|
|
|
|
|
|
|
extension NotificationError {
|
|
|
|
static func failDebug(_ description: String) -> NotificationError {
|
|
|
|
owsFailDebug(description)
|
|
|
|
return NotificationError.assertionError(description: description)
|
|
|
|
}
|
|
|
|
}
|
2019-01-30 23:27:53 +01:00
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|