// // 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, screenLockUI: ScreenLockUI, ) 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 screenLockUI.waitForScreenUnlockThrowingPrevious() 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 = 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 { guard let viewController = CurrentAppContext().frontmostViewController() else { return } await DebugLogs(dumper: .fromGlobals()).promptToSubmitLogs( from: viewController, supportTag: supportTag, ) } @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() }, ) } } }