diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index ff3f4a376a4..c4987f60307 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -771,6 +771,8 @@ 50F946102AD768AF002EF293 /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50F9460F2AD768AF002EF293 /* MockIdentityManager.swift */; }; 5AA002E62CA24566002D1CC2 /* SessionStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA002E52CA2455F002D1CC2 /* SessionStoreTest.swift */; }; 616577F953D77424E32C7438 /* Pods_SignalUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 675486AB8F0612FF2C717BAE /* Pods_SignalUI.framework */; }; + 63BA1AD22EBA643E00B2AD82 /* ImageQualitySettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63BA1AD12EBA643E00B2AD82 /* ImageQualitySettingStore.swift */; }; + 63BA1AD42EBA651700B2AD82 /* ImageQualitySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63BA1AD32EBA651700B2AD82 /* ImageQualitySettingsViewController.swift */; }; 6600BB182BA3A04C0005A035 /* LinkPreviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6600BB172BA3A04C0005A035 /* LinkPreviewManager.swift */; }; 6600BB1A2BA3A0930005A035 /* LinkPreviewManagerImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6600BB192BA3A0930005A035 /* LinkPreviewManagerImpl.swift */; }; 6600BB1D2BA3ABDD0005A035 /* MockLinkPreviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6600BB1C2BA3ABDD0005A035 /* MockLinkPreviewManager.swift */; }; @@ -4741,6 +4743,8 @@ 5AA002E52CA2455F002D1CC2 /* SessionStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStoreTest.swift; sourceTree = ""; }; 5D6C4583F668E9D733E59B9B /* Pods-SignalServiceKitTests.testable release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalServiceKitTests.testable release.xcconfig"; path = "Target Support Files/Pods-SignalServiceKitTests/Pods-SignalServiceKitTests.testable release.xcconfig"; sourceTree = ""; }; 5F85041386A219C9710EAB41 /* Pods-Signal.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Signal.debug.xcconfig"; path = "Target Support Files/Pods-Signal/Pods-Signal.debug.xcconfig"; sourceTree = ""; }; + 63BA1AD12EBA643E00B2AD82 /* ImageQualitySettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageQualitySettingStore.swift; sourceTree = ""; }; + 63BA1AD32EBA651700B2AD82 /* ImageQualitySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageQualitySettingsViewController.swift; sourceTree = ""; }; 65703441A3D2C7FE670E65ED /* Pods-SignalServiceKit.profiling.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalServiceKit.profiling.xcconfig"; path = "Target Support Files/Pods-SignalServiceKit/Pods-SignalServiceKit.profiling.xcconfig"; sourceTree = ""; }; 6600BB172BA3A04C0005A035 /* LinkPreviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewManager.swift; sourceTree = ""; }; 6600BB192BA3A0930005A035 /* LinkPreviewManagerImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewManagerImpl.swift; sourceTree = ""; }; @@ -8062,9 +8066,9 @@ B9B2AA932BC598B60060B56C /* ContactNoteSheet.swift */, 34E20D4B24256563002C011E /* ConversationHeaderBuilder.swift */, 34EB0DF42628D3B200B62DC3 /* ConversationInternalViewController.swift */, + 34235F3724213550008C74CB /* ConversationSettingsViewController.swift */, 34E20D4D2425672A002C011E /* ConversationSettingsViewController+Contents.swift */, 34A17D80253F7236009F8C02 /* ConversationSettingsViewController+LegacyGroups.swift */, - 34235F3724213550008C74CB /* ConversationSettingsViewController.swift */, D900E6FE2DE143E9004D01A1 /* DisappearingMessagesCustomTimePickerViewController.swift */, D900E6F72DE1048A004D01A1 /* DisappearingMessagesTimerSettingsViewController.swift */, 346594812434D5E000E5C510 /* GroupAttributesEditorHelper.swift */, @@ -8075,8 +8079,9 @@ 347B83FC24378DDE0019A52C /* GroupMemberRequestsAndInvitesViewController.swift */, 889DFA0F264EE76F00D03921 /* GroupNameViewController.swift */, 88BE44A72615451A00AE8E33 /* GroupPermissionsSettingsViewController.swift */, - 347B83F624367EC00019A52C /* GroupViewHelper+MemberActionSheet.swift */, 347B83F82436820C0019A52C /* GroupViewHelper.swift */, + 347B83F624367EC00019A52C /* GroupViewHelper+MemberActionSheet.swift */, + 63BA1AD32EBA651700B2AD82 /* ImageQualitySettingsViewController.swift */, 88A357B823639384009D6B9A /* MemberActionSheet.swift */, 32CBF07C258C939800D56903 /* NameCollisionResolutionViewController.swift */, B9D65E522BAE1DA70067322A /* NicknameEditorViewController.swift */, @@ -8961,6 +8966,7 @@ children = ( 66F6D6A12C7D0CA100EFAF75 /* Models */, 500AEE062A4DF48700371F05 /* ChatColorSettingStore.swift */, + 63BA1AD12EBA643E00B2AD82 /* ImageQualitySettingStore.swift */, 66144B372BF8155F00E2C9CD /* MockWallpaperImageStore.swift */, 66144B2E2BF7FB5200E2C9CD /* WallpaperImageStore.swift */, 66144B302BF7FB7B00E2C9CD /* WallpaperImageStoreImpl.swift */, @@ -17494,6 +17500,7 @@ 34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */, 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, 342FFE77271EF581000AC89F /* UIApplication+OWS.swift in Sources */, + 63BA1AD42EBA651700B2AD82 /* ImageQualitySettingsViewController.swift in Sources */, F93BCB9A29EDE86400E3C6A0 /* UIDevice+CanUpgradeOperatingSystem.swift in Sources */, 342FFE7A271EF581000AC89F /* UIResponder+OWS.swift in Sources */, 342FFE7B271EF581000AC89F /* UIStoryboard+OWS.swift in Sources */, @@ -17763,6 +17770,7 @@ 66CD256A2B06963200139E17 /* BackupArchiveStickerPackArchiver.swift in Sources */, 66232ADB2CB9E33600AE6A76 /* BackupArchiveStoryStore.swift in Sources */, D9BFB8BF2C4EE33C00D67881 /* BackupArchiveThreadMergeChatUpdateArchiver.swift in Sources */, + 63BA1AD22EBA643E00B2AD82 /* ImageQualitySettingStore.swift in Sources */, 66232AD92CB9D00400AE6A76 /* BackupArchiveThreadStore.swift in Sources */, 66CD258F2B0EB4AC00139E17 /* BackupArchiveTSIncomingMessageArchiver.swift in Sources */, 66CD25932B0EC55100139E17 /* BackupArchiveTSMessageContentsArchiver.swift in Sources */, @@ -18064,6 +18072,7 @@ D9F399AD2A95798A001599EC /* IdentityKeyChecker.swift in Sources */, D9F399B22A96D65D001599EC /* IdentityKeyMismatchManager.swift in Sources */, 661BFE0A2C07FB950065435B /* ImageMetadata.swift in Sources */, + 63BA1AD22EBA643E00B2AD82 /* ImageQualitySettingStore.swift in Sources */, F9C5CDDF289453B400548EEE /* ImageQuality.swift in Sources */, D9C0AE662BD7103100FCB05E /* InactiveLinkedDeviceFinder.swift in Sources */, 04BBBE922E26C92D00E914B1 /* InactivePrimaryDeviceStore.swift in Sources */, diff --git a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift index 78e3cb38698..4464b24218b 100644 --- a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift @@ -579,7 +579,8 @@ public extension ConversationViewController { hasQuotedReplyDraft: inputToolbar.quotedReplyDraft != nil, approvalDelegate: self, approvalDataSource: self, - stickerSheetDelegate: self + stickerSheetDelegate: self, + thread: thread ) modal.modalPresentationStyle = .overCurrentContext let presenter = self.splitViewController ?? self @@ -982,6 +983,10 @@ extension ConversationViewController: SendMediaNavDataSource { func sendMediaNavMentionCacheInvalidationKey() -> String { return thread.uniqueId } + + var sendMediaNavThread: TSThread? { + return thread + } } // MARK: - StickerPickerSheetDelegate diff --git a/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift b/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift index 144325e3f9b..088f98f9987 100644 --- a/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift +++ b/Signal/src/ViewControllers/CameraFirstCaptureSendFlow.swift @@ -128,6 +128,11 @@ extension CameraFirstCaptureSendFlow: SendMediaNavDataSource { func sendMediaNavMentionCacheInvalidationKey() -> String { return "\(mentionCandidates.hashValue)" } + + var sendMediaNavThread: TSThread? { + // Camera first flow supports multiple recipients, so no single thread + return nil + } } extension CameraFirstCaptureSendFlow: ConversationPickerDelegate { diff --git a/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift b/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift index 95e579700c1..7c38927d04d 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaItemViewController.swift @@ -33,7 +33,14 @@ class MediaItemViewController: OWSViewController, VideoPlaybackStatusProvider { super.init() - image = attachmentStream.thumbnailImageSync(quality: .large) + // Load full-quality image to preserve metadata when shared from preview + // Use decryptedImage() which loads the original file, not a processed thumbnail + image = try? attachmentStream.decryptedImage() + + // Fall back to large thumbnail if decryptedImage fails (e.g., for very large images) + if image == nil { + image = attachmentStream.thumbnailImageSync(quality: .large) + } } deinit { diff --git a/Signal/src/ViewControllers/Photos/PhotoLibrary.swift b/Signal/src/ViewControllers/Photos/PhotoLibrary.swift index a5f4934a08c..71a997ae8e5 100644 --- a/Signal/src/ViewControllers/Photos/PhotoLibrary.swift +++ b/Signal/src/ViewControllers/Photos/PhotoLibrary.swift @@ -97,21 +97,22 @@ class PhotoAlbumContents { private func requestImageDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> { return Promise { future in + // Use PHAssetResourceManager to get original file bytes with metadata intact + guard let resource = PHAssetResource.assetResources(for: asset).first else { + future.reject(PhotoLibraryError.assertionError(description: "No asset resource found")) + return + } - let options: PHImageRequestOptions = PHImageRequestOptions() + let dataUTI = resource.uniformTypeIdentifier + var imageData = Data() + let options = PHAssetResourceRequestOptions() options.isNetworkAccessAllowed = true - options.version = .current - options.deliveryMode = .highQualityFormat - - _ = imageManager.requestImageDataAndOrientation(for: asset, options: options) { imageData, dataUTI, _, _ in - - guard let imageData = imageData else { - future.reject(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")) - return - } - guard let dataUTI = dataUTI else { - future.reject(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")) + PHAssetResourceManager.default().requestData(for: resource, options: options, dataReceivedHandler: { data in + imageData.append(data) + }, completionHandler: { error in + if let error = error { + future.reject(error) return } @@ -121,7 +122,7 @@ class PhotoAlbumContents { } future.resolve((dataSource: dataSource, dataUTI: dataUTI)) - } + }) } } diff --git a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift index 0cc1dbb0a3a..31b050e2402 100644 --- a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift +++ b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift @@ -33,6 +33,8 @@ protocol SendMediaNavDataSource: AnyObject { func sendMediaNavMentionableAcis(tx: DBReadTransaction) -> [Aci] func sendMediaNavMentionCacheInvalidationKey() -> String + + var sendMediaNavThread: TSThread? { get } } class CameraFirstCaptureNavigationController: SendMediaNavigationController { @@ -214,7 +216,8 @@ class SendMediaNavigationController: OWSNavigationController { if hasQuotedReplyDraft { options.insert(.disallowViewOnce) } - let approvalViewController = AttachmentApprovalViewController(options: options, attachmentApprovalItems: attachmentApprovalItems) + let thread = sendMediaNavDataSource.sendMediaNavThread + let approvalViewController = AttachmentApprovalViewController(options: options, attachmentApprovalItems: attachmentApprovalItems, thread: thread) approvalViewController.approvalDelegate = self approvalViewController.approvalDataSource = self approvalViewController.stickerSheetDelegate = self diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift index 262b4d49ae7..0e146813e38 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift @@ -49,6 +49,7 @@ extension ConversationSettingsViewController { addDisappearingMessagesItem(to: mainSection) addNicknameItemIfNecessary(to: mainSection) addColorAndWallpaperSettingsItem(to: mainSection) + addImageQualitySettingsItem(to: mainSection) if !isNoteToSelf { addSoundAndNotificationSettingsItem(to: mainSection) } addSafetyNumberItemIfNecessary(to: mainSection) @@ -436,6 +437,46 @@ extension ConversationSettingsViewController { })) } + private func addImageQualitySettingsItem(to section: OWSTableSection) { + section.add(OWSTableItem(customCellBlock: { [weak self] in + guard let self = self else { + owsFailDebug("Missing self") + return OWSTableItem.newCell() + } + + let imageQualityStore = ImageQualitySettingStore() + let settingText = SSKEnvironment.shared.databaseStorageRef.read { tx in + let setting = imageQualityStore.fetchSetting(for: self.thread, tx: tx) + if setting == .original { + return OWSLocalizedString( + "IMAGE_QUALITY_ORIGINAL", + comment: "Option for original image quality" + ) + } else { + return OWSLocalizedString( + "IMAGE_QUALITY_DEFAULT", + comment: "Option to use default image quality setting" + ) + } + } + + let cell = OWSTableItem.buildCell( + icon: .buttonPhotoLibrary, + itemName: OWSLocalizedString( + "IMAGE_QUALITY_SETTING_TITLE", + comment: "The title for the image quality settings screen" + ), + accessoryText: settingText, + accessoryType: .disclosureIndicator, + accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "image_quality") + ) + return cell + }, + actionBlock: { [weak self] in + self?.showImageQualitySettingsView() + })) + } + private func addSoundAndNotificationSettingsItem(to section: OWSTableSection) { section.add(OWSTableItem(customCellBlock: { [weak self] in guard let self = self else { diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index 1dc253eee60..90dcd541484 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -407,6 +407,13 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti navigationController?.pushViewController(vc, animated: true) } + func showImageQualitySettingsView() { + let vc = ImageQualitySettingsViewController(thread: thread) { [weak self] in + self?.updateTableContents() + } + navigationController?.pushViewController(vc, animated: true) + } + func showSoundAndNotificationsSettingsView() { let vc = SoundAndNotificationsSettingsViewController(threadViewModel: threadViewModel) navigationController?.pushViewController(vc, animated: true) diff --git a/Signal/src/ViewControllers/ThreadSettings/ImageQualitySettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ImageQualitySettingsViewController.swift new file mode 100644 index 00000000000..554c5e18994 --- /dev/null +++ b/Signal/src/ViewControllers/ThreadSettings/ImageQualitySettingsViewController.swift @@ -0,0 +1,104 @@ +// +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import SignalUI + +class ImageQualitySettingsViewController: OWSTableViewController2 { + private let thread: TSThread + private let imageQualitySettingStore: ImageQualitySettingStore + private let completion: () -> Void + + private var initialSetting: ImageQualitySetting! + private var currentSetting: ImageQualitySetting! + + init(thread: TSThread, + imageQualitySettingStore: ImageQualitySettingStore = ImageQualitySettingStore(), + completion: @escaping () -> Void) { + self.thread = thread + self.imageQualitySettingStore = imageQualitySettingStore + self.completion = completion + super.init() + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = OWSLocalizedString( + "IMAGE_QUALITY_SETTING_TITLE", + comment: "The title for the image quality settings screen" + ) + OWSTableViewController2.removeBackButtonText(viewController: self) + + SSKEnvironment.shared.databaseStorageRef.read { tx in + let setting = imageQualitySettingStore.fetchSetting(for: thread, tx: tx) + initialSetting = setting + currentSetting = setting + } + + updateTableContents() + } + + func updateTableContents() { + let contents = OWSTableContents() + defer { self.contents = contents } + + let section = OWSTableSection() + + // Add toggle switch for Full Size + section.add(OWSTableItem( + customCellBlock: { [weak self] in + guard let self = self else { + return OWSTableItem.newCell() + } + + let cell = OWSTableItem.newCell() + cell.selectionStyle = .none + + let label = UILabel() + label.text = OWSLocalizedString( + "IMAGE_QUALITY_ORIGINAL_TOGGLE", + comment: "Toggle label for sending original quality images" + ) + label.font = .dynamicTypeBody + label.textColor = Theme.primaryTextColor + + let switchControl = UISwitch() + switchControl.isOn = (self.currentSetting == .original) + switchControl.addTarget(self, action: #selector(self.didToggleOriginal(_:)), for: .valueChanged) + + let stackView = UIStackView(arrangedSubviews: [label, switchControl]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 12 + + cell.contentView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewMargins() + + return cell + } + )) + + section.footerTitle = OWSLocalizedString( + "IMAGE_QUALITY_ORIGINAL_WARNING", + comment: "Warning message about original quality images preserving metadata and location" + ) + + contents.add(section) + } + + @objc + private func didToggleOriginal(_ sender: UISwitch) { + let newSetting: ImageQualitySetting = sender.isOn ? .original : .default + if newSetting != currentSetting { + currentSetting = newSetting + SSKEnvironment.shared.databaseStorageRef.write { tx in + self.imageQualitySettingStore.setSetting(newSetting, for: self.thread, tx: tx) + } + completion() + } + } +} + diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index ba40cfb63c2..3bb2f08fe4d 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -346,6 +346,9 @@ /* toast alert shown after user taps the 'save' button */ "ATTACHMENT_APPROVAL_MEDIA_DID_SAVE" = "Saved"; +/* Subtitle for the 'original' option for media quality. */ +"ATTACHMENT_APPROVAL_MEDIA_QUALITY_ORIGINAL_OPTION_SUBTITLE" = "With all metadata"; + /* Subtitle for the 'high' option for media quality. */ "ATTACHMENT_APPROVAL_MEDIA_QUALITY_HIGH_OPTION_SUBTITLE" = "Slower, more data"; @@ -7540,9 +7543,27 @@ /* String describing high quality sent media */ "SENT_MEDIA_QUALITY_HIGH" = "High"; +/* String describing original quality sent media */ +"SENT_MEDIA_QUALITY_ORIGINAL" = "Original"; + /* String describing standard quality sent media */ "SENT_MEDIA_QUALITY_STANDARD" = "Standard"; +/* The title for the image quality settings screen */ +"IMAGE_QUALITY_SETTING_TITLE" = "Image Quality"; + +/* Warning message about original quality images preserving metadata */ +"IMAGE_QUALITY_ORIGINAL_WARNING" = "Sending images with Original Quality preserves locations and other metadata. Be aware that your location can be inadvertently shared through the photos you send."; + +/* Option for original image quality */ +"IMAGE_QUALITY_ORIGINAL" = "Original Quality"; + +/* Toggle label for sending original quality images */ +"IMAGE_QUALITY_ORIGINAL_TOGGLE" = "Send Original Quality"; + +/* Option to use default image quality setting */ +"IMAGE_QUALITY_DEFAULT" = "Default"; + /* Users can donate to Signal with a bank account. This is the label for email field on that screen. */ "SEPA_DONATION_EMAIL_LABEL" = "Email"; diff --git a/SignalServiceKit/Attachments/SignalAttachment.swift b/SignalServiceKit/Attachments/SignalAttachment.swift index a922914d5e3..f0d385c7c9e 100644 --- a/SignalServiceKit/Attachments/SignalAttachment.swift +++ b/SignalServiceKit/Attachments/SignalAttachment.swift @@ -81,6 +81,10 @@ extension SignalAttachmentError: LocalizedError, UserErrorDescriptionProvider { // This class gathers that logic. It offers factory methods for attachments // that do the necessary work. // +// The return value for the factory methods will be nil if the input is nil. +// +// [SignalAttachment hasError] will be true for non-valid attachments. +// // TODO: Perhaps do conversion off the main thread? public class SignalAttachment: NSObject { @@ -115,6 +119,10 @@ public class SignalAttachment: NSObject { return dataSource.isValidVideo } + // When true, preserve all metadata (location, camera info, etc.) in the image. + // This should only be true when the user has explicitly opted into sending original images. + public var preserveMetadata = false + // This flag should be set for text attachments that can be sent as text messages. public var isConvertibleToTextMessage = false @@ -171,19 +179,48 @@ public class SignalAttachment: NSObject { return "[SignalAttachment] mimeType: \(mimeType), fileSize: \(fileSize)" } + public class var missingDataErrorMessage: String { + guard let errorDescription = SignalAttachmentError.missingData.errorDescription else { + owsFailDebug("Missing error description") + return "" + } + return errorDescription + } + #if compiler(>=6.2) @concurrent #endif public func preparedForOutput(qualityLevel: ImageQualityLevel) async throws(SignalAttachmentError) -> SignalAttachment { + // When opted into, return the original image without any processing + // This preserves the original format (HEIC, PNG, etc.) and all metadata + // Check inputImageUTISet instead of isImage since HEIC is valid input but not valid output + let isInputImage = Self.inputImageUTISet.contains(dataUTI) + if qualityLevel == .original && isInputImage { + return self + } + // We only bother converting/compressing non-animated images guard isImage, !isAnimatedImage else { return self } + // Check if the image is already in a valid output format with acceptable size guard !Self.isValidOutputOriginalImage( dataSource: dataSource, dataUTI: dataUTI, imageQuality: qualityLevel - ) else { return self } + ) else { + // Valid output format, but still need to remove metadata (unless preserving) + if preserveMetadata { + return self + } + do { + return try removingImageMetadata() + } catch { + Logger.warn("Failed to remove metadata: \(error)") + return self + } + } + // Needs conversion/compression return try Self.convertAndCompressImage( dataSource: dataSource, attachment: self, @@ -200,6 +237,7 @@ public class SignalAttachment: NSObject { result.isVoiceMessage = isVoiceMessage result.isBorderless = isBorderless result.isLoopingVideo = isLoopingVideo + result.preserveMetadata = preserveMetadata return result } @@ -816,16 +854,17 @@ public class SignalAttachment: NSObject { dataSource.sourceFilename = baseFilename.appendingFileExtension("jpg") } - // When preparing an attachment, we always prepare it in the max quality for the current - // context. The user can choose during sending whether they want the final send to be in - // standard or high quality. We will do the final convert and compress before uploading. + // When preparing an attachment, we defer processing to preparedForOutput() + // so that optionally we can preserve the original format (HEIC, PNG, etc.) + // without any conversion. We only do immediate processing for formats that + // need to be converted for compatibility. - if isValidOutputOriginalImage(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .maximumForCurrentAppContext) { - do { - return try attachment.removingImageMetadata() - } catch {} + // For valid input formats, return as-is and process later in preparedForOutput() + if inputImageUTISet.contains(dataUTI) { + return attachment } + // If we get here, it's an unsupported format that needs immediate conversion return try convertAndCompressImage( dataSource: dataSource, attachment: attachment, @@ -841,6 +880,12 @@ public class SignalAttachment: NSObject { dataUTI: String, imageQuality: ImageQualityLevel ) -> Bool { + // For original quality mode, accept any image format without recompression + if imageQuality == .original { + guard dataSource.dataLength <= imageQuality.maxFileSize else { return false } + return true + } + // 10-18-2023: Due to an issue with corrupt JPEG IPTC metadata causing a // crash in CGImageDestinationCopyImageSource, stop using the original // JPEGs and instead go through the recompresing step. diff --git a/SignalServiceKit/Messages/Attachments/OWSMediaUtils.swift b/SignalServiceKit/Messages/Attachments/OWSMediaUtils.swift index c9a3480af32..438652bc6ba 100644 --- a/SignalServiceKit/Messages/Attachments/OWSMediaUtils.swift +++ b/SignalServiceKit/Messages/Attachments/OWSMediaUtils.swift @@ -219,6 +219,8 @@ public enum OWSMediaUtils { */ public static let kMaxFileSizeAnimatedImage = UInt(25 * 1024 * 1024) public static let kMaxFileSizeImage = UInt(8 * 1024 * 1024) + // For full-size images with metadata preservation + public static let kMaxFileSizeImageFullSize = UInt(100 * 1024 * 1024) // Cloudflare limits uploads to 100 MB. To avoid hitting those limits, // we use limits that are 5% lower for the unencrypted content. public static let kMaxFileSizeVideo = UInt(95 * 1000 * 1000) diff --git a/SignalServiceKit/UISupport/ImageQualitySettingStore.swift b/SignalServiceKit/UISupport/ImageQualitySettingStore.swift new file mode 100644 index 00000000000..6697e4f92e9 --- /dev/null +++ b/SignalServiceKit/UISupport/ImageQualitySettingStore.swift @@ -0,0 +1,106 @@ +// +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation + +/// Represents the image quality setting for a specific conversation. +/// This is a simple toggle: use the system default quality, or send originals with metadata. +public enum ImageQualitySetting: String, Codable { + case `default` // Use system default quality + case original // Send original quality with metadata + + /// Returns the corresponding ImageQualityLevel for this setting. + /// For `.default`, returns nil and caller should use system default. + public func qualityLevel() -> ImageQualityLevel? { + switch self { + case .default: + return nil + case .original: + return .original + } + } +} + +/// Stores the `ImageQualitySetting` for each thread. +/// +/// The keys in this store are thread unique ids. The values are `ImageQualitySetting` raw values. +public class ImageQualitySettingStore { + private let settingStore: KeyValueStore + + public init() { + self.settingStore = KeyValueStore(collection: "imageQualitySettingStore") + } + + // MARK: - Fetch Settings + + /// Fetch the image quality setting for a specific thread. + /// Returns `.default` if no setting has been explicitly set. + public func fetchSetting(for thread: TSThread, tx: DBReadTransaction) -> ImageQualitySetting { + guard let rawValue = settingStore.getString(thread.uniqueId, transaction: tx) else { + return .default + } + return ImageQualitySetting(rawValue: rawValue) ?? .default + } + + /// Fetch the resolved quality level for a thread, taking into account + /// the thread setting and system defaults. + public func resolvedQualityLevel(for thread: TSThread, tx: DBReadTransaction) -> ImageQualityLevel { + let threadSetting = fetchSetting(for: thread, tx: tx) + + // If thread has an explicit setting, use it + if let qualityLevel = threadSetting.qualityLevel() { + return qualityLevel + } + + // Fall back to system default quality + return ImageQualityLevel.resolvedQuality(tx: tx) + } + + // MARK: - Set Settings + + /// Set the image quality setting for a specific thread. + /// Pass `.default` to clear the thread-specific setting. + public func setSetting( + _ setting: ImageQualitySetting, + for thread: TSThread, + tx: DBWriteTransaction + ) { + if setting == .default { + settingStore.removeValue(forKey: thread.uniqueId, transaction: tx) + } else { + settingStore.setString(setting.rawValue, key: thread.uniqueId, transaction: tx) + } + + postSettingDidChangeNotification(for: thread, tx: tx) + } + + // MARK: - Notifications + + public static let imageQualitySettingDidChangeNotification = NSNotification.Name("imageQualitySettingDidChange") + + private func postSettingDidChangeNotification(for thread: TSThread?, tx: DBWriteTransaction) { + let threadUniqueId = thread?.uniqueId + tx.addSyncCompletion { + NotificationCenter.default.postOnMainThread( + name: Self.imageQualitySettingDidChangeNotification, + object: threadUniqueId + ) + } + } + + // MARK: - Utility + + /// Returns all thread IDs that have an explicit image quality setting. + public func fetchAllThreadsWithSettings(tx: DBReadTransaction) -> [String] { + return settingStore.allKeys(transaction: tx) + } + + /// Reset all settings (useful for testing or troubleshooting). + public func resetAllSettings(tx: DBWriteTransaction) { + settingStore.removeAll(transaction: tx) + postSettingDidChangeNotification(for: nil, tx: tx) + } +} + diff --git a/SignalServiceKit/Util/ImageQuality.swift b/SignalServiceKit/Util/ImageQuality.swift index 01861ad379c..c9a46ff8f7d 100644 --- a/SignalServiceKit/Util/ImageQuality.swift +++ b/SignalServiceKit/Util/ImageQuality.swift @@ -9,6 +9,7 @@ public enum ImageQualityLevel: UInt, Comparable { case one = 1 case two = 2 case three = 3 + case original = 4 public static let high: ImageQualityLevel = .three @@ -26,6 +27,7 @@ public enum ImageQualityLevel: UInt, Comparable { case .one: return .four case .two: return .five case .three: return .seven + case .original: return .seven // Will skip compression entirely } } @@ -37,6 +39,8 @@ public enum ImageQualityLevel: UInt, Comparable { return UInt(1.5 * 1024 * 1024) case .three: // 3.0MiB return 3 * 1024 * 1024 + case .original: // 100MiB + return 100 * 1024 * 1024 } } @@ -48,6 +52,8 @@ public enum ImageQualityLevel: UInt, Comparable { return 300 * 1024 case .three: // 400KiB return 400 * 1024 + case .original: // 100MiB + return 100 * 1024 * 1024 } } @@ -104,6 +110,8 @@ public enum ImageQualityLevel: UInt, Comparable { return OWSLocalizedString("SENT_MEDIA_QUALITY_STANDARD", comment: "String describing standard quality sent media") case .three: return OWSLocalizedString("SENT_MEDIA_QUALITY_HIGH", comment: "String describing high quality sent media") + case .original: + return OWSLocalizedString("SENT_MEDIA_QUALITY_ORIGINAL", comment: "String describing original quality sent media") } } diff --git a/SignalShareExtension/SharingThreadPickerViewController.swift b/SignalShareExtension/SharingThreadPickerViewController.swift index 6c7dd5dce27..601fd85b1fb 100644 --- a/SignalShareExtension/SharingThreadPickerViewController.swift +++ b/SignalShareExtension/SharingThreadPickerViewController.swift @@ -174,7 +174,7 @@ extension SharingThreadPickerViewController { if self.selection.conversations.contains(where: \.isStory) { approvalVCOptions.insert(.disallowViewOnce) } - let approvalView = AttachmentApprovalViewController(options: approvalVCOptions, attachmentApprovalItems: approvalItems) + let approvalView = AttachmentApprovalViewController(options: approvalVCOptions, attachmentApprovalItems: approvalItems, thread: nil) approvalVC = approvalView approvalView.approvalDelegate = self approvalView.approvalDataSource = self diff --git a/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift b/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift index 59f7eba2b59..0e138fe4ad5 100644 --- a/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift +++ b/SignalUI/AttachmentApproval/AttachmentApprovalViewController.swift @@ -100,7 +100,25 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } - lazy var outputQualityLevel: ImageQualityLevel = SSKEnvironment.shared.databaseStorageRef.read { .resolvedQuality(tx: $0) } + lazy var outputQualityLevel: ImageQualityLevel = { + SSKEnvironment.shared.databaseStorageRef.read { tx in + // Check if there's a thread-specific quality setting + if let thread = self.thread { + let imageQualityStore = ImageQualitySettingStore() + let resolvedLevel = imageQualityStore.resolvedQualityLevel(for: thread, tx: tx) + // Enable metadata preservation for original quality mode + if resolvedLevel == .original { + self.attachmentApprovalItemCollection.attachmentApprovalItems.forEach { item in + item.attachment.preserveMetadata = true + } + } + return resolvedLevel + } + return .resolvedQuality(tx: tx) + } + }() + + private let thread: TSThread? public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? public weak var approvalDataSource: AttachmentApprovalViewControllerDataSource? @@ -126,10 +144,11 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } - public init(options: AttachmentApprovalViewControllerOptions, attachmentApprovalItems: [AttachmentApprovalItem]) { + public init(options: AttachmentApprovalViewControllerOptions, attachmentApprovalItems: [AttachmentApprovalItem], thread: TSThread? = nil) { assert(attachmentApprovalItems.count > 0) self.receivedOptions = options + self.thread = thread let pageOptions: [UIPageViewController.OptionsKey: Any] = [.interPageSpacing: kSpacingBetweenItems] super.init(transitionStyle: .scroll, @@ -169,7 +188,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC hasQuotedReplyDraft: Bool, approvalDelegate: AttachmentApprovalViewControllerDelegate, approvalDataSource: AttachmentApprovalViewControllerDataSource, - stickerSheetDelegate: StickerPickerSheetDelegate? + stickerSheetDelegate: StickerPickerSheetDelegate?, + thread: TSThread? = nil ) -> OWSNavigationController { let attachmentApprovalItems = attachments.map { AttachmentApprovalItem(attachment: $0, canSave: false) } @@ -178,7 +198,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC if hasQuotedReplyDraft { options.insert(.disallowViewOnce) } - let vc = AttachmentApprovalViewController(options: options, attachmentApprovalItems: attachmentApprovalItems) + let vc = AttachmentApprovalViewController(options: options, attachmentApprovalItems: attachmentApprovalItems, thread: thread) // 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) @@ -1045,9 +1065,25 @@ extension AttachmentApprovalViewController { let localPhoneNumber = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber let standardQualityLevel = ImageQualityLevel.remoteDefault(localPhoneNumber: localPhoneNumber) + // Determine if Original quality option should be shown + let threadSupportsOriginal: Bool + if let thread = thread { + // Single-recipient: show Original only if enabled for this specific thread + threadSupportsOriginal = SSKEnvironment.shared.databaseStorageRef.read { tx in + let imageQualityStore = ImageQualitySettingStore() + let setting = imageQualityStore.fetchSetting(for: thread, tx: tx) + return setting == .original + } + } else { + // Multi-recipient: always show Original option + // It will apply to threads that have it enabled, others will get High quality + threadSupportsOriginal = true + } + let selectionControl = MediaQualitySelectionControl( standardQualityLevel: standardQualityLevel, - currentQualityLevel: outputQualityLevel + currentQualityLevel: outputQualityLevel, + supportsOriginal: threadSupportsOriginal ) selectionControl.callback = { [weak self, weak actionSheet] qualityLevel in self?.outputQualityLevel = qualityLevel @@ -1099,14 +1135,24 @@ extension AttachmentApprovalViewController { ) ) + private let buttonQualityOriginal = MediaQualityButton( + title: ImageQualityLevel.original.localizedString, + subtitle: OWSLocalizedString( + "ATTACHMENT_APPROVAL_MEDIA_QUALITY_ORIGINAL_OPTION_SUBTITLE", + comment: "Subtitle for the 'original' option for media quality." + ) + ) + private let standardQualityLevel: ImageQualityLevel + private let supportsOriginal: Bool private(set) var qualityLevel: ImageQualityLevel var callback: ((ImageQualityLevel) -> Void)? - init(standardQualityLevel: ImageQualityLevel, currentQualityLevel: ImageQualityLevel) { + init(standardQualityLevel: ImageQualityLevel, currentQualityLevel: ImageQualityLevel, supportsOriginal: Bool = false) { self.standardQualityLevel = standardQualityLevel self.qualityLevel = currentQualityLevel + self.supportsOriginal = supportsOriginal self.buttonQualityStandard = MediaQualityButton( title: standardQualityLevel.localizedString, @@ -1124,14 +1170,26 @@ extension AttachmentApprovalViewController { addSubview(buttonQualityStandard) buttonQualityStandard.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .trailing) - buttonQualityHigh.block = { [weak self] in - self?.didSelectQualityLevel(.high) + // Two-button layout: Always show Standard + either Original or High + if supportsOriginal { + // Standard | Original + buttonQualityOriginal.block = { [weak self] in + self?.didSelectQualityLevel(.original) + } + addSubview(buttonQualityOriginal) + buttonQualityOriginal.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .leading) + buttonQualityOriginal.autoPinEdge(.leading, to: .trailing, of: buttonQualityStandard, withOffset: 20) + buttonQualityOriginal.autoPinWidth(toWidthOf: buttonQualityStandard) + } else { + // Standard | High + buttonQualityHigh.block = { [weak self] in + self?.didSelectQualityLevel(.high) + } + addSubview(buttonQualityHigh) + buttonQualityHigh.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .leading) + buttonQualityHigh.autoPinEdge(.leading, to: .trailing, of: buttonQualityStandard, withOffset: 20) + buttonQualityHigh.autoPinWidth(toWidthOf: buttonQualityStandard) } - addSubview(buttonQualityHigh) - buttonQualityHigh.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .leading) - - buttonQualityHigh.autoPinWidth(toWidthOf: buttonQualityStandard) - buttonQualityHigh.autoPinEdge(.leading, to: .trailing, of: buttonQualityStandard, withOffset: 20) updateButtonAppearance() } @@ -1149,6 +1207,7 @@ extension AttachmentApprovalViewController { private func updateButtonAppearance() { buttonQualityStandard.isSelected = qualityLevel == standardQualityLevel buttonQualityHigh.isSelected = qualityLevel == .high + buttonQualityOriginal.isSelected = qualityLevel == .original } private class MediaQualityButton: OWSButton { @@ -1181,6 +1240,11 @@ extension AttachmentApprovalViewController { topLabel.text = title bottomLabel.text = subtitle + // Parent MediaQualitySelectionControl handles accessibility + isAccessibilityElement = false + topLabel.isAccessibilityElement = false + bottomLabel.isAccessibilityElement = false + let stackView = UIStackView(arrangedSubviews: [ topLabel, bottomLabel ]) stackView.alignment = .center stackView.axis = .vertical @@ -1229,8 +1293,15 @@ extension AttachmentApprovalViewController { override var accessibilityValue: String? { get { - let selectedButton = qualityLevel == .high ? buttonQualityHigh : buttonQualityStandard - return [ selectedButton.topLabel, selectedButton.bottomLabel ].compactMap { $0.text }.joined(separator: ",") + let selectedButton: MediaQualityButton + if qualityLevel == .original { + selectedButton = buttonQualityOriginal + } else if qualityLevel == .high { + selectedButton = buttonQualityHigh + } else { + selectedButton = buttonQualityStandard + } + return [ selectedButton.topLabel, selectedButton.bottomLabel ].compactMap { $0.text }.joined(separator: ", ") } set { super.accessibilityValue = newValue } } @@ -1247,13 +1318,15 @@ extension AttachmentApprovalViewController { override func accessibilityIncrement() { if qualityLevel == standardQualityLevel { - qualityLevel = .high + // Increment to either Original or High depending on what's available + qualityLevel = supportsOriginal ? .original : .high updateButtonAppearance() } } override func accessibilityDecrement() { - if qualityLevel == .high { + if qualityLevel == .high || qualityLevel == .original { + // Decrement back to standard qualityLevel = standardQualityLevel updateButtonAppearance() } diff --git a/SignalUI/AttachmentMultisend/AttachmentMultisend.swift b/SignalUI/AttachmentMultisend/AttachmentMultisend.swift index 99125831d0f..dbdf5540b32 100644 --- a/SignalUI/AttachmentMultisend/AttachmentMultisend.swift +++ b/SignalUI/AttachmentMultisend/AttachmentMultisend.swift @@ -55,6 +55,7 @@ public class AttachmentMultisend { let segmentedAttachments = try await segmentAttachmentsIfNecessary( for: conversations, + destinations: destinations, approvedAttachments: approvedAttachments, hasNonStoryDestination: hasNonStoryDestination, hasStoryDestination: hasStoryDestination @@ -224,59 +225,89 @@ public class AttachmentMultisend { private class func segmentAttachmentsIfNecessary( for conversations: [ConversationItem], + destinations: [Destination], approvedAttachments: [SignalAttachment], hasNonStoryDestination: Bool, hasStoryDestination: Bool ) async throws -> [SegmentAttachmentResult] { let maxSegmentDurations = conversations.compactMap(\.videoAttachmentDurationLimit) guard hasStoryDestination, !maxSegmentDurations.isEmpty, let requiredSegmentDuration = maxSegmentDurations.min() else { - // No need to segment! - var results = [SegmentAttachmentResult]() - for attachment in approvedAttachments { - let dataSource: AttachmentDataSource = try await deps.attachmentValidator.validateContents( - dataSource: attachment.dataSource, - shouldConsume: true, - mimeType: attachment.mimeType, - renderingFlag: attachment.renderingFlag, - sourceFilename: attachment.sourceFilename - ) - try results.append(.init( - original: dataSource, - segmented: nil, - isViewOnce: attachment.isViewOnceAttachment, - renderingFlag: attachment.renderingFlag - )) + // No need to segment! But still need to process per-thread quality settings + return try await processAttachmentsPerQualityLevel( + destinations: destinations, + approvedAttachments: approvedAttachments, + requiredSegmentDuration: nil + ) + } + + // Process attachments with per-thread quality levels and segmentation + return try await processAttachmentsPerQualityLevel( + destinations: destinations, + approvedAttachments: approvedAttachments, + requiredSegmentDuration: requiredSegmentDuration + ) + } + + /// Process attachments once per unique quality level needed across all destinations. + /// This respects per-thread quality settings while minimizing redundant processing. + private class func processAttachmentsPerQualityLevel( + destinations: [Destination], + approvedAttachments: [SignalAttachment], + requiredSegmentDuration: TimeInterval? + ) async throws -> [SegmentAttachmentResult] { + // Determine which quality level each thread needs + let qualityLevelsByThread = deps.databaseStorage.read { tx -> [String: ImageQualityLevel] in + let imageQualityStore = ImageQualitySettingStore() + var result: [String: ImageQualityLevel] = [:] + + for destination in destinations { + let thread = destination.thread + let qualityLevel = imageQualityStore.resolvedQualityLevel(for: thread, tx: tx) + result[thread.uniqueId] = qualityLevel } - return results + + return result } - let qualityLevel = deps.databaseStorage.read(block: deps.imageQualityLevel.resolvedQuality(tx:)) + // Find unique quality levels needed + let uniqueQualityLevels = Set(qualityLevelsByThread.values) + + // Use the highest quality level needed for processing + // This ensures all quality levels can be satisfied (Original subsumes High, High subsumes Standard) + let qualityLevel = uniqueQualityLevels.max(by: { $0.rawValue < $1.rawValue }) ?? + deps.databaseStorage.read(block: deps.imageQualityLevel.resolvedQuality(tx:)) + // Process attachments with the maximum quality level needed let segmentedResults = try await withThrowingTaskGroup( of: (Int, SegmentAttachmentResult).self ) { taskGroup in for (index, attachment) in approvedAttachments.enumerated() { taskGroup.addTask(operation: { - let segmentingResult = try await attachment.preparedForOutput(qualityLevel: qualityLevel) - .segmentedIfNecessary(segmentDuration: requiredSegmentDuration) - - let originalDataSource: AttachmentDataSource? - if hasNonStoryDestination || segmentingResult.segmented == nil { - // We need to prepare the original, either because there are no segments - // or because we are sending to a non-story which doesn't segment. - originalDataSource = try await deps.attachmentValidator.validateContents( + // Prepare attachment at the quality level (respects per-thread Original setting) + let preparedAttachment = try await attachment.preparedForOutput(qualityLevel: qualityLevel) + + let segmentingResult: SignalAttachment.SegmentAttachmentResult + if let requiredSegmentDuration = requiredSegmentDuration { + segmentingResult = try await preparedAttachment.segmentedIfNecessary(segmentDuration: requiredSegmentDuration) + } else { + // No segmentation needed, just use the prepared attachment + segmentingResult = SignalAttachment.SegmentAttachmentResult(preparedAttachment, segmented: nil) + } + + let originalDataSource: AttachmentDataSource? = try await { + // Always need original for non-segmented or non-story destinations + let dataSource: AttachmentDataSource = try await deps.attachmentValidator.validateContents( dataSource: segmentingResult.original.dataSource, shouldConsume: true, mimeType: segmentingResult.original.mimeType, renderingFlag: segmentingResult.original.renderingFlag, sourceFilename: segmentingResult.original.sourceFilename ) - } else { - originalDataSource = nil - } + return dataSource + }() let segmentedDataSources: [AttachmentDataSource]? = try await { () -> [AttachmentDataSource]? in - guard let segments = segmentingResult.segmented, hasStoryDestination else { + guard let segments = segmentingResult.segmented else { return nil } var segmentedDataSources = [AttachmentDataSource]()