Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Notifications/UserNotificationsPresenter.swift
1 views
//
// Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import Intents
public import UIKit
import UserNotifications

public class UserNotificationConfig {

    class var allNotificationCategories: Set<UNNotificationCategory> {
        let categories = AppNotificationCategory.allCases.map { notificationCategory($0) }
        return Set(categories)
    }

    class func notificationActions(for category: AppNotificationCategory) -> [UNNotificationAction] {
        return category.actions.compactMap { notificationAction($0) }
    }

    class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory {
        return UNNotificationCategory(
            identifier: category.rawValue,
            actions: notificationActions(for: category),
            intentIdentifiers: [],
            options: [],
        )
    }

    class func notificationAction(_ action: AppNotificationAction) -> UNNotificationAction {
        switch action {
        case .callBack:
            return UNNotificationAction(
                identifier: action.rawValue,
                title: CallStrings.callBackButtonTitle,
                options: .foreground,
                icon: UNNotificationActionIcon(systemImageName: "phone"),
            )
        case .markAsRead:
            return UNNotificationAction(
                identifier: action.rawValue,
                title: MessageStrings.markAsReadNotificationAction,
                icon: UNNotificationActionIcon(systemImageName: "message"),
            )
        case .reply:
            return UNTextInputNotificationAction(
                identifier: action.rawValue,
                title: MessageStrings.replyNotificationAction,
                icon: UNNotificationActionIcon(systemImageName: "arrowshape.turn.up.left"),
                textInputButtonTitle: MessageStrings.sendButton,
                textInputPlaceholder: "",
            )
        case .showThread:
            return UNNotificationAction(
                identifier: action.rawValue,
                title: CallStrings.showThreadButtonTitle,
                icon: UNNotificationActionIcon(systemImageName: "bubble.left.and.bubble.right"),
            )
        case .reactWithThumbsUp:
            return UNNotificationAction(
                identifier: action.rawValue,
                title: MessageStrings.reactWithThumbsUpNotificationAction,
                icon: UNNotificationActionIcon(systemImageName: "hand.thumbsup"),
            )
        }
    }
}

// MARK: -

public class UserNotificationPresenter {
    private static var notificationCenter: UNUserNotificationCenter { UNUserNotificationCenter.current() }

    // 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 = 20

    public init() {}

