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

import LibSignalClient
import SignalServiceKit
import SignalUI

public class NotificationActionHandler {

    private static var callService: CallService { AppEnvironment.shared.callService }

    @MainActor
    class func handleNotificationResponse(
        _ response: UNNotificationResponse,
        appReadiness: AppReadinessSetter,
    ) async throws {
        owsAssertDebug(appReadiness.isAppReady)

        let userInfo = AppNotificationUserInfo(response.notification.request.content.userInfo)

        switch response.actionIdentifier {
        case UNNotificationDefaultActionIdentifier:
            Logger.debug("default action")
            let defaultAction = userInfo.defaultAction ?? .showThread
            if DebugFlags.internalLogging {
                Logger.info("Performing default action: \(defaultAction)")
            }
            switch defaultAction {
            case .showThread:
                try await showThread(userInfo: userInfo)
            case .showMyStories:
                await showMyStories(appReadiness: appReadiness)
            case .showMessage:
                showMessage(userInfo: userInfo)
            case .showCallLobby:
                showCallLobby(userInfo: userInfo)
            case .submitDebugLogs:
                await submitDebugLogs(supportTag: nil)
            case .submitDebugLogsForBackupsMediaError:
                await submitDebugLogs(supportTag: "BackupsMedia")
            case .reregister:
                await reregister(appReadiness: appReadiness)
            case .showChatList:
                // No need to do anything.
                break
            case .showLinkedDevices:
                showLinkedDevices()
            case .showBackupsSettings:
                showBackupsSettings()
            }
        case UNNotificationDismissActionIdentifier:
            // TODO - mark as read?
            Logger.debug("dismissed notification")
            return
        default:
            guard let responseAction = AppNotificationAction(rawValue: response.actionIdentifier) else {
                throw OWSAssertionError("unable to find action for actionIdentifier: \(response.actionIdentifier)")
            }
            if DebugFlags.internalLogging {
                Logger.info("Performing action: \(responseAction)")
            }
            switch responseAction {
            case .callBack:
                try await self.callBack(userInfo: userInfo)
            case .markAsRead:
                try await markAsRead(userInfo: userInfo)
            case .reply:
                guard let textInputResponse = response as? UNTextInputNotificationResponse else {
                    throw OWSAssertionError("response had unexpected type: \(response)")
                }
                try await reply(userInfo: userInfo, replyText: textInputResponse.userText)
            case .showThread:
                try await showThread(userInfo: userInfo)
            case .reactWithThumbsUp:
                try await reactWithThumbsUp(userInfo: userInfo)
            }
        }
    }

    // MARK: -

    @MainActor
    private class func callBack(userInfo: AppNotificationUserInfo) async throws {
        let aci = userInfo.callBackAci
        let phoneNumber = userInfo.callBackPhoneNumber
        let address = SignalServiceAddress.legacyAddress(serviceId: aci, phoneNumber: phoneNumber)
        guard address.isValid else {
            throw OWSAssertionError("Missing or invalid address.")
        }
        let thread = TSContactThread.getOrCreateThread(contactAddress: address)

        guard let viewController = UIApplication.shared.frontmostViewController else {
            throw OWSAssertionError("Missing frontmostViewController.")
        }
        let prepareResult: CallStarter.PrepareToStartCallResult
        do throws(CallStarter.PrepareToStartCallError) {
            prepareResult = try await CallStarter.prepareToStartCall(from: viewController, shouldAskForCameraPermission: false)
        } catch {
            CallStarter.showPrepareToStartCallError(error, from: viewController)
            return
        }
        callService.callUIAdapter.startAndShowOutgoingCall(thread: thread, prepareResult: prepareResult, hasLocalVideo: false)
    }

    private class func markAsRead(userInfo: AppNotificationUserInfo) async throws {
        let notificationMessage = try await self.notificationMessage(forUserInfo: userInfo)
        try await self.markMessageAsRead(notificationMessage: notificationMessage)
    }

    private class func reply(userInfo: AppNotificationUserInfo, replyText: String) async throws {
        guard !replyText.isEmpty else { return }

