Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {}
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down