diff --git a/WordPress/Classes/Services/MediaImportService.swift b/WordPress/Classes/Services/MediaImportService.swift index b5f5b9cfc731..b164e364a214 100644 --- a/WordPress/Classes/Services/MediaImportService.swift +++ b/WordPress/Classes/Services/MediaImportService.swift @@ -1,5 +1,6 @@ import Foundation import CocoaLumberjack +import PhotosUI /// Encapsulates importing assets such as PHAssets, images, videos, or files at URLs to Media objects. /// @@ -276,6 +277,11 @@ class MediaImportService: NSObject { exporter.videoOptions = self.exporterVideoOptions exporter.allowableFileExtensions = allowableFileExtensions.isEmpty ? MediaImportService.defaultAllowableFileExtensions : allowableFileExtensions return exporter + case let provider as NSItemProvider: + let exporter = ItemProviderMediaExporter(provider: provider) + exporter.imageOptions = self.exporterImageOptions + exporter.videoOptions = self.exporterVideoOptions + return exporter case let image as UIImage: let exporter = MediaImageExporter(image: image, filename: nil) exporter.options = self.exporterImageOptions diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 5f0ce3d1bec8..eff9f22c550f 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -42,6 +42,7 @@ enum FeatureFlag: Int, CaseIterable { case commentModerationUpdate case compliancePopover case domainFocus + case nativePhotoPicker /// Returns a boolean indicating if the feature is enabled var enabled: Bool { @@ -134,6 +135,8 @@ enum FeatureFlag: Int, CaseIterable { return true case .domainFocus: return true + case .nativePhotoPicker: + return false } } @@ -236,6 +239,8 @@ extension FeatureFlag { return "Compliance Popover" case .domainFocus: return "Domain Focus" + case .nativePhotoPicker: + return "Native Photo Picker" } } } diff --git a/WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift b/WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift new file mode 100644 index 000000000000..e16f69eb626c --- /dev/null +++ b/WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift @@ -0,0 +1,117 @@ +import Foundation +import PhotosUI + +/// Manages export of media assets: images and video. +final class ItemProviderMediaExporter: MediaExporter { + var mediaDirectoryType: MediaDirectory = .uploads + var imageOptions: MediaImageExporter.Options? + var videoOptions: MediaVideoExporter.Options? + + private let provider: NSItemProvider + + init(provider: NSItemProvider) { + self.provider = provider + } + + func export(onCompletion: @escaping (MediaExport) -> Void, onError: @escaping (MediaExportError) -> Void) -> Progress { + let progress = Progress.discreteProgress(totalUnitCount: MediaExportProgressUnits.done) + + // It's important to use the `MediaImageExporter` because it strips the + // GPS data and performs other image manipulations before the upload. + func processImage(at url: URL) throws { + let exporter = MediaImageExporter(url: url) + exporter.mediaDirectoryType = mediaDirectoryType + if let imageOptions { + exporter.options = imageOptions + } + // If image format is not supported, switch to `.heic`. + if exporter.options.exportImageType == nil, + let type = provider.registeredTypeIdentifiers.first, + !ItemProviderMediaExporter.supportedImageTypes.contains(type) { + exporter.options.exportImageType = UTType.heic.identifier + } + let exportProgress = exporter.export(onCompletion: onCompletion, onError: onError) + progress.addChild(exportProgress, withPendingUnitCount: MediaExportProgressUnits.halfDone) + } + + // `MediaImageExporter` doesn't support GIF, so it requires special handling. + func processGIF(at url: URL) throws { + let pixelSize = url.pixelSize + let media = MediaExport(url: url, fileSize: url.fileSize, width: pixelSize.width, height: pixelSize.height, duration: nil) + onCompletion(media) + } + + func processVideo(at url: URL) throws { + let exporter = MediaVideoExporter(url: url) + exporter.mediaDirectoryType = mediaDirectoryType + if let videoOptions { + exporter.options = videoOptions + } + let exportProgress = exporter.export(onCompletion: onCompletion, onError: onError) + progress.addChild(exportProgress, withPendingUnitCount: MediaExportProgressUnits.halfDone) + } + + let loadProgress = provider.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in + guard let url else { + onError(ExportError.underlyingError(error)) + return + } + DDLogDebug("Loaded file representation (filename: '\(url.lastPathComponent)', types: \(self.provider.registeredTypeIdentifiers)") + + // Retaining `self` on purpose. + do { + let copyURL = try self.mediaFileManager.makeLocalMediaURL(withFilename: url.lastPathComponent, fileExtension: url.pathExtension) + try FileManager.default.copyItem(at: url, to: copyURL) + + if self.hasConformingType(.gif) { + try processGIF(at: copyURL) + } else if self.hasConformingType(.image) { + try processImage(at: copyURL) + } else if self.hasConformingType(.movie) || self.hasConformingType(.video) { + try processVideo(at: copyURL) + } else { + onError(ExportError.unsupportedContentType) + } + } catch { + onError(ExportError.underlyingError(error)) + } + } + progress.addChild(loadProgress, withPendingUnitCount: MediaExportProgressUnits.halfDone) + return progress + } + + /// The list of image formats supported by the backend. + /// See https://wordpress.com/support/accepted-filetypes/. + /// + /// One notable format missing from the list is `.webp`, which is not supported + /// by `CGImageDestinationCreateWithURL` and, in turn, `MediaImageExporter`. + /// + /// If the format is not supported, the app fallbacks to `.heic` which is + /// similar to `.webp`: more efficient than traditional formats and supports + /// opacity, unlike `.jpeg`. + private static let supportedImageTypes: Set = Set([ + UTType.png, + UTType.jpeg, + UTType.gif, + UTType.heic, + UTType.svg + ].map(\.identifier)) + + private func hasConformingType(_ type: UTType) -> Bool { + provider.hasItemConformingToTypeIdentifier(type.identifier) + } + + enum ExportError: MediaExportError { + case unsupportedContentType + case underlyingError(Error?) + + var description: String { + switch self { + case .unsupportedContentType: + return NSLocalizedString("mediaExporter.error.unsupportedContentType", value: "Unsupported content type", comment: "An error message the app shows if media import fails") + case .underlyingError(let error): + return error?.localizedDescription ?? NSLocalizedString("mediaExporter.error.unknown", value: "The item could not be added to the Media library", comment: "An error message the app shows if media import fails") + } + } + } +} diff --git a/WordPress/Classes/Utility/Media/MediaAssetExporter.swift b/WordPress/Classes/Utility/Media/MediaAssetExporter.swift index d500e8336fb7..fd4db4ab49f0 100644 --- a/WordPress/Classes/Utility/Media/MediaAssetExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaAssetExporter.swift @@ -2,6 +2,7 @@ import Foundation import MobileCoreServices import UniformTypeIdentifiers import AVFoundation +import Photos /// Media export handling of PHAssets /// diff --git a/WordPress/Classes/Utility/Media/MediaFileManager.swift b/WordPress/Classes/Utility/Media/MediaFileManager.swift index 0391ac48874d..a20805cc44e3 100644 --- a/WordPress/Classes/Utility/Media/MediaFileManager.swift +++ b/WordPress/Classes/Utility/Media/MediaFileManager.swift @@ -9,7 +9,10 @@ enum MediaDirectory { /// System Caches directory, for creating discardable media files, such as thumbnails. case cache /// System temporary directory, used for unit testing or temporary media files. - case temporary + case temporary(id: UUID) + + /// Use a new ID for every test scenario to make sure all tests are isolated. + static var temporary: MediaDirectory { .temporary(id: UUID()) } /// Returns the directory URL for the directory type. /// @@ -22,8 +25,8 @@ enum MediaDirectory { parentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! case .cache: parentDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! - case .temporary: - parentDirectory = fileManager.temporaryDirectory + case .temporary(let id): + parentDirectory = fileManager.temporaryDirectory.appendingPathComponent(id.uuidString) } return parentDirectory.appendingPathComponent(MediaFileManager.mediaDirectoryName, isDirectory: true) } diff --git a/WordPress/Classes/Utility/Media/MediaImageExporter.swift b/WordPress/Classes/Utility/Media/MediaImageExporter.swift index ae7b7d040aa4..74092d606473 100644 --- a/WordPress/Classes/Utility/Media/MediaImageExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaImageExporter.swift @@ -1,4 +1,6 @@ import Foundation +import CoreGraphics +import UIKit import MobileCoreServices import UniformTypeIdentifiers diff --git a/WordPress/Classes/Utility/Media/MediaVideoExporter.swift b/WordPress/Classes/Utility/Media/MediaVideoExporter.swift index fc7d775eeb9b..e4bc225a65d9 100644 --- a/WordPress/Classes/Utility/Media/MediaVideoExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaVideoExporter.swift @@ -1,6 +1,7 @@ import Foundation import MobileCoreServices import UniformTypeIdentifiers +import AVFoundation /// Media export handling of Videos from PHAssets or AVAssets. /// diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift index e73857e10fd2..b70bd453194c 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift @@ -1,9 +1,10 @@ import MobileCoreServices import WPMediaPicker +import PhotosUI /// Prepares the alert controller that will be presented when tapping the "+" button in Media Library final class MediaLibraryMediaPickingCoordinator { - typealias PickersDelegate = StockPhotosPickerDelegate & WPMediaPickerViewControllerDelegate & TenorPickerDelegate + typealias PickersDelegate = StockPhotosPickerDelegate & WPMediaPickerViewControllerDelegate & TenorPickerDelegate & PHPickerViewControllerDelegate private weak var delegate: PickersDelegate? private var tenor: TenorPicker? @@ -117,7 +118,13 @@ final class MediaLibraryMediaPickingCoordinator { } private func showMediaPicker(origin: UIViewController, blog: Blog) { - mediaLibrary.presentPicker(origin: origin, blog: blog) + if FeatureFlag.nativePhotoPicker.enabled { + let picker = PHPickerViewController(configuration: .make()) + picker.delegate = self + origin.present(picker, animated: true) + } else { + mediaLibrary.presentPicker(origin: origin, blog: blog) + } } } @@ -134,3 +141,9 @@ extension MediaLibraryMediaPickingCoordinator: StockPhotosPickerDelegate { stockPhotos = nil } } + +extension MediaLibraryMediaPickingCoordinator: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + delegate?.picker(picker, didFinishPicking: results) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift index 1f0fa48ffca3..d01d8e8786dd 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift @@ -5,6 +5,7 @@ import WordPressShared import WPMediaPicker import MobileCoreServices import UniformTypeIdentifiers +import PhotosUI /// Displays the user's media library in a grid /// @@ -473,6 +474,17 @@ class MediaLibraryViewController: WPMediaPickerViewController { } } +extension MediaLibraryViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + dismiss(animated: true) + + for result in results { + let info = MediaAnalyticsInfo(origin: .mediaLibrary(.deviceLibrary), selectionMethod: .fullScreenPicker) + MediaCoordinator.shared.addMedia(from: result.itemProvider, to: blog, analyticsInfo: info) + } + } +} + // MARK: - UIDocumentPickerDelegate extension MediaLibraryViewController: UIDocumentPickerDelegate { diff --git a/WordPress/Classes/ViewRelated/Media/NSItemProvider+Exportable.swift b/WordPress/Classes/ViewRelated/Media/NSItemProvider+Exportable.swift new file mode 100644 index 000000000000..93c8e09ac589 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/NSItemProvider+Exportable.swift @@ -0,0 +1,14 @@ +import UIKit + +extension NSItemProvider: ExportableAsset { + public var assetMediaType: MediaType { + if hasItemConformingToTypeIdentifier(UTType.image.identifier) { + return .image + } else if hasItemConformingToTypeIdentifier(UTType.video.identifier) || + hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + return .video + } else { + return .document + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/PHPickerController+Extensions.swift b/WordPress/Classes/ViewRelated/Media/PHPickerController+Extensions.swift new file mode 100644 index 000000000000..e9f2bb945646 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/PHPickerController+Extensions.swift @@ -0,0 +1,13 @@ +import UIKit +import PhotosUI + +extension PHPickerConfiguration { + /// Returns the picker configuration optimized for the Jetpack app. + static func make() -> PHPickerConfiguration { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.preferredAssetRepresentationMode = .compatible + configuration.selection = .ordered + configuration.selectionLimit = 0 // Unlimited + return configuration + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 7c60427081e7..fab0a449ec36 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -425,6 +425,14 @@ 0C896DE42A3A7BDC00D7D4E7 /* SettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C896DDF2A3A763400D7D4E7 /* SettingsCell.swift */; }; 0C896DE52A3A7C1F00D7D4E7 /* SiteVisibility+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C896DE12A3A767200D7D4E7 /* SiteVisibility+Extensions.swift */; }; 0C896DE72A3A832B00D7D4E7 /* SiteVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C896DE62A3A832B00D7D4E7 /* SiteVisibilityTests.swift */; }; + 0C8FC9A12A8BC8630059DCE4 /* PHPickerController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FC9A02A8BC8630059DCE4 /* PHPickerController+Extensions.swift */; }; + 0C8FC9A22A8BC8630059DCE4 /* PHPickerController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FC9A02A8BC8630059DCE4 /* PHPickerController+Extensions.swift */; }; + 0C8FC9A42A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FC9A32A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift */; }; + 0C8FC9A52A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FC9A32A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift */; }; + 0C8FC9A72A8BFAAE0059DCE4 /* NSItemProvider+Exportable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FC9A62A8BFAAD0059DCE4 /* NSItemProvider+Exportable.swift */; }; + 0C8FC9A82A8BFAAE0059DCE4 /* NSItemProvider+Exportable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FC9A62A8BFAAD0059DCE4 /* NSItemProvider+Exportable.swift */; }; + 0C8FC9AA2A8C57000059DCE4 /* ItemProviderMediaExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FC9A92A8C57000059DCE4 /* ItemProviderMediaExporterTests.swift */; }; + 0C8FC9AC2A8C57930059DCE4 /* test-webp.webp in Resources */ = {isa = PBXBuildFile; fileRef = 0C8FC9AB2A8C57930059DCE4 /* test-webp.webp */; }; 0CB4056B29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; 0CB4056C29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; 0CB4056E29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */; }; @@ -6099,6 +6107,11 @@ 0C896DDF2A3A763400D7D4E7 /* SettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCell.swift; sourceTree = ""; }; 0C896DE12A3A767200D7D4E7 /* SiteVisibility+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiteVisibility+Extensions.swift"; sourceTree = ""; }; 0C896DE62A3A832B00D7D4E7 /* SiteVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteVisibilityTests.swift; sourceTree = ""; }; + 0C8FC9A02A8BC8630059DCE4 /* PHPickerController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHPickerController+Extensions.swift"; sourceTree = ""; }; + 0C8FC9A32A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProviderMediaExporter.swift; sourceTree = ""; }; + 0C8FC9A62A8BFAAD0059DCE4 /* NSItemProvider+Exportable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSItemProvider+Exportable.swift"; sourceTree = ""; }; + 0C8FC9A92A8C57000059DCE4 /* ItemProviderMediaExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProviderMediaExporterTests.swift; sourceTree = ""; }; + 0C8FC9AB2A8C57930059DCE4 /* test-webp.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = "test-webp.webp"; sourceTree = ""; }; 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationService.swift; sourceTree = ""; }; 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationServiceTests.swift; sourceTree = ""; }; 0CB4057029C8DCF4008EED0A /* BlogDashboardPersonalizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationViewModel.swift; sourceTree = ""; }; @@ -10048,6 +10061,7 @@ 08B6E5191F036CAD00268F57 /* MediaFileManager.swift */, 08F8CD291EBD22EF0049D0C0 /* MediaExporter.swift */, 08C388651ED7705E0057BE49 /* MediaAssetExporter.swift */, + 0C8FC9A32A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift */, 08F8CD2E1EBD29440049D0C0 /* MediaImageExporter.swift */, 086103951EE09C91004D7C01 /* MediaVideoExporter.swift */, 08F8CD381EBD2C970049D0C0 /* MediaURLExporter.swift */, @@ -10072,6 +10086,7 @@ 08F8CD3A1EBD2D020049D0C0 /* MediaURLExporterTests.swift */, 08E77F461EE9D72F006F9515 /* MediaThumbnailExporterTests.swift */, DCC662502810915D00962D0C /* BlogVideoLimitsTests.swift */, + 0C8FC9A92A8C57000059DCE4 /* ItemProviderMediaExporterTests.swift */, ); name = Media; sourceTree = ""; @@ -12381,6 +12396,8 @@ D80BC79F2074722000614A59 /* CameraCaptureCoordinator.swift */, D80BC7A12074739300614A59 /* MediaLibraryStrings.swift */, D80BC7A3207487F200614A59 /* MediaLibraryPicker.swift */, + 0C8FC9A02A8BC8630059DCE4 /* PHPickerController+Extensions.swift */, + 0C8FC9A62A8BFAAD0059DCE4 /* NSItemProvider+Exportable.swift */, 7E14635620B3BEAB00B95F41 /* WPStyleGuide+Loader.swift */, 7ECD5B8020C4D823001AEBC5 /* MediaPreviewHelper.swift */, ); @@ -16620,6 +16637,7 @@ 08F8CD331EBD2AA80049D0C0 /* test-image-device-photo-gps-portrait.jpg */, 08F8CD341EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg */, 08DF9C431E8475530058678C /* test-image-portrait.jpg */, + 0C8FC9AB2A8C57930059DCE4 /* test-webp.webp */, E1E4CE0517739FAB00430844 /* test-image.jpg */, 084D94AE1EDF842600C385A6 /* test-video-device-gps.m4v */, D88A64A3208D8FB6008AE9BC /* stock-photos-search-response.json */, @@ -19392,6 +19410,7 @@ D848CC0B20FF2D5D00A9038F /* notifications-user-range.json in Resources */, 855408861A6F105700DDBD79 /* app-review-prompt-all-enabled.json in Resources */, 32110569250C0E960048446F /* 100x100-png in Resources */, + 0C8FC9AC2A8C57930059DCE4 /* test-webp.webp in Resources */, DC772B0428200A3700664C02 /* stats-visits-day-11.json in Resources */, D848CC1320FF31BB00A9038F /* notifications-blockquote-range.json in Resources */, D848CC0D20FF2D7C00A9038F /* notifications-post-range.json in Resources */, @@ -21277,6 +21296,7 @@ C3C2F84828AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift in Sources */, F181EDE526B2AC7200C61241 /* BackgroundTasksCoordinator.swift in Sources */, 011F52DA2A1CA53300B04114 /* CheckoutViewController.swift in Sources */, + 0C8FC9A42A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift in Sources */, 8B93412F257029F60097D0AC /* FilterChipButton.swift in Sources */, 4A1E77C6298897F6006281CC /* SharingSyncService.swift in Sources */, CEBD3EAB0FF1BA3B00C1396E /* Blog.m in Sources */, @@ -22546,6 +22566,7 @@ E6431DE61C4E892900FD8D90 /* SharingDetailViewController.m in Sources */, FA8E2FE027C6377000DA0982 /* DashboardQuickStartCardCell.swift in Sources */, B50EED791C0E5B2400D278CA /* SettingsPickerViewController.swift in Sources */, + 0C8FC9A72A8BFAAE0059DCE4 /* NSItemProvider+Exportable.swift in Sources */, 17C1D6912670E4A2006C8970 /* UIFont+Fitting.swift in Sources */, 329F8E5824DDBD11002A5311 /* ReaderTopicCollectionViewCoordinator.swift in Sources */, 5948AD0E1AB734F2006E8882 /* WPAppAnalytics.m in Sources */, @@ -22568,6 +22589,7 @@ FA1ACAA21BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift in Sources */, 5D1181E71B4D6DEB003F3084 /* WPStyleGuide+Reader.swift in Sources */, 3250490724F988220036B47F /* Interpolation.swift in Sources */, + 0C8FC9A12A8BC8630059DCE4 /* PHPickerController+Extensions.swift in Sources */, E17FEAD8221490F7006E1D2D /* PostEditorAnalyticsSession.swift in Sources */, 738B9A5221B85CF20005062B /* SiteCreationWizardLauncher.swift in Sources */, 46F583AF2624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift in Sources */, @@ -23408,6 +23430,7 @@ 8B7F51CB24EED8A8008CF5B5 /* ReaderTrackerTests.swift in Sources */, D848CC0320FF04FA00A9038F /* FormattableUserContentTests.swift in Sources */, 5948AD111AB73D19006E8882 /* WPAppAnalyticsTests.m in Sources */, + 0C8FC9AA2A8C57000059DCE4 /* ItemProviderMediaExporterTests.swift in Sources */, 0A69300B28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift in Sources */, FF8032661EE9E22200861F28 /* MediaProgressCoordinatorTests.swift in Sources */, 173D82E7238EE2A7008432DA /* FeatureFlagTests.swift in Sources */, @@ -24590,6 +24613,7 @@ FABB23542602FC2C00C8785C /* NotificationName+Names.swift in Sources */, FABB23552602FC2C00C8785C /* NSFetchedResultsController+Helpers.swift in Sources */, FABB23562602FC2C00C8785C /* BlogSettings+Discussion.swift in Sources */, + 0C8FC9A22A8BC8630059DCE4 /* PHPickerController+Extensions.swift in Sources */, FABB23572602FC2C00C8785C /* PostTagPickerViewController.swift in Sources */, FABB23582602FC2C00C8785C /* SignupEpilogueCell.swift in Sources */, FABB23592602FC2C00C8785C /* NoteBlockActionsTableViewCell.swift in Sources */, @@ -25056,6 +25080,7 @@ FABB24B52602FC2C00C8785C /* RestoreStatusView.swift in Sources */, FABB24B62602FC2C00C8785C /* CustomizeInsightsCell.swift in Sources */, FABB24B72602FC2C00C8785C /* SVProgressHUD+Dismiss.m in Sources */, + 0C8FC9A52A8BD39A0059DCE4 /* ItemProviderMediaExporter.swift in Sources */, F4D9188729D78C9100974A71 /* BlogDetailsViewController+Strings.swift in Sources */, FABB24B82602FC2C00C8785C /* DynamicHeightCollectionView.swift in Sources */, FABB24B92602FC2C00C8785C /* RegisterDomainDetailsViewModel+RowList.swift in Sources */, @@ -25104,6 +25129,7 @@ C373D6E828045281008F8C26 /* SiteIntentData.swift in Sources */, FABB24DA2602FC2C00C8785C /* HomeWidgetAllTimeData.swift in Sources */, DC3B9B2D27739760003F7249 /* TimeZoneSelectorViewModel.swift in Sources */, + 0C8FC9A82A8BFAAE0059DCE4 /* NSItemProvider+Exportable.swift in Sources */, FABB24DB2602FC2C00C8785C /* UIView+Borders.swift in Sources */, FABB24DC2602FC2C00C8785C /* BasePost.m in Sources */, FABB24DD2602FC2C00C8785C /* main.swift in Sources */, diff --git a/WordPress/WordPressTest/ItemProviderMediaExporterTests.swift b/WordPress/WordPressTest/ItemProviderMediaExporterTests.swift new file mode 100644 index 000000000000..e01524807055 --- /dev/null +++ b/WordPress/WordPressTest/ItemProviderMediaExporterTests.swift @@ -0,0 +1,161 @@ +import Foundation +import OHHTTPStubs +import XCTest +@testable import WordPress + +final class ItemProviderMediaExporterTests: XCTestCase { + + // MARK: - Images + + func testThatWebPIsConvertedToSupportedFormat() throws { + // GIVEN a provider with a WebP image + let provider = try makeProvider(forResource: "test-webp", withExtension: "webp", type: .webP) + + // WHEN + let exporter = ItemProviderMediaExporter(provider: provider) + exporter.mediaDirectoryType = .temporary + + let media = try exportedMedia(from: exporter) + + // THEN it switched to heic from webp + XCTAssertEqual(media.url.pathExtension, "heic") + XCTAssertEqual(media.width, 1024.0) + XCTAssertEqual(media.height, 772.0) + XCTAssertNotNil(UIImage(data: try Data(contentsOf: media.url))) + + MediaExporterTests.cleanUpExportedMedia(atURL: media.url) + } + + func testThatGPSDataIsRemoved() throws { + // GIVEN an image with GPS data + let imageName = "test-image-device-photo-gps" + let provider = try makeProvider(forResource: imageName, withExtension: "jpg", type: .jpeg) + + do { + // Sanity check: verify that the original image has EXIF data + let imageURL = try XCTUnwrap(Bundle.test.url(forResource: imageName, withExtension: "jpg")) + let properties = try getImageProperties(for: imageURL) + XCTAssertNotNil(properties[kCGImagePropertyGPSDictionary]) + } + + // WHEN + let exporter = ItemProviderMediaExporter(provider: provider) + exporter.mediaDirectoryType = .temporary + exporter.imageOptions = .init(stripsGeoLocationIfNeeded: true) + + let media = try exportedMedia(from: exporter) + + // THEN it exported the image as jpeg (still has EXIF) + XCTAssertEqual(media.url.pathExtension, "jpeg") + XCTAssertNotNil(UIImage(data: try Data(contentsOf: media.url))) + + // THEN but GPS data was removed + let properties = try getImageProperties(for: media.url) + XCTAssertNil(properties[kCGImagePropertyGPSDictionary]) + + MediaExporterTests.cleanUpExportedMedia(atURL: media.url) + } + + func testThatGIFIsExported() throws { + // GIVEN a GIF file + let provider = try makeProvider(forResource: "test-gif", withExtension: "gif", type: .gif) + + // WHEN + let exporter = ItemProviderMediaExporter(provider: provider) + exporter.mediaDirectoryType = .temporary + + let media = try exportedMedia(from: exporter) + + // THEN + XCTAssertEqual(media.url.pathExtension, "gif") + XCTAssertEqual(media.height, 360) + XCTAssertEqual(media.width, 360) + + MediaExporterTests.cleanUpExportedMedia(atURL: media.url) + } + + // MARK: - Video + + func testThatVideoIsExported() throws { + // GIVEN a video + let provider = try makeProvider(forResource: "test-video-device-gps", withExtension: "m4v", type: .mpeg4Movie) + + // WHEN + let exporter = ItemProviderMediaExporter(provider: provider) + exporter.mediaDirectoryType = .temporary + + let media = try exportedMedia(from: exporter) + + // THEN the video is transcoded to one of the supported containers (.mp4) + XCTAssertEqual(media.url.pathExtension, "mp4") + + // THEN video metadata is saved + XCTAssertEqual(media.height, 360) + XCTAssertEqual(media.width, 640) + XCTAssertEqual(media.duration ?? 0.0, 3.47, accuracy: 0.01) + + MediaExporterTests.cleanUpExportedMedia(atURL: media.url) + } + + // MARK: - Error Handling + + func testThatExportFailsWithUnsupportedData() throws { + // GIVEN + let provider = NSItemProvider() + provider.registerDataRepresentation(forTypeIdentifier: UTType.exe.identifier, visibility: .all) { completion in + completion(Data(), nil) + return nil + } + + // WHEN + let exporter = ItemProviderMediaExporter(provider: provider) + exporter.mediaDirectoryType = .temporary + + do { + let _ = try exportedMedia(from: exporter) + XCTFail("Expected the export to fail") + } catch { + // THEN + let error = try XCTUnwrap(error as? ItemProviderMediaExporter.ExportError) + if case .unsupportedContentType = error { + // Expected + } else { + XCTFail("Unexpected error: \(error)") + } + } + } +} + +// MARK: - ItemProviderMediaExporterTests (Helpers) + +private extension ItemProviderMediaExporterTests { + func makeProvider(forResource name: String, withExtension ext: String, type: UTType) throws -> NSItemProvider { + let imageURL = try XCTUnwrap(Bundle.test.url(forResource: name, withExtension: ext)) + let provider = NSItemProvider() + provider.registerFileRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in + completion(imageURL, false, nil) + return nil + } + return provider + } + + func exportedMedia(from exporter: ItemProviderMediaExporter) throws -> MediaExport { + let expectation = self.expectation(description: "mediaExported") + var result: Result? + _ = exporter.export(onCompletion: { media in + result = .success(media) + expectation.fulfill() + }, onError: { error in + result = .failure(error) + expectation.fulfill() + }) + wait(for: [expectation], timeout: 2) + return try XCTUnwrap(result).get() + } + + func getImageProperties(for imageURL: URL) throws -> [CFString: Any] { + let source = try XCTUnwrap(CGImageSourceCreateWithURL(imageURL as CFURL, nil)) + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + return properties ?? [:] + } +} diff --git a/WordPress/WordPressTest/Test Data/test-webp.webp b/WordPress/WordPressTest/Test Data/test-webp.webp new file mode 100644 index 000000000000..a608fc85c203 Binary files /dev/null and b/WordPress/WordPressTest/Test Data/test-webp.webp differ