diff --git a/Modules/Package.resolved b/Modules/Package.resolved
index b36c195bea18..6a70c94bb00e 100644
--- a/Modules/Package.resolved
+++ b/Modules/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "97462f04f7472535df3293d3f7601aaff8a4684769091394d4b1ca5bde9f8ed1",
+ "originHash" : "61e4f4fbfd341181ca9b893646e997a1b3b76afe110c2500eb4b500401ef1e34",
"pins" : [
{
"identity" : "alamofire",
@@ -131,8 +131,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/wordpress-mobile/GutenbergKit",
"state" : {
- "revision" : "b6604e26792725b2e8125b3b715e1d4a298441b6",
- "version" : "0.19.0"
+ "revision" : "297fcb55e3694c4f325468386972ac89f414cc70",
+ "version" : "0.20.0-alpha.0"
}
},
{
diff --git a/Modules/Package.swift b/Modules/Package.swift
index 4b1217724507..ec15ba8a087f 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.19.0"),
+ .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.20.0-alpha.0"),
.package(
url: "https://github.com/automattic/wordpress-rs",
exact: "0.6.0"
diff --git a/Package.resolved b/Package.resolved
index e4f561d43c78..bba5f23b413e 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "e5b9b888f12b9e2adfe5e293101989f58ec52d16d70ef0002c28093d8c2ed39f",
+ "originHash" : "1bbbb11e671a32eda51b479a3e29fd18839bb400ff80395fba2ed7a8553e9baa",
"pins" : [
{
"identity" : "alamofire",
@@ -131,8 +131,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/wordpress-mobile/GutenbergKit",
"state" : {
- "revision" : "7180587f49d3c3bfdb34cc3e80b2a9d22a3cd93e",
- "version" : "0.18.1"
+ "revision" : "297fcb55e3694c4f325468386972ac89f414cc70",
+ "version" : "0.20.0-alpha.0"
}
},
{
diff --git a/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift b/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift
new file mode 100644
index 000000000000..91273aab7963
--- /dev/null
+++ b/Tests/KeystoneTests/Tests/Features/Media/GBKMediaUploadProcessorTests.swift
@@ -0,0 +1,422 @@
+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"))
+ }
+
+ /// Destination names come from a check-then-act `fileExists` loop, and
+ /// GutenbergKit processes uploads concurrently, so exports of the same
+ /// source must not share a directory to race in.
+ @Test func concurrentExportsOfTheSameFileDoNotCollide() 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 outputURLs = try await withThrowingTaskGroup(of: URL.self) { group in
+ for _ in 0..<8 {
+ group.addTask {
+ let result = try await processor.processFile(
+ at: url,
+ mimeType: "image/jpeg",
+ filename: url.lastPathComponent
+ )
+ guard case .processed(let outputURL, _, _) = result else {
+ throw ProcessingError.expectedProcessedFile
+ }
+ return outputURL
+ }
+ }
+ return try await group.reduce(into: [URL]()) { $0.append($1) }
+ }
+ defer { outputURLs.forEach(cleanUp) }
+
+ // Every export is its own file, and every one of them survived the
+ // others finishing rather than being overwritten or swept away.
+ #expect(Set(outputURLs).count == outputURLs.count)
+ for outputURL in outputURLs {
+ #expect(FileManager.default.fileExists(atPath: outputURL.path))
+ #expect(max(try imageSize(at: outputURL).width, try imageSize(at: outputURL).height) == 200)
+ }
+ }
+
+ /// A failed export must not leave its temporary directory behind: nothing
+ /// else sweeps it, so an abandoned export would outlive the app session.
+ ///
+ /// The video exporter throws after `makeLocalMediaURL` has already created
+ /// the directory, which is exactly what an implementation without the
+ /// failure-path cleanup would leak.
+ @Test func failedExportLeavesNoDirectoryBehind() async throws {
+ let directory = MediaDirectory.temporary(id: UUID())
+ let processor = GBKMediaUploadProcessor(
+ videoDurationLimit: 1,
+ allowableFileExtensions: [],
+ makeMediaSettings: makeSettingsFactory(makeSettings()),
+ makeExportDirectory: { directory }
+ )
+ 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)
+ }
+
+ #expect(!FileManager.default.fileExists(atPath: directory.url.path))
+ }
+
+ // 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
+ }
+ }
+
+ /// SVG conforms to `UTType.image`, so it reaches the image branch, but
+ /// ImageIO cannot decode or encode it. It must pass through untouched
+ /// rather than fail in the exporter.
+ @Test func svgPassesThroughUntouched() async throws {
+ let settings = makeSettings()
+ settings.imageOptimizationEnabled = true
+ settings.removeLocationSetting = true
+ let processor = makeProcessor(settings: settings)
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).svg")
+ try #""#
+ .write(to: url, atomically: true, encoding: .utf8)
+ defer { cleanUp(url) }
+
+ let result = try await processor.processFile(
+ at: url,
+ mimeType: "image/svg+xml",
+ 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: - Files without an extension
+
+ /// GutenbergKit names the temp file after the multipart `filename`, which
+ /// the editor does not guarantee carries an extension (its native inserter
+ /// derives one from a URL path segment). Such a file resolves to
+ /// `public.data`, so the reported MIME type has to stand in for the type.
+ @Test func extensionlessImageIsProcessedUsingReportedMIMEType() async throws {
+ let settings = makeSettings()
+ settings.imageOptimizationEnabled = true
+ settings.maxImageSizeSetting = 200
+ let processor = makeProcessor(settings: settings)
+ let url = try copyFixtureDroppingExtension("test-image-device-photo-gps.jpg")
+ defer { cleanUp(url) }
+
+ let result = try await processor.processFile(
+ at: url,
+ mimeType: "image/jpeg",
+ filename: url.lastPathComponent
+ )
+
+ guard case .processed(let outputURL, let mimeType, _) = result else {
+ Issue.record("Expected a processed file")
+ return
+ }
+ defer { cleanUp(outputURL) }
+ #expect(mimeType == "image/jpeg")
+ #expect(max(try imageSize(at: outputURL).width, try imageSize(at: outputURL).height) == 200)
+ }
+
+ /// The fallback only applies when the URL yields no type of its own — a
+ /// mismatched MIME type must not override what the file actually is.
+ @Test func fileExtensionWinsOverReportedMIMEType() async throws {
+ let settings = makeSettings()
+ settings.imageOptimizationEnabled = false
+ settings.removeLocationSetting = false
+ let processor = makeProcessor(settings: settings)
+ let url = try fixtureURL("test-gif.gif")
+
+ let result = try await processor.processFile(at: url, mimeType: "image/jpeg", filename: url.lastPathComponent)
+
+ // Classified as a GIF from the extension, not as a JPEG from the
+ // reported type, so it passes through instead of being re-encoded.
+ guard case .original = result else {
+ Issue.record("Expected the original file to pass through")
+ return
+ }
+ }
+
+ @Test func extensionlessFileWithUnusableMIMETypeThrows() async throws {
+ let processor = makeProcessor(settings: makeSettings())
+ let url = try copyFixtureDroppingExtension("test-image-device-photo-gps.jpg")
+ defer { cleanUp(url) }
+
+ await #expect(throws: MediaURLExporter.URLExportError.self) {
+ try await processor.processFile(at: url, mimeType: "not-a-mime-type", filename: url.lastPathComponent)
+ }
+ }
+
+ // MARK: - handlesFile
+
+ /// The invariant the metadata gate rests on: declining a file must mean
+ /// `processFile` would have returned it unchanged. If this fails, the gate
+ /// is skipping work that `processFile` would actually have done.
+ @Test(arguments: [true, false], [true, false])
+ func decliningAFileImpliesProcessFileWouldNotTouchIt(
+ optimizationEnabled: Bool,
+ removeLocation: Bool
+ ) async throws {
+ let settings = makeSettings()
+ settings.imageOptimizationEnabled = optimizationEnabled
+ settings.removeLocationSetting = removeLocation
+ settings.maxImageSizeSetting = 200
+ let processor = makeProcessor(settings: settings)
+
+ let fixtures: [(filename: String, mimeType: String)] = [
+ ("test-image-device-photo-gps.jpg", "image/jpeg"),
+ ("iphone-photo.heic", "image/heic"),
+ ("test-gif.gif", "image/gif"),
+ ("test-video-device-gps.m4v", "video/mp4")
+ ]
+
+ for fixture in fixtures {
+ guard !processor.handlesFile(ofType: fixture.mimeType, named: fixture.filename) else {
+ continue
+ }
+ let url = try fixtureURL(fixture.filename)
+ let result = try await processor.processFile(
+ at: url,
+ mimeType: fixture.mimeType,
+ filename: fixture.filename
+ )
+ guard case .original = result else {
+ Issue.record("Declined \(fixture.filename) but processFile would have processed it")
+ return
+ }
+ }
+ }
+
+ @Test func gifIsDeclinedBeforeBeingCopiedToDisk() {
+ let processor = makeProcessor(settings: makeSettings())
+ #expect(!processor.handlesFile(ofType: "image/gif", named: "animation.gif"))
+ }
+
+ @Test func imagesAndVideosAreAlwaysClaimed() {
+ let processor = makeProcessor(settings: makeSettings())
+ #expect(processor.handlesFile(ofType: "image/jpeg", named: "photo.jpg"))
+ #expect(processor.handlesFile(ofType: "image/heic", named: "photo.heic"))
+ #expect(processor.handlesFile(ofType: "video/mp4", named: "clip.mp4"))
+ }
+
+ /// A document is only worth claiming when there is a restriction to
+ /// enforce; otherwise `processFile` returns it unchanged.
+ @Test func documentsAreClaimedOnlyToEnforceAllowedExtensions() {
+ let unrestricted = makeProcessor(settings: makeSettings())
+ #expect(!unrestricted.handlesFile(ofType: "application/pdf", named: "doc.pdf"))
+
+ let restricted = GBKMediaUploadProcessor(
+ videoDurationLimit: nil,
+ allowableFileExtensions: ["pdf"],
+ makeMediaSettings: makeSettingsFactory(makeSettings())
+ )
+ #expect(restricted.handlesFile(ofType: "application/pdf", named: "doc.pdf"))
+ }
+
+ /// A part with no `Content-Type` arrives as `text/plain`, and a real
+ /// `Content-Type` may carry parameters or arbitrary casing. None of those
+ /// may cause a photo to be mistaken for a document and declined.
+ @Test(
+ arguments: [
+ "image/jpeg; charset=binary",
+ "IMAGE/JPEG",
+ "text/plain",
+ "application/octet-stream",
+ ""
+ ]
+ )
+ func imagesAreClaimedWhateverTheReportedMIMEType(mimeType: String) {
+ let processor = makeProcessor(settings: makeSettings())
+ #expect(processor.handlesFile(ofType: mimeType, named: "photo.jpg"))
+ }
+
+ /// Nothing decidable from the metadata means the file is claimed, so
+ /// `processFile` can read the type off the bytes instead.
+ @Test func unrecognizableMetadataIsClaimed() {
+ let processor = makeProcessor(settings: makeSettings())
+ #expect(processor.handlesFile(ofType: "text/plain", named: "upload"))
+ }
+
+ // 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
+ }
+
+ /// Copies a fixture to a temporary file with no path extension, mirroring
+ /// an upload whose multipart `filename` carried none.
+ private func copyFixtureDroppingExtension(_ filename: String) throws -> URL {
+ let source = try fixtureURL(filename)
+ let destination = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: false)
+ try FileManager.default.copyItem(at: source, to: destination)
+ #expect(destination.pathExtension.isEmpty)
+ return destination
+ }
+
+ 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)
+ }
+
+ private enum ProcessingError: Error {
+ case expectedProcessedFile
+ }
+}
+
+/// 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..8e5bd1090ac9
--- /dev/null
+++ b/WordPress/Classes/ViewRelated/NewGutenberg/GBKMediaUploadProcessor.swift
@@ -0,0 +1,362 @@
+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
+
+ /// The temporary directory an export is written to.
+ ///
+ /// GutenbergKit deletes the processed file after uploading it, so exports
+ /// go to a temporary directory rather than the uploads directory tracked by
+ /// `MediaFileManager`.
+ ///
+ /// Every export gets its own directory. Destination names come from
+ /// `URL.incrementalFilename()`, a check-then-act `fileExists` loop with no
+ /// locking, and uploads are processed concurrently — one task per
+ /// connection — so sharing a directory lets two exports of the same source
+ /// name resolve to the same path and clobber each other. A per-export
+ /// directory removes the shared state instead of racing on it.
+ private let makeExportDirectory: @Sendable () -> MediaDirectory
+
+ /// Raster image types the WordPress REST API reliably accepts. Other image
+ /// formats (e.g. HEIC) are converted to JPEG during processing, mirroring
+ /// `ItemProviderMediaExporter`.
+ ///
+ /// - Note: SVG is deliberately absent. It is web-safe, but it is a vector
+ /// format that ImageIO cannot decode or encode, so it never reaches the
+ /// exporter — `processFile` returns it unchanged (see below).
+ private static let webSafeImageTypes: Set = [.png, .jpeg, .gif]
+
+ @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() },
+ makeExportDirectory: @escaping @Sendable () -> MediaDirectory = { .temporary(id: UUID()) }
+ ) {
+ self.videoDurationLimit = videoDurationLimit
+ self.allowableFileExtensions = allowableFileExtensions
+ self.makeMediaSettings = makeMediaSettings
+ self.makeExportDirectory = makeExportDirectory
+ }
+
+ // MARK: - MediaUploadDelegate
+
+ /// Whether the file is worth materializing for `processFile`.
+ ///
+ /// GutenbergKit calls this from the multipart headers alone, before
+ /// streaming the upload to a temp file. Returning `false` skips that copy
+ /// and forwards the original request body to WordPress unchanged, so it is
+ /// only correct where `processFile` would return `.original` for *any*
+ /// Media settings — the metadata here cannot answer anything finer.
+ ///
+ /// This is a fast path, never a second place the policy lives: every `false`
+ /// below mirrors a branch of `processFile` that ignores `settings`.
+ /// Declining is also unrecoverable — the file is never seen again — so
+ /// anything undecidable from metadata claims the file and decides for real
+ /// once the bytes are on disk.
+ func handlesFile(ofType mimeType: String, named filename: String) -> Bool {
+ // The URL the file will be written to isn't available yet, so classify
+ // from the reported type alone, falling back to the filename extension
+ // when it is a placeholder. Both are untrustworthy in ways `processFile`
+ // can recover from and this cannot, hence the bias toward `true`.
+ guard let type = Self.type(ofMIMEType: mimeType) ?? Self.type(ofExtensionIn: filename) else {
+ return true
+ }
+ guard let expected = try? Self.expectedExport(of: nil, type: type) else {
+ return true
+ }
+ switch expected {
+ case .gif:
+ // Always returned unchanged, whatever the settings.
+ return false
+ case .other:
+ // Claim these only to enforce the site's allowed extensions, which
+ // `processFile` throws on. Declining would spend a full upload on a
+ // file the site rejects and surface the server's error instead of
+ // ours. With no restriction to enforce, there is nothing to do.
+ return !allowableFileExtensions.isEmpty
+ case .image, .video:
+ // An image may be downscaled, stripped, or converted, and a video
+ // is always exported. Both depend on settings or on the file's
+ // contents, so decide in `processFile`.
+ return true
+ }
+ }
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ let sourceType = Self.sourceType(of: url, reportedMIMEType: mimeType)
+ let expected = try Self.expectedExport(of: url, type: sourceType)
+ 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:
+ // SVG conforms to `UTType.image`, so it lands here, but ImageIO
+ // cannot decode or encode it: the export would fail rather than
+ // produce a file. Upload it unchanged, like a GIF.
+ if sourceType == .svg {
+ return .original
+ }
+
+ // Skip the export when nothing would change the file: no
+ // downscaling, no location stripping, and no format conversion.
+ //
+ // This is narrower than "processing changes nothing". With
+ // optimization off, `imageQualityForUpload` is still `.high`, so a
+ // web-safe image that reaches the exporter is re-encoded at that
+ // quality even though `imageSizeForUpload` leaves its dimensions
+ // alone. That mirrors `MediaImportService`, which maps the same
+ // settings the same way.
+ //
+ // Skipping the export also skips the exporter's unconditional EXIF
+ // orientation normalization, so a sideways-shot photo uploads with
+ // its orientation flag intact rather than rotated into its pixels.
+ // That is deliberate: the normalization predates WordPress 5.3,
+ // whose `wp_create_image_subsizes` rotates on the server for every
+ // site, self-hosted included. Re-encoding here to bake in a
+ // rotation the server performs anyway would cost a lossy pass on a
+ // photo the user asked not to optimize.
+ //
+ // See WordPress-iOS#12703 and core changeset 46202.
+ if !settings.imageOptimizationEnabled,
+ !settings.removeLocationSetting,
+ let sourceType,
+ Self.webSafeImageTypes.contains(sourceType)
+ {
+ return .original
+ }
+ case .video:
+ // Always process video to apply the export preset, duration
+ // limit, and location stripping.
+ break
+ }
+
+ let exportImageType = Self.exportImageType(for: expected, sourceType: sourceType)
+ let directory = makeExportDirectory()
+
+ do {
+ let export = try await makeExporter(
+ for: url,
+ expected: expected,
+ settings: settings,
+ exportImageType: exportImageType,
+ directory: directory
+ )
+ .export()
+
+ let mimeType = try Self.mimeType(of: export.url, exportImageType: exportImageType)
+ return .processed(export.url, mimeType: mimeType, filename: export.url.lastPathComponent)
+ } catch {
+ // Nothing else sweeps this directory: GutenbergKit removes only the
+ // file it is handed, and `MediaFileManager`'s cleanup covers the
+ // uploads directory alone. On the success path the directory is
+ // left holding the file GutenbergKit is about to upload, but a
+ // failure here would otherwise abandon a full-size export — and any
+ // directory the export already created — for the lifetime of the
+ // app's container.
+ try? FileManager.default.removeItem(at: directory.url)
+ throw error
+ }
+ }
+
+ // MARK: - Exporter configuration
+
+ /// Builds an exporter configured from the app's Media settings, mirroring
+ /// the option mapping in `MediaImportService`.
+ ///
+ /// Returns the concrete exporter for the branch rather than
+ /// `MediaURLExporter`, which re-derives the type from the path extension in
+ /// `exportURL` and so would reject a file classified via its reported MIME
+ /// type. `MediaImageExporter` reads the type from the file's contents with
+ /// `CGImageSourceGetType`, so it handles an extensionless image correctly.
+ private func makeExporter(
+ for url: URL,
+ expected: MediaURLExporter.URLExportExpectation,
+ settings: MediaSettings,
+ exportImageType: UTType?,
+ directory: MediaDirectory
+ ) -> any MediaExporter {
+ switch expected {
+ case .video:
+ let exporter = MediaVideoExporter(url: url)
+ exporter.mediaDirectoryType = directory
+ var options = MediaVideoExporter.Options()
+ options.stripsGeoLocationIfNeeded = settings.removeLocationSetting
+ options.exportPreset = settings.maxVideoSizeSetting.videoPreset
+ options.durationLimit = videoDurationLimit
+ exporter.options = options
+ return exporter
+ case .image, .gif, .other:
+ // Only images reach the exporter: `.gif` and `.other` return
+ // `.original` before this point.
+ let exporter = MediaImageExporter(url: url)
+ exporter.mediaDirectoryType = directory
+ var options = MediaImageExporter.Options()
+ options.maximumImageSize = maximumImageSize(from: settings)
+ options.stripsGeoLocationIfNeeded = settings.removeLocationSetting
+ options.imageCompressionQuality = settings.imageQualityForUpload.doubleValue
+ // `exportImageType` is the destination type `MediaImageExporter`
+ // writes, and it also determines the output file extension. Left
+ // nil, the source type is kept.
+ options.exportImageType = exportImageType?.identifier
+ exporter.options = options
+ return exporter
+ }
+ }
+
+ private func maximumImageSize(from settings: MediaSettings) -> CGFloat? {
+ let maxUploadSize = settings.imageSizeForUpload
+ return maxUploadSize < Int.max ? CGFloat(maxUploadSize) : nil
+ }
+
+ // MARK: - Type resolution
+
+ /// The type of the file to process.
+ ///
+ /// Resolved from the file itself, falling back to the type the editor
+ /// reported. The URL resolves its type from the path extension alone, and
+ /// an upload can arrive without one — GutenbergKit names the temp file
+ /// after the multipart `filename`, which the editor does not guarantee
+ /// carries an extension (its native inserter derives one from a URL path
+ /// segment). Such a file resolves to the generic `public.data`, which
+ /// conforms to no media type, so the export would be rejected outright.
+ private static func sourceType(of url: URL, reportedMIMEType: String) -> UTType? {
+ guard let type = url.typeIdentifier.flatMap(UTType.init), type != .data else {
+ return type(ofMIMEType: reportedMIMEType)
+ }
+ return type
+ }
+
+ /// The type a reported MIME type names, or `nil` when it names nothing
+ /// usable.
+ ///
+ /// `UTType(mimeType:)` matches the bare `type/subtype` only, so the header
+ /// is normalized first. Two shapes reach us that it would otherwise miss:
+ ///
+ /// - Parameters and casing: `Content-Type` may carry parameters
+ /// (`image/jpeg; charset=binary`) and its casing is not significant
+ /// (RFC 9110 §8.3). Left as-is, both resolve to a dynamic UTType that
+ /// conforms to nothing.
+ /// - Placeholders: GutenbergKit's multipart parser defaults a part with no
+ /// `Content-Type` to `text/plain` (RFC 7578 §4.4), and it picks the file
+ /// part by the presence of a `filename` parameter rather than by content
+ /// type — so a real image can arrive labeled `text/plain`. Treating that
+ /// as authoritative would classify a photo as a document.
+ private static func type(ofMIMEType mimeType: String) -> UTType? {
+ let normalized = mimeType.prefix(while: { $0 != ";" })
+ .trimmingCharacters(in: .whitespaces)
+ .lowercased()
+ guard !normalized.isEmpty, !placeholderMIMETypes.contains(normalized) else {
+ return nil
+ }
+ return UTType(mimeType: normalized)
+ }
+
+ /// MIME types that carry no information about the file. `octet-stream` is
+ /// the generic "unknown bytes" type; `text/plain` is what GutenbergKit's
+ /// multipart parser substitutes for a part that sent no `Content-Type`.
+ private static let placeholderMIMETypes: Set = [
+ "application/octet-stream", "text/plain"
+ ]
+
+ /// The type a filename's extension names, for use before the file exists.
+ /// `processFile` reads the type off the file itself instead.
+ private static func type(ofExtensionIn filename: String) -> UTType? {
+ let fileExtension = (filename as NSString).pathExtension.lowercased()
+ guard !fileExtension.isEmpty else {
+ return nil
+ }
+ return UTType(filenameExtension: fileExtension)
+ }
+
+ /// Classifies a file the way `MediaURLExporter.expectedExport(with:)` does,
+ /// but from an already-resolved type so the caller can supply one the URL
+ /// alone cannot provide.
+ /// - Parameter url: The file being classified, or `nil` when only the type
+ /// is known — `handlesFile` runs before the file exists.
+ private static func expectedExport(
+ of url: URL?,
+ type: UTType?
+ ) throws -> MediaURLExporter.URLExportExpectation {
+ if let url, !url.isFileURL {
+ throw MediaURLExporter.URLExportError.invalidFileURL
+ }
+ guard let type else {
+ throw MediaURLExporter.URLExportError.unknownFileUTI
+ }
+ if type == .gif {
+ return .gif
+ } else if type.conforms(to: .video) || type.conforms(to: .movie) {
+ return .video
+ } else if type.conforms(to: .image) {
+ return .image
+ } else if type.conforms(to: .content) || type.conforms(to: .zip) {
+ return .other
+ }
+ throw MediaURLExporter.URLExportError.unsupportedFileType
+ }
+
+ /// The type `MediaImageExporter` should write, or `nil` to keep the source
+ /// type. Only image exports convert: everything the REST API accepts as-is
+ /// is left alone, and the rest becomes JPEG.
+ private static func exportImageType(
+ for expected: MediaURLExporter.URLExportExpectation,
+ sourceType: UTType?
+ ) -> UTType? {
+ guard case .image = expected, let sourceType else {
+ return nil
+ }
+ return webSafeImageTypes.contains(sourceType) ? nil : .jpeg
+ }
+
+ /// The MIME type of a finished export.
+ ///
+ /// An image export writes `exportImageType` when set, so that value is
+ /// authoritative and needs no round-trip through the output path. Only the
+ /// cases that keep the source type — every video, and a web-safe image —
+ /// fall back to reading the file back.
+ private static func mimeType(of url: URL, exportImageType: UTType?) throws -> String {
+ let type = exportImageType ?? url.typeIdentifier.flatMap(UTType.init)
+ guard let mimeType = type?.preferredMIMEType else {
+ throw MediaURLExporter.URLExportError.unknownFileUTI
+ }
+ return mimeType
+ }
+}
diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift
index 46e498603e0d..4f227a83dcb6 100644
--- a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift
+++ b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift
@@ -16,6 +16,9 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont
private lazy var mediaPickerHelper = GutenbergMediaPickerHelper(context: self, blog: blog)
+ /// 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
@@ -55,10 +58,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) {