Signal-iOS/SignalMessaging/ViewControllers/SendMessageFlow.swift
2021-01-21 20:19:43 -03:00

682 lines
25 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import PromiseKit
@objc
public protocol SendMessageDelegate: AnyObject {
func sendMessageFlowDidComplete(threads: [TSThread])
func sendMessageFlowDidCancel()
}
// MARK: -
protocol SignalAttachmentProvider {
func buildAttachmentForSending() throws -> SignalAttachment
var isBorderless: Bool { get }
}
// MARK: -
// This can be used to forward an existing attachment stream.
struct TSAttachmentStreamCloner: SignalAttachmentProvider {
let attachmentStream: TSAttachmentStream
func buildAttachmentForSending() throws -> SignalAttachment {
try attachmentStream.cloneAsSignalAttachment()
}
var isBorderless: Bool {
attachmentStream.isBorderless
}
}
// MARK: -
public enum SendMessageFlowType {
case `default`
case forward
}
// MARK: -
public enum SendMessageFlowError: Error {
case invalidContent
}
// MARK: -
enum SendMessageUnapprovedContent {
case text(messageBody: MessageBody)
case contactShare(contactShare: ContactShareViewModel)
// stickerAttachment is required if the sticker is not installed.
case sticker(stickerMetadata: StickerMetadata, stickerAttachment: TSAttachmentStream?)
case genericAttachment(signalAttachmentProvider: SignalAttachmentProvider)
case media(signalAttachmentProviders: [SignalAttachmentProvider], messageBody: MessageBody?)
fileprivate var needsApproval: Bool {
switch self {
case .text:
return true
case .contactShare:
return true
case .sticker:
return false
case .genericAttachment:
return false
case .media:
return !isBorderless
}
}
fileprivate var isBorderless: Bool {
switch self {
case .media(let attachmentStreamProviders, _):
guard attachmentStreamProviders.count == 1,
let attachmentStreamProvider = attachmentStreamProviders.first,
attachmentStreamProvider.isBorderless else {
return false
}
return true
default:
return false
}
}
// Some content types don't need approval.
fileprivate func tryToBuildContentWithoutApproval() throws -> SendMessageApprovedContent? {
switch self {
case .text:
owsAssertDebug(needsApproval)
return nil
case .contactShare:
owsAssertDebug(needsApproval)
return nil
case .sticker(let stickerMetadata, let stickerAttachment):
owsAssertDebug(!needsApproval)
let stickerInfo = stickerMetadata.stickerInfo
if StickerManager.isStickerInstalled(stickerInfo: stickerInfo) {
return .installedSticker(stickerMetadata: stickerMetadata)
} else {
guard let stickerAttachment = stickerAttachment else {
throw SendMessageFlowError.invalidContent
}
let stickerData = try stickerAttachment.readDataFromFile()
return .uninstalledSticker(stickerMetadata: stickerMetadata, stickerData: stickerData)
}
case .genericAttachment(let signalAttachmentProvider):
owsAssertDebug(!needsApproval)
return .genericAttachment(signalAttachmentProvider: signalAttachmentProvider)
case .media(let signalAttachmentProviders, let messageBody):
guard signalAttachmentProviders.count == 1,
let signalAttachmentProvider = signalAttachmentProviders.first,
signalAttachmentProvider.isBorderless else {
owsAssertDebug(needsApproval)
return nil
}
owsAssertDebug(!needsApproval)
owsAssertDebug(messageBody == nil)
return .borderlessMedia(signalAttachmentProvider: signalAttachmentProvider)
}
}
}
// MARK: -
enum SendMessageApprovedContent {
case text(messageBody: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?)
case contactShare(contactShare: ContactShareViewModel)
case installedSticker(stickerMetadata: StickerMetadata)
case uninstalledSticker(stickerMetadata: StickerMetadata, stickerData: Data)
case genericAttachment(signalAttachmentProvider: SignalAttachmentProvider)
case borderlessMedia(signalAttachmentProvider: SignalAttachmentProvider)
case media(signalAttachments: [SignalAttachment], messageBody: MessageBody?)
}
// MARK: -
@objc
class SendMessageFlow: NSObject {
// MARK: Dependencies
var databaseStorage: SDSDatabaseStorage {
return SSKEnvironment.shared.databaseStorage
}
var messageSenderJobQueue: MessageSenderJobQueue {
return SSKEnvironment.shared.messageSenderJobQueue
}
// MARK: -
private let flowType: SendMessageFlowType
private let useConversationComposeForSingleRecipient: Bool
private weak var delegate: SendMessageDelegate?
private weak var navigationController: UINavigationController?
var unapprovedContent: SendMessageUnapprovedContent
var selectedConversations: [ConversationItem] = []
public init(flowType: SendMessageFlowType,
unapprovedContent: SendMessageUnapprovedContent,
useConversationComposeForSingleRecipient: Bool,
navigationController: UINavigationController,
delegate: SendMessageDelegate) {
self.flowType = flowType
self.unapprovedContent = unapprovedContent
self.useConversationComposeForSingleRecipient = useConversationComposeForSingleRecipient
self.navigationController = navigationController
self.delegate = delegate
super.init()
let conversationPicker = ConversationPickerViewController()
conversationPicker.delegate = self
if navigationController.viewControllers.isEmpty {
navigationController.setViewControllers([
conversationPicker
], animated: false)
} else {
navigationController.pushViewController(conversationPicker, animated: true)
}
}
fileprivate func fireComplete(threads: [TSThread]) {
delegate?.sendMessageFlowDidComplete(threads: threads)
}
fileprivate func fireCancelled() {
delegate?.sendMessageFlowDidCancel()
}
}
// MARK: - Approval
extension SendMessageFlow {
private func pushViewController(_ viewController: UIViewController, animated: Bool) {
guard let navigationController = navigationController else {
owsFailDebug("Missing navigationController.")
return
}
navigationController.pushViewController(viewController, animated: animated)
}
func approve() {
if useConversationComposeForSingleRecipient,
selectedConversations.count == 1,
case .text(let messageBody) = unapprovedContent {
showConversationComposeForSingleRecipient(messageBody: messageBody)
return
}
do {
if let approvedContent = try unapprovedContent.tryToBuildContentWithoutApproval() {
owsAssertDebug(!unapprovedContent.needsApproval)
send(approvedContent: approvedContent)
return
}
owsAssertDebug(unapprovedContent.needsApproval)
try showApprovalUI()
} catch {
owsFailDebug("Error: \(error)")
self.fireCancelled()
}
}
private func showConversationComposeForSingleRecipient(messageBody: MessageBody) {
let conversations = self.selectedConversations
firstly { () -> Promise<[TSThread]> in
self.threads(for: conversations)
}.then(on: .global()) { (threads: [TSThread]) -> Promise<TSThread> in
guard threads.count == 1,
let thread = threads.first else {
throw OWSAssertionError("Unexpected thread state.")
}
return self.databaseStorage.write { transaction -> Promise<TSThread> in
thread.update(withDraft: messageBody, transaction: transaction)
// We need to call uiDatabaseSnapshotFlushPromise() before the
// transaction is committed.
return firstly {
self.databaseStorage.uiDatabaseSnapshotFlushPromise()
}.map {
return thread
}
}
}.done { (thread: TSThread) in
Logger.info("Transitioning to single thread.")
SignalApp.shared().presentConversation(for: thread, animated: true)
}.catch { error in
owsFailDebug("Error: \(error)")
self.showSendFailedAlert()
}
}
func showApprovalUI() throws {
switch unapprovedContent {
case .text(let messageBody):
guard !messageBody.text.isEmpty else {
throw OWSAssertionError("Missing messageBody.")
}
let approvalView = TextApprovalViewController(messageBody: messageBody)
approvalView.delegate = self
pushViewController(approvalView, animated: true)
case .contactShare(let oldContactShare):
let newContactShare = oldContactShare.copyForResending()
let approvalView = ContactShareApprovalViewController(contactShare: newContactShare)
approvalView.delegate = self
pushViewController(approvalView, animated: true)
case .media(let signalAttachmentProviders, let messageBody):
let options: AttachmentApprovalViewControllerOptions = .hasCancel
let sendButtonImageName = "send-solid-24"
let attachmentApprovalItems = try signalAttachmentProviders.map { signalAttachmentProvider -> AttachmentApprovalItem in
let signalAttachment = try signalAttachmentProvider.buildAttachmentForSending()
return AttachmentApprovalItem(attachment: signalAttachment, canSave: false)
}
let approvalViewController = AttachmentApprovalViewController(options: options,
sendButtonImageName: sendButtonImageName,
attachmentApprovalItems: attachmentApprovalItems)
approvalViewController.approvalDelegate = self
approvalViewController.messageBody = messageBody
pushViewController(approvalViewController, animated: true)
default:
throw OWSAssertionError("Invalid message type or message type does not need approval.")
}
}
}
// MARK: - Sending
extension SendMessageFlow {
func send(approvedContent: SendMessageApprovedContent) {
firstly {
try tryToSend(approvedContent: approvedContent)
}.done { (threads: [TSThread]) in
self.fireComplete(threads: threads)
}.catch { error in
owsFailDebug("Error: \(error)")
self.showSendFailedAlert()
}
}
private func showSendFailedAlert() {
guard let navigationController = navigationController else {
owsFailDebug("Missing navigationController.")
return
}
guard let viewController = navigationController.topViewController else {
owsFailDebug("Missing topViewController.")
return
}
let message = NSLocalizedString("ERROR_DESCRIPTION_CLIENT_SENDING_FAILURE",
comment: "Generic notice when message failed to send.")
let actionSheet = ActionSheetController(title: CommonStrings.errorAlertTitle,
message: message)
actionSheet.addAction(ActionSheetAction(title: CommonStrings.okayButton) { [weak self] _ in
self?.fireCancelled()
})
viewController.presentActionSheet(actionSheet)
}
func tryToSend(approvedContent: SendMessageApprovedContent) throws -> Promise<[TSThread]> {
switch approvedContent {
case .text(let messageBody, let linkPreviewDraft):
guard !messageBody.text.isEmpty else {
throw OWSAssertionError("Missing messageBody.")
}
return sendInEachThread { thread in
self.send(messageBody: messageBody, linkPreviewDraft: linkPreviewDraft, thread: thread)
}
case .contactShare(let contactShare):
return sendInEachThread { thread in
let contactShareCopy = contactShare.copyForResending()
if let avatarImage = contactShareCopy.avatarImage {
self.databaseStorage.write { transaction in
contactShareCopy.dbRecord.saveAvatarImage(avatarImage, transaction: transaction)
}
}
self.send(contactShare: contactShareCopy, thread: thread)
}
case .installedSticker(let stickerMetadata):
let stickerInfo = stickerMetadata.stickerInfo
return sendInEachThread { thread in
ThreadUtil.enqueueMessage(withInstalledSticker: stickerInfo, thread: thread)
}
case .uninstalledSticker(let stickerMetadata, let stickerData):
return sendInEachThread { thread in
ThreadUtil.enqueueMessage(withUninstalledSticker: stickerMetadata, stickerData: stickerData, thread: thread)
}
case .genericAttachment(let signalAttachmentProvider):
return sendInEachThread { thread in
let signalAttachment = try signalAttachmentProvider.buildAttachmentForSending()
self.send(messageBody: nil, attachment: signalAttachment, thread: thread)
}
case .borderlessMedia(let signalAttachmentProvider):
return sendInEachThread { thread in
let signalAttachment = try signalAttachmentProvider.buildAttachmentForSending()
self.send(messageBody: nil, attachment: signalAttachment, thread: thread)
}
case .media(let signalAttachments, let messageBody):
let conversations = selectedConversations
return AttachmentMultisend.sendApprovedMedia(conversations: conversations,
approvalMessageBody: messageBody,
approvedAttachments: signalAttachments)
}
}
func send(messageBody: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?, thread: TSThread) {
databaseStorage.read { transaction in
ThreadUtil.enqueueMessage(with: messageBody,
thread: thread,
quotedReplyModel: nil,
linkPreviewDraft: linkPreviewDraft,
transaction: transaction)
}
}
func send(contactShare: ContactShareViewModel, thread: TSThread) {
ThreadUtil.enqueueMessage(withContactShare: contactShare.dbRecord, thread: thread)
}
func send(messageBody: MessageBody?, attachment: SignalAttachment, thread: TSThread) {
databaseStorage.read { transaction in
ThreadUtil.enqueueMessage(with: messageBody,
mediaAttachments: [attachment],
thread: thread,
quotedReplyModel: nil,
linkPreviewDraft: nil,
transaction: transaction)
}
}
func sendInEachThread(enqueueBlock: @escaping (TSThread) throws -> Void) -> Promise<[TSThread]> {
AssertIsOnMainThread()
let conversations = self.selectedConversations
return firstly {
self.threads(for: conversations)
}.map { (threads: [TSThread]) -> [TSThread] in
// TODO: Move off main thread?
for thread in threads {
try enqueueBlock(thread)
// We're sending a message to this thread, approve any pending message request
ThreadUtil.addToProfileWhitelistIfEmptyOrPendingRequestWithSneakyTransaction(thread: thread)
}
return threads
}
}
func threads(for conversationItems: [ConversationItem]) -> Promise<[TSThread]> {
return DispatchQueue.global().async(.promise) {
guard conversationItems.count > 0 else {
throw OWSAssertionError("No recipients.")
}
var threads: [TSThread] = []
self.databaseStorage.write { transaction in
for conversation in conversationItems {
let thread: TSThread
switch conversation.messageRecipient {
case .contact(let address):
thread = TSContactThread.getOrCreateThread(withContactAddress: address,
transaction: transaction)
case .group(let groupThread):
thread = groupThread
}
threads.append(thread)
}
}
return threads
}
}
}
// MARK: -
extension SendMessageFlow: ConversationPickerDelegate {
var selectedConversationsForConversationPicker: [ConversationItem] {
return selectedConversations
}
func conversationPicker(_ conversationPickerViewController: ConversationPickerViewController,
didSelectConversation conversation: ConversationItem) {
self.selectedConversations.append(conversation)
}
func conversationPicker(_ conversationPickerViewController: ConversationPickerViewController,
didDeselectConversation conversation: ConversationItem) {
self.selectedConversations = self.selectedConversations.filter {
$0.messageRecipient != conversation.messageRecipient
}
}
func conversationPickerDidCompleteSelection(_ conversationPickerViewController: ConversationPickerViewController) {
approve()
}
func conversationPickerCanCancel(_ conversationPickerViewController: ConversationPickerViewController) -> Bool {
return true
}
func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController) {
fireCancelled()
}
func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode {
return unapprovedContent.needsApproval ? .next : .send
}
}
// MARK: -
extension SendMessageFlow: TextApprovalViewControllerDelegate {
func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody?, linkPreviewDraft: OWSLinkPreviewDraft?) {
assert(messageBody?.text.count ?? 0 > 0)
guard let messageBody = messageBody else {
owsFailDebug("Missing messageBody.")
fireCancelled()
return
}
send(approvedContent: .text(messageBody: messageBody, linkPreviewDraft: linkPreviewDraft))
}
func textApprovalDidCancel(_ textApproval: TextApprovalViewController) {
fireCancelled()
}
func textApprovalCustomTitle(_ textApproval: TextApprovalViewController) -> String? {
switch flowType {
case .`default`:
return NSLocalizedString("MESSAGE_APPROVAL_DIALOG_TITLE", comment: "Title for the 'message approval' dialog.")
case .forward:
return NSLocalizedString("FORWARD_MESSAGE", comment: "Label and title for 'message forwarding' views.")
}
}
func textApprovalRecipientsDescription(_ textApproval: TextApprovalViewController) -> String? {
let conversations = selectedConversations
guard conversations.count > 0 else {
return nil
}
return conversations.map { $0.title }.joined(separator: ", ")
}
func textApprovalMode(_ textApproval: TextApprovalViewController) -> ApprovalMode {
return .send
}
}
// MARK: -
extension SendMessageFlow: ContactShareApprovalViewControllerDelegate {
func approveContactShare(_ approveContactShare: ContactShareApprovalViewController,
didApproveContactShare contactShare: ContactShareViewModel) {
send(approvedContent: .contactShare(contactShare: contactShare))
}
func approveContactShare(_ approveContactShare: ContactShareApprovalViewController,
didCancelContactShare contactShare: ContactShareViewModel) {
fireCancelled()
}
func contactApprovalCustomTitle(_ contactApproval: ContactShareApprovalViewController) -> String? {
switch flowType {
case .`default`:
return nil
case .forward:
return NSLocalizedString("FORWARD_CONTACT", comment: "Label and title for 'contact forwarding' views.")
}
}
func contactApprovalRecipientsDescription(_ contactApproval: ContactShareApprovalViewController) -> String? {
let conversations = selectedConversations
guard conversations.count > 0 else {
return nil
}
return conversations.map { $0.title }.joined(separator: ", ")
}
func contactApprovalMode(_ contactApproval: ContactShareApprovalViewController) -> ApprovalMode {
return .send
}
}
// MARK: -
extension SendMessageFlow: AttachmentApprovalViewControllerDelegate {
func attachmentApprovalDidAppear(_ attachmentApproval: AttachmentApprovalViewController) {
// We can ignore this event.
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageBody newMessageBody: MessageBody?) {
// TODO: We could update unapprovedContent to reflect newMessageBody.
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) {
// We can ignore this event.
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController,
didApproveAttachments attachments: [SignalAttachment], messageBody: MessageBody?) {
send(approvedContent: .media(signalAttachments: attachments, messageBody: messageBody))
}
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
fireCancelled()
}
func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) {
// TODO: Extend SendMessageFlow to handle camera first capture flow, share extension.
owsFailDebug("Cannot add more to message forwards.")
}
var attachmentApprovalTextInputContextIdentifier: String? {
return nil
}
var attachmentApprovalRecipientNames: [String] {
selectedConversations.map { $0.title }
}
var attachmentApprovalMentionableAddresses: [SignalServiceAddress] {
guard selectedConversations.count == 1,
case .group(let groupThread) = selectedConversations.first?.messageRecipient,
Mention.threadAllowsMentionSend(groupThread) else { return [] }
return groupThread.recipientAddresses
}
}
// MARK: -
public class SendMessageController: SendMessageDelegate {
private weak var fromViewController: UIViewController?
let sendMessageFlow = AtomicOptional<SendMessageFlow>(nil)
public required init(fromViewController: UIViewController) {
self.fromViewController = fromViewController
}
public func sendMessageFlowDidComplete(threads: [TSThread]) {
AssertIsOnMainThread()
sendMessageFlow.set(nil)
guard let fromViewController = fromViewController else {
return
}
if threads.count == 1,
let thread = threads.first {
SignalApp.shared().presentConversation(for: thread, animated: true)
} else {
fromViewController.navigationController?.popToViewController(fromViewController, animated: true)
}
}
public func sendMessageFlowDidCancel() {
AssertIsOnMainThread()
sendMessageFlow.set(nil)
guard let fromViewController = fromViewController else {
return
}
fromViewController.navigationController?.popToViewController(fromViewController, animated: true)
}
}
// MARK: -
extension TSAttachmentStream {
func cloneAsSignalAttachment() throws -> SignalAttachment {
guard let sourceUrl = originalMediaURL else {
throw OWSAssertionError("Missing originalMediaURL.")
}
guard let dataUTI = MIMETypeUtil.utiType(forMIMEType: contentType) else {
throw OWSAssertionError("Missing dataUTI.")
}
let newUrl = OWSFileSystem.temporaryFileUrl(fileExtension: sourceUrl.pathExtension)
try FileManager.default.copyItem(at: sourceUrl, to: newUrl)
let clonedDataSource = try DataSourcePath.dataSource(with: newUrl,
shouldDeleteOnDeallocation: true)
clonedDataSource.sourceFilename = sourceFilename
var signalAttachment: SignalAttachment
if isVoiceMessage {
signalAttachment = SignalAttachment.voiceMessageAttachment(dataSource: clonedDataSource, dataUTI: dataUTI)
} else {
signalAttachment = SignalAttachment.attachment(dataSource: clonedDataSource, dataUTI: dataUTI, imageQuality: .original)
}
signalAttachment.captionText = caption
signalAttachment.isBorderless = isBorderless
return signalAttachment
}
}