    /// Request notification permissions.
    func registerNotificationSettings() async {
        do {
            let granted = try await Self.notificationCenter.requestAuthorization(options: [.badge, .sound, .alert])
            Logger.info("Notification permission? \(granted)")
        } catch {
            owsFailDebug("Notification permission request failed with error: \(error)")
        }
        Self.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories)
    }

    var hasReceivedSyncMessageRecentlyWithSneakyTransaction: Bool {
        let db = DependenciesBridge.shared.db
        let deviceManager = DependenciesBridge.shared.deviceManager
        return db.read { tx in
            return deviceManager.hasReceivedSyncMessage(inLastSeconds: 60, transaction: tx)
        }
    }

    // MARK: - Notify

    func notify(
        category: AppNotificationCategory,
        title: String?,
        body: String,
        threadIdentifier: String?,
        userInfo: AppNotificationUserInfo,
        interaction: INInteraction?,
        sound: Sound?,
        replacingIdentifier: String? = nil,
        forceBeforeRegistered: Bool = false,
        isMainAppAndActive: Bool,
        notificationSuppressionRule: NotificationSuppressionRule,
    ) async {
        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        // TODO: It might make sense to have the callers check this instead. Further investigation is required.
        guard forceBeforeRegistered || tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
            Logger.info("suppressing notification since user hasn't yet completed registration.")
            return
        }
        // TODO: It might make sense to have the callers check this instead. Further investigation is required.
        if case .incomingGroupStoryReply = category, !StoryManager.areStoriesEnabled {
            return
        }

        let content = UNMutableNotificationContent()
        content.categoryIdentifier = category.rawValue
        content.userInfo = userInfo.build()
        if let sound, sound != .standard(.none) {
            content.sound = sound.notificationSound(isQuiet: isMainAppAndActive)
        }

        var notificationIdentifier: String = UUID().uuidString
        if let replacingIdentifier {
            notificationIdentifier = replacingIdentifier
            Logger.debug("replacing notification with identifier: \(notificationIdentifier)")
            cancelNotificationSync(identifier: notificationIdentifier)
        }

        let trigger: UNNotificationTrigger?
        let checkForCancel = (
            category == .incomingMessageWithActions_CanReply
                || category == .incomingMessageWithActions_CannotReply
                || category == .incomingMessageWithoutActions
                || category == .incomingReactionWithActions_CanReply
                || category == .incomingReactionWithActions_CannotReply,
        )
        if checkForCancel, !isMainAppAndActive, hasReceivedSyncMessageRecentlyWithSneakyTransaction {
            assert(userInfo.threadId != nil)
            trigger = UNTimeIntervalNotificationTrigger(timeInterval: kNotificationDelayForRemoteRead, repeats: false)
        } else if category == .newDeviceLinked {
            let db = DependenciesBridge.shared.db
            let deviceStore = DependenciesBridge.shared.deviceStore
            let linkedDeviceDetails = db.read { tx in
                deviceStore.mostRecentlyLinkedDeviceDetails(tx: tx)
            }

            let delay = {
                if let linkedDeviceDetails {
                    return linkedDeviceDetails.notificationDelay
                } else {
                    owsFailDebug("mostRecentlyLinkedDeviceDetails should be set before scheduling notification")
                    return TimeInterval.random(in: .hour...(3 * .hour))
                }
            }()

            trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false)
        } else if category == .backupsEnabled {
            let delay = TimeInterval.random(in: .hour...(3 * .hour))
            trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false)
        } else {
            trigger = nil
        }

        if shouldPresentNotification(category: category, userInfo: userInfo, notificationSuppressionRule: notificationSuppressionRule) {
            if let displayableTitle = title?.filterForDisplay {
                content.title = displayableTitle
            }
            content.body = body.filterForDisplay
        } else {
            // Play sound and vibrate, but without a `body` no banner will show.
        }

        if let threadIdentifier {
            content.threadIdentifier = threadIdentifier
        }

        var contentToUse: UNNotificationContent = content
        if let interaction {
            do {
                try await interaction.donate()
            } catch {
                owsFailDebug("Failed to donate incoming message intent \(error)")
            }

            if let intent = interaction.intent as? UNNotificationContentProviding {
                do {
                    contentToUse = try content.updating(from: intent)
                } catch {
                    owsFailDebug("Failed to update UNNotificationContent for comm style notification")
                }
            }
        }

        let request = UNNotificationRequest(identifier: notificationIdentifier, content: contentToUse, trigger: trigger)

        do {
            try await Self.notificationCenter.add(request)
        } catch {
            owsFailDebug("Error presenting notification with identifier \(notificationIdentifier): \(error)")
        }
    }

    private func shouldPresentNotification(
        category: AppNotificationCategory,
        userInfo: AppNotificationUserInfo,
        notificationSuppressionRule: NotificationSuppressionRule,
    ) -> Bool {
        switch category {
        case .incomingMessageFromNoLongerVerifiedIdentity,
             .missedCallWithActions,
             .missedCallWithoutActions,
             .missedCallFromNoLongerVerifiedIdentity,
             .transferRelaunch,
             .deregistration,
             .newDeviceLinked,
             .backupsEnabled,
             .backupsMediaTierQuotaConsumed,
             .attachmentBackfill:
            // Always show these notifications
            return true

        case .internalError, .listMediaIntegrityCheckFailure:
            // Only show errors alerts on builds run by a test population (beta, internal, etc.)
            return DebugFlags.testPopulationErrorAlerts

        case .incomingMessageWithActions_CanReply,
             .incomingMessageWithActions_CannotReply,
             .incomingMessageWithoutActions,
             .incomingReactionWithActions_CanReply,
             .incomingReactionWithActions_CannotReply,
             .infoOrErrorMessage,
             .pollEndNotification,
             .pollVoteNotification:
            // Don't show these notifications when the thread is visible.
            if
                let notificationThreadUniqueId = userInfo.threadId,
                case .messagesInThread(let suppressedThreadUniqueId) = notificationSuppressionRule,
                suppressedThreadUniqueId == notificationThreadUniqueId
            {
                return false
            }
            return true

        case .incomingGroupStoryReply:
            // Show notifications any time we're not currently showing the group reply sheet for that story
            if
                let notificationThreadUniqueId = userInfo.threadId,
                let notificationStoryTimestamp = userInfo.storyTimestamp,
                case .groupStoryReplies(let suppressedThreadUniqueId, let suppressedStoryTimestamp) = notificationSuppressionRule,
                suppressedThreadUniqueId == notificationThreadUniqueId,
                suppressedStoryTimestamp == notificationStoryTimestamp
            {
                return false
            }
            return true

        case .failedStorySend:
            if case .failedStorySends = notificationSuppressionRule {
                return false
            }
            return true
        }
    }

    // MARK: - Replacement

    func replaceNotification(messageId: String) async -> Bool {
        return self.cancelSync(
            notificationRequests: await getNotificationsRequests(),
            matching: .messageIds([messageId]),
        )
    }

    // MARK: - Cancellation

    func cancelNotifications(threadId: String) async {
        await cancel(cancellation: .threadId(threadId))
    }

    func cancelNotifications(messageIds: [String]) async {
        await cancel(cancellation: .messageIds(Set(messageIds)))
    }

    func cancelNotifications(reactionId: String) async {
        await cancel(cancellation: .reactionId(reactionId))
    }

    func cancelNotificationsForMissedCalls(withThreadUniqueId threadId: String) async {
        await cancel(cancellation: .missedCalls(inThreadWithUniqueId: threadId))
    }

    func cancelNotificationsForStoryMessage(withUniqueId storyMessageUniqueId: String) async {
        await cancel(cancellation: .storyMessage(storyMessageUniqueId))
    }

    func cancelPendingNotificationsForBackupsEnabled() async {
        let backupsEnabledRequests = await Self.notificationCenter
            .pendingNotificationRequests()
            .filter {
                $0.content.categoryIdentifier == AppNotificationCategory.backupsEnabled.rawValue
            }

        Self.notificationCenter.removePendingNotificationRequests(
            withIdentifiers: backupsEnabledRequests.map(\.identifier),
        )
    }

    public func clearAllNotifications() {
        Logger.info("Clearing all notifications")

        Self.notificationCenter.removeAllPendingNotificationRequests()
        Self.notificationCenter.removeAllDeliveredNotifications()
    }

    public func clearNotificationsForAppActivate() {
        Logger.info("Clearing notifications for app activate.")

        Task {
            let shouldRemoveNotificationRequestPredicate: (UNNotificationRequest) -> Bool = { request in
                guard
                    let appNotificationCategory = AppNotificationCategory(
                        rawValue: request.content.categoryIdentifier,
                    )
                else {
                    return true
                }

                return appNotificationCategory.shouldClearOnAppActivate
            }

            let pendingNotificationIDsToRemove = await Self.notificationCenter.pendingNotificationRequests()
                .filter { shouldRemoveNotificationRequestPredicate($0) }
                .map(\.identifier)

            let deliveredNotificationIDsToRemove = await Self.notificationCenter.deliveredNotifications()
                .filter { shouldRemoveNotificationRequestPredicate($0.request) }
                .map(\.request.identifier)

            Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: pendingNotificationIDsToRemove)
            Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: deliveredNotificationIDsToRemove)
        }
    }

    public func clearDeliveredNewLinkedDevicesNotifications() {
        Logger.info("Clearing delivered new linked device notifications")

        Task {
            let pendingNotificationRequestIDs = await Self.notificationCenter.deliveredNotifications()
                .filter { notification in
                    notification.request.content.categoryIdentifier == AppNotificationCategory.newDeviceLinked.rawValue
                }
                .map(\.request.identifier)

            Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: pendingNotificationRequestIDs)
        }
    }

    private enum CancellationType: Equatable, Hashable {
        case threadId(String)
        case messageIds(Set<String>)
        case reactionId(String)
        case missedCalls(inThreadWithUniqueId: String)
        case storyMessage(String)
    }

    private func getNotificationsRequests() async -> [UNNotificationRequest] {
        return await (
            Self.notificationCenter.deliveredNotifications().map({ $0.request })
                + Self.notificationCenter.pendingNotificationRequests()
        )
    }

    private func cancel(cancellation: CancellationType) async {
        self.cancelSync(notificationRequests: await getNotificationsRequests(), matching: cancellation)
    }

    func existingPollVoteNotification(author: Data, pollId: String) async -> Bool {
        let notificationRequests = await getNotificationsRequests()
        for request in notificationRequests {
            let userInfo = AppNotificationUserInfo(request.content.userInfo)
            if
                let requestPollAuthor = userInfo.voteAuthorServiceIdBinary,
                let requestPollId = userInfo.messageId,
                requestPollAuthor == author,
                requestPollId == pollId
            {
                return true
            }
        }
        return false
    }

    @discardableResult
    private func cancelSync(
        notificationRequests: [UNNotificationRequest],
        matching cancellationType: CancellationType,
    ) -> Bool {
        let requestMatchesPredicate: (UNNotificationRequest) -> Bool = { request in
            let userInfo = AppNotificationUserInfo(request.content.userInfo)
            switch cancellationType {
            case .threadId(let threadId):
                if let requestThreadId = userInfo.threadId, requestThreadId == threadId {
                    return true
                }
            case .messageIds(let messageIds):
                if let requestMessageId = userInfo.messageId, messageIds.contains(requestMessageId) {
                    return true
                }
            case .reactionId(let reactionId):
                if let requestReactionId = userInfo.reactionId, requestReactionId == reactionId {
                    return true
                }
            case .missedCalls(let threadUniqueId):
                if userInfo.isMissedCall == true, let requestThreadId = userInfo.threadId, threadUniqueId == requestThreadId {
                    return true
                }
            case .storyMessage(let storyMessageUniqueId):
                if let requestStoryMessageId = userInfo.storyMessageId, requestStoryMessageId == storyMessageUniqueId {
                    return true
                }
            }

            return false
        }

        let identifiersToCancel: [String] = {
            notificationRequests.compactMap { request in
                if requestMatchesPredicate(request) {
                    return request.identifier
                }

                return nil
            }
        }()

        guard !identifiersToCancel.isEmpty else {
            return false
        }

        Logger.info("Removing delivered/pending notifications with identifiers: \(identifiersToCancel)")

        Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToCancel)
        Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToCancel)

        return true
    }

    // This method is thread-safe.
    private func cancelNotificationSync(identifier: String) {
        Logger.warn("Canceling notification for identifier: \(identifier)")

        Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
        Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
    }
}

public protocol ConversationSplit {
    var visibleThread: TSThread? { get }
}

public protocol StoryGroupReplier: UIViewController {
    var storyMessage: StoryMessage { get }
    var threadUniqueId: String? { get }
}

public protocol LinkAndSyncProgressUI {
    var shouldSuppressNotifications: Bool { get }
}

extension Sound {
    func notificationSound(isQuiet: Bool) -> UNNotificationSound {
        guard let filename = filename(quiet: isQuiet) else {
            owsFailDebug("[Notification Sounds] sound filename was unexpectedly nil")
            return UNNotificationSound.default
        }
        if
            !FileManager.default.fileExists(atPath: (Sounds.soundsDirectory as NSString).appendingPathComponent(filename)),
            !FileManager.default.fileExists(atPath: (Bundle.main.bundlePath as NSString).appendingPathComponent(filename))
        {
            Logger.info("[Notification Sounds] sound file doesn't exist!")
        }
        return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename))
    }
}