diff --git a/Signal/Attachments/PasteboardAttachment.swift b/Signal/Attachments/PasteboardAttachment.swift index b23b26933c..3eb06fec88 100644 --- a/Signal/Attachments/PasteboardAttachment.swift +++ b/Signal/Attachments/PasteboardAttachment.swift @@ -94,7 +94,7 @@ enum PasteboardAttachment { /// Returns an attachment from the pasteboard, or nil if no attachment /// can be found. @MainActor - static func loadPreviewableAttachments() async throws -> [PreviewableAttachment]? { + static func loadPreviewableAttachments(attachmentLimits: OutgoingAttachmentLimits) async throws -> [PreviewableAttachment]? { guard UIPasteboard.general.numberOfItems >= 1, let pasteboardUTITypes = UIPasteboard.general.types(forItemSet: nil) @@ -107,6 +107,7 @@ enum PasteboardAttachment { let attachment = try await loadPreviewableAttachment( atIndex: IndexSet(integer: index), pasteboardUTIs: utiSet, + attachmentLimits: attachmentLimits, retrySinglePixelImages: true, ) @@ -138,7 +139,12 @@ enum PasteboardAttachment { } @MainActor - private static func loadPreviewableAttachment(atIndex index: IndexSet, pasteboardUTIs: [String], retrySinglePixelImages: Bool) async throws -> PreviewableAttachment? { + private static func loadPreviewableAttachment( + atIndex index: IndexSet, + pasteboardUTIs: [String], + attachmentLimits: OutgoingAttachmentLimits, + retrySinglePixelImages: Bool, + ) async throws -> PreviewableAttachment? { var pasteboardUTISet = Set(filterDynamicUTITypes(pasteboardUTIs)) guard pasteboardUTISet.count > 0 else { return nil @@ -163,7 +169,12 @@ enum PasteboardAttachment { // pasteboard after a brief delay (once, then give up). if retrySinglePixelImages, (try? dataSource.imageSource())?.imageMetadata()?.pixelSize == CGSize(square: 1) { try? await Task.sleep(nanoseconds: NSEC_PER_MSEC * 50) - return try await loadPreviewableAttachment(atIndex: index, pasteboardUTIs: pasteboardUTIs, retrySinglePixelImages: false) + return try await loadPreviewableAttachment( + atIndex: index, + pasteboardUTIs: pasteboardUTIs, + attachmentLimits: attachmentLimits, + retrySinglePixelImages: false, + ) } return try PreviewableAttachment.imageAttachment(dataSource: dataSource, dataUTI: dataUTI, canBeBorderless: true) @@ -176,7 +187,7 @@ enum PasteboardAttachment { } // [15M] TODO: Don't ignore errors for pasteboard videos. - return try? await PreviewableAttachment.compressVideoAsMp4(dataSource: dataSource) + return try? await PreviewableAttachment.compressVideoAsMp4(dataSource: dataSource, attachmentLimits: attachmentLimits) } } for dataUTI in SignalAttachment.audioUTISet { @@ -184,7 +195,7 @@ enum PasteboardAttachment { guard let dataSource = buildDataSource(atIndex: index, dataUTI: dataUTI) else { return nil } - return try PreviewableAttachment.audioAttachment(dataSource: dataSource, dataUTI: dataUTI) + return try PreviewableAttachment.audioAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) } } @@ -192,7 +203,7 @@ enum PasteboardAttachment { guard let dataSource = buildDataSource(atIndex: index, dataUTI: dataUTI) else { return nil } - return try PreviewableAttachment.genericAttachment(dataSource: dataSource, dataUTI: dataUTI) + return try PreviewableAttachment.genericAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) } static func loadPreviewableStickerAttachment() throws -> PreviewableAttachment? { diff --git a/Signal/Attachments/SignalAttachmentCloner.swift b/Signal/Attachments/SignalAttachmentCloner.swift index 61578eca5d..793fb09fb9 100644 --- a/Signal/Attachments/SignalAttachmentCloner.swift +++ b/Signal/Attachments/SignalAttachmentCloner.swift @@ -8,7 +8,10 @@ import SignalServiceKit import SignalUI enum SignalAttachmentCloner { - static func cloneAsSignalAttachment(attachment: ReferencedAttachmentStream) throws -> PreviewableAttachment { + static func cloneAsSignalAttachment( + attachment: ReferencedAttachmentStream, + attachmentLimits: OutgoingAttachmentLimits, + ) throws -> PreviewableAttachment { guard let dataUTI = MimeTypeUtil.utiTypeForMimeType(attachment.attachmentStream.mimeType) else { throw OWSAssertionError("Missing dataUTI.") } @@ -24,14 +27,14 @@ enum SignalAttachmentCloner { let result: PreviewableAttachment switch attachment.reference.renderingFlag { case .default: - result = try PreviewableAttachment.buildAttachment(dataSource: decryptedDataSource, dataUTI: dataUTI) + result = try PreviewableAttachment.buildAttachment(dataSource: decryptedDataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) case .voiceMessage: - result = try PreviewableAttachment.voiceMessageAttachment(dataSource: decryptedDataSource, dataUTI: dataUTI) + result = try PreviewableAttachment.voiceMessageAttachment(dataSource: decryptedDataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) case .borderless: result = try PreviewableAttachment.imageAttachment(dataSource: decryptedDataSource, dataUTI: dataUTI) result.rawValue.isBorderless = true case .shouldLoop: - result = try PreviewableAttachment.buildAttachment(dataSource: decryptedDataSource, dataUTI: dataUTI) + result = try PreviewableAttachment.buildAttachment(dataSource: decryptedDataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) result.rawValue.isLoopingVideo = true } return result diff --git a/Signal/ConversationView/ConversationInputToolbar.swift b/Signal/ConversationView/ConversationInputToolbar.swift index 3555c8f6f3..4af5189674 100644 --- a/Signal/ConversationView/ConversationInputToolbar.swift +++ b/Signal/ConversationView/ConversationInputToolbar.swift @@ -61,7 +61,7 @@ protocol ConversationInputToolbarDelegate: AnyObject { func pollButtonPressed() - func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment) + func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) func showUnblockConversationUI(completion: ((Bool) -> Void)?) } @@ -3256,8 +3256,8 @@ extension ConversationInputToolbar: StickerKeyboardDelegate { extension ConversationInputToolbar: AttachmentKeyboardDelegate { - func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment) { - inputToolbarDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment) + func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) { + inputToolbarDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment, attachmentLimits: attachmentLimits) } func didTapPhotos() { diff --git a/Signal/ConversationView/ConversationViewController+BodyRangesTextViewDelegate.swift b/Signal/ConversationView/ConversationViewController+BodyRangesTextViewDelegate.swift index 3dfcaeab1b..3ebb322b3e 100644 --- a/Signal/ConversationView/ConversationViewController+BodyRangesTextViewDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+BodyRangesTextViewDelegate.swift @@ -40,7 +40,11 @@ extension ConversationViewController: BodyRangesTextViewDelegate { public func textViewDidInsertMemoji(_ memojiGlyph: OWSAdaptiveImageGlyph) { do { - self.didPasteAttachments([try PasteboardAttachment.loadPreviewableMemojiAttachment(fromMemojiGlyph: memojiGlyph)]) + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + self.didPasteAttachments( + [try PasteboardAttachment.loadPreviewableMemojiAttachment(fromMemojiGlyph: memojiGlyph)], + attachmentLimits: attachmentLimits, + ) } catch { self.showErrorAlert(attachmentError: error as? SignalAttachmentError) } diff --git a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift index a8c52d8048..ef6de4d113 100644 --- a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift @@ -367,15 +367,17 @@ extension ConversationViewController: ConversationInputToolbarDelegate { } @MainActor - func tryToSendAttachments( + fileprivate func tryToSendAttachments( _ approvedAttachments: ApprovedAttachments, from viewController: UIViewController, messageBody: MessageBody?, + attachmentLimits: OutgoingAttachmentLimits, ) async throws -> Bool { return try await tryToSendAttachments( approvedAttachments, messageBody: messageBody, from: viewController, + attachmentLimits: attachmentLimits, untrustedThreshold: Date().addingTimeInterval(-OWSIdentityManagerImpl.Constants.defaultUntrustedInterval), ) } @@ -392,6 +394,7 @@ extension ConversationViewController: ConversationInputToolbarDelegate { _ approvedAttachments: ApprovedAttachments, messageBody: MessageBody?, from viewController: UIViewController, + attachmentLimits: OutgoingAttachmentLimits, untrustedThreshold: Date, ) async throws -> Bool { AssertIsOnMainThread() @@ -401,7 +404,10 @@ extension ConversationViewController: ConversationInputToolbarDelegate { } let imageQuality = approvedAttachments.imageQuality - let imageQualityLevel = ImageQualityLevel.resolvedValue(imageQuality: imageQuality) + let imageQualityLevel = ImageQualityLevel.resolvedValue( + imageQuality: imageQuality, + standardQualityLevel: attachmentLimits.standardQualityLevel, + ) let sendableAttachments = try await approvedAttachments.attachments.mapAsync { return try await SendableAttachment.forPreviewableAttachment($0, imageQualityLevel: imageQualityLevel) } @@ -539,7 +545,7 @@ extension ConversationViewController: ConversationInputToolbarDelegate { present(OWSNavigationController(rootViewController: newPollViewController), animated: true) } - public func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment) { + public func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) { AssertIsOnMainThread() dismissKeyBoard() @@ -548,6 +554,7 @@ extension ConversationViewController: ConversationInputToolbarDelegate { asset: asset, attachment: attachment, hasQuotedReplyDraft: inputToolbar?.quotedReplyDraft != nil, + attachmentLimits: attachmentLimits, delegate: self, dataSource: self, ) @@ -572,7 +579,7 @@ public extension ConversationViewController { ) } - func showApprovalDialog(forAttachments attachments: [PreviewableAttachment]) { + func showApprovalDialog(forAttachments attachments: [PreviewableAttachment], attachmentLimits: OutgoingAttachmentLimits) { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { @@ -587,6 +594,7 @@ public extension ConversationViewController { attachments: attachments, initialMessageBody: inputToolbar.messageBodyForSending, hasQuotedReplyDraft: inputToolbar.quotedReplyDraft != nil, + attachmentLimits: attachmentLimits, approvalDelegate: self, approvalDataSource: self, stickerSheetDelegate: self, @@ -647,6 +655,7 @@ private extension ConversationViewController { func takePictureOrVideo() { AssertIsOnMainThread() + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() ows_askForCameraPermissions { [weak self] cameraGranted in guard let self else { return } guard cameraGranted else { @@ -663,6 +672,7 @@ private extension ConversationViewController { let pickerModal = SendMediaNavigationController.showingCameraFirst( hasQuotedReplyDraft: self.inputToolbar?.quotedReplyDraft != nil, + attachmentLimits: attachmentLimits, ) pickerModal.sendMediaNavDelegate = self pickerModal.sendMediaNavDataSource = self @@ -689,6 +699,7 @@ private extension ConversationViewController { let pickerModal = SendMediaNavigationController.showingNativePicker( hasQuotedReplyDraft: inputToolbar?.quotedReplyDraft != nil, + attachmentLimits: .currentLimits(), ) pickerModal.sendMediaNavDelegate = self pickerModal.sendMediaNavDataSource = self @@ -819,17 +830,19 @@ extension ConversationViewController: UIDocumentPickerDelegate { let contentTypeIdentifier = (resourceValues?.contentType ?? .data).identifier + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + // Although we want to be able to send higher quality attachments through // the document picker, it's more important that we ensure the sent format // is one all clients can accept (e.g., *not* QuickTime .mov). if SignalAttachment.videoUTISet.contains(contentTypeIdentifier) { - self.showApprovalDialogAfterProcessingVideo(dataSource: dataSource) + self.showApprovalDialogAfterProcessingVideo(dataSource: dataSource, attachmentLimits: attachmentLimits) return } let attachment: PreviewableAttachment do { - attachment = try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: contentTypeIdentifier) + attachment = try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: contentTypeIdentifier, attachmentLimits: attachmentLimits) } catch { DispatchQueue.main.async { self.showErrorAlert(attachmentError: error as? SignalAttachmentError) @@ -837,10 +850,10 @@ extension ConversationViewController: UIDocumentPickerDelegate { return } - showApprovalDialog(forAttachments: [attachment]) + showApprovalDialog(forAttachments: [attachment], attachmentLimits: attachmentLimits) } - private func showApprovalDialogAfterProcessingVideo(dataSource: DataSourcePath) { + private func showApprovalDialogAfterProcessingVideo(dataSource: DataSourcePath, attachmentLimits: OutgoingAttachmentLimits) { AssertIsOnMainThread() ModalActivityIndicatorViewController.present( @@ -848,9 +861,9 @@ extension ConversationViewController: UIDocumentPickerDelegate { canCancel: true, asyncBlock: { modalActivityIndicator in do { - let attachment = try await PreviewableAttachment.compressVideoAsMp4(dataSource: dataSource) + let attachment = try await PreviewableAttachment.compressVideoAsMp4(dataSource: dataSource, attachmentLimits: attachmentLimits) modalActivityIndicator.dismissIfNotCanceled(completionIfNotCanceled: { - self.showApprovalDialog(forAttachments: [attachment]) + self.showApprovalDialog(forAttachments: [attachment], attachmentLimits: attachmentLimits) }) } catch { owsFailDebug("Error: \(error).") @@ -881,6 +894,7 @@ extension ConversationViewController: SendMediaNavDelegate { approvedAttachments, messageBody: messageBody, from: sendMediaNavigationController, + attachmentLimits: sendMediaNavigationController.attachmentLimits, ) } } @@ -891,6 +905,7 @@ extension ConversationViewController: SendMediaNavDelegate { _ approvedAttachments: ApprovedAttachments, messageBody: MessageBody?, from viewController: UIViewController, + attachmentLimits: OutgoingAttachmentLimits, ) async { let didSend: Bool do { @@ -898,6 +913,7 @@ extension ConversationViewController: SendMediaNavDelegate { approvedAttachments, from: viewController, messageBody: messageBody, + attachmentLimits: attachmentLimits, ) } catch { self.showErrorAlert(attachmentError: error as? SignalAttachmentError) diff --git a/Signal/ConversationView/ConversationViewController+Delegates.swift b/Signal/ConversationView/ConversationViewController+Delegates.swift index ccbaadc53c..e82e025ef1 100644 --- a/Signal/ConversationView/ConversationViewController+Delegates.swift +++ b/Signal/ConversationView/ConversationViewController+Delegates.swift @@ -21,6 +21,7 @@ extension ConversationViewController: AttachmentApprovalViewControllerDelegate { approvedAttachments, messageBody: messageBody, from: attachmentApproval, + attachmentLimits: attachmentApproval.attachmentLimits, ) } } @@ -221,11 +222,16 @@ extension ConversationViewController: ConversationHeaderViewDelegate { extension ConversationViewController: ConversationInputTextViewDelegate { public func didAttemptAttachmentPaste() { + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + // If trying to paste a sticker, forego anything async since // the pasteboard will be cleared as soon as paste() exits. if PasteboardAttachment.hasStickerAttachment() { do { - self.didPasteAttachments([try PasteboardAttachment.loadPreviewableStickerAttachment()].compacted()) + self.didPasteAttachments( + [try PasteboardAttachment.loadPreviewableStickerAttachment()].compacted(), + attachmentLimits: attachmentLimits, + ) } catch { self.showErrorAlert(attachmentError: error as? SignalAttachmentError) } @@ -234,10 +240,10 @@ extension ConversationViewController: ConversationInputTextViewDelegate { ModalActivityIndicatorViewController.present(fromViewController: self, asyncBlock: { modal in do { - let attachments = try await PasteboardAttachment.loadPreviewableAttachments() + let attachments = try await PasteboardAttachment.loadPreviewableAttachments(attachmentLimits: attachmentLimits) modal.dismiss { // Note: attachment array might be nil at this point; that's fine. - self.didPasteAttachments(attachments) + self.didPasteAttachments(attachments, attachmentLimits: attachmentLimits) } } catch { modal.dismiss { @@ -247,7 +253,10 @@ extension ConversationViewController: ConversationInputTextViewDelegate { }) } - func didPasteAttachments(_ attachments: [PreviewableAttachment]?) { + func didPasteAttachments( + _ attachments: [PreviewableAttachment]?, + attachmentLimits: OutgoingAttachmentLimits, + ) { AssertIsOnMainThread() guard let attachments, attachments.count > 0 else { @@ -263,11 +272,12 @@ extension ConversationViewController: ConversationInputTextViewDelegate { ApprovedAttachments(nonViewOnceAttachments: [a], imageQuality: .standard), messageBody: nil, from: self, + attachmentLimits: attachmentLimits, ) } } else { dismissKeyBoard() - showApprovalDialog(forAttachments: attachments) + showApprovalDialog(forAttachments: attachments, attachmentLimits: attachmentLimits) } } diff --git a/Signal/ConversationView/ConversationViewController+VoiceMessage.swift b/Signal/ConversationView/ConversationViewController+VoiceMessage.swift index 81249a3a77..62d533af0b 100644 --- a/Signal/ConversationView/ConversationViewController+VoiceMessage.swift +++ b/Signal/ConversationView/ConversationViewController+VoiceMessage.swift @@ -105,13 +105,16 @@ extension ConversationViewController { func sendVoiceMessageDraft(_ voiceMemoDraft: VoiceMessageSendableDraft) { inputToolbar?.hideVoiceMemoUI(animated: true) + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + do { - let attachment = try voiceMemoDraft.prepareAttachment() + let attachment = try voiceMemoDraft.prepareAttachment(attachmentLimits: attachmentLimits) Task { @MainActor in await self.sendAttachments( ApprovedAttachments(nonViewOnceAttachments: [attachment], imageQuality: .standard), messageBody: nil, from: self, + attachmentLimits: attachmentLimits, ) clearVoiceMessageDraft() } diff --git a/Signal/ConversationView/VoiceMessage/VoiceMessageSendableDraft.swift b/Signal/ConversationView/VoiceMessage/VoiceMessageSendableDraft.swift index 7d02ec7ed3..a3c68f4bdb 100644 --- a/Signal/ConversationView/VoiceMessage/VoiceMessageSendableDraft.swift +++ b/Signal/ConversationView/VoiceMessage/VoiceMessageSendableDraft.swift @@ -25,12 +25,16 @@ extension VoiceMessageSendableDraft { ) } - func prepareAttachment() throws -> PreviewableAttachment { + func prepareAttachment(attachmentLimits: OutgoingAttachmentLimits) throws -> PreviewableAttachment { let attachmentUrl = try prepareForSending() let dataSource = DataSourcePath(fileUrl: attachmentUrl, ownership: .owned) dataSource.sourceFilename = userVisibleFilename(currentDate: Date()) - return try PreviewableAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: UTType.mpeg4Audio.identifier) + return try PreviewableAttachment.voiceMessageAttachment( + dataSource: dataSource, + dataUTI: UTType.mpeg4Audio.identifier, + attachmentLimits: attachmentLimits, + ) } } diff --git a/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift b/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift index 924b13e90a..3007263a82 100644 --- a/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift +++ b/Signal/Usernames/Links/UsernameLinkScanQRCodeViewController.swift @@ -161,6 +161,8 @@ extension UsernameLinkScanQRCodeViewController: PHPickerViewControllerDelegate { return } + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + Task { @MainActor in async let dismiss: Void = { @MainActor () async -> Void in await withCheckedContinuation { continuation in @@ -171,7 +173,10 @@ extension UsernameLinkScanQRCodeViewController: PHPickerViewControllerDelegate { }() do { - let attachment = try await TypedItemProvider.buildVisualMediaAttachment(forItemProvider: selectedItem.itemProvider) + let attachment = try await TypedItemProvider.buildVisualMediaAttachment( + forItemProvider: selectedItem.itemProvider, + attachmentLimits: attachmentLimits, + ) guard let image = attachment.rawValue.image(), let ciImage = CIImage(image: image) diff --git a/Signal/src/ViewControllers/Attachment Keyboard/AttachmentKeyboard.swift b/Signal/src/ViewControllers/Attachment Keyboard/AttachmentKeyboard.swift index fa9fa8f624..4b3715f0a0 100644 --- a/Signal/src/ViewControllers/Attachment Keyboard/AttachmentKeyboard.swift +++ b/Signal/src/ViewControllers/Attachment Keyboard/AttachmentKeyboard.swift @@ -8,7 +8,7 @@ import SignalServiceKit import SignalUI protocol AttachmentKeyboardDelegate: AnyObject { - func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment) + func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) func didTapPhotos() func didTapGif() func didTapFile() @@ -123,8 +123,8 @@ class AttachmentKeyboard: CustomKeyboard { extension AttachmentKeyboard: RecentPhotosDelegate { - func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment) { - delegate?.didSelectRecentPhoto(asset: asset, attachment: attachment) + func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) { + delegate?.didSelectRecentPhoto(asset: asset, attachment: attachment, attachmentLimits: attachmentLimits) } } diff --git a/Signal/src/ViewControllers/Attachment Keyboard/RecentPhotoCollectionView.swift b/Signal/src/ViewControllers/Attachment Keyboard/RecentPhotoCollectionView.swift index 6c804ad648..96cf5edefb 100644 --- a/Signal/src/ViewControllers/Attachment Keyboard/RecentPhotoCollectionView.swift +++ b/Signal/src/ViewControllers/Attachment Keyboard/RecentPhotoCollectionView.swift @@ -9,7 +9,7 @@ import SignalServiceKit import SignalUI protocol RecentPhotosDelegate: AnyObject { - func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment) + func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) } class RecentPhotosCollectionView: UICollectionView { @@ -256,13 +256,14 @@ extension RecentPhotosCollectionView: UICollectionViewDelegate, UICollectionView self.fetchingAttachmentIndex = indexPath let asset = collectionContents.asset(at: indexPath.item) + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() Task { defer { self.fetchingAttachmentIndex = nil } do { - let attachment = try await collectionContents.outgoingAttachment(for: asset) - self.recentPhotosDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment) + let attachment = try await collectionContents.outgoingAttachment(for: asset, attachmentLimits: attachmentLimits) + self.recentPhotosDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment, attachmentLimits: attachmentLimits) } catch { Logger.warn("\(error)") switch error { diff --git a/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift b/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift index 18c87a6abc..3b93c0166f 100644 --- a/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift +++ b/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift @@ -26,10 +26,12 @@ class CameraFirstCaptureSendFlow { private var selectedConversations: [ConversationItem] { selection.conversations } private let storiesOnly: Bool + private let attachmentLimits: OutgoingAttachmentLimits private var showsStoriesInPicker = true - init(storiesOnly: Bool, delegate: CameraFirstCaptureDelegate) { + init(storiesOnly: Bool, attachmentLimits: OutgoingAttachmentLimits, delegate: CameraFirstCaptureDelegate) { self.storiesOnly = storiesOnly + self.attachmentLimits = attachmentLimits self.delegate = delegate } @@ -167,12 +169,13 @@ extension CameraFirstCaptureSendFlow: ConversationPickerDelegate { if let approvedAttachments { let approvedMessageBody = self.approvedMessageBody let selectedConversations = self.selectedConversations - Task { @MainActor in + Task { @MainActor [attachmentLimits] in do { _ = try await AttachmentMultisend.enqueueApprovedMedia( conversations: selectedConversations, approvedMessageBody: approvedMessageBody, approvedAttachments: approvedAttachments, + attachmentLimits: attachmentLimits, ) self.delegate?.cameraFirstCaptureSendFlowDidComplete(self) } catch { diff --git a/Signal/src/ViewControllers/ForwardMessageViewController.swift b/Signal/src/ViewControllers/ForwardMessageViewController.swift index 53d939d2ef..232af6905e 100644 --- a/Signal/src/ViewControllers/ForwardMessageViewController.swift +++ b/Signal/src/ViewControllers/ForwardMessageViewController.swift @@ -20,14 +20,20 @@ class ForwardMessageViewController: OWSNavigationController { private typealias Content = ForwardMessageContent private var content: Content + private let attachmentLimits: OutgoingAttachmentLimits private var textMessage: String? private let selection = ConversationPickerSelection() var selectedConversations: [ConversationItem] { selection.conversations } - private init(content: Content) { + private init( + content: Content, + attachmentLimits: OutgoingAttachmentLimits, + ) { self.content = content + self.attachmentLimits = attachmentLimits + self.pickerVC = ConversationPickerViewController( selection: selection, overrideTitle: OWSLocalizedString( @@ -71,11 +77,12 @@ class ForwardMessageViewController: OWSNavigationController { from fromViewController: UIViewController, delegate: ForwardMessageDelegate, ) { + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() do { let content: Content = try SSKEnvironment.shared.databaseStorageRef.read { tx in - return try Content.build(itemViewModel: itemViewModel, tx: tx) + return try Content.build(itemViewModel: itemViewModel, attachmentLimits: attachmentLimits, tx: tx) } - present(content: content, from: fromViewController, delegate: delegate) + present(content: content, from: fromViewController, attachmentLimits: attachmentLimits, delegate: delegate) } catch { ForwardMessageViewController.showAlertForForwardError(error: error, forwardedInteractionCount: 1) } @@ -86,11 +93,12 @@ class ForwardMessageViewController: OWSNavigationController { from fromViewController: UIViewController, delegate: ForwardMessageDelegate, ) { + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() do { let content: Content = try SSKEnvironment.shared.databaseStorageRef.read { tx in - try Content.build(selectionItems: selectionItems, tx: tx) + try Content.build(selectionItems: selectionItems, attachmentLimits: attachmentLimits, tx: tx) } - present(content: content, from: fromViewController, delegate: delegate) + present(content: content, from: fromViewController, attachmentLimits: attachmentLimits, delegate: delegate) } catch { ForwardMessageViewController.showAlertForForwardError(error: error, forwardedInteractionCount: selectionItems.count) } @@ -102,13 +110,15 @@ class ForwardMessageViewController: OWSNavigationController { from fromViewController: UIViewController, delegate: ForwardMessageDelegate, ) { + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() do { let attachments = try attachmentStreams.map { attachmentStream in - try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachmentStream) + try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachmentStream, attachmentLimits: attachmentLimits) } present( content: ForwardMessageContent(allItems: [ForwardMessageItem(interaction: message, attachments: attachments)]), from: fromViewController, + attachmentLimits: attachmentLimits, delegate: delegate, ) } catch let error { @@ -124,6 +134,7 @@ class ForwardMessageViewController: OWSNavigationController { from fromViewController: UIViewController, delegate: ForwardMessageDelegate, ) { + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() var attachments: [PreviewableAttachment] = [] var textAttachment: TextAttachment? switch storyMessage.attachment { @@ -144,7 +155,7 @@ class ForwardMessageViewController: OWSNavigationController { guard let attachment else { throw OWSAssertionError("Missing attachment stream for forwarded story message") } - let signalAttachment = try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachment) + let signalAttachment = try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachment, attachmentLimits: attachmentLimits) attachments = [signalAttachment] } catch let error { ForwardMessageViewController.showAlertForForwardError( @@ -159,6 +170,7 @@ class ForwardMessageViewController: OWSNavigationController { present( content: ForwardMessageContent(allItems: [ForwardMessageItem(attachments: attachments, textAttachment: textAttachment)]), from: fromViewController, + attachmentLimits: attachmentLimits, delegate: delegate, ) } @@ -171,6 +183,7 @@ class ForwardMessageViewController: OWSNavigationController { present( content: ForwardMessageContent(allItems: [ForwardMessageItem(messageBody: messageBody)]), from: fromViewController, + attachmentLimits: .currentLimits(), delegate: delegate, ) } @@ -178,9 +191,10 @@ class ForwardMessageViewController: OWSNavigationController { private class func present( content: Content, from fromViewController: UIViewController, + attachmentLimits: OutgoingAttachmentLimits, delegate: ForwardMessageDelegate, ) { - let sheet = ForwardMessageViewController(content: content) + let sheet = ForwardMessageViewController(content: content, attachmentLimits: attachmentLimits) sheet.forwardMessageDelegate = delegate fromViewController.present(sheet, animated: true) { UIApplication.shared.hideKeyboard() @@ -346,6 +360,7 @@ extension ForwardMessageViewController { conversations: conversations, approvedMessageBody: item.messageBody, approvedAttachments: ApprovedAttachments(nonViewOnceAttachments: item.attachments, imageQuality: .high), + attachmentLimits: attachmentLimits, ) } else if let textAttachment = item.textAttachment { // TODO: we want to reuse the uploaded link preview image attachment instead of re-uploading @@ -569,6 +584,7 @@ struct ForwardMessageItem { interaction: TSInteraction, componentState: CVComponentState, selectionType: CVSelectionType, + attachmentLimits: OutgoingAttachmentLimits, transaction: DBReadTransaction, ) throws -> Self { let shouldHaveText = (selectionType == .allContent || selectionType == .secondaryContent) @@ -622,7 +638,7 @@ struct ForwardMessageItem { } attachments = try attachmentStreams.map { attachmentStream in - try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachmentStream) + try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachmentStream, attachmentLimits: attachmentLimits) } stickerMetadata = componentState.stickerMetadata @@ -742,18 +758,21 @@ private struct ForwardMessageContent { static func build( itemViewModel: CVItemViewModelImpl, + attachmentLimits: OutgoingAttachmentLimits, tx: DBReadTransaction, ) throws -> Self { return Self(allItems: [try ForwardMessageItem.build( interaction: itemViewModel.interaction, componentState: itemViewModel.renderItem.componentState, selectionType: .allContent, + attachmentLimits: attachmentLimits, transaction: tx, )]) } static func build( selectionItems: [CVSelectionItem], + attachmentLimits: OutgoingAttachmentLimits, tx: DBReadTransaction, ) throws -> Self { let items = try selectionItems.map { selectionItem throws -> ForwardMessageItem in @@ -766,6 +785,7 @@ private struct ForwardMessageContent { interaction: interaction, componentState: componentState, selectionType: selectionItem.selectionType, + attachmentLimits: attachmentLimits, transaction: tx, ) } diff --git a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift index 147ca6ab3f..a2f06219ec 100644 --- a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift +++ b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift @@ -31,12 +31,13 @@ class GifPickerNavigationViewController: OWSNavigationController { } extension GifPickerNavigationViewController: GifPickerViewControllerDelegate { - func gifPickerDidSelect(attachment: PreviewableAttachment) { + func gifPickerDidSelect(attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) { AssertIsOnMainThread() let attachmentApprovalItem = AttachmentApprovalItem(attachment: attachment, canSave: false) let attachmentApproval = AttachmentApprovalViewController.loadWithSneakyTransaction( attachmentApprovalItems: [attachmentApprovalItem], + attachmentLimits: attachmentLimits, options: self.hasQuotedReplyDraft ? [.disallowViewOnce] : [], ) attachmentApproval.approvalDataSource = self @@ -60,7 +61,11 @@ extension GifPickerNavigationViewController: AttachmentApprovalViewControllerDel didApproveAttachments approvedAttachments: ApprovedAttachments, messageBody: MessageBody?, ) { - approvalDelegate?.attachmentApproval(attachmentApproval, didApproveAttachments: approvedAttachments, messageBody: messageBody) + approvalDelegate?.attachmentApproval( + attachmentApproval, + didApproveAttachments: approvedAttachments, + messageBody: messageBody, + ) } func attachmentApprovalDidCancel() { @@ -99,7 +104,7 @@ extension GifPickerNavigationViewController: AttachmentApprovalViewControllerDat protocol GifPickerViewControllerDelegate: AnyObject { @MainActor - func gifPickerDidSelect(attachment: PreviewableAttachment) + func gifPickerDidSelect(attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) @MainActor func gifPickerDidCancel() } @@ -495,12 +500,14 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect func getFileForCell(_ cell: GifPickerCell) { GiphyDownloader.giphyDownloader.cancelAllRequests() + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + fileForCellTask?.cancel() fileForCellTask = Task { do { let asset = try await cell.requestRenditionForSending() - let attachment = try await buildAttachment(forAsset: asset) - self.delegate?.gifPickerDidSelect(attachment: attachment) + let attachment = try await buildAttachment(forAsset: asset, attachmentLimits: attachmentLimits) + self.delegate?.gifPickerDidSelect(attachment: attachment, attachmentLimits: attachmentLimits) } catch { let alert = ActionSheetController( title: OWSLocalizedString("GIF_PICKER_FAILURE_ALERT_TITLE", comment: "Shown when selected GIF couldn't be fetched"), @@ -518,7 +525,10 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect } @concurrent - private nonisolated func buildAttachment(forAsset asset: ProxiedContentAsset) async throws -> PreviewableAttachment { + private nonisolated func buildAttachment( + forAsset asset: ProxiedContentAsset, + attachmentLimits: OutgoingAttachmentLimits, + ) async throws -> PreviewableAttachment { guard let giphyAsset = asset.assetDescription as? GiphyAsset else { throw OWSAssertionError("Invalid asset description.") } @@ -531,7 +541,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect try FileManager.default.copyItem(atPath: assetFilePath, toPath: consumableFilePath) let dataSource = DataSourcePath(filePath: consumableFilePath, ownership: .owned) - let attachment = try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: assetTypeIdentifier) + let attachment = try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: assetTypeIdentifier, attachmentLimits: attachmentLimits) attachment.rawValue.isLoopingVideo = attachment.isVideo return attachment } diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Camera.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Camera.swift index aa600d2a45..9d6d7cc49a 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Camera.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Camera.swift @@ -17,6 +17,8 @@ extension ChatListViewController: CameraFirstCaptureDelegate { // Dismiss any message actions if they're presented conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true) + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + ows_askForCameraPermissions { cameraAccessGranted in guard cameraAccessGranted else { Logger.warn("Camera permission denied") @@ -29,7 +31,11 @@ extension ChatListViewController: CameraFirstCaptureDelegate { Logger.warn("Proceeding with no microphone access.") } - let cameraModal = CameraFirstCaptureNavigationController.cameraFirstModal(hasQuotedReplyDraft: false, delegate: self) + let cameraModal = CameraFirstCaptureNavigationController.cameraFirstModal( + hasQuotedReplyDraft: false, + attachmentLimits: attachmentLimits, + delegate: self, + ) cameraModal.modalPresentationStyle = .overFullScreen // Defer hiding status bar until modal is fully onscreen diff --git a/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift index eb78749d21..7c50641b85 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/StoriesViewController.swift @@ -259,6 +259,8 @@ class StoriesViewController: OWSViewController, StoryListDataSourceDelegate, Hom // Dismiss any message actions if they're presented conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true) + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + ows_askForCameraPermissions { cameraGranted in guard cameraGranted else { return Logger.warn("camera permission denied.") @@ -273,6 +275,7 @@ class StoriesViewController: OWSViewController, StoryListDataSourceDelegate, Hom let modal = CameraFirstCaptureNavigationController.cameraFirstModal( storiesOnly: true, hasQuotedReplyDraft: false, + attachmentLimits: attachmentLimits, delegate: self, ) self.presentFullScreen(modal, animated: true) diff --git a/Signal/src/ViewControllers/Photos/CameraCaptureSession.swift b/Signal/src/ViewControllers/Photos/CameraCaptureSession.swift index 0170026bc9..d2ecf53c2a 100644 --- a/Signal/src/ViewControllers/Photos/CameraCaptureSession.swift +++ b/Signal/src/ViewControllers/Photos/CameraCaptureSession.swift @@ -61,6 +61,7 @@ protocol CameraCaptureSessionDelegate: AnyObject { class CameraCaptureSession: NSObject { private weak var delegate: CameraCaptureSessionDelegate? + private let attachmentLimits: OutgoingAttachmentLimits // There can only ever be one `CapturePreviewView` per AVCaptureSession lazy var previewView = CapturePreviewView(session: avCaptureSession) @@ -84,12 +85,13 @@ class CameraCaptureSession: NSObject { init( delegate: CameraCaptureSessionDelegate, - maxPlaintextVideoBytes: UInt64, + attachmentLimits: OutgoingAttachmentLimits, qrCodeSampleBufferScanner: QRCodeSampleBufferScanner, ) { self.delegate = delegate + self.attachmentLimits = attachmentLimits self.videoCapture = VideoCapture( - maxPlaintextVideoBytes: maxPlaintextVideoBytes, + attachmentLimits: attachmentLimits, qrCodeSampleBufferScanner: qrCodeSampleBufferScanner, ) @@ -838,6 +840,7 @@ class CameraCaptureSession: NSObject { let attachment = try PreviewableAttachment.videoAttachment( dataSource: dataSource, dataUTI: UTType.mpeg4Movie.identifier, + attachmentLimits: self.attachmentLimits, ) delegate?.cameraCaptureSession(self, didFinishProcessing: attachment) } @@ -1144,7 +1147,7 @@ private enum VideoCaptureError: Error { private class VideoCapture: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate { - private let maxPlaintextVideoBytes: UInt64 + private let attachmentLimits: OutgoingAttachmentLimits private let qrCodeSampleBufferScanner: QRCodeSampleBufferScanner let videoDataOutput = AVCaptureVideoDataOutput() @@ -1179,8 +1182,8 @@ private class VideoCapture: NSObject, AVCaptureVideoDataOutputSampleBufferDelega } } - init(maxPlaintextVideoBytes: UInt64, qrCodeSampleBufferScanner: QRCodeSampleBufferScanner) { - self.maxPlaintextVideoBytes = maxPlaintextVideoBytes + init(attachmentLimits: OutgoingAttachmentLimits, qrCodeSampleBufferScanner: QRCodeSampleBufferScanner) { + self.attachmentLimits = attachmentLimits self.qrCodeSampleBufferScanner = qrCodeSampleBufferScanner super.init() @@ -1409,7 +1412,7 @@ private class VideoCapture: NSObject, AVCaptureVideoDataOutputSampleBufferDelega if shouldCheckFileSize, let fileSize = (try? OWSFileSystem.fileSize(of: assetWriter.outputURL)), - (fileSize + estimatedTeardownOverhead) >= self.maxPlaintextVideoBytes + (fileSize + estimatedTeardownOverhead) >= self.attachmentLimits.maxPlaintextVideoBytes { Logger.warn("stopping recording before hitting max file size") needsFinishAssetWriterSession = true diff --git a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift index c23cfd7c9e..db8ebdc32b 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift @@ -37,6 +37,12 @@ protocol PhotoCaptureViewControllerDataSource: AnyObject { } class PhotoCaptureViewController: OWSViewController, OWSNavigationChildController { + private let attachmentLimits: OutgoingAttachmentLimits + + init(attachmentLimits: OutgoingAttachmentLimits) { + self.attachmentLimits = attachmentLimits + super.init() + } weak var delegate: PhotoCaptureViewControllerDelegate? weak var dataSource: PhotoCaptureViewControllerDataSource? @@ -45,7 +51,7 @@ class PhotoCaptureViewController: OWSViewController, OWSNavigationChildControlle private lazy var qrCodeSampleBufferScanner = QRCodeSampleBufferScanner(delegate: self) private lazy var cameraCaptureSession = CameraCaptureSession( delegate: self, - maxPlaintextVideoBytes: OutgoingAttachmentLimits.currentLimits().maxPlaintextVideoBytes, + attachmentLimits: attachmentLimits, qrCodeSampleBufferScanner: qrCodeSampleBufferScanner, ) diff --git a/Signal/src/ViewControllers/Photos/PhotoLibrary.swift b/Signal/src/ViewControllers/Photos/PhotoLibrary.swift index b3e53216b0..0da649e36b 100644 --- a/Signal/src/ViewControllers/Photos/PhotoLibrary.swift +++ b/Signal/src/ViewControllers/Photos/PhotoLibrary.swift @@ -136,14 +136,14 @@ class PhotoAlbumContents { } } - func outgoingAttachment(for asset: PHAsset) async throws -> PreviewableAttachment { + func outgoingAttachment(for asset: PHAsset, attachmentLimits: OutgoingAttachmentLimits) async throws -> PreviewableAttachment { switch asset.mediaType { case .image: let (dataSource, dataUTI) = try await requestImageDataSource(for: asset) return try PreviewableAttachment.imageAttachment(dataSource: dataSource, dataUTI: dataUTI) case .video: let video = try await requestVideoDataSource(for: asset) - return try await PreviewableAttachment.compressVideoAsMp4(asset: video, baseFilename: nil) + return try await PreviewableAttachment.compressVideoAsMp4(asset: video, baseFilename: nil, attachmentLimits: attachmentLimits) case .unknown, .audio: fallthrough @unknown default: diff --git a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift index 5278118452..17968539f6 100644 --- a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift +++ b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift @@ -13,7 +13,11 @@ protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments approvedAttachments: ApprovedAttachments, messageBody: MessageBody?) + func sendMediaNav( + _ sendMediaNavigationController: SendMediaNavigationController, + didApproveAttachments approvedAttachments: ApprovedAttachments, + messageBody: MessageBody?, + ) func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didFinishWithTextAttachment textAttachment: UnsentTextAttachment) @@ -48,12 +52,17 @@ class CameraFirstCaptureNavigationController: SendMediaNavigationController { class func cameraFirstModal( storiesOnly: Bool = false, hasQuotedReplyDraft: Bool, + attachmentLimits: OutgoingAttachmentLimits, delegate: CameraFirstCaptureDelegate, ) -> CameraFirstCaptureNavigationController { - let navController = CameraFirstCaptureNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft) + let navController = CameraFirstCaptureNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft, attachmentLimits: attachmentLimits) navController.setViewControllers([navController.captureViewController], animated: false) - let cameraFirstCaptureSendFlow = CameraFirstCaptureSendFlow(storiesOnly: storiesOnly, delegate: delegate) + let cameraFirstCaptureSendFlow = CameraFirstCaptureSendFlow( + storiesOnly: storiesOnly, + attachmentLimits: attachmentLimits, + delegate: delegate, + ) navController.cameraFirstCaptureSendFlow = cameraFirstCaptureSendFlow navController.sendMediaNavDelegate = cameraFirstCaptureSendFlow navController.sendMediaNavDataSource = cameraFirstCaptureSendFlow @@ -74,9 +83,11 @@ class SendMediaNavigationController: OWSNavigationController { fileprivate var storiesOnly: Bool = false private let hasQuotedReplyDraft: Bool + let attachmentLimits: OutgoingAttachmentLimits - fileprivate init(hasQuotedReplyDraft: Bool) { + fileprivate init(hasQuotedReplyDraft: Bool, attachmentLimits: OutgoingAttachmentLimits) { self.hasQuotedReplyDraft = hasQuotedReplyDraft + self.attachmentLimits = attachmentLimits super.init() } @@ -91,8 +102,8 @@ class SendMediaNavigationController: OWSNavigationController { weak var sendMediaNavDelegate: SendMediaNavDelegate? weak var sendMediaNavDataSource: SendMediaNavDataSource? - class func showingCameraFirst(hasQuotedReplyDraft: Bool) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft) + class func showingCameraFirst(hasQuotedReplyDraft: Bool, attachmentLimits: OutgoingAttachmentLimits) -> SendMediaNavigationController { + let navController = SendMediaNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft, attachmentLimits: attachmentLimits) navController.setViewControllers([navController.captureViewController], animated: false) return navController } @@ -107,13 +118,13 @@ class SendMediaNavigationController: OWSNavigationController { return config } - class func showingNativePicker(hasQuotedReplyDraft: Bool) -> SendMediaNavigationController { + class func showingNativePicker(hasQuotedReplyDraft: Bool, attachmentLimits: OutgoingAttachmentLimits) -> SendMediaNavigationController { // We want to present the photo picker in a sheet and then have the // editor appear behind it after you select photos, so present this // navigation controller as transparent with an empty view, then when // you select photos, `showApprovalViewController` will make it appear // behind the dismissing sheet and transition to the editor. - let navController = SendMediaNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft) + let navController = SendMediaNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft, attachmentLimits: attachmentLimits) navController.pushViewController(UIViewController(), animated: false) navController.view.layer.opacity = 0 navController.modalPresentationStyle = .overCurrentContext @@ -141,10 +152,11 @@ class SendMediaNavigationController: OWSNavigationController { asset: PHAsset, attachment: PreviewableAttachment, hasQuotedReplyDraft: Bool, + attachmentLimits: OutgoingAttachmentLimits, delegate: SendMediaNavDelegate, dataSource: SendMediaNavDataSource, ) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft) + let navController = SendMediaNavigationController(hasQuotedReplyDraft: hasQuotedReplyDraft, attachmentLimits: attachmentLimits) navController.sendMediaNavDelegate = delegate navController.sendMediaNavDataSource = dataSource navController.modalPresentationStyle = .overCurrentContext @@ -175,7 +187,7 @@ class SendMediaNavigationController: OWSNavigationController { // MARK: - Child View Controllers fileprivate lazy var captureViewController: PhotoCaptureViewController = { - let viewController = PhotoCaptureViewController() + let viewController = PhotoCaptureViewController(attachmentLimits: attachmentLimits) viewController.delegate = self viewController.dataSource = self return viewController @@ -208,6 +220,7 @@ class SendMediaNavigationController: OWSNavigationController { } let approvalViewController = AttachmentApprovalViewController.loadWithSneakyTransaction( attachmentApprovalItems: pendingAttachments.map(\.approvalItem), + attachmentLimits: attachmentLimits, options: options, ) approvalViewController.approvalDelegate = self @@ -408,8 +421,11 @@ extension SendMediaNavigationController: PHPickerViewControllerDelegate { } } else { didAddAttachments = true - return { - let attachment = try await TypedItemProvider.buildVisualMediaAttachment(forItemProvider: result.itemProvider) + return { [attachmentLimits] in + let attachment = try await TypedItemProvider.buildVisualMediaAttachment( + forItemProvider: result.itemProvider, + attachmentLimits: attachmentLimits, + ) let approvalItem = AttachmentApprovalItem(attachment: attachment, canSave: false) return PendingAttachment(source: .systemLibrary(systemIdentifier: assetIdentifier), approvalItem: approvalItem) } @@ -454,8 +470,11 @@ extension SendMediaNavigationController: PHPickerViewControllerDelegate { continue } didAddAttachments = true - resolvablePendingAttachments.append({ - let attachment = try await TypedItemProvider.buildVisualMediaAttachment(forItemProvider: result.itemProvider) + resolvablePendingAttachments.append({ [attachmentLimits] in + let attachment = try await TypedItemProvider.buildVisualMediaAttachment( + forItemProvider: result.itemProvider, + attachmentLimits: attachmentLimits, + ) let approvalItem = AttachmentApprovalItem(attachment: attachment, canSave: false) return PendingAttachment(source: .systemLibrary(systemIdentifier: assetIdentifier), approvalItem: approvalItem) }) diff --git a/SignalServiceKit/Attachments/AttachmentLimits.swift b/SignalServiceKit/Attachments/AttachmentLimits.swift index 1fca98f2d3..d1ff163bda 100644 --- a/SignalServiceKit/Attachments/AttachmentLimits.swift +++ b/SignalServiceKit/Attachments/AttachmentLimits.swift @@ -36,13 +36,21 @@ public struct IncomingAttachmentLimits { /// Limits imposed on attachments we send to others. public struct OutgoingAttachmentLimits { private let remoteConfig: RemoteConfig + private let callingCode: Int? - public static func currentLimits(remoteConfig: RemoteConfig = .current) -> Self { - return Self(remoteConfig: remoteConfig) + public static func currentLimits( + remoteConfig: RemoteConfig = .current, + callingCode: Int? = ImageQualityLevel.defaultCallingCode(), + ) -> Self { + return Self(remoteConfig: remoteConfig, callingCode: callingCode) } - init(remoteConfig: RemoteConfig) { + init( + remoteConfig: RemoteConfig, + callingCode: Int?, + ) { self.remoteConfig = remoteConfig + self.callingCode = callingCode } // MARK: - Overall @@ -68,4 +76,11 @@ public struct OutgoingAttachmentLimits { } return maxPlaintextBytes } + + public var standardQualityLevel: ImageQualityLevel { + return ImageQualityLevel.standardQualityLevel( + remoteConfig: remoteConfig, + callingCode: callingCode, + ) + } } diff --git a/SignalServiceKit/Environment/RemoteConfigManager.swift b/SignalServiceKit/Environment/RemoteConfigManager.swift index abbc083508..0e86fdf243 100644 --- a/SignalServiceKit/Environment/RemoteConfigManager.swift +++ b/SignalServiceKit/Environment/RemoteConfigManager.swift @@ -610,8 +610,8 @@ private enum ValueFlag: String, FlagType { var isHotSwappable: Bool { switch self { case .applePayDisabledRegions: true - case .attachmentMaxEncryptedBytes: false - case .attachmentMaxEncryptedReceiveBytes: false + case .attachmentMaxEncryptedBytes: true + case .attachmentMaxEncryptedReceiveBytes: true case .automaticSessionResetAttemptInterval: true case .backgroundRefreshInterval: true case .callQualitySurveyPPM: true @@ -633,7 +633,7 @@ private enum ValueFlag: String, FlagType { case .reactiveProfileKeyAttemptInterval: true case .replaceableInteractionExpiration: false case .sepaEnabledRegions: true - case .standardMediaQualityLevel: false + case .standardMediaQualityLevel: true case .backupListMediaDefaultRefreshIntervalMs: true case .backupListMediaOutOfQuotaRefreshIntervalMs: true case .pinnedMessageLimit: true diff --git a/SignalServiceKit/Util/ImageQuality.swift b/SignalServiceKit/Util/ImageQuality.swift index 4df8e127b8..34d1f2261b 100644 --- a/SignalServiceKit/Util/ImageQuality.swift +++ b/SignalServiceKit/Util/ImageQuality.swift @@ -55,17 +55,19 @@ public enum ImageQualityLevel: UInt, Comparable { // High quality is always level three. If not remotely specified, standard // uses quality level two. public static func standardQualityLevel( - remoteConfig: RemoteConfig = .current, - callingCode: Int? = { () -> Int? in - let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef - let tsAccountManager = DependenciesBridge.shared.tsAccountManager - let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction - return localIdentifiers.flatMap({ phoneNumberUtil.parseE164($0.phoneNumber) })?.getCallingCode() - }(), + remoteConfig: RemoteConfig, + callingCode: Int?, ) -> ImageQualityLevel { return remoteConfig.standardMediaQualityLevel(callingCode: callingCode) ?? .two } + public static func defaultCallingCode( + phoneNumberUtil: PhoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef, + localIdentifiers: LocalIdentifiers? = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction, + ) -> Int? { + return localIdentifiers.flatMap({ phoneNumberUtil.parseE164($0.phoneNumber) })?.getCallingCode() + } + public var startingTier: ImageQualityTier { switch self { case .one: return .four @@ -109,7 +111,7 @@ public enum ImageQualityLevel: UInt, Comparable { public static func resolvedValue( imageQuality: ImageQuality, - standardQualityLevel: @autoclosure () -> Self = .standardQualityLevel(), + standardQualityLevel: Self, maximumForCurrentAppContext: Self = .maximumForCurrentAppContext(), ) -> ImageQualityLevel { let targetQualityLevel: Self @@ -117,7 +119,7 @@ public enum ImageQualityLevel: UInt, Comparable { case .high: targetQualityLevel = .three case .standard: - targetQualityLevel = standardQualityLevel() + targetQualityLevel = standardQualityLevel } // If the max quality we allow is less than the stored preference, // we have to restrict ourselves to the max allowed. diff --git a/SignalShareExtension/ShareViewController.swift b/SignalShareExtension/ShareViewController.swift index 624caee286..2189be81e8 100644 --- a/SignalShareExtension/ShareViewController.swift +++ b/SignalShareExtension/ShareViewController.swift @@ -151,9 +151,12 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager self.connectionTokens.append(chatConnectionManager.requestUnidentifiedConnection()) + let attachmentLimits = OutgoingAttachmentLimits.currentLimits() + let conversationPicker: SharingThreadPickerViewController conversationPicker = SharingThreadPickerViewController( areAttachmentStoriesCompatPrecheck: typedItemProviders.allSatisfy { $0.isStoriesCompatible }, + attachmentLimits: attachmentLimits, shareViewDelegate: self, ) @@ -202,6 +205,7 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA }() typedItems = try await buildAndValidateAttachments( for: typedItemProviders, + attachmentLimits: attachmentLimits, setProgress: { loadViewControllerForProgress?.progress = $0 }, ) } catch { @@ -382,6 +386,7 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA private func buildAndValidateAttachments( for typedItemProviders: [TypedItemProvider], + attachmentLimits: OutgoingAttachmentLimits, setProgress: @MainActor (Progress) -> Void, ) async throws -> [TypedItem] { let progress = Progress(totalUnitCount: Int64(typedItemProviders.count)) @@ -394,7 +399,7 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA setProgress(progress) - let typedItems = try await self.buildAttachments(for: itemsAndProgresses) + let typedItems = try await self.buildAttachments(for: itemsAndProgresses, attachmentLimits: attachmentLimits) try Task.checkCancellation() // Make sure the user is not trying to share more than our attachment limit. @@ -461,11 +466,14 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA throw ShareViewControllerError.noConformingInputItem } - private nonisolated func buildAttachments(for itemsAndProgresses: [(TypedItemProvider, Progress)]) async throws -> [TypedItem] { + private nonisolated func buildAttachments( + for itemsAndProgresses: [(TypedItemProvider, Progress)], + attachmentLimits: OutgoingAttachmentLimits, + ) 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: [TypedItem] = [] for (typedItemProvider, progress) in itemsAndProgresses { - result.append(try await typedItemProvider.buildAttachment(progress: progress)) + result.append(try await typedItemProvider.buildAttachment(attachmentLimits: attachmentLimits, progress: progress)) } return result } diff --git a/SignalShareExtension/SharingThreadPickerViewController.swift b/SignalShareExtension/SharingThreadPickerViewController.swift index bb2457b3e5..c12f40bc5c 100644 --- a/SignalShareExtension/SharingThreadPickerViewController.swift +++ b/SignalShareExtension/SharingThreadPickerViewController.swift @@ -26,6 +26,8 @@ class SharingThreadPickerViewController: ConversationPickerViewController { /// actually sending if stories are selected. let areAttachmentStoriesCompatPrecheck: Bool + private let attachmentLimits: OutgoingAttachmentLimits + var typedItems: [TypedItem] { didSet { owsPrecondition(typedItems.count <= 1 || typedItems.allSatisfy(\.isVisualMedia)) @@ -38,9 +40,14 @@ class SharingThreadPickerViewController: ConversationPickerViewController { private var selectedConversations: [ConversationItem] { selection.conversations } - init(areAttachmentStoriesCompatPrecheck: Bool, shareViewDelegate: ShareViewDelegate) { + init( + areAttachmentStoriesCompatPrecheck: Bool, + attachmentLimits: OutgoingAttachmentLimits, + shareViewDelegate: ShareViewDelegate, + ) { self.typedItems = [] self.areAttachmentStoriesCompatPrecheck = areAttachmentStoriesCompatPrecheck + self.attachmentLimits = attachmentLimits self.shareViewDelegate = shareViewDelegate super.init(selection: ConversationPickerSelection()) @@ -165,7 +172,11 @@ class SharingThreadPickerViewController: ConversationPickerViewController { if self.selection.conversations.contains(where: \.isStory) { approvalVCOptions.insert(.disallowViewOnce) } - let approvalView = AttachmentApprovalViewController.loadWithSneakyTransaction(attachmentApprovalItems: approvalItems, options: approvalVCOptions) + let approvalView = AttachmentApprovalViewController.loadWithSneakyTransaction( + attachmentApprovalItems: approvalItems, + attachmentLimits: attachmentLimits, + options: approvalVCOptions, + ) approvalVC = approvalView approvalView.approvalDelegate = self approvalView.approvalDataSource = self @@ -287,6 +298,7 @@ class SharingThreadPickerViewController: ConversationPickerViewController { conversations: selectedConversations, approvedMessageBody: messageBody, approvedAttachments: attachments, + attachmentLimits: attachmentLimits, ) } catch { return SendFailure(outgoingMessages: [], error: error) diff --git a/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift b/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift index 13af1d74a6..5e44369604 100644 --- a/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift +++ b/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift @@ -151,14 +151,18 @@ public final class AttachmentApprovalViewController: UIPageViewController, UIPag } } + public let attachmentLimits: OutgoingAttachmentLimits + public static func loadWithSneakyTransaction( attachmentApprovalItems: [AttachmentApprovalItem], + attachmentLimits: OutgoingAttachmentLimits, options: AttachmentApprovalViewControllerOptions, ) -> Self { let databaseStorage = SSKEnvironment.shared.databaseStorageRef return Self( attachmentApprovalItems: attachmentApprovalItems, defaultImageQuality: databaseStorage.read(block: ImageQuality.fetchValue(tx:)), + attachmentLimits: attachmentLimits, options: options, ) } @@ -166,11 +170,13 @@ public final class AttachmentApprovalViewController: UIPageViewController, UIPag private init( attachmentApprovalItems: [AttachmentApprovalItem], defaultImageQuality: ImageQuality, + attachmentLimits: OutgoingAttachmentLimits, options: AttachmentApprovalViewControllerOptions, ) { assert(attachmentApprovalItems.count > 0) self.outputImageQuality = defaultImageQuality + self.attachmentLimits = attachmentLimits self.receivedOptions = options let pageOptions: [UIPageViewController.OptionsKey: Any] = [.interPageSpacing: kSpacingBetweenItems] @@ -210,6 +216,7 @@ public final class AttachmentApprovalViewController: UIPageViewController, UIPag attachments: [PreviewableAttachment], initialMessageBody: MessageBody?, hasQuotedReplyDraft: Bool, + attachmentLimits: OutgoingAttachmentLimits, approvalDelegate: AttachmentApprovalViewControllerDelegate, approvalDataSource: AttachmentApprovalViewControllerDataSource, stickerSheetDelegate: StickerPickerSheetDelegate?, @@ -221,7 +228,11 @@ public final class AttachmentApprovalViewController: UIPageViewController, UIPag if hasQuotedReplyDraft { options.insert(.disallowViewOnce) } - let vc = AttachmentApprovalViewController.loadWithSneakyTransaction(attachmentApprovalItems: attachmentApprovalItems, options: options) + let vc = AttachmentApprovalViewController.loadWithSneakyTransaction( + attachmentApprovalItems: attachmentApprovalItems, + attachmentLimits: attachmentLimits, + options: options, + ) // The data source needs to be set before the message body because it is needed to hydrate mentions. vc.approvalDataSource = approvalDataSource vc.setMessageBody(initialMessageBody, txProvider: DependenciesBridge.shared.db.readTxProvider) @@ -771,7 +782,7 @@ public final class AttachmentApprovalViewController: UIPageViewController, UIPag } dataSource.sourceFilename = filename - return try PreviewableAttachment.videoAttachment(dataSource: dataSource, dataUTI: dataUTI) + return try PreviewableAttachment.videoAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) } func attachmentApprovalItem(before currentItem: AttachmentApprovalItem) -> AttachmentApprovalItem? { diff --git a/SignalUI/AttachmentMultisend/AttachmentMultisend.swift b/SignalUI/AttachmentMultisend/AttachmentMultisend.swift index 5251e84fd4..64aee7583f 100644 --- a/SignalUI/AttachmentMultisend/AttachmentMultisend.swift +++ b/SignalUI/AttachmentMultisend/AttachmentMultisend.swift @@ -22,6 +22,7 @@ public class AttachmentMultisend { conversations: [ConversationItem], approvedMessageBody: MessageBody?, approvedAttachments: ApprovedAttachments, + attachmentLimits: OutgoingAttachmentLimits, ) async throws -> [EnqueueResult] { let destinations = try await prepareDestinations( forSendingMessageBody: approvedMessageBody, @@ -40,7 +41,10 @@ public class AttachmentMultisend { } let imageQuality = approvedAttachments.imageQuality - let imageQualityLevel = ImageQualityLevel.resolvedValue(imageQuality: imageQuality) + let imageQualityLevel = ImageQualityLevel.resolvedValue( + imageQuality: imageQuality, + standardQualityLevel: attachmentLimits.standardQualityLevel, + ) let sendableAttachments = try await approvedAttachments.attachments.mapAsync { return try await SendableAttachment.forPreviewableAttachment($0, imageQualityLevel: imageQualityLevel) } @@ -50,6 +54,7 @@ public class AttachmentMultisend { sendableAttachments: sendableAttachments, hasNonStoryDestination: hasNonStoryDestination, hasStoryDestination: hasStoryDestination, + attachmentLimits: attachmentLimits, ) return try await deps.databaseStorage.awaitableWrite { tx in @@ -153,6 +158,7 @@ public class AttachmentMultisend { sendableAttachments: [SendableAttachment], hasNonStoryDestination: Bool, hasStoryDestination: Bool, + attachmentLimits: OutgoingAttachmentLimits, ) async throws -> [SegmentAttachmentResult] { let maxSegmentDurations = conversations.compactMap(\.videoAttachmentDurationLimit) guard hasStoryDestination, !maxSegmentDurations.isEmpty, let requiredSegmentDuration = maxSegmentDurations.min() else { @@ -174,7 +180,10 @@ public class AttachmentMultisend { var segmentedResults = [SegmentAttachmentResult]() for attachment in sendableAttachments { - let segmentingResult = try await attachment.segmentedIfNecessary(segmentDuration: requiredSegmentDuration) + let segmentingResult = try await attachment.segmentedIfNecessary( + segmentDuration: requiredSegmentDuration, + attachmentLimits: attachmentLimits, + ) let originalDataSource: AttachmentDataSource? if hasNonStoryDestination || segmentingResult.segmented == nil { diff --git a/SignalUI/Attachments/PreviewableAttachment.swift b/SignalUI/Attachments/PreviewableAttachment.swift index dd4c88e3bf..b721ba6c56 100644 --- a/SignalUI/Attachments/PreviewableAttachment.swift +++ b/SignalUI/Attachments/PreviewableAttachment.swift @@ -113,14 +113,18 @@ public struct PreviewableAttachment { } // Factory method for video attachments. - public static func videoAttachment(dataSource: DataSourcePath, dataUTI: String) throws -> Self { + public static func videoAttachment( + dataSource: DataSourcePath, + dataUTI: String, + attachmentLimits: OutgoingAttachmentLimits, + ) throws -> Self { try OWSMediaUtils.validateVideoExtension(ofPath: dataSource.fileUrl.path) try OWSMediaUtils.validateVideoAsset(atPath: dataSource.fileUrl.path) return try newAttachment( dataSource: dataSource, dataUTI: dataUTI, validUTISet: SignalAttachment.videoUTISet, - maxFileSize: OutgoingAttachmentLimits.currentLimits().maxPlaintextVideoBytes, + maxFileSize: attachmentLimits.maxPlaintextVideoBytes, ) } @@ -131,16 +135,26 @@ public struct PreviewableAttachment { } @MainActor - public static func compressVideoAsMp4(dataSource: DataSourcePath, sessionCallback: (@MainActor (AVAssetExportSession) -> Void)? = nil) async throws -> Self { + public static func compressVideoAsMp4( + dataSource: DataSourcePath, + attachmentLimits: OutgoingAttachmentLimits, + sessionCallback: (@MainActor (AVAssetExportSession) -> Void)? = nil, + ) async throws -> Self { return try await compressVideoAsMp4( asset: AVAsset(url: dataSource.fileUrl), baseFilename: dataSource.sourceFilename, + attachmentLimits: attachmentLimits, sessionCallback: sessionCallback, ) } @MainActor - public static func compressVideoAsMp4(asset: AVAsset, baseFilename: String?, sessionCallback: (@MainActor (AVAssetExportSession) -> Void)? = nil) async throws -> Self { + public static func compressVideoAsMp4( + asset: AVAsset, + baseFilename: String?, + attachmentLimits: OutgoingAttachmentLimits, + sessionCallback: (@MainActor (AVAssetExportSession) -> Void)? = nil, + ) async throws -> Self { let startTime = MonotonicDate() guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) else { @@ -195,25 +209,33 @@ public struct PreviewableAttachment { let formattedDuration = OWSOperation.formattedNs((endTime - startTime).nanoseconds) Logger.info("transcoded video in \(formattedDuration)s") - return try videoAttachment(dataSource: dataSource, dataUTI: UTType.mpeg4Movie.identifier) + return try videoAttachment(dataSource: dataSource, dataUTI: UTType.mpeg4Movie.identifier, attachmentLimits: attachmentLimits) } // MARK: Audio Attachments // Factory method for audio attachments. - public static func audioAttachment(dataSource: DataSourcePath, dataUTI: String) throws(SignalAttachmentError) -> Self { + public static func audioAttachment( + dataSource: DataSourcePath, + dataUTI: String, + attachmentLimits: OutgoingAttachmentLimits, + ) throws(SignalAttachmentError) -> Self { return try newAttachment( dataSource: dataSource, dataUTI: dataUTI, validUTISet: SignalAttachment.audioUTISet, - maxFileSize: OutgoingAttachmentLimits.currentLimits().maxPlaintextAudioBytes, + maxFileSize: attachmentLimits.maxPlaintextAudioBytes, ) } // MARK: Generic Attachments // Factory method for generic attachments. - public static func genericAttachment(dataSource: DataSourcePath, dataUTI: String) throws(SignalAttachmentError) -> Self { + public static func genericAttachment( + dataSource: DataSourcePath, + dataUTI: String, + attachmentLimits: OutgoingAttachmentLimits, + ) throws(SignalAttachmentError) -> Self { // [15M] TODO: Enforce this at compile-time rather than runtime. owsPrecondition(!SignalAttachment.videoUTISet.contains(dataUTI)) owsPrecondition(!SignalAttachment.inputImageUTISet.contains(dataUTI)) @@ -221,14 +243,18 @@ public struct PreviewableAttachment { dataSource: dataSource, dataUTI: dataUTI, validUTISet: nil, - maxFileSize: OutgoingAttachmentLimits.currentLimits().maxPlaintextBytes, + maxFileSize: attachmentLimits.maxPlaintextBytes, ) } // MARK: Voice Messages - public static func voiceMessageAttachment(dataSource: DataSourcePath, dataUTI: String) throws(SignalAttachmentError) -> Self { - let attachment = try audioAttachment(dataSource: dataSource, dataUTI: dataUTI) + public static func voiceMessageAttachment( + dataSource: DataSourcePath, + dataUTI: String, + attachmentLimits: OutgoingAttachmentLimits, + ) throws(SignalAttachmentError) -> Self { + let attachment = try audioAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) attachment.rawValue.isVoiceMessage = true return attachment } @@ -260,6 +286,7 @@ public struct PreviewableAttachment { ofTypes types: AttachmentTypes = .all, dataSource: DataSourcePath, dataUTI: String, + attachmentLimits: OutgoingAttachmentLimits, options: BuildOptions = [], ) throws -> Self { if SignalAttachment.inputImageUTISet.contains(dataUTI) { @@ -272,18 +299,18 @@ public struct PreviewableAttachment { guard types.contains(.video) else { throw SignalAttachmentError.invalidFileFormat } - return try videoAttachment(dataSource: dataSource, dataUTI: dataUTI) + return try videoAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) } if SignalAttachment.audioUTISet.contains(dataUTI) { guard types.contains(.audio) else { throw SignalAttachmentError.invalidFileFormat } - return try audioAttachment(dataSource: dataSource, dataUTI: dataUTI) + return try audioAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) } guard types.contains(.other) else { throw SignalAttachmentError.invalidFileFormat } - return try genericAttachment(dataSource: dataSource, dataUTI: dataUTI) + return try genericAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) } // MARK: Helper Methods diff --git a/SignalUI/Attachments/SendableAttachment.swift b/SignalUI/Attachments/SendableAttachment.swift index 28000a57e8..aba605fb8d 100644 --- a/SignalUI/Attachments/SendableAttachment.swift +++ b/SignalUI/Attachments/SendableAttachment.swift @@ -105,7 +105,10 @@ public struct SendableAttachment { /// If the attachment is a video longer than `storyVideoSegmentMaxDuration`, /// segments into separate attachments under that duration. /// Otherwise returns a result with only the original and nil segmented attachments. - public func segmentedIfNecessary(segmentDuration: TimeInterval) async throws -> SegmentAttachmentResult { + public func segmentedIfNecessary( + segmentDuration: TimeInterval, + attachmentLimits: OutgoingAttachmentLimits, + ) async throws -> SegmentAttachmentResult { guard SignalAttachment.videoUTISet.contains(self.dataUTI) else { return SegmentAttachmentResult(self, segmented: nil) } @@ -132,7 +135,7 @@ public struct SendableAttachment { let segments = try segmentFileUrls.map { url in let dataSource = DataSourcePath(fileUrl: url, ownership: .owned) // [15M] TODO: This doesn't transfer all SignalAttachment fields. - let attachment = try PreviewableAttachment.videoAttachment(dataSource: dataSource, dataUTI: self.dataUTI) + let attachment = try PreviewableAttachment.videoAttachment(dataSource: dataSource, dataUTI: self.dataUTI, attachmentLimits: attachmentLimits) return Self(nonImagePreviewableAttachment: attachment) } return SegmentAttachmentResult(self, segmented: segments) diff --git a/SignalUI/Attachments/TypedItemProvider.swift b/SignalUI/Attachments/TypedItemProvider.swift index e1094276e1..eaeb35a5b3 100644 --- a/SignalUI/Attachments/TypedItemProvider.swift +++ b/SignalUI/Attachments/TypedItemProvider.swift @@ -149,8 +149,11 @@ 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 -> PreviewableAttachment { - let typedItem = try await make(for: itemProvider).buildAttachment() + public static func buildVisualMediaAttachment( + forItemProvider itemProvider: NSItemProvider, + attachmentLimits: OutgoingAttachmentLimits, + ) async throws -> PreviewableAttachment { + let typedItem = try await make(for: itemProvider).buildAttachment(attachmentLimits: attachmentLimits) switch typedItem { case .other(let attachment) where attachment.isVisualMedia: return attachment @@ -178,7 +181,10 @@ public struct TypedItemProvider { // MARK: Methods - public nonisolated func buildAttachment(progress: Progress? = nil) async throws -> TypedItem { + public nonisolated func buildAttachment( + attachmentLimits: OutgoingAttachmentLimits, + 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 { @@ -197,7 +203,7 @@ 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 { - attachment = try await buildFileAttachment(mustBeVisualMedia: true, progress: progress) + attachment = try await buildFileAttachment(mustBeVisualMedia: true, attachmentLimits: attachmentLimits, 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( @@ -207,9 +213,9 @@ public struct TypedItemProvider { attachment = try Self.createAttachment(withImage: image) } case .movie: - attachment = try await self.buildFileAttachment(mustBeVisualMedia: true, progress: progress) + attachment = try await self.buildFileAttachment(mustBeVisualMedia: true, attachmentLimits: attachmentLimits, progress: progress) case .pdf, .data: - attachment = try await self.buildFileAttachment(mustBeVisualMedia: false, progress: progress) + attachment = try await self.buildFileAttachment(mustBeVisualMedia: false, attachmentLimits: attachmentLimits, progress: progress) case .fileUrl, .json: let url: NSURL = try await loadObjectWithKeyedUnarchiverFallback( overrideTypeIdentifier: TypedItemProvider.ItemType.fileUrl.typeIdentifier, @@ -224,6 +230,7 @@ public struct TypedItemProvider { dataSource: dataSource, dataUTI: dataUTI, mustBeVisualMedia: false, + attachmentLimits: attachmentLimits, progress: progress, ) case .webUrl: @@ -231,7 +238,7 @@ public struct TypedItemProvider { cannotLoadError: .cannotLoadURLObject, failedLoadError: .loadURLObjectFailed, ) - return try Self.createAttachment(withText: (url as URL).absoluteString) + return try Self.createAttachment(withText: (url as URL).absoluteString, attachmentLimits: attachmentLimits) case .contact: let contactData = try await loadDataRepresentation() return .contact(contactData) @@ -240,7 +247,7 @@ public struct TypedItemProvider { cannotLoadError: .cannotLoadStringObject, failedLoadError: .loadStringObjectFailed, ) - return try Self.createAttachment(withText: text as String) + return try Self.createAttachment(withText: text as String, attachmentLimits: attachmentLimits) case .pkPass: let pkPass = try await loadDataRepresentation() let fileExtension = MimeTypeUtil.fileExtensionForUtiType(itemType.typeIdentifier) @@ -248,12 +255,16 @@ public struct TypedItemProvider { throw SignalAttachmentError.missingData } let dataSource = try DataSourcePath(writingTempFileData: pkPass, fileExtension: fileExtension) - attachment = try PreviewableAttachment.genericAttachment(dataSource: dataSource, dataUTI: itemType.typeIdentifier) + attachment = try PreviewableAttachment.genericAttachment(dataSource: dataSource, dataUTI: itemType.typeIdentifier, attachmentLimits: attachmentLimits) } return .other(attachment) } - private nonisolated func buildFileAttachment(mustBeVisualMedia: Bool, progress: Progress?) async throws -> PreviewableAttachment { + private nonisolated func buildFileAttachment( + mustBeVisualMedia: Bool, + attachmentLimits: OutgoingAttachmentLimits, + progress: Progress?, + ) async throws -> PreviewableAttachment { let (dataSource, dataUTI): (DataSourcePath, String) = try await withCheckedThrowingContinuation { continuation in let typeIdentifier = itemType.typeIdentifier _ = itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { fileUrl, error in @@ -275,7 +286,13 @@ public struct TypedItemProvider { } } - return try await _buildFileAttachment(dataSource: dataSource, dataUTI: dataUTI, mustBeVisualMedia: mustBeVisualMedia, progress: progress) + return try await _buildFileAttachment( + dataSource: dataSource, + dataUTI: dataUTI, + mustBeVisualMedia: mustBeVisualMedia, + attachmentLimits: attachmentLimits, + progress: progress, + ) } private nonisolated func loadDataRepresentation( @@ -337,7 +354,10 @@ public struct TypedItemProvider { } } - private nonisolated static func createAttachment(withText text: String) throws -> TypedItem { + private nonisolated static func createAttachment( + withText text: String, + attachmentLimits: OutgoingAttachmentLimits, + ) throws -> TypedItem { let filteredText = FilteredString(rawValue: text) if let messageText = TypedItem.MessageText(filteredValue: filteredText) { return .text(messageText) @@ -351,6 +371,7 @@ public struct TypedItemProvider { return .other(try PreviewableAttachment.genericAttachment( dataSource: dataSource, dataUTI: UTType.plainText.identifier, + attachmentLimits: attachmentLimits, )) } } @@ -383,6 +404,7 @@ public struct TypedItemProvider { dataSource: DataSourcePath, dataUTI: String, mustBeVisualMedia: Bool, + attachmentLimits: OutgoingAttachmentLimits, progress: Progress?, ) async throws -> PreviewableAttachment { if SignalAttachment.videoUTISet.contains(dataUTI) { @@ -393,6 +415,7 @@ public struct TypedItemProvider { } return try await PreviewableAttachment.compressVideoAsMp4( dataSource: dataSource, + attachmentLimits: attachmentLimits, sessionCallback: { exportSession in guard let progress else { return } progressPoller = ProgressPoller(progress: progress, pollInterval: 0.1, fractionCompleted: { return exportSession.progress }) @@ -404,7 +427,7 @@ public struct TypedItemProvider { // an image or throw an error. return try PreviewableAttachment.imageAttachment(dataSource: dataSource, dataUTI: dataUTI) } else { - return try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: dataUTI) + return try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits) } } }