Skip to content
Open
4 changes: 2 additions & 2 deletions Modules/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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 {}
4 changes: 4 additions & 0 deletions WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public enum FeatureFlag: Int, CaseIterable {
case statsAds
case customPostTypes
case mediaLibraryV2
case gbkMediaUploadOptimization

/// Returns a boolean indicating if the feature is enabled.
///
Expand Down Expand Up @@ -89,6 +90,8 @@ public enum FeatureFlag: Int, CaseIterable {
return BuildConfiguration.current == .debug
case .mediaLibraryV2:
return BuildConfiguration.current == .debug
case .gbkMediaUploadOptimization:
return true
}
}

Expand Down Expand Up @@ -133,6 +136,7 @@ extension FeatureFlag {
case .statsAds: "Stats Ads Tab"
case .customPostTypes: "Custom Post Types"
case .mediaLibraryV2: "Media Library v2"
case .gbkMediaUploadOptimization: "NBE Media Upload Optimization"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ public enum RemoteFeatureFlag: Int, CaseIterable {
case .dotComWebLogin:
return "Log in to WordPress.com from web browser"
case .newGutenberg:
return "Experimental Block Editor"
return "New Block Editor (NBE)"
Comment thread
dcalhoun marked this conversation as resolved.
Outdated
case .newGutenbergPlugins:
return "Experimental Block Editor Plugins"
case .statsAds:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid
FeatureFlag.intelligence,
FeatureFlag.newStats,
RemoteFeatureFlag.newGutenberg,
FeatureFlag.gbkMediaUploadOptimization,
Comment thread
dcalhoun marked this conversation as resolved.
Outdated
FeatureFlag.customPostTypes,
FeatureFlag.newSupport,
]
Expand Down Expand Up @@ -77,6 +78,6 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid
static let editorFeedbackMessage = NSLocalizedString("experimentalFeatures.editorFeedbackMessage", value: "Are you willing to share feedback on the experimental editor?", comment: "Message for the alert asking for feedback on the experimental editor")
static let editorFeedbackDecline = NSLocalizedString("experimentalFeatures.editorFeedbackDecline", value: "Not now", comment: "Dismiss button title for the alert asking for feedback")
static let editorFeedbackAccept = NSLocalizedString("experimentalFeatures.editorFeedbackAccept", value: "Send feedback", comment: "Accept button title for the alert asking for feedback")
static let editorNote = NSLocalizedString("experimentalFeatures.editorNote", value: "Experimental Block Editor will become the default in a future release and the ability to disable it will be removed.", comment: "Communicates the future removal of the option to disable the experimental editor, displayed beneath the experimental features list")
static let editorNote = NSLocalizedString("experimentalFeatures.editorNote.v2", value: "New Block Editor will become the default in a future release and the ability to disable it will be removed.", comment: "Communicates the future removal of the option to disable the experimental editor, displayed beneath the experimental features list")
}
}
Original file line number Diff line number Diff line change
@@ -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<String>
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<UTType> = [.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<String>,
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
}
}
Loading