session-ios/Session/Notifications/AppNotifications.swift

442 lines
15 KiB
Swift

//
// 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
let kAudioNotificationsThrottleCount = 2
let kAudioNotificationsThrottleInterval: TimeInterval = 5
protocol NotificationPresenterAdaptee: class {
func registerNotificationSettings() -> Promise<Void>
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?)
func cancelNotifications(threadId: String)
func clearAllNotifications()
}
@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 identityManager: OWSIdentityManager {
return OWSIdentityManager.shared()
}
var preferences: OWSPreferences {
return Environment.shared.preferences
}
var previewType: NotificationType {
return preferences.notificationPreviewType()
}
// 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()
}
public func notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) {
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.
let messageText = DisplayableText.filterNotificationText(rawMessageText)
// Don't fire the notification if the current user isn't mentioned
// and isOnlyNotifyingForMentions is on.
let isUserMentioned = MentionUtilities.isUserMentioned(in: messageText ?? "")
if let groupThread = thread as? TSGroupThread, groupThread.isOnlyNotifyingForMentions && !isUserMentioned {
return
}
let context = Contact.context(for: thread)
let senderName = Storage.shared.getContact(with: incomingMessage.authorId)?.displayName(for: context) ?? incomingMessage.authorId
let notificationTitle: String?
switch previewType {
case .noNameNoPreview:
notificationTitle = nil
case .nameNoPreview, .namePreview:
switch thread {
case is TSContactThread:
notificationTitle = senderName
case is TSGroupThread:
var groupName = thread.name()
if groupName.count < 1 {
groupName = MessageStrings.newGroupDefaultTitle
}
notificationTitle = String(format: NotificationStrings.incomingGroupMessageTitleFormat,
senderName,
groupName)
default:
owsFailDebug("unexpected thread: \(thread)")
return
}
}
var notificationBody: String?
switch previewType {
case .noNameNoPreview, .nameNoPreview:
notificationBody = NotificationStrings.incomingMessageBody
case .namePreview:
notificationBody = messageText
}
guard let threadId = thread.uniqueId else {
owsFailDebug("threadId was unexpectedly nil")
return
}
assert((notificationBody ?? notificationTitle) != nil)
// Don't reply from lockscreen if anyone in this conversation is
// "no longer verified".
var category = AppNotificationCategory.incomingMessage
let userInfo = [
AppNotificationUserInfoKey.threadId: threadId
]
DispatchQueue.main.async {
notificationBody = MentionUtilities.highlightMentions(in: notificationBody!, threadID: thread.uniqueId!)
let sound = self.requestSound(thread: thread)
self.adaptee.notify(category: category,
title: notificationTitle,
body: notificationBody ?? "",
userInfo: userInfo,
sound: sound)
}
}
public func notifyForFailedSend(inThread thread: TSThread) {
let notificationTitle: String?
switch previewType {
case .noNameNoPreview:
notificationTitle = nil
case .nameNoPreview, .namePreview:
notificationTitle = thread.name()
}
let notificationBody = NotificationStrings.failedToSendBody
guard let threadId = thread.uniqueId else {
owsFailDebug("threadId was unexpectedly nil")
return
}
let userInfo = [
AppNotificationUserInfoKey.threadId: threadId
]
DispatchQueue.main.async {
let sound = self.requestSound(thread: thread)
self.adaptee.notify(category: .errorMessage,
title: notificationTitle,
body: notificationBody,
userInfo: userInfo,
sound: sound)
}
}
@objc
public func cancelNotifications(threadId: String) {
self.adaptee.cancelNotifications(threadId: threadId)
}
@objc
public func clearAllNotifications() {
adaptee.clearAllNotifications()
}
// 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
}
}
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)")
}
return markAsRead(thread: thread)
}
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)")
}
return markAsRead(thread: thread).then { () -> Promise<Void> in
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
}
}
func showThread(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String 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.
let shouldAnimate = UIApplication.shared.applicationState == .active
signalApp.presentConversationAndScrollToFirstUnreadMessage(forThreadId: threadId, animated: shouldAnimate)
return Promise.value(())
}
func showHomeVC() -> Promise<Void> {
signalApp.showHomeView()
return Promise.value(())
}
private func markAsRead(thread: TSThread) -> Promise<Void> {
return Storage.write { transaction in
thread.markAllAsRead(with: transaction)
}
}
}
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)
}
}