Signal-iOS/SignalShareExtension/SharingThreadPickerViewController.swift
2024-04-09 10:48:17 -05:00

728 lines
30 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import UIKit
import Foundation
import SignalUI
class SharingThreadPickerViewController: ConversationPickerViewController {
weak var shareViewDelegate: ShareViewDelegate?
/// It can take a while to fully process attachments, and until we do we aren't
/// fully sure if the attachments are stories-compatible. To speed things up,
/// we do some fast pre-checks and store the result here.
/// True if these pre-checks determine all attachments are stories-compatible.
/// Once this is set, we show stories forever, even if the attachments end up being
/// incompatible, because it would be weird to have the stories destinations disappear.
/// Instead, we show an error when actually sending if stories are selected.
public var areAttachmentStoriesCompatPrecheck: Bool? {
didSet {
// If we've already processed attachments, ignore the setting.
guard attachments == nil else {
areAttachmentStoriesCompatPrecheck = nil
return
}
updateStoriesState()
updateApprovalMode()
}
}
var attachments: [SignalAttachment]? {
didSet {
updateStoriesState()
updateApprovalMode()
}
}
var isTextMessage: Bool {
guard let attachments = attachments, attachments.count == 1, let attachment = attachments.first else { return false }
return attachment.isConvertibleToTextMessage && attachment.dataLength < kOversizeTextMessageSizeThreshold
}
var isContactShare: Bool {
guard let attachments = attachments, attachments.count == 1, let attachment = attachments.first else { return false }
return attachment.isConvertibleToContactShare
}
var approvedAttachments: [SignalAttachment]?
var approvedContactShare: ContactShareDraft?
var approvalMessageBody: MessageBody?
var approvalLinkPreviewDraft: OWSLinkPreviewDraft?
var outgoingMessages = AtomicArray<PreparedOutgoingMessage>(lock: .init())
var mentionCandidates: [SignalServiceAddress] = []
var selectedConversations: [ConversationItem] { selection.conversations }
public init(shareViewDelegate: ShareViewDelegate) {
self.shareViewDelegate = shareViewDelegate
super.init(selection: ConversationPickerSelection())
shouldBatchUpdateIdentityKeys = true
pickerDelegate = self
}
public func presentActionSheetOnNavigationController(_ alert: ActionSheetController) {
if let navigationController = shareViewDelegate?.shareViewNavigationController {
navigationController.presentActionSheet(alert)
} else {
super.presentActionSheet(alert)
}
}
private func updateMentionCandidates() {
AssertIsOnMainThread()
guard selectedConversations.count == 1,
case .group(let groupThreadId) = selectedConversations.first?.messageRecipient else {
mentionCandidates = []
return
}
let groupThread = databaseStorage.read { readTx in
TSGroupThread.anyFetchGroupThread(uniqueId: groupThreadId, transaction: readTx)
}
owsAssertDebug(groupThread != nil)
if let groupThread = groupThread, groupThread.allowsMentionSend {
mentionCandidates = groupThread.recipientAddressesWithSneakyTransaction
} else {
mentionCandidates = []
}
}
private func updateStoriesState() {
if areAttachmentStoriesCompatPrecheck == true {
sectionOptions.insert(.stories)
} else if let attachments = attachments, attachments.allSatisfy({ $0.isValidImage || $0.isValidVideo }) {
sectionOptions.insert(.stories)
} else if isTextMessage {
sectionOptions.insert(.stories)
} else {
sectionOptions.remove(.stories)
}
}
}
// MARK: - Approval
extension SharingThreadPickerViewController {
func approve() {
do {
let vc = try buildApprovalViewController(withCancelButton: false)
navigationController?.pushViewController(vc, animated: true)
} catch {
shareViewDelegate?.shareViewFailed(error: error)
}
}
func buildApprovalViewController(for thread: TSThread) throws -> UIViewController {
AssertIsOnMainThread()
loadViewIfNeeded()
guard let conversationItem = conversation(for: thread) else {
throw OWSAssertionError("Unexpectedly missing conversation for selected thread")
}
selection.add(conversationItem)
return try buildApprovalViewController(withCancelButton: true)
}
func buildApprovalViewController(withCancelButton: Bool) throws -> UIViewController {
guard let attachments = attachments, let firstAttachment = attachments.first else {
throw OWSAssertionError("Unexpectedly missing attachments")
}
let approvalVC: UIViewController
if isTextMessage {
guard let messageText = String(data: firstAttachment.data, encoding: .utf8)?.filterForDisplay else {
throw OWSAssertionError("Missing or invalid message text for text attachment")
}
let approvalView = TextApprovalViewController(messageBody: MessageBody(text: messageText, ranges: .empty))
approvalVC = approvalView
approvalView.delegate = self
} else if isContactShare {
let cnContact = try SystemContact.parseVCardData(firstAttachment.data)
let contactShareDraft = databaseStorage.read { tx in
return ContactShareDraft.load(
cnContact: cnContact,
signalContact: SystemContact(cnContact: cnContact),
contactManager: Self.contactsManager,
phoneNumberUtil: Self.phoneNumberUtil,
profileManager: Self.profileManager,
recipientManager: DependenciesBridge.shared.recipientManager,
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
tx: tx
)
}
let approvalView = ContactShareViewController(contactShareDraft: contactShareDraft)
approvalVC = approvalView
approvalView.shareDelegate = self
} else {
let approvalItems = attachments.map { AttachmentApprovalItem(attachment: $0, canSave: false) }
var approvalVCOptions: AttachmentApprovalViewControllerOptions = withCancelButton ? [ .hasCancel ] : []
if self.selection.conversations.contains(where: \.isStory) {
approvalVCOptions.insert(.disallowViewOnce)
}
let approvalView = AttachmentApprovalViewController(options: approvalVCOptions, attachmentApprovalItems: approvalItems)
approvalVC = approvalView
approvalView.approvalDelegate = self
approvalView.approvalDataSource = self
}
return approvalVC
}
}
// MARK: - Sending
extension SharingThreadPickerViewController {
func send() {
let dismissSendProgress = showSendProgress()
firstly {
tryToSend()
}.done {
dismissSendProgress {}
self.shareViewDelegate?.shareViewWasCompleted()
}.catch { error in
dismissSendProgress { self.showSendFailure(error: error) }
}
}
func tryToSend() -> Promise<Void> {
outgoingMessages.removeAll()
if isTextMessage {
guard let body = approvalMessageBody, !body.text.isEmpty else {
return Promise(error: OWSAssertionError("Missing body."))
}
let linkPreviewDraft = approvalLinkPreviewDraft
let nonStorySendPromise = sendToOutgoingMessageThreads { thread in
return firstly(on: DispatchQueue.global()) { () -> Promise<Void> in
return self.databaseStorage.write { transaction in
let unpreparedMessage = UnpreparedOutgoingMessage.build(
thread: thread,
messageBody: body,
quotedReplyDraft: nil,
linkPreviewDraft: linkPreviewDraft,
transaction: transaction
)
do {
let preparedMessage = try unpreparedMessage.prepare(tx: transaction)
self.outgoingMessages.append(preparedMessage)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
transaction: transaction
)
} catch {
return Promise(error: error)
}
}
}
}
// Send the text message to any selected story recipients
// as a text story with default styling.
let storyConversations = selectedConversations.filter { $0.outgoingMessageClass == OutgoingStoryMessage.self }
let storySendPromise = StorySharing.sendTextStoryFromShareExtension(
with: body,
linkPreviewDraft: linkPreviewDraft,
to: storyConversations,
messagesReadyToSend: { messages in
self.outgoingMessages.append(contentsOf: messages)
}
)
return Promise<Void>.when(fulfilled: [nonStorySendPromise, storySendPromise])
} else if isContactShare {
guard let contactShare = approvedContactShare else {
return Promise(error: OWSAssertionError("Missing contactShare."))
}
return sendToOutgoingMessageThreads { thread in
return firstly(on: DispatchQueue.global()) { () -> Promise<Void> in
return self.databaseStorage.write { transaction in
let builder = TSOutgoingMessageBuilder(thread: thread)
let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
builder.expiresInSeconds = dmConfigurationStore.durationSeconds(for: thread, tx: transaction.asV2Read)
let message = builder.build(transaction: transaction)
let unpreparedMessage = UnpreparedOutgoingMessage.forMessage(
message,
contactShareDraft: contactShare
)
do {
let preparedMessage = try unpreparedMessage.prepare(tx: transaction)
self.outgoingMessages.append(preparedMessage)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
transaction: transaction
)
} catch {
return Promise(error: error)
}
}
}
}
} else {
guard let approvedAttachments = approvedAttachments else {
return Promise(error: OWSAssertionError("Missing approvedAttachments."))
}
return sendToConversations { conversations in
return AttachmentMultisend.sendApprovedMediaFromShareExtension(
conversations: conversations,
approvalMessageBody: self.approvalMessageBody,
approvedAttachments: approvedAttachments,
messagesReadyToSend: { messages in
self.outgoingMessages.append(contentsOf: messages)
}
)
}
}
}
func showSendProgress() -> (((() -> Void)?) -> Void) {
AssertIsOnMainThread()
let actionSheet = ActionSheetController()
let headerWithProgress = UIView()
headerWithProgress.backgroundColor = Theme.actionSheetBackgroundColor
headerWithProgress.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
let progressLabel = UILabel()
progressLabel.textAlignment = .center
progressLabel.numberOfLines = 0
progressLabel.lineBreakMode = .byWordWrapping
progressLabel.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
progressLabel.textColor = Theme.primaryTextColor
progressLabel.text = OWSLocalizedString("SHARE_EXTENSION_SENDING_IN_PROGRESS_TITLE", comment: "Alert title")
headerWithProgress.addSubview(progressLabel)
progressLabel.autoPinWidthToSuperviewMargins()
progressLabel.autoPinTopToSuperviewMargin()
let progressView = UIProgressView(progressViewStyle: .default)
headerWithProgress.addSubview(progressView)
progressView.autoPinWidthToSuperviewMargins()
progressView.autoPinEdge(.top, to: .bottom, of: progressLabel, withOffset: 8)
progressView.autoPinBottomToSuperviewMargin()
actionSheet.customHeader = headerWithProgress
let cancelAction = ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel) { [weak self] _ in
self?.shareViewDelegate?.shareViewWasCancelled()
}
actionSheet.addAction(cancelAction)
presentActionSheetOnNavigationController(actionSheet)
let progressFormat = OWSLocalizedString("SHARE_EXTENSION_SENDING_IN_PROGRESS_FORMAT",
comment: "Send progress for share extension. Embeds {{ %1$@ number of attachments uploaded, %2$@ total number of attachments}}")
let attachmentIds: [TSResourceId]? = databaseStorage.read { tx in
return self.outgoingMessages.first?.attachmentIdsForUpload(tx: tx)
}
var observer: NSObjectProtocol?
if let attachmentIds {
// Populate the initial progress for all attachments at 0
var progressPerAttachment: [TSResourceId: Float] =
Dictionary(uniqueKeysWithValues: attachmentIds.map { ($0, 0) })
observer = NotificationCenter.default.addObserver(
forName: Upload.Constants.resourceUploadProgressNotification,
object: nil,
queue: nil
) { notification in
// We can safely show the progress for just the first message,
// all the messages share the same attachment upload progress.
guard let notificationAttachmentId = notification.userInfo?[Upload.Constants.uploadResourceIDKey] as? TSResourceId else {
owsFailDebug("Missing notificationAttachmentId.")
return
}
guard let progress = notification.userInfo?[Upload.Constants.uploadProgressKey] as? NSNumber else {
owsFailDebug("Missing progress.")
return
}
guard attachmentIds.contains(notificationAttachmentId) else { return }
progressPerAttachment[notificationAttachmentId] = progress.floatValue
// Attachments can upload in parallel, so we show the progress
// of the average of all the individual attachment's progress.
progressView.progress = progressPerAttachment.values.reduce(0, +) / Float(attachmentIds.count)
// In order to indicate approximately how many attachments remain
// to upload, we look at the number that have had their progress
// reach 100%.
let totalCompleted = progressPerAttachment.values.filter { $0 == 1 }.count
progressLabel.text = String(
format: progressFormat,
OWSFormat.formatInt(min(totalCompleted + 1, attachmentIds.count)),
OWSFormat.formatInt(attachmentIds.count)
)
}
}
return { completion in
observer.map { NotificationCenter.default.removeObserver($0) }
actionSheet.dismiss(animated: true, completion: completion)
}
}
func sendToConversations(enqueueBlock: @escaping ([ConversationItem]) -> Promise<[TSThread]>) -> Promise<Void> {
AssertIsOnMainThread()
let conversations = self.selectedConversations
return firstly {
enqueueBlock(conversations)
}.done { threads in
for thread in threads {
// We're sending a message to this thread, approve any pending message request
ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(thread)
}
}
}
func sendToOutgoingMessageThreads(enqueueBlock: @escaping (TSThread) -> Promise<Void>) -> Promise<Void> {
AssertIsOnMainThread()
let conversations = self.selectedConversations.filter { $0.outgoingMessageClass == TSOutgoingMessage.self }
return firstly {
self.threads(for: conversations)
}.then { (threads: [TSThread]) -> Promise<Void> in
var sendPromises = [Promise<Void>]()
for thread in threads {
sendPromises.append(enqueueBlock(thread))
// We're sending a message to this thread, approve any pending message request
ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(thread)
}
return Promise.when(fulfilled: sendPromises)
}
}
func threads(for conversationItems: [ConversationItem]) -> Promise<[TSThread]> {
return firstly(on: DispatchQueue.sharedUserInteractive) {
var threads: [TSThread] = []
if !conversationItems.isEmpty {
self.databaseStorage.write { transaction in
for conversation in conversationItems {
guard let thread = conversation.getOrCreateThread(transaction: transaction) else {
owsFailDebug("Missing thread for conversation")
continue
}
threads.append(thread)
}
}
}
return threads
}
}
func showSendFailure(error: Error) {
AssertIsOnMainThread()
owsFailDebug("Error: \(error)")
let cancelAction = ActionSheetAction(
title: CommonStrings.cancelButton,
style: .cancel
) { [weak self] _ in
guard let self = self else { return }
self.databaseStorage.write { transaction in
for message in self.outgoingMessages.get() {
// If we sent the message to anyone, mark it as failed
message.updateWithAllSendingRecipientsMarkedAsFailed(tx: transaction)
}
}
self.shareViewDelegate?.shareViewWasCancelled()
}
let failureTitle = OWSLocalizedString("SHARE_EXTENSION_SENDING_FAILURE_TITLE", comment: "Alert title")
if let untrustedIdentityError = error as? UntrustedIdentityError {
let untrustedServiceId = untrustedIdentityError.serviceId
let failureFormat = OWSLocalizedString(
"SHARE_EXTENSION_FAILED_SENDING_BECAUSE_UNTRUSTED_IDENTITY_FORMAT",
comment: "alert body when sharing file failed because of untrusted/changed identity keys"
)
let displayName = databaseStorage.read { tx in
return contactsManager.displayName(for: SignalServiceAddress(untrustedServiceId), tx: tx).resolvedValue()
}
let failureMessage = String(format: failureFormat, displayName)
let actionSheet = ActionSheetController(title: failureTitle, message: failureMessage)
actionSheet.addAction(cancelAction)
// Capture the identity key before showing the prompt about it.
let identityKey = databaseStorage.read { tx in
let identityManager = DependenciesBridge.shared.identityManager
return identityManager.identityKey(for: SignalServiceAddress(untrustedServiceId), tx: tx.asV2Read)
}
let confirmAction = ActionSheetAction(
title: SafetyNumberStrings.confirmSendButton,
style: .default
) { [weak self] _ in
guard let self = self else { return }
// Confirm Identity
self.databaseStorage.write { transaction in
let identityManager = DependenciesBridge.shared.identityManager
let verificationState = identityManager.verificationState(
for: SignalServiceAddress(untrustedServiceId),
tx: transaction.asV2Write
)
switch verificationState {
case .verified:
owsFailDebug("Unexpected state")
case .noLongerVerified, .implicit(isAcknowledged: _):
Logger.info("marked recipient: \(untrustedServiceId) as default verification status.")
guard let identityKey else {
owsFailDebug("Can't be untrusted unless there's already an identity key.")
return
}
_ = identityManager.setVerificationState(
.implicit(isAcknowledged: true),
of: identityKey,
for: SignalServiceAddress(untrustedServiceId),
isUserInitiatedChange: true,
tx: transaction.asV2Write
)
}
}
// Resend
self.resendMessages()
}
actionSheet.addAction(confirmAction)
presentActionSheetOnNavigationController(actionSheet)
} else {
let actionSheet = ActionSheetController(title: failureTitle)
actionSheet.addAction(cancelAction)
let retryAction = ActionSheetAction(title: CommonStrings.retryButton, style: .default) { [weak self] _ in
self?.resendMessages()
}
actionSheet.addAction(retryAction)
presentActionSheetOnNavigationController(actionSheet)
}
}
func resendMessages() {
AssertIsOnMainThread()
owsAssertDebug(outgoingMessages.count > 0)
var promises = [Promise<Void>]()
databaseStorage.write { transaction in
for message in outgoingMessages.get() {
promises.append(SSKEnvironment.shared.messageSenderJobQueueRef.add(
.promise,
message: message,
transaction: transaction
))
}
}
let dismissSendProgress = showSendProgress()
Promise.when(fulfilled: promises).done {
dismissSendProgress {}
self.shareViewDelegate?.shareViewWasCompleted()
}.catch { error in
dismissSendProgress { self.showSendFailure(error: error) }
}
}
}
// MARK: -
extension SharingThreadPickerViewController: ConversationPickerDelegate {
func conversationPickerSelectionDidChange(_ conversationPickerViewController: ConversationPickerViewController) {
updateMentionCandidates()
}
func conversationPickerDidCompleteSelection(_ conversationPickerViewController: ConversationPickerViewController) {
// Check if the attachments are compatible with sending to stories.
let storySelections = selection.conversations.compactMap({ $0 as? StoryConversationItem })
if !storySelections.isEmpty, let attachments = attachments {
let areImagesOrVideos = attachments.allSatisfy({ $0.isValidImage || $0.isValidVideo })
let isTextMessage = attachments.count == 1 && attachments.first.map {
$0.isConvertibleToTextMessage && $0.dataLength < kOversizeTextMessageSizeThreshold
} ?? false
if !areImagesOrVideos && !isTextMessage {
// Can't send to stories!
storySelections.forEach { self.selection.remove($0) }
self.updateUIForCurrentSelection(animated: false)
self.tableView.reloadData()
let vc = ConversationPickerFailedRecipientsSheet(
failedAttachments: attachments,
failedStoryConversationItems: storySelections,
remainingConversationItems: self.selection.conversations,
onApprove: { [weak self] in
guard
let strongSelf = self,
strongSelf.selection.conversations.isEmpty.negated
else {
return
}
strongSelf.conversationPickerDidCompleteSelection(strongSelf)
})
self.present(vc, animated: true)
return
}
}
approve()
}
func conversationPickerCanCancel(_ conversationPickerViewController: ConversationPickerViewController) -> Bool {
return true
}
func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController) {
shareViewDelegate?.shareViewWasCancelled()
}
func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode {
return attachments?.isEmpty != false ? .loading : .next
}
func conversationPickerDidBeginEditingText() {}
func conversationPickerSearchBarActiveDidChange(_ conversationPickerViewController: ConversationPickerViewController) {}
}
// MARK: -
extension SharingThreadPickerViewController: TextApprovalViewControllerDelegate {
func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody?, linkPreviewDraft: OWSLinkPreviewDraft?) {
assert(messageBody?.text.nilIfEmpty != nil)
approvalMessageBody = messageBody
approvalLinkPreviewDraft = linkPreviewDraft
send()
}
func textApprovalDidCancel(_ textApproval: TextApprovalViewController) {
shareViewDelegate?.shareViewWasCancelled()
}
func textApprovalCustomTitle(_ textApproval: TextApprovalViewController) -> String? {
return nil
}
func textApprovalRecipientsDescription(_ textApproval: TextApprovalViewController) -> String? {
let conversations = selectedConversations
guard conversations.count > 0 else {
return nil
}
return conversations.map { $0.titleWithSneakyTransaction }.joined(separator: ", ")
}
func textApprovalMode(_ textApproval: TextApprovalViewController) -> ApprovalMode {
return .send
}
}
// MARK: -
extension SharingThreadPickerViewController: ContactShareViewControllerDelegate {
func contactShareViewController(_ viewController: ContactShareViewController, didApproveContactShare contactShare: ContactShareDraft) {
approvedContactShare = contactShare
send()
}
func contactShareViewControllerDidCancel(_ viewController: ContactShareViewController) {
shareViewDelegate?.shareViewWasCancelled()
}
func titleForContactShareViewController(_ viewController: ContactShareViewController) -> String? {
return nil
}
func recipientsDescriptionForContactShareViewController(_ viewController: ContactShareViewController) -> String? {
let conversations = selectedConversations
guard conversations.count > 0 else {
return nil
}
return conversations.map { $0.titleWithSneakyTransaction }.joined(separator: ", ")
}
func approvalModeForContactShareViewController(_ viewController: ContactShareViewController) -> SignalUI.ApprovalMode {
return .send
}
}
// MARK: -
extension SharingThreadPickerViewController: AttachmentApprovalViewControllerDelegate {
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageBody newMessageBody: MessageBody?) {
self.approvalMessageBody = newMessageBody
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeViewOnceState isViewOnce: Bool) {
// We can ignore this event.
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) {
// We can ignore this event.
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageBody: MessageBody?) {
self.approvedAttachments = attachments
self.approvalMessageBody = messageBody
send()
}
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
shareViewDelegate?.shareViewWasCancelled()
}
func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) {
owsFailDebug("Cannot add more to message forwards.")
}
}
// MARK: -
extension SharingThreadPickerViewController: AttachmentApprovalViewControllerDataSource {
var attachmentApprovalTextInputContextIdentifier: String? {
return nil
}
var attachmentApprovalRecipientNames: [String] {
selectedConversations.map { $0.titleWithSneakyTransaction }
}
func attachmentApprovalMentionableAddresses(tx: DBReadTransaction) -> [SignalServiceAddress] {
mentionCandidates
}
func attachmentApprovalMentionCacheInvalidationKey() -> String {
return "\(mentionCandidates.hashValue)"
}
}