diff --git a/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift b/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift index f7eb48dd73..4ab5e8c214 100644 --- a/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift +++ b/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift @@ -171,8 +171,7 @@ extension UsernameLinkScanQRCodeViewController: PHPickerViewControllerDelegate { }() do { - let typedItemProvider = try TypedItemProvider.make(for: selectedItem.itemProvider) - let attachment = try await typedItemProvider.buildAttachment() + let attachment = try await TypedItemProvider.buildVisualMediaAttachment(forItemProvider: selectedItem.itemProvider) guard let image = attachment.image(), let ciImage = CIImage(image: image) diff --git a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift index c93009d50d..c2fb95a9a5 100644 --- a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift +++ b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift @@ -401,13 +401,8 @@ extension SendMediaNavigationController: PHPickerViewControllerDelegate { let attachment = PHPickerAttachment( result: result, attachmentApprovalItemPromise: Promise.wrapAsync { - let attachment = try await TypedItemProvider - .make(for: result.itemProvider) - .buildAttachment() - return AttachmentApprovalItem( - attachment: attachment, - canSave: false - ) + let attachment = try await TypedItemProvider.buildVisualMediaAttachment(forItemProvider: result.itemProvider) + return AttachmentApprovalItem(attachment: attachment, canSave: false) } ) return .phPicker(attachment: attachment) @@ -441,13 +436,8 @@ extension SendMediaNavigationController: PHPickerViewControllerDelegate { PHPickerAttachment( result: result, attachmentApprovalItemPromise: Promise.wrapAsync { - let attachment = try await TypedItemProvider - .make(for: result.itemProvider) - .buildAttachment() - return AttachmentApprovalItem( - attachment: attachment, - canSave: false - ) + let attachment = try await TypedItemProvider.buildVisualMediaAttachment(forItemProvider: result.itemProvider) + return AttachmentApprovalItem(attachment: attachment, canSave: false) } ) }.forEach { attachment in diff --git a/Signal/src/ViewControllers/SendMessageFlow.swift b/Signal/src/ViewControllers/SendMessageFlow.swift index a128e56f0a..53a5b24f40 100644 --- a/Signal/src/ViewControllers/SendMessageFlow.swift +++ b/Signal/src/ViewControllers/SendMessageFlow.swift @@ -29,8 +29,8 @@ struct SendMessageUnapprovedContent { struct SendMessageApprovedContent { let messageBody: MessageBody let linkPreviewDraft: OWSLinkPreviewDraft? - init?(messageBody: MessageBody?, linkPreviewDraft: OWSLinkPreviewDraft?) { - guard let messageBody, !messageBody.text.isEmpty else { + init?(messageBody: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?) { + guard !messageBody.text.isEmpty else { return nil } self.messageBody = messageBody @@ -262,7 +262,7 @@ extension SendMessageFlow: ConversationPickerDelegate { // MARK: - extension SendMessageFlow: TextApprovalViewControllerDelegate { - func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody?, linkPreviewDraft: OWSLinkPreviewDraft?) { + func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?) { guard let approvedContent = SendMessageApprovedContent(messageBody: messageBody, linkPreviewDraft: linkPreviewDraft) else { owsFailDebug("Missing messageBody.") fireCancelled() diff --git a/SignalServiceKit/Attachments/SignalAttachment.swift b/SignalServiceKit/Attachments/SignalAttachment.swift index 2d35dcbb22..66ee20c3c5 100644 --- a/SignalServiceKit/Attachments/SignalAttachment.swift +++ b/SignalServiceKit/Attachments/SignalAttachment.swift @@ -71,12 +71,6 @@ public class SignalAttachment: NSObject { public var captionText: String? - // This flag should be set for text attachments that can be sent as text messages. - public var isConvertibleToTextMessage = false - - // This flag should be set for attachments that can be sent as contact shares. - public var isConvertibleToContactShare = false - // Attachment types are identified using UTIs. // // See: https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html @@ -147,8 +141,6 @@ public class SignalAttachment: NSObject { private func replacingDataSource(with newDataSource: DataSource, dataUTI: String? = nil) -> SignalAttachment { let result = SignalAttachment(dataSource: newDataSource, dataUTI: dataUTI ?? self.dataUTI) result.captionText = captionText - result.isConvertibleToTextMessage = isConvertibleToTextMessage - result.isConvertibleToContactShare = isConvertibleToContactShare result.isVoiceMessage = isVoiceMessage result.isBorderless = isBorderless result.isLoopingVideo = isLoopingVideo diff --git a/SignalServiceKit/Util/DataSource.swift b/SignalServiceKit/Util/DataSource.swift index ce95bfd9a9..053cd5a8db 100644 --- a/SignalServiceKit/Util/DataSource.swift +++ b/SignalServiceKit/Util/DataSource.swift @@ -65,11 +65,6 @@ public class DataSourceValue: DataSource { self.init(data, fileExtension: fileExtension) } - public convenience init(oversizeText: String) { - let data = Data(oversizeText.filterForDisplay.utf8) - self.init(data, fileExtension: MimeTypeUtil.oversizeTextAttachmentFileExtension) - } - deinit { if let _dataUrl { try? OWSFileSystem.deleteFileIfExists(url: _dataUrl) diff --git a/SignalServiceKit/Util/String+SSK.swift b/SignalServiceKit/Util/String+SSK.swift index 0b4023f512..460ca59228 100644 --- a/SignalServiceKit/Util/String+SSK.swift +++ b/SignalServiceKit/Util/String+SSK.swift @@ -16,6 +16,15 @@ extension NSString { // MARK: - +public struct FilteredString { + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue.filterStringForDisplay() + } +} + +// MARK: - + public extension String { var stripped: String { ows_stripped() diff --git a/SignalShareExtension/ShareViewController.swift b/SignalShareExtension/ShareViewController.swift index ca88483198..5a8e93495d 100644 --- a/SignalShareExtension/ShareViewController.swift +++ b/SignalShareExtension/ShareViewController.swift @@ -185,7 +185,7 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA loadViewControllerForProgress = initialLoadViewController } - let attachments: [SignalAttachment] + let typedItems: [TypedItem] do { // If buildAndValidateAttachments takes longer than 200ms, we want to show // the new load view. If it takes less than 200ms, we'll exit out of this @@ -201,7 +201,7 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA try Task.checkCancellation() self.setViewControllers([loadViewControllerToDisplay], animated: false) }() - attachments = try await buildAndValidateAttachments( + typedItems = try await buildAndValidateAttachments( for: typedItemProviders, setProgress: { loadViewControllerForProgress?.progress = $0 } ) @@ -210,8 +210,8 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA return } - Logger.info("Setting picker attachments: \(attachments)") - conversationPicker.attachments = attachments + Logger.info("Setting picker attachments: \(typedItems.count)") + conversationPicker.typedItems = typedItems if let preSelectedThread { let approvalViewController = try conversationPicker.buildApprovalViewController(for: preSelectedThread) @@ -377,7 +377,7 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA private func buildAndValidateAttachments( for typedItemProviders: [TypedItemProvider], setProgress: @MainActor (Progress) -> Void - ) async throws -> [SignalAttachment] { + ) async throws -> [TypedItem] { let progress = Progress(totalUnitCount: Int64(typedItemProviders.count)) let itemsAndProgresses = typedItemProviders.map { @@ -388,15 +388,18 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA setProgress(progress) - let attachments = try await self.buildAttachments(for: itemsAndProgresses) + let typedItems = try await self.buildAttachments( + for: itemsAndProgresses, + mustBeVisualMedia: itemsAndProgresses.count >= 2, + ) try Task.checkCancellation() // Make sure the user is not trying to share more than our attachment limit. - guard attachments.filter({ !$0.isConvertibleToTextMessage }).count <= SignalAttachment.maxAttachmentsAllowed else { + guard typedItems.count <= SignalAttachment.maxAttachmentsAllowed else { throw ShareViewControllerError.tooManyAttachments } - return attachments + return typedItems } private func presentAttachmentError(_ error: any Error) { @@ -453,11 +456,14 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA throw ShareViewControllerError.noConformingInputItem } - nonisolated private func buildAttachments(for itemsAndProgresses: [(TypedItemProvider, Progress)]) async throws -> [SignalAttachment] { + private nonisolated func buildAttachments( + for itemsAndProgresses: [(TypedItemProvider, Progress)], + mustBeVisualMedia: Bool, + ) async throws -> [TypedItem] { // FIXME: does not use a task group because SignalAttachment likes to load things into RAM and resize them; doing this in parallel can exhaust available RAM - var result: [SignalAttachment] = [] + var result: [TypedItem] = [] for (typedItemProvider, progress) in itemsAndProgresses { - result.append(try await typedItemProvider.buildAttachment(progress: progress)) + result.append(try await typedItemProvider.buildAttachment(mustBeVisualMedia: mustBeVisualMedia, progress: progress)) } return result } diff --git a/SignalShareExtension/SharingThreadPickerViewController.swift b/SignalShareExtension/SharingThreadPickerViewController.swift index a5596716a9..87dedbf6cd 100644 --- a/SignalShareExtension/SharingThreadPickerViewController.swift +++ b/SignalShareExtension/SharingThreadPickerViewController.swift @@ -26,29 +26,20 @@ class SharingThreadPickerViewController: ConversationPickerViewController { /// actually sending if stories are selected. public let areAttachmentStoriesCompatPrecheck: Bool - var attachments: [SignalAttachment]? { + var typedItems: [TypedItem] { didSet { + owsPrecondition(typedItems.count <= 1 || typedItems.allSatisfy(\.isVisualMedia)) updateStoriesState() updateApprovalMode() } } - private var isTextMessage: Bool { - guard let attachments = attachments, attachments.count == 1, let attachment = attachments.first else { return false } - // TODO: it may be convertible to an oversize text message, check that - return attachment.isConvertibleToTextMessage && attachment.dataSource.dataLength <= OWSMediaUtils.kOversizeTextMessageSizeThresholdBytes - } - - private var isContactShare: Bool { - guard let attachments = attachments, attachments.count == 1, let attachment = attachments.first else { return false } - return attachment.isConvertibleToContactShare - } - private var mentionCandidates: [Aci] = [] private var selectedConversations: [ConversationItem] { selection.conversations } public init(areAttachmentStoriesCompatPrecheck: Bool, shareViewDelegate: ShareViewDelegate) { + self.typedItems = [] self.areAttachmentStoriesCompatPrecheck = areAttachmentStoriesCompatPrecheck self.shareViewDelegate = shareViewDelegate @@ -93,21 +84,18 @@ class SharingThreadPickerViewController: ConversationPickerViewController { } private func updateStoriesState() { - if areAttachmentStoriesCompatPrecheck == true { - sectionOptions.insert(.stories) - } else if let attachments = attachments, attachments.allSatisfy({ $0.dataSource.isValidImage || $0.dataSource.isValidVideo }) { - sectionOptions.insert(.stories) - } else if isTextMessage { + if areAttachmentStoriesCompatPrecheck || canSendTypedItemsToStory() { sectionOptions.insert(.stories) } else { sectionOptions.remove(.stories) } } -} -// MARK: - Approval + private func canSendTypedItemsToStory() -> Bool { + return !typedItems.isEmpty && typedItems.allSatisfy(\.isStoriesCompatible) + } -extension SharingThreadPickerViewController { + // MARK: - Approval func approve() { do { @@ -129,23 +117,22 @@ extension SharingThreadPickerViewController { } func buildApprovalViewController(withCancelButton: Bool) throws -> UIViewController { - guard let attachments = attachments, let firstAttachment = attachments.first else { + guard let anyItem = typedItems.first else { throw OWSAssertionError("Unexpectedly missing attachments") } let approvalVC: UIViewController - if isTextMessage { - guard let messageText = String(data: firstAttachment.dataSource.data, encoding: .utf8)?.filterForDisplay else { - throw OWSAssertionError("Missing or invalid message text for text attachment") - } - let approvalView = TextApprovalViewController(messageBody: MessageBody(text: messageText, ranges: .empty)) + switch anyItem { + case .text(let inlineMessageText): + let approvalView = TextApprovalViewController( + messageBody: MessageBody(text: inlineMessageText.filteredValue.rawValue, ranges: .empty), + ) approvalVC = approvalView approvalView.delegate = self - } else if isContactShare { - let cnContact = try SystemContact.parseVCardData(firstAttachment.dataSource.data) - + case .contact(let contactData): + let cnContact = try SystemContact.parseVCardData(contactData) let contactShareDraft = SSKEnvironment.shared.databaseStorageRef.read { tx in return ContactShareDraft.load( cnContact: cnContact, @@ -158,13 +145,22 @@ extension SharingThreadPickerViewController { tx: tx ) } - let approvalView = ContactShareViewController(contactShareDraft: contactShareDraft) approvalVC = approvalView approvalView.shareDelegate = self - } else { - let approvalItems = attachments.map { AttachmentApprovalItem(attachment: $0, canSave: false) } + case .other: + // We know that the first element of typedItems isn't .text or .contact + // (see prior cases); the others must be visual media (see the precondition + // on `typedItems`), so they also can't be .text or .contact. + let approvalItems = typedItems.map { + switch $0 { + case .text, .contact: + owsFail("not possible") + case .other(let attachment): + return AttachmentApprovalItem(attachment: attachment, canSave: false) + } + } var approvalVCOptions: AttachmentApprovalViewControllerOptions = withCancelButton ? [ .hasCancel ] : [] if self.selection.conversations.contains(where: \.isStory) { approvalVCOptions.insert(.disallowViewOnce) @@ -177,18 +173,16 @@ extension SharingThreadPickerViewController { return approvalVC } -} -// MARK: - Sending + // MARK: - Sending -extension SharingThreadPickerViewController { + private enum ApprovedSend { + case text(messageBody: MessageBody, linkPreview: OWSLinkPreviewDraft?) + case contact(contactShare: ContactShareDraft) + case other(attachments: ApprovedAttachments, messageBody: MessageBody?) + } - fileprivate func send( - approvedAttachments: ApprovedAttachments = .empty(), - approvedContactShare: ContactShareDraft? = nil, - approvedLinkPreview: OWSLinkPreviewDraft? = nil, - approvedMessageBody: MessageBody? = nil, - ) { + private func send(_ approvedSend: ApprovedSend) { // Start presenting empty; the attachments will get set later. self.presentOrUpdateSendProgressSheet(attachmentIds: []) @@ -197,12 +191,7 @@ extension SharingThreadPickerViewController { Task { switch await tryToSend( selectedConversations: selectedConversations, - isTextMessage: isTextMessage, - isContactShare: isContactShare, - messageBody: approvedMessageBody, - attachments: approvedAttachments, - linkPreviewDraft: approvedLinkPreview, - contactShareDraft: approvedContactShare + approvedSend: approvedSend, ) { case .success: self.dismissSendProgressSheet {} @@ -220,23 +209,18 @@ extension SharingThreadPickerViewController { private nonisolated func tryToSend( selectedConversations: [ConversationItem], - isTextMessage: Bool, - isContactShare: Bool, - messageBody: MessageBody?, - attachments: ApprovedAttachments?, - linkPreviewDraft: OWSLinkPreviewDraft?, - contactShareDraft: ContactShareDraft? + approvedSend: ApprovedSend, ) async -> Result { - if isTextMessage { - guard let messageBody, !messageBody.text.isEmpty else { + switch approvedSend { + case .text(let messageBody, let linkPreview): + guard !messageBody.text.isEmpty else { return .failure(.init(outgoingMessages: [], error: OWSAssertionError("Missing body."))) } let linkPreviewDataSource: LinkPreviewDataSource? - if let linkPreviewDraft { - linkPreviewDataSource = try? await DependenciesBridge.shared.linkPreviewManager.buildDataSource( - from: linkPreviewDraft - ) + if let linkPreview { + let linkPreviewManager = DependenciesBridge.shared.linkPreviewManager + linkPreviewDataSource = try? await linkPreviewManager.buildDataSource(from: linkPreview) } else { linkPreviewDataSource = nil } @@ -259,20 +243,16 @@ extension SharingThreadPickerViewController { // as a text story with default styling. StorySharing.sendTextStory( with: messageBody, - linkPreviewDraft: linkPreviewDraft, + linkPreviewDraft: linkPreview, to: storyConversations ) } ) - } else if isContactShare { - guard let contactShareDraft else { - return .failure(.init(outgoingMessages: [], error: OWSAssertionError("Missing contactShare."))) - } + case .contact(let contactShare): let contactShareForSending: ContactShareDraft.ForSending do { - contactShareForSending = try await DependenciesBridge.shared.contactShareManager.validateAndPrepare( - draft: contactShareDraft - ) + let contactShareManager = DependenciesBridge.shared.contactShareManager + contactShareForSending = try await contactShareManager.validateAndPrepare(draft: contactShare) } catch { return .failure(.init(outgoingMessages: [], error: error)) } @@ -299,11 +279,7 @@ extension SharingThreadPickerViewController { // We don't send contact shares to stories storySendBlock: nil ) - } else { - guard let attachments else { - return .failure(.init(outgoingMessages: [], error: OWSAssertionError("Missing approvedAttachments."))) - } - + case .other(let attachments, let messageBody): // This method will also add threads to the profile whitelist. let sendResult = AttachmentMultisend.sendApprovedMedia( conversations: selectedConversations, @@ -580,18 +556,13 @@ extension SharingThreadPickerViewController: ConversationPickerDelegate { 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.dataSource.isValidImage || $0.dataSource.isValidVideo }) - let isTextMessage = attachments.count == 1 && attachments.first.map { - $0.isConvertibleToTextMessage && $0.dataSource.dataLength <= OWSMediaUtils.kOversizeTextMessageSizeThresholdBytes - } ?? false - if !areImagesOrVideos && !isTextMessage { + if !storySelections.isEmpty { + if !self.canSendTypedItemsToStory() { // 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 @@ -620,7 +591,7 @@ extension SharingThreadPickerViewController: ConversationPickerDelegate { } func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode { - return attachments?.isEmpty != false ? .loading : .next + return typedItems.isEmpty ? .loading : .next } func conversationPickerDidBeginEditingText() {} @@ -631,9 +602,9 @@ extension SharingThreadPickerViewController: ConversationPickerDelegate { // MARK: - extension SharingThreadPickerViewController: TextApprovalViewControllerDelegate { - func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody?, linkPreviewDraft: OWSLinkPreviewDraft?) { - assert(messageBody?.text.nilIfEmpty != nil) - send(approvedLinkPreview: linkPreviewDraft, approvedMessageBody: messageBody) + func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?) { + assert(messageBody.text.nilIfEmpty != nil) + send(.text(messageBody: messageBody, linkPreview: linkPreviewDraft)) } func textApprovalDidCancel(_ textApproval: TextApprovalViewController) { @@ -662,7 +633,7 @@ extension SharingThreadPickerViewController: TextApprovalViewControllerDelegate extension SharingThreadPickerViewController: ContactShareViewControllerDelegate { func contactShareViewController(_ viewController: ContactShareViewController, didApproveContactShare contactShare: ContactShareDraft) { - send(approvedContactShare: contactShare) + send(.contact(contactShare: contactShare)) } func contactShareViewControllerDidCancel(_ viewController: ContactShareViewController) { @@ -707,7 +678,7 @@ extension SharingThreadPickerViewController: AttachmentApprovalViewControllerDel didApproveAttachments approvedAttachments: ApprovedAttachments, messageBody: MessageBody?, ) { - send(approvedAttachments: approvedAttachments, approvedMessageBody: messageBody) + send(.other(attachments: approvedAttachments, messageBody: messageBody)) } func attachmentApprovalDidCancel() { diff --git a/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift b/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift index f69307b2ce..bd02fb875e 100644 --- a/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift +++ b/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift @@ -158,9 +158,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.receivedOptions = options let pageOptions: [UIPageViewController.OptionsKey: Any] = [.interPageSpacing: kSpacingBetweenItems] - super.init(transitionStyle: .scroll, - navigationOrientation: .horizontal, - options: pageOptions) + super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: pageOptions) let isAddMoreVisibleBlock = { [weak self] in return self?.isAddMoreVisible ?? false diff --git a/SignalUI/Attachments/TypedItemProvider.swift b/SignalUI/Attachments/TypedItemProvider.swift index baeee835a1..4e57b86810 100644 --- a/SignalUI/Attachments/TypedItemProvider.swift +++ b/SignalUI/Attachments/TypedItemProvider.swift @@ -23,6 +23,41 @@ private enum ItemProviderError: Error { case fileUrlWasBplist } +// MARK: - TypedItem + +public enum TypedItem { + case text(InlineMessageText) + case contact(Data) + case other(SignalAttachment) + + public struct InlineMessageText { + public let filteredValue: FilteredString + public init?(filteredValue: FilteredString) { + guard filteredValue.rawValue.utf8.count <= OWSMediaUtils.kOversizeTextMessageSizeThresholdBytes else { + return nil + } + self.filteredValue = filteredValue + } + } + + public var isVisualMedia: Bool { + switch self { + case .text, .contact: false + case .other(let attachment): attachment.isImage || attachment.isVideo + } + } + + public var isStoriesCompatible: Bool { + switch self { + case .text: return true + case .contact: return false + case .other(let attachment): + // TODO: Consolidate with isVisualMedia after fixing validity checks. + return attachment.dataSource.isValidImage || attachment.dataSource.isValidVideo + } + } +} + // MARK: - TypedItemProvider public struct TypedItemProvider { @@ -117,6 +152,16 @@ public struct TypedItemProvider { /// to come earlier in the list than their fallbacks. private static let itemTypeOrder: [TypedItemProvider.ItemType] = [.movie, .image, .contact, .json, .plainText, .text, .pdf, .pkPass, .fileUrl, .webUrl, .data] + public static func buildVisualMediaAttachment(forItemProvider itemProvider: NSItemProvider) async throws -> SignalAttachment { + let typedItem = try await make(for: itemProvider).buildAttachment(mustBeVisualMedia: true) + switch typedItem { + case .text, .contact: + owsFail("not possible because mustBeVisualMedia is true") + case .other(let attachment): + return attachment + } + } + public static func make(for itemProvider: NSItemProvider) throws -> TypedItemProvider { for typeIdentifier in forcedDataTypeIdentifiers { if itemProvider.hasItemConformingToTypeIdentifier(typeIdentifier) { @@ -136,7 +181,7 @@ public struct TypedItemProvider { // MARK: Methods - public nonisolated func buildAttachment(progress: Progress? = nil) async throws -> SignalAttachment { + public nonisolated func buildAttachment(mustBeVisualMedia: Bool, progress: Progress? = nil) async throws -> TypedItem { // Whenever this finishes, mark its progress as fully complete. This // handles item providers that can't provide partial progress updates. defer { @@ -145,6 +190,7 @@ public struct TypedItemProvider { } } + let attachment: SignalAttachment switch itemType { case .image: // some apps send a usable file to us and some throw a UIImage at us, the UIImage can come in either directly @@ -154,67 +200,72 @@ public struct TypedItemProvider { // 2) try to load a UIImage directly in the case that is what was sent over // 3) try to NSKeyedUnarchive NSData directly into a UIImage do { - return try await buildFileAttachment(progress: progress) + attachment = try await buildFileAttachment(mustBeVisualMedia: mustBeVisualMedia, progress: progress) } catch SignalAttachmentError.couldNotParseImage, ItemProviderError.fileUrlWasBplist { Logger.warn("failed to parse image directly from file; checking for loading UIImage directly") let image: UIImage = try await loadObjectWithKeyedUnarchiverFallback( cannotLoadError: .cannotLoadUIImageObject, failedLoadError: .loadUIImageObjectFailed ) - return try Self.createAttachment(withImage: image) + attachment = try Self.createAttachment(withImage: image) } case .movie, .pdf, .data: - return try await self.buildFileAttachment(progress: progress) + attachment = try await self.buildFileAttachment(mustBeVisualMedia: mustBeVisualMedia, progress: progress) case .fileUrl, .json: let url: NSURL = try await loadObjectWithKeyedUnarchiverFallback( overrideTypeIdentifier: TypedItemProvider.ItemType.fileUrl.typeIdentifier, cannotLoadError: .cannotLoadURLObject, failedLoadError: .loadURLObjectFailed ) - let (dataSource, dataUTI) = try Self.copyFileUrl( fileUrl: url as URL, defaultTypeIdentifier: UTType.data.identifier ) - - return try await _buildFileAttachment( + attachment = try await _buildFileAttachment( dataSource: dataSource, dataUTI: dataUTI, - progress: progress + mustBeVisualMedia: mustBeVisualMedia, + progress: progress, ) case .webUrl: + if mustBeVisualMedia { + throw SignalAttachmentError.invalidFileFormat + } let url: NSURL = try await loadObjectWithKeyedUnarchiverFallback( cannotLoadError: .cannotLoadURLObject, failedLoadError: .loadURLObjectFailed ) return try Self.createAttachment(withText: (url as URL).absoluteString) case .contact: - let contactData = try await loadDataRepresentation() - let dataSource = DataSourceValue(contactData, utiType: itemType.typeIdentifier) - guard let dataSource else { - throw SignalAttachmentError.missingData + if mustBeVisualMedia { + throw SignalAttachmentError.invalidFileFormat } - let attachment = try SignalAttachment.genericAttachment(dataSource: dataSource, dataUTI: itemType.typeIdentifier) - attachment.isConvertibleToContactShare = true - return attachment + let contactData = try await loadDataRepresentation() + return .contact(contactData) case .plainText, .text: + if mustBeVisualMedia { + throw SignalAttachmentError.invalidFileFormat + } let text: NSString = try await loadObjectWithKeyedUnarchiverFallback( cannotLoadError: .cannotLoadStringObject, failedLoadError: .loadStringObjectFailed ) return try Self.createAttachment(withText: text as String) case .pkPass: + if mustBeVisualMedia { + throw SignalAttachmentError.invalidFileFormat + } let pkPass = try await loadDataRepresentation() let dataSource = DataSourceValue(pkPass, utiType: itemType.typeIdentifier) guard let dataSource else { throw SignalAttachmentError.missingData } - let attachment = try SignalAttachment.genericAttachment(dataSource: dataSource, dataUTI: itemType.typeIdentifier) - return attachment + attachment = try SignalAttachment.genericAttachment(dataSource: dataSource, dataUTI: itemType.typeIdentifier) } + return .other(attachment) } - private nonisolated func buildFileAttachment(progress: Progress?) async throws -> SignalAttachment { + private nonisolated func buildFileAttachment(mustBeVisualMedia: Bool, progress: Progress?) async throws -> SignalAttachment { let (dataSource, dataUTI): (DataSourcePath, String) = try await withCheckedThrowingContinuation { continuation in let typeIdentifier = itemType.typeIdentifier _ = itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { fileUrl, error in @@ -236,7 +287,7 @@ public struct TypedItemProvider { } } - return try await _buildFileAttachment(dataSource: dataSource, dataUTI: dataUTI, progress: progress) + return try await _buildFileAttachment(dataSource: dataSource, dataUTI: dataUTI, mustBeVisualMedia: mustBeVisualMedia, progress: progress) } private nonisolated func loadDataRepresentation( @@ -298,11 +349,21 @@ public struct TypedItemProvider { } } - private nonisolated static func createAttachment(withText text: String) throws -> SignalAttachment { - let dataSource = DataSourceValue(oversizeText: text) - let attachment = try SignalAttachment.genericAttachment(dataSource: dataSource, dataUTI: UTType.text.identifier) - attachment.isConvertibleToTextMessage = true - return attachment + private nonisolated static func createAttachment(withText text: String) throws -> TypedItem { + let filteredText = FilteredString(rawValue: text) + if let inlineMessageText = TypedItem.InlineMessageText(filteredValue: filteredText) { + return .text(inlineMessageText) + } else { + // If this is too large to send as an inline message, fall back to treating + // it as a generic attachment that happens to contain text. + return .other(try SignalAttachment.genericAttachment( + dataSource: DataSourceValue( + Data(filteredText.rawValue.utf8), + fileExtension: MimeTypeUtil.oversizeTextAttachmentFileExtension, + ), + dataUTI: UTType.text.identifier, + )) + } } private nonisolated static func createAttachment(withImage image: UIImage) throws -> SignalAttachment { @@ -339,6 +400,7 @@ public struct TypedItemProvider { private nonisolated func _buildFileAttachment( dataSource: DataSourcePath, dataUTI: String, + mustBeVisualMedia: Bool, progress: Progress? ) async throws -> SignalAttachment { if SignalAttachment.videoUTISet.contains(dataUTI) { @@ -355,6 +417,10 @@ public struct TypedItemProvider { progressPoller?.startPolling() } ) + } else if mustBeVisualMedia { + // If it's not a video but must be visual media, then we must parse it as + // an image or throw an error. + return try SignalAttachment.imageAttachment(dataSource: dataSource, dataUTI: dataUTI) } else { return try SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI) } diff --git a/SignalUI/RecipientPickers/ConversationPickerFailedRecipientsSheet.swift b/SignalUI/RecipientPickers/ConversationPickerFailedRecipientsSheet.swift index dc6f48aa7b..de3e9f79f2 100644 --- a/SignalUI/RecipientPickers/ConversationPickerFailedRecipientsSheet.swift +++ b/SignalUI/RecipientPickers/ConversationPickerFailedRecipientsSheet.swift @@ -4,24 +4,20 @@ // import Foundation -public import SignalServiceKit +import SignalServiceKit public class ConversationPickerFailedRecipientsSheet: OWSTableSheetViewController { - let failedAttachments: [SignalAttachment] let failedStoryConversationItems: [StoryConversationItem] let remainingConversationItems: [ConversationItem] let onApprove: () -> Void public init( - failedAttachments: [SignalAttachment], failedStoryConversationItems: [StoryConversationItem], remainingConversationItems: [ConversationItem], onApprove: @escaping () -> Void ) { - assert(failedAttachments.isEmpty.negated) - assert(failedStoryConversationItems.isEmpty.negated) - self.failedAttachments = failedAttachments + assert(!failedStoryConversationItems.isEmpty) self.failedStoryConversationItems = failedStoryConversationItems self.remainingConversationItems = remainingConversationItems self.onApprove = onApprove diff --git a/SignalUI/ViewControllers/TextApprovalViewController.swift b/SignalUI/ViewControllers/TextApprovalViewController.swift index f8e41e179d..1727349190 100644 --- a/SignalUI/ViewControllers/TextApprovalViewController.swift +++ b/SignalUI/ViewControllers/TextApprovalViewController.swift @@ -8,7 +8,7 @@ public import SignalServiceKit public protocol TextApprovalViewControllerDelegate: AnyObject { - func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody?, linkPreviewDraft: OWSLinkPreviewDraft?) + func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?) func textApprovalDidCancel(_ textApproval: TextApprovalViewController)