session-ios/Session/Notifications/AppNotifications.swift
Morgan Pretty ea32e407a9 Applied theming to a number of screens, some minor cleanup and bug fixes
Updated the HomeVC, SettingsVC and GlobalSearch UI to use theming
Removed the "fade view" gradients from the various screens
Added a simple log to the PagedDatabaseObserver to make debugging easier
Updated the FullConversationCell to also show the "read" state for messages
Updated the read receipt icons to use SFSymbols directly
Updated the PlaceholderIcon to use the PrimaryColour's as it's colour options
2022-08-12 17:28:00 +10:00

561 lines
20 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: -
@objc
func handleMessageRead(notification: Notification) {
AssertIsOnMainThread()
switch notification.object {
case let interaction as Interaction:
guard interaction.variant == .standardIncoming else { return }
Logger.debug("canceled notification for message: \(interaction)")
cancelNotifications(identifiers: interaction.notificationIdentifiers)
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) {
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
// Ensure we should be showing a notification for the thread
guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else {
return
}
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)
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()
}
guard notificationBody != nil || notificationTitle != nil else {
SNLog("AppNotifications error: No notification content")
return
}
// Don't reply from lockscreen if anyone in this conversation is
// "no longer verified".
let category = AppNotificationCategory.incomingMessage
let userInfo = [
AppNotificationUserInfoKey.threadId: thread.id
]
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let userBlindedKey: String? = SessionThread.getUserHexEncodedBlindedKey(
threadId: thread.id,
threadVariant: thread.variant
)
DispatchQueue.main.async {
notificationBody = MentionUtilities.highlightMentionsNoAttributes(
in: (notificationBody ?? ""),
threadVariant: thread.variant,
currentUserPublicKey: userPublicKey,
currentUserBlindedPublicKey: userBlindedKey
)
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 notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
// No call notifications for muted or group threads
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
guard thread.variant != .closedGroup && thread.variant != .openGroup else { return }
guard
interaction.variant == .infoCall,
let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8),
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
CallMessage.MessageInfo.self,
from: infoMessageData
)
else { return }
// Only notify missed calls
guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return }
let category = AppNotificationCategory.errorMessage
let userInfo = [
AppNotificationUserInfoKey.threadId: thread.id
]
let notificationTitle = interaction.previewText(db)
var notificationBody: String?
if messageInfo.state == .permissionDenied {
notificationBody = String(
format: "modal_call_missed_tips_explanation".localized(),
SessionThread.displayName(
threadId: thread.id,
variant: thread.variant,
closedGroupName: nil, // Not supported
openGroupName: nil // Not supported
)
)
}
DispatchQueue.main.async {
let sound = self.requestSound(thread: thread)
self.adaptee.notify(
category: category,
title: notificationTitle,
body: notificationBody ?? "",
userInfo: userInfo,
sound: sound,
replacingIdentifier: UUID().uuidString
)
}
}
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 Storage.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
}
// 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 = Storage.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 = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
}
let (promise, seal) = Promise<Void>.pending()
Storage.shared.writeAsync { db in
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: getUserHexEncodedPublicKey(db),
variant: .standardOutgoing,
body: replyText,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText)
).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
)
}
.done { seal.fulfill(()) }
.catch { error in
Storage.shared.read { [weak self] db in
self?.notificationPresenter.notifyForFailedSend(db, in: thread)
}
seal.reject(error)
}
.retainUntilComplete()
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: Bool = (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()
Storage.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)
}
}