diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 47ebf3c10ed2..0c7b1499acc7 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "7180587f49d3c3bfdb34cc3e80b2a9d22a3cd93e", - "version" : "0.18.1" + "branch" : "pr-build/357", + "revision" : "72d422699607aec7b5e666db422787b5e2a6378d" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 9d07d6b80bb7..8feb7ee92788 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -62,7 +62,7 @@ let package = Package( revision: "b34794c9a3f32312e1593d4a3d120572afa0d010" ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), - .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.18.1"), + .package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/357"), .package( url: "https://github.com/automattic/wordpress-rs", exact: "0.6.0" diff --git a/Package.resolved b/Package.resolved index e4f561d43c78..76af08fe709c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "7180587f49d3c3bfdb34cc3e80b2a9d22a3cd93e", - "version" : "0.18.1" + "branch" : "pr-build/357", + "revision" : "72d422699607aec7b5e666db422787b5e2a6378d" } }, { diff --git a/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift b/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift new file mode 100644 index 000000000000..9d6c5a052f49 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift @@ -0,0 +1,174 @@ +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +import WordPressShared + +@testable import WordPress + +struct GBKMediaUploadProcessorTests { + + // MARK: - Images + + @Test func imageIsResizedWhenOptimizationEnabled() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = true + settings.maxImageSizeSetting = 200 + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-image-device-photo-gps.jpg") + + let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent) + + guard case .processed(let outputURL, let mimeType, let filename) = result else { + Issue.record("Expected a processed file") + return + } + defer { cleanUp(outputURL) } + let size = try imageSize(at: outputURL) + #expect(max(size.width, size.height) == 200) + #expect(mimeType == "image/jpeg") + #expect(filename.hasPrefix("test-image-device-photo-gps")) + } + + @Test func imageIsUntouchedWhenProcessingWouldBeNoOp() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = false + settings.removeLocationSetting = false + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-image-device-photo-gps.jpg") + + let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent) + + guard case .original = result else { + Issue.record("Expected the original file to pass through") + return + } + } + + @Test func gpsDataIsStrippedWhenRemoveLocationEnabled() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = false + settings.removeLocationSetting = true + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("test-image-device-photo-gps.jpg") + + let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent) + + guard case .processed(let outputURL, _, _) = result else { + Issue.record("Expected a processed file") + return + } + defer { cleanUp(outputURL) } + #expect(try imageProperties(at: url)[kCGImagePropertyGPSDictionary] != nil) + #expect(try imageProperties(at: outputURL)[kCGImagePropertyGPSDictionary] == nil) + } + + @Test func heicIsConvertedToJPEG() async throws { + let settings = makeSettings() + settings.imageOptimizationEnabled = false + settings.removeLocationSetting = false + let processor = makeProcessor(settings: settings) + let url = try fixtureURL("iphone-photo.heic") + + let result = try await processor.processFile(at: url, mimeType: "image/heic", filename: url.lastPathComponent) + + guard case .processed(let outputURL, let mimeType, let filename) = result else { + Issue.record("Expected a processed file") + return + } + defer { cleanUp(outputURL) } + #expect(mimeType == "image/jpeg") + #expect(filename.hasSuffix(".jpg") || filename.hasSuffix(".jpeg")) + } + + // MARK: - GIFs and other files + + @Test func gifPassesThroughUntouched() async throws { + let processor = makeProcessor(settings: makeSettings()) + let url = try fixtureURL("test-gif.gif") + + let result = try await processor.processFile(at: url, mimeType: "image/gif", filename: url.lastPathComponent) + + guard case .original = result else { + Issue.record("Expected the original file to pass through") + return + } + } + + @Test func disallowedFileExtensionThrows() async throws { + let processor = GBKMediaUploadProcessor( + videoDurationLimit: nil, + allowableFileExtensions: ["pdf"], + makeMediaSettings: makeSettingsFactory(makeSettings()) + ) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + try "plain text".write(to: url, atomically: true, encoding: .utf8) + defer { cleanUp(url) } + + await #expect(throws: MediaURLExporter.URLExportError.self) { + try await processor.processFile(at: url, mimeType: "text/plain", filename: url.lastPathComponent) + } + } + + // MARK: - Videos + + @Test func videoExceedingDurationLimitThrows() async throws { + let processor = GBKMediaUploadProcessor( + videoDurationLimit: 1, + allowableFileExtensions: [], + makeMediaSettings: makeSettingsFactory(makeSettings()) + ) + let url = try fixtureURL("test-video-device-gps.m4v") + + await #expect(throws: (any Error).self) { + try await processor.processFile(at: url, mimeType: "video/mp4", filename: url.lastPathComponent) + } + } + + // MARK: - Helpers + + private func makeProcessor(settings: MediaSettings) -> GBKMediaUploadProcessor { + GBKMediaUploadProcessor( + videoDurationLimit: nil, + allowableFileExtensions: [], + makeMediaSettings: makeSettingsFactory(settings) + ) + } + + private func makeSettings() -> MediaSettings { + MediaSettings(database: EphemeralKeyValueDatabase()) + } + + private func makeSettingsFactory(_ settings: MediaSettings) -> @Sendable () -> MediaSettings { + nonisolated(unsafe) let settings = settings + return { settings } + } + + private func fixtureURL(_ filename: String) throws -> URL { + let bundle = Bundle(for: BundleAnchor.self) + let name = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + let url = try #require(bundle.url(forResource: name, withExtension: ext)) + return url + } + + private func imageProperties(at url: URL) throws -> [CFString: Any] { + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + return properties + } + + private func imageSize(at url: URL) throws -> CGSize { + let properties = try imageProperties(at: url) + let width = try #require(properties[kCGImagePropertyPixelWidth] as? CGFloat) + let height = try #require(properties[kCGImagePropertyPixelHeight] as? CGFloat) + return CGSize(width: width, height: height) + } + + private func cleanUp(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} + +/// Anchor for resolving the test bundle from Swift Testing suites. +private final class BundleAnchor {} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/GBKMediaUploadProcessor.swift b/WordPress/Classes/ViewRelated/NewGutenberg/GBKMediaUploadProcessor.swift new file mode 100644 index 000000000000..182fc633dfd5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NewGutenberg/GBKMediaUploadProcessor.swift @@ -0,0 +1,129 @@ +import Foundation +import GutenbergKit +import UniformTypeIdentifiers +import WordPressData + +/// Processes media files picked in the GutenbergKit editor before upload, +/// applying the app's Media settings (image optimization, max upload size, +/// image quality, video resolution, and location stripping). +/// +/// Assigned to `GutenbergKit.EditorViewController.mediaUploadDelegate`, which +/// holds it weakly and invokes it off the main actor, so the type is `Sendable` +/// and snapshots the `Blog`-derived values it needs at initialization. +final class GBKMediaUploadProcessor: MediaUploadDelegate, Sendable { + private let videoDurationLimit: TimeInterval? + private let allowableFileExtensions: Set + private let makeMediaSettings: @Sendable () -> MediaSettings + + /// Image types the WordPress REST API reliably accepts. Other image + /// formats (e.g. HEIC) are converted to JPEG during processing, mirroring + /// `ItemProviderMediaExporter`. + private static let webSafeImageTypes: Set = [.png, .jpeg, .gif, .svg] + + @MainActor + convenience init(blog: Blog) { + // HEIC isn't supported when uploading an image, so we filter it out, + // mirroring `MediaImportService`. + var allowedFileTypes = blog.allowedFileTypes + allowedFileTypes.remove("heic") + + self.init( + videoDurationLimit: blog.videoDurationLimit, + allowableFileExtensions: allowedFileTypes + ) + } + + init( + videoDurationLimit: TimeInterval?, + allowableFileExtensions: Set, + makeMediaSettings: @escaping @Sendable () -> MediaSettings = { MediaSettings() } + ) { + self.videoDurationLimit = videoDurationLimit + self.allowableFileExtensions = allowableFileExtensions + self.makeMediaSettings = makeMediaSettings + } + + // MARK: - MediaUploadDelegate + + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + let expected = try MediaURLExporter.expectedExport(with: url) + let settings = makeMediaSettings() + + switch expected { + case .gif: + // GIFs are uploaded unchanged; processing would only copy the file. + return .original + case .other: + // Non-media files are uploaded unchanged, but enforce the site's + // allowed file extensions, mirroring `MediaURLExporter.exportURL`. + if let fileExtension = url.typeIdentifierFileExtension, + !MediaImportService.defaultAllowableFileExtensions.contains(fileExtension), + !allowableFileExtensions.isEmpty, + !allowableFileExtensions.contains(fileExtension) + { + throw MediaURLExporter.URLExportError.unsupportedFileType + } + return .original + case .image: + // Skip processing when it would be a no-op: optimization and + // location stripping disabled, and the format is web-safe. + if !settings.imageOptimizationEnabled, + !settings.removeLocationSetting, + let type = url.typeIdentifier.flatMap(UTType.init), + Self.webSafeImageTypes.contains(type) + { + return .original + } + case .video: + // Always process video to apply the export preset, duration + // limit, and location stripping. + break + } + + let export = try await makeExporter(for: url, settings: settings).export() + + guard let mimeType = export.url.typeIdentifier.flatMap(UTType.init)?.preferredMIMEType else { + throw MediaURLExporter.URLExportError.unknownFileUTI + } + return .processed(export.url, mimeType: mimeType, filename: export.url.lastPathComponent) + } + + // MARK: - Exporter configuration + + /// Builds an exporter configured from the app's Media settings, mirroring + /// the option mapping in `MediaImportService`. + private func makeExporter(for url: URL, settings: MediaSettings) -> MediaURLExporter { + let exporter = MediaURLExporter(url: url) + // GutenbergKit deletes the processed file after uploading it, so the + // export is written to a temporary directory rather than the uploads + // directory tracked by `MediaFileManager`. + exporter.mediaDirectoryType = .temporary + + var imageOptions = MediaImageExporter.Options() + imageOptions.maximumImageSize = maximumImageSize(from: settings) + imageOptions.stripsGeoLocationIfNeeded = settings.removeLocationSetting + imageOptions.imageCompressionQuality = settings.imageQualityForUpload.doubleValue + if let type = url.typeIdentifier.flatMap(UTType.init), !Self.webSafeImageTypes.contains(type) { + imageOptions.exportImageType = UTType.jpeg.identifier + } + exporter.imageOptions = imageOptions + + var videoOptions = MediaVideoExporter.Options() + videoOptions.stripsGeoLocationIfNeeded = settings.removeLocationSetting + videoOptions.exportPreset = settings.maxVideoSizeSetting.videoPreset + videoOptions.durationLimit = videoDurationLimit + exporter.videoOptions = videoOptions + + var urlOptions = MediaURLExporter.Options() + urlOptions.allowableFileExtensions = allowableFileExtensions + urlOptions.stripsGeoLocationIfNeeded = settings.removeLocationSetting + exporter.urlOptions = urlOptions + + return exporter + } + + private func maximumImageSize(from settings: MediaSettings) -> CGFloat? { + let maxUploadSize = settings.imageSizeForUpload + return maxUploadSize < Int.max ? CGFloat(maxUploadSize) : nil + } +} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift index 2fe894311e9a..9716bdae098f 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift @@ -13,6 +13,9 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont /* private */ let editorViewController: GutenbergKit.EditorViewController private let status: String // TODO: Can be deleted? + /// Retains the media upload processor, which the editor holds weakly. + private let mediaUploadProcessor: GBKMediaUploadProcessor + private var keyboardShowObserver: Any? private var keyboardHideObserver: Any? private var keyboardFrame = CGRect.zero @@ -52,10 +55,12 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont dependencies: cachedDependencies, mediaPicker: MediaPickerController(blog: blog) ) + self.mediaUploadProcessor = GBKMediaUploadProcessor(blog: blog) super.init(nibName: nil, bundle: nil) self.editorViewController.delegate = self + self.editorViewController.mediaUploadDelegate = mediaUploadProcessor } required init?(coder aDecoder: NSCoder) {