Signal-iOS/Signal/Notifications/NotificationActionHandler.swift

397 lines
18 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
public class NotificationActionHandler: Dependencies {
class func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
AssertIsOnMainThread()
firstly {
try handleNotificationResponse(response)
}.done {
completionHandler()
}.catch { error in
owsFailDebug("error: \(error)")
completionHandler()
}
}
private class func handleNotificationResponse( _ response: UNNotificationResponse) throws -> Promise<Void> {
AssertIsOnMainThread()
owsAssertDebug(AppReadiness.isAppReady)
let userInfo = response.notification.request.content.userInfo
let action: AppNotificationAction
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
Logger.debug("default action")
let defaultActionString = userInfo[AppNotificationUserInfoKey.defaultAction] as? String
let defaultAction = defaultActionString.flatMap { AppNotificationAction(rawValue: $0) }
action = defaultAction ?? .showThread
case UNNotificationDismissActionIdentifier:
// TODO - mark as read?
Logger.debug("dismissed notification")
return Promise.value(())
default:
if let responseAction = UserNotificationConfig.action(identifier: response.actionIdentifier) {
action = responseAction
} else {
throw OWSAssertionError("unable to find action for actionIdentifier: \(response.actionIdentifier)")
}
}
if DebugFlags.internalLogging {
Logger.info("Performing action: \(action)")
}
switch action {
case .answerCall:
return try answerCall(userInfo: userInfo)
case .callBack:
return try callBack(userInfo: userInfo)
case .declineCall:
return try declineCall(userInfo: userInfo)
case .markAsRead:
return try markAsRead(userInfo: userInfo)
case .reply:
guard let textInputResponse = response as? UNTextInputNotificationResponse else {
throw OWSAssertionError("response had unexpected type: \(response)")
}
return try reply(userInfo: userInfo, replyText: textInputResponse.userText)
case .showThread:
return try showThread(userInfo: userInfo)
case .showMyStories:
return showMyStories()
case .reactWithThumbsUp:
return try reactWithThumbsUp(userInfo: userInfo)
case .showCallLobby:
return try showCallLobby(userInfo: userInfo)
case .submitDebugLogs:
return submitDebugLogs()
case .reregister:
return reregister()
case .showChatList:
// No need to do anything.
return .value(())
}
}
// MARK: -
private class func answerCall(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
guard let localCallIdString = userInfo[AppNotificationUserInfoKey.localCallId] as? String else {
throw OWSAssertionError("localCallIdString was unexpectedly nil")
}
guard let localCallId = UUID(uuidString: localCallIdString) else {
throw OWSAssertionError("unable to build localCallId. localCallIdString: \(localCallIdString)")
}
callService.callUIAdapter.answerCall(localId: localCallId)
return Promise.value(())
}
private class func callBack(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
let aciString = userInfo[AppNotificationUserInfoKey.callBackAciString] as? String
let phoneNumber = userInfo[AppNotificationUserInfoKey.callBackPhoneNumber] as? String
let address = SignalServiceAddress.legacyAddress(aciString: aciString, phoneNumber: phoneNumber)
guard address.isValid else {
throw OWSAssertionError("Missing or invalid address.")
}
let thread = TSContactThread.getOrCreateThread(contactAddress: address)
callService.callUIAdapter.startAndShowOutgoingCall(thread: thread, hasLocalVideo: false)
return Promise.value(())
}
private class func declineCall(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
guard let localCallIdString = userInfo[AppNotificationUserInfoKey.localCallId] as? String else {
throw OWSAssertionError("localCallIdString was unexpectedly nil")
}
guard let localCallId = UUID(uuidString: localCallIdString) else {
throw OWSAssertionError("unable to build localCallId. localCallIdString: \(localCallIdString)")
}
callService.callUIAdapter.localHangupCall(localId: localCallId)
return Promise.value(())
}
private class func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
return firstly {
self.notificationMessage(forUserInfo: userInfo)
}.then(on: DispatchQueue.global()) { (notificationMessage: NotificationMessage) in
self.markMessageAsRead(notificationMessage: notificationMessage)
}
}
private class func reply(userInfo: [AnyHashable: Any], replyText: String) throws -> Promise<Void> {
return firstly { () -> Promise<NotificationMessage> in
self.notificationMessage(forUserInfo: userInfo)
}.then(on: DispatchQueue.global()) { (notificationMessage: NotificationMessage) -> Promise<Void> in
let thread = notificationMessage.thread
let interaction = notificationMessage.interaction
guard (interaction is TSOutgoingMessage) || (interaction is TSIncomingMessage) else {
throw OWSAssertionError("Unexpected interaction type.")
}
return firstly(on: DispatchQueue.global()) { () -> Promise<Void> in
self.databaseStorage.write { transaction in
let builder = TSOutgoingMessageBuilder(thread: thread)
builder.messageBody = replyText
// 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
builder.expiresInSeconds = dmConfigurationStore.durationSeconds(for: thread, tx: transaction.asV2Read)
}
let unpreparedMessage = UnpreparedOutgoingMessage.forMessage(TSOutgoingMessage(
outgoingMessageWithBuilder: builder,
transaction: transaction
))
do {
let preparedMessage = try unpreparedMessage.prepare(tx: transaction)
return ThreadUtil.enqueueMessagePromise(message: preparedMessage, transaction: transaction)
} catch {
return Promise(error: error)
}
}
}.recover(on: DispatchQueue.global()) { error -> Promise<Void> in
Logger.warn("Failed to send reply message from notification with error: \(error)")
self.notificationPresenter.notifyForFailedSend(inThread: thread)
throw error
}.then(on: DispatchQueue.global()) { () -> Promise<Void> in
self.markMessageAsRead(notificationMessage: notificationMessage)
}
}
}
private class func showThread(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
return firstly { () -> Promise<NotificationMessage> in
self.notificationMessage(forUserInfo: userInfo)
}.done(on: DispatchQueue.main) { notificationMessage in
if notificationMessage.isGroupStoryReply {
self.showGroupStoryReplyThread(notificationMessage: notificationMessage)
} else {
self.showThread(notificationMessage: notificationMessage)
}
}
}
private class func showMyStories() -> Promise<Void> {
return Promise { future in
AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
SignalApp.shared.showMyStories(animated: UIApplication.shared.applicationState == .active)
future.resolve()
}
}
}
private class func showThread(notificationMessage: NotificationMessage) {
// 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(
forThreadId: notificationMessage.thread.uniqueId,
animated: UIApplication.shared.applicationState == .active
)
}
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: [AnyHashable: Any]) throws -> Promise<Void> {
return firstly { () -> Promise<NotificationMessage> in
self.notificationMessage(forUserInfo: userInfo)
}.then(on: DispatchQueue.global()) { (notificationMessage: NotificationMessage) -> Promise<Void> in
let thread = notificationMessage.thread
let interaction = notificationMessage.interaction
guard let incomingMessage = interaction as? TSIncomingMessage else {
throw OWSAssertionError("Unexpected interaction type.")
}
return firstly(on: DispatchQueue.global()) { () -> Promise<Void> in
self.databaseStorage.write { transaction in
ReactionManager.localUserReacted(
to: incomingMessage.uniqueId,
emoji: "👍",
isRemoving: false,
isHighPriority: false,
tx: transaction
)
}
}.recover(on: DispatchQueue.global()) { error -> Promise<Void> in
Logger.warn("Failed to send reply message from notification with error: \(error)")
self.notificationPresenter.notifyForFailedSend(inThread: thread)
throw error
}.then(on: DispatchQueue.global()) { () -> Promise<Void> in
self.markMessageAsRead(notificationMessage: notificationMessage)
}
}
}
private class func showCallLobby(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
return firstly { () -> Promise<NotificationMessage> in
self.notificationMessage(forUserInfo: userInfo)
}.done(on: DispatchQueue.main) { notificationMessage in
let thread = notificationMessage.thread
let currentCall = Self.callService.currentCall
if currentCall?.thread.uniqueId == thread.uniqueId {
WindowManager.shared.returnToCallView()
} else if let thread = thread as? TSGroupThread, currentCall == nil {
GroupCallViewController.presentLobby(thread: thread)
} else {
// If currentCall is non-nil, we can't join a call anyway, fallback to showing the thread.
// Individual calls don't have a lobby, just show the thread.
return self.showThread(notificationMessage: notificationMessage)
}
}
}
private class func submitDebugLogs() -> Promise<Void> {
Promise { future in
DebugLogs.submitLogsWithSupportTag(nil) {
future.resolve()
}
}
}
private class func reregister() -> Promise<Void> {
Promise { future in
AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
guard let viewController = CurrentAppContext().frontmostViewController() else {
Logger.error("Responding to reregister notification action without a view controller!")
future.resolve()
return
}
Logger.info("Reregistering from deregistered notification")
RegistrationUtils.reregister(fromViewController: viewController)
future.resolve()
}
}
}
private struct NotificationMessage {
let thread: TSThread
let interaction: TSInteraction?
let storyMessage: StoryMessage?
let isGroupStoryReply: Bool
let hasPendingMessageRequest: Bool
}
private class func notificationMessage(forUserInfo userInfo: [AnyHashable: Any]) -> Promise<NotificationMessage> {
firstly(on: DispatchQueue.global()) { () throws -> NotificationMessage in
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
throw OWSAssertionError("threadId was unexpectedly nil")
}
let messageId = userInfo[AppNotificationUserInfoKey.messageId] as? String
return try self.databaseStorage.read { (transaction) throws -> NotificationMessage in
guard let thread = TSThread.anyFetch(uniqueId: threadId, transaction: transaction) else {
throw OWSAssertionError("unable to find thread with id: \(threadId)")
}
let interaction: TSInteraction?
if let messageId = messageId {
interaction = TSInteraction.anyFetch(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) -> Promise<Void> {
guard let interaction = notificationMessage.interaction else {
return Promise(error: OWSAssertionError("missing interaction"))
}
let (promise, future) = Promise<Void>.pending()
self.receiptManager.markAsReadLocally(
beforeSortId: interaction.sortId,
thread: notificationMessage.thread,
hasPendingMessageRequest: notificationMessage.hasPendingMessageRequest
) {
future.resolve()
}
return promise
}
}