        let notificationMessage = try await self.notificationMessage(forUserInfo: userInfo)
        let thread = notificationMessage.thread
        let interaction = notificationMessage.interaction
        var draftModelForSending: DraftQuotedReplyModel.ForSending?
        guard (interaction is TSOutgoingMessage) || (interaction is TSIncomingMessage) else {
            throw OWSAssertionError("Unexpected interaction type.")
        }

        let optionalDraftModel: DraftQuotedReplyModel? = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            if
                let incomingMessage = notificationMessage.interaction as? TSIncomingMessage,
                let draftQuotedReplyModel = DependenciesBridge.shared.quotedReplyManager.buildDraftQuotedReply(
                    originalMessage: incomingMessage,
                    loadNormalizedImage: NormalizedImage.loadImage(imageSource:maxPixelSize:),
                    tx: transaction,
                )
            {
                return draftQuotedReplyModel
            }
            return nil
        }

        if let draftModel = optionalDraftModel {
            draftModelForSending = try? await DependenciesBridge.shared.quotedReplyManager.prepareDraftForSending(draftModel)
        }

        let messageBody = try await DependenciesBridge.shared.attachmentContentValidator
            .prepareOversizeTextIfNeeded(MessageBody(text: replyText, ranges: .empty))

        do {
            try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
                let builder: TSOutgoingMessageBuilder = .withDefaultValues(thread: thread)
                builder.setMessageBody(messageBody)

                // If we're replying to a group story reply, keep the reply within that context.
                if
                    let incomingMessage = interaction as? TSIncomingMessage,
                    notificationMessage.isGroupStoryReply,
                    let storyTimestamp = incomingMessage.storyTimestamp,
                    let storyAuthorAci = incomingMessage.storyAuthorAci
                {
                    builder.storyTimestamp = storyTimestamp
                    builder.storyAuthorAci = storyAuthorAci
                } else {
                    // We only use the thread's DM timer for normal messages & 1:1 story
                    // replies -- group story replies last for the lifetime of the story.
                    let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
                    let dmConfig = dmConfigurationStore.fetchOrBuildDefault(for: .thread(thread), tx: transaction)
                    builder.expiresInSeconds = dmConfig.durationSeconds
                    builder.expireTimerVersion = NSNumber(value: dmConfig.timerVersion)
                }

                let unpreparedMessage = UnpreparedOutgoingMessage.forMessage(
                    TSOutgoingMessage(
                        outgoingMessageWith: builder,
                        additionalRecipients: [],
                        explicitRecipients: [],
                        skippedRecipients: [],
                        transaction: transaction,
                    ),
                    body: messageBody,
                    quotedReplyDraft: draftModelForSending,
                )
                let preparedMessage = try unpreparedMessage.prepare(tx: transaction)
                return ThreadUtil.enqueueMessagePromise(message: preparedMessage, transaction: transaction)
            }.awaitableWithUncooperativeCancellationHandling()
        } catch {
            Logger.warn("Failed to send reply message from notification with error: \(error)")
            SSKEnvironment.shared.notificationPresenterRef.notifyUserOfFailedSend(inThread: thread)
            throw error
        }
        try await self.markMessageAsRead(notificationMessage: notificationMessage)
    }

    @MainActor
    private class func showThread(userInfo: AppNotificationUserInfo) async throws {
        let notificationMessage = try await self.notificationMessage(forUserInfo: userInfo)
        if notificationMessage.isGroupStoryReply {
            self.showGroupStoryReplyThread(notificationMessage: notificationMessage)
        } else {
            self.showThread(uniqueId: notificationMessage.thread.uniqueId)
        }
    }

    @MainActor
    private class func showMyStories(appReadiness: AppReadiness) async {
        await withCheckedContinuation { continuation in
            appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
                continuation.resume()
            }
        }
        SignalApp.shared.showMyStories(animated: UIApplication.shared.applicationState == .active)
    }

    @MainActor
    private class func showMessage(userInfo: AppNotificationUserInfo) {
        guard let threadId = userInfo.threadId else {
            owsFailDebug("Missing threadId for showMessage action.")
            return
        }
        SignalApp.shared.presentConversationForThread(
            threadUniqueId: threadId,
            focusMessageId: userInfo.messageId,
            animated: UIApplication.shared.applicationState == .active,
        )
    }

    @MainActor
    private class func showThread(uniqueId: String) {
        // If this happens when 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.
        SignalApp.shared.presentConversationAndScrollToFirstUnreadMessage(
            threadUniqueId: uniqueId,
            animated: UIApplication.shared.applicationState == .active,
        )
    }

    @MainActor
    private class func showGroupStoryReplyThread(notificationMessage: NotificationMessage) {
        guard notificationMessage.isGroupStoryReply, let storyMessage = notificationMessage.storyMessage else {
            return owsFailDebug("Unexpectedly missing story message")
        }

        guard let frontmostViewController = CurrentAppContext().frontmostViewController() else { return }

        if let replySheet = frontmostViewController as? StoryGroupReplier {
            if replySheet.storyMessage.uniqueId == storyMessage.uniqueId {
                return // we're already in the right place
            } else {
                // we need to drop the viewer before we present the new viewer
                replySheet.presentingViewController?.dismiss(animated: false) {
                    showGroupStoryReplyThread(notificationMessage: notificationMessage)
                }
                return
            }
        } else if let storyPageViewController = frontmostViewController as? StoryPageViewController {
            if storyPageViewController.currentMessage?.uniqueId == storyMessage.uniqueId {
                // we're in the right place, just pop the replies sheet
                storyPageViewController.currentContextViewController.presentRepliesAndViewsSheet()
                return
            } else {
                // we need to drop the viewer before we present the new viewer
                storyPageViewController.dismiss(animated: false) {
                    showGroupStoryReplyThread(notificationMessage: notificationMessage)
                }
                return
            }
        }

        let vc = StoryPageViewController(
            context: storyMessage.context,
            // Fresh state when coming in from a notification; no need to share.
            spoilerState: SpoilerRenderState(),
            loadMessage: storyMessage,
            action: .presentReplies,
        )
        frontmostViewController.present(vc, animated: true)
    }

    private class func reactWithThumbsUp(userInfo: AppNotificationUserInfo) async throws {
        let notificationMessage = try await self.notificationMessage(forUserInfo: userInfo)

        let thread = notificationMessage.thread
        let interaction = notificationMessage.interaction
        guard let incomingMessage = interaction as? TSIncomingMessage else {
            throw OWSAssertionError("Unexpected interaction type.")
        }

        do {
            try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
                ReactionManager.localUserReacted(
                    to: incomingMessage.uniqueId,
                    emoji: "👍",
                    isRemoving: false,
                    isHighPriority: false,
                    tx: transaction,
                )
            }.awaitableWithUncooperativeCancellationHandling()
        } catch {
            Logger.warn("Failed to send reply message from notification with error: \(error)")
            SSKEnvironment.shared.notificationPresenterRef.notifyUserOfFailedSend(inThread: thread)
            throw error
        }
        try await self.markMessageAsRead(notificationMessage: notificationMessage)
    }

    @MainActor
    private class func showCallLobby(userInfo: AppNotificationUserInfo) {
        let threadUniqueId = userInfo.threadId
        let callLinkRoomId = userInfo.roomId

        enum LobbyTarget {
            case groupThread(groupId: GroupIdentifier, uniqueId: String)
            case callLink(CallLink)

            var callTarget: CallTarget {
                switch self {
                case .groupThread(let groupId, uniqueId: _):
                    return .groupThread(groupId)
                case .callLink(let callLink):
                    return .callLink(callLink)
                }
            }
        }

        let lobbyTarget = { () -> LobbyTarget? in
            if let threadUniqueId {
                return SSKEnvironment.shared.databaseStorageRef.read { tx in
                    if let groupId = try? (TSThread.fetchViaCache(uniqueId: threadUniqueId, transaction: tx) as? TSGroupThread)?.groupIdentifier {
                        return .groupThread(groupId: groupId, uniqueId: threadUniqueId)
                    }
                    return nil
                }
            }
            if let callLinkRoomId {
                return SSKEnvironment.shared.databaseStorageRef.read { tx in
                    let callLinkStore = DependenciesBridge.shared.callLinkStore
                    if let callLinkRecord = try? callLinkStore.fetch(roomId: callLinkRoomId, tx: tx) {
                        return .callLink(CallLink(rootKey: callLinkRecord.rootKey))
                    }
                    return nil
                }
            }
            return nil
        }()
        guard let lobbyTarget else {
            owsFailDebug("Couldn't resolve destination for call lobby.")
            return
        }

        let currentCall = Self.callService.callServiceState.currentCall
        if currentCall?.mode.matches(lobbyTarget.callTarget) == true {
            AppEnvironment.shared.windowManagerRef.returnToCallView()
            return
        }

        if currentCall == nil {
            callService.initiateCall(to: lobbyTarget.callTarget, isVideo: true)
            return
        }

        switch lobbyTarget {
        case .groupThread(groupId: _, let uniqueId):
            // If currentCall is non-nil, we can't join a call anyway, so fall back to showing the thread.
            self.showThread(uniqueId: uniqueId)
        case .callLink:
            // Nothing to show for a call link.
            break
        }
    }

    @MainActor
    private class func submitDebugLogs(supportTag: String?) async {
        await withCheckedContinuation { continuation in
            DebugLogs.submitLogs(supportTag: supportTag, dumper: .fromGlobals()) {
                continuation.resume()
            }
        }
    }

    @MainActor
    private class func reregister(appReadiness: AppReadinessSetter) async {
        await withCheckedContinuation { continuation in
            appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
                continuation.resume()
            }
        }
        guard let viewController = CurrentAppContext().frontmostViewController() else {
            Logger.error("Responding to reregister notification action without a view controller!")
            return
        }
        Logger.info("Reregistering from deregistered notification")
        RegistrationUtils.reregister(fromViewController: viewController, appReadiness: appReadiness)
    }

    @MainActor
    private class func showLinkedDevices() {
        SignalApp.shared.showAppSettings(mode: .linkedDevices)
    }

    @MainActor
    private class func showBackupsSettings() {
        SignalApp.shared.showAppSettings(mode: .backups())
    }

    private struct NotificationMessage {
        let thread: TSThread
        let interaction: TSInteraction?
        let storyMessage: StoryMessage?
        let isGroupStoryReply: Bool
        let hasPendingMessageRequest: Bool
    }

    private class func notificationMessage(forUserInfo userInfo: AppNotificationUserInfo) async throws -> NotificationMessage {
        guard let threadId = userInfo.threadId else {
            throw OWSAssertionError("threadId was unexpectedly nil")
        }
        let messageId = userInfo.messageId

        return try SSKEnvironment.shared.databaseStorageRef.read { transaction throws -> NotificationMessage in
            guard let thread = TSThread.fetchViaCache(uniqueId: threadId, transaction: transaction) else {
                throw OWSAssertionError("unable to find thread with id: \(threadId)")
            }

            let interaction: TSInteraction?
            if let messageId {
                interaction = TSInteraction.fetchViaCache(uniqueId: messageId, transaction: transaction)
            } else {
                interaction = nil
            }

            let storyMessage: StoryMessage?
            if
                let message = interaction as? TSMessage,
                let storyTimestamp = message.storyTimestamp?.uint64Value,
                let storyAuthorAci = message.storyAuthorAci
            {
                storyMessage = StoryFinder.story(timestamp: storyTimestamp, author: storyAuthorAci.wrappedAciValue, transaction: transaction)
            } else {
                storyMessage = nil
            }

            let hasPendingMessageRequest = thread.hasPendingMessageRequest(transaction: transaction)

            return NotificationMessage(
                thread: thread,
                interaction: interaction,
                storyMessage: storyMessage,
                isGroupStoryReply: (interaction as? TSMessage)?.isGroupStoryReply == true,
                hasPendingMessageRequest: hasPendingMessageRequest,
            )
        }
    }

    private class func markMessageAsRead(notificationMessage: NotificationMessage) async throws {
        guard let interaction = notificationMessage.interaction else {
            throw OWSAssertionError("missing interaction")
        }
        return await withCheckedContinuation { continuation in
            SSKEnvironment.shared.receiptManagerRef.markAsReadLocally(
                beforeSortId: interaction.sortId,
                thread: notificationMessage.thread,
                hasPendingMessageRequest: notificationMessage.hasPendingMessageRequest,
                completion: { continuation.resume() },
            )
        }
    }
}