session-ios/Session/Notifications/AppNotifications.swift
Morgan Pretty 3514ed4f50 Updated the JobRunner to have multiple job queues (needs more testing)
Added a backoff to the Poller retry
Updated the "blocking" behaviour of the JobRunner
Tweaked the Job dependency handling to better handle orphaned dependencies
Fixed an issue where the Conversation screen wasn't observing database changes
2022-05-28 17:25:38 +10:00

531 lines
19 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import PromiseKit
import SessionMessagingKit
import SignalUtilitiesKit
/// 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"
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"
}
}
}
// Delay notification of incoming messages when it's a background polling to
// avoid too many notifications fired at the same time
let kNotificationDelayForBackgroumdPoll: TimeInterval = 5
let kAudioNotificationsThrottleCount = 2
let kAudioNotificationsThrottleInterval: TimeInterval = 5
protocol NotificationPresenterAdaptee: AnyObject {
func registerNotificationSettings() -> Promise<Void>
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?)
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?)
func cancelNotifications(threadId: String)
func cancelNotifications(identifiers: [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 preferences: OWSPreferences {
return Environment.shared.preferences
}
// MARK: -
@objc
func handleMessageRead(notification: Notification) {
AssertIsOnMainThread()
switch notification.object {
case let incomingMessage as TSIncomingMessage:
Logger.debug("canceled notification for message: \(incomingMessage)")
if let identifier = incomingMessage.notificationIdentifier {
cancelNotification(identifier)
} else {
cancelNotifications(threadId: incomingMessage.uniqueThreadId)
}
default:
break
}
}
// MARK: - Presenting Notifications
func registerNotificationSettings() -> Promise<Void> {
return adaptee.registerNotificationSettings()
}
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return }
let isMessageRequest = thread.isMessageRequest(db)
// If the thread is a message request and the user hasn't hidden message requests then we need
// to check if this is the only message request thread (group threads can't be message requests
// so just ignore those and if the user has hidden message requests then we want to show the
// notification regardless of how many message requests there are)
if thread.variant == .contact {
if isMessageRequest && !db[.hasHiddenMessageRequests] {
let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db)
.fetchCount(db)
// Allow this to show a notification if there are no message requests (ie. this is the first one)
guard (numMessageRequestThreads ?? 0) == 0 else { return }
}
else if isMessageRequest && db[.hasHiddenMessageRequests] {
// If there are other interactions on this thread already then don't show the notification
if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return }
db[.hasHiddenMessageRequests] = false
}
}
let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll)
// 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)
// Don't fire the notification if the current user isn't mentioned
// and isOnlyNotifyingForMentions is on.
if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) {
return
}
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: .nameAndPreview)
switch previewType {
case .noNameNoPreview:
notificationTitle = "Session"
case .nameNoPreview, .nameAndPreview:
switch thread.variant {
case .contact:
notificationTitle = (isMessageRequest ? "Session" : senderName)
case .closedGroup, .openGroup:
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)
)
notificationTitle = (isBackgroundPoll ? groupName :
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()
}
assert((notificationBody ?? notificationTitle) != nil)
// Don't reply from lockscreen if anyone in this conversation is
// "no longer verified".
let category = AppNotificationCategory.incomingMessage
let userInfo = [
AppNotificationUserInfoKey.threadId: thread.id
]
DispatchQueue.main.async {
notificationBody = MentionUtilities.highlightMentions(
in: (notificationBody ?? ""),
threadVariant: thread.variant
)
let sound: Preferences.Sound? = self.requestSound(thread: thread)
self.adaptee.notify(
category: category,
title: notificationTitle,
body: notificationBody ?? "",
userInfo: userInfo,
sound: sound,
replacingIdentifier: identifier
)
}
}
public func notifyForFailedSend(_ db: Database, in thread: SessionThread) {
let notificationTitle: String?
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
.defaulting(to: .nameAndPreview)
switch previewType {
case .noNameNoPreview: notificationTitle = nil
case .nameNoPreview, .nameAndPreview:
notificationTitle = 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)
)
}
let notificationBody = NotificationStrings.failedToSendBody
let userInfo = [
AppNotificationUserInfoKey.threadId: thread.id
]
DispatchQueue.main.async {
let sound: Preferences.Sound? = self.requestSound(thread: thread)
self.adaptee.notify(
category: .errorMessage,
title: notificationTitle,
body: notificationBody,
userInfo: userInfo,
sound: sound
)
}
}
@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 = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)
private func requestSound(thread: SessionThread) -> Preferences.Sound? {
guard checkIfShouldPlaySound() else {
return nil
}
return thread.notificationSound
}
private func checkIfShouldPlaySound() -> Bool {
AssertIsOnMainThread()
guard UIApplication.shared.applicationState == .active else { return true }
guard GRDBStorage.shared[.playNotificationSoundInForeground] else { return false }
let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000))
let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs))
let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold }
guard recentNotifications.count < kAudioNotificationsThrottleCount else {
return false
}
mostRecentNotifications.append(nowMs)
return true
}
}
class NotificationActionHandler {
static let shared: NotificationActionHandler = NotificationActionHandler()
// MARK: - Dependencies
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: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
throw NotificationError.failDebug("threadId was unexpectedly nil")
}
guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: 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: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
}
let promise: Promise<Void> = GRDBStorage.shared.write { db in
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: getUserHexEncodedPublicKey(db),
variant: .standardOutgoing,
body: replyText,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
hasMention: replyText.contains("@\(currentUserPublicKey)")
).inserted(db)
try Interaction.markAsRead(
db,
interactionId: interaction.id,
threadId: thread.id,
includingOlder: true,
trySendReadReceipt: true
)
return try MessageSender.sendNonDurably(
db,
interaction: interaction,
in: thread
)
}
promise.catch { [weak self] error in
GRDBStorage.shared.read { db in
self?.notificationPresenter.notifyForFailedSend(db, in: 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
SessionApp.presentConversation(for: threadId, animated: shouldAnimate)
return Promise.value(())
}
func showHomeVC() -> Promise<Void> {
SessionApp.showHomeView()
return Promise.value(())
}
private func markAsRead(thread: SessionThread) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending()
GRDBStorage.shared.writeAsync(
updates: { db in
try Interaction.markAsRead(
db,
interactionId: try thread.interactions
.select(.id)
.order(Interaction.Columns.timestampMs.desc)
.asRequest(of: Int64?.self)
.fetchOne(db),
threadId: thread.id,
includingOlder: true,
trySendReadReceipt: true
)
},
completion: { _, result in
switch result {
case .success: seal.fulfill(())
case .failure(let error): seal.reject(error)
}
}
)
return promise
}
}
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)
}
}