Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 3 additions & 3 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 @@ -64,7 +64,7 @@ let package = Package(
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.15.0"),
.package(
url: "https://github.com/automattic/wordpress-rs",
exact: "0.3.0"
exact: "0.4.0"
),
.package(
url: "https://github.com/Automattic/color-studio",
Expand Down
12 changes: 12 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ public enum MediaTrackerEvent: Sendable {
case mediaLibraryFilterChanged(kind: MediaKind?) // nil = "All"
case mediaLibrarySearched(queryLength: Int) // fires AFTER 300ms debounce trailing edge; non-empty only
case mediaLibraryGridModeToggled(isAspectRatio: Bool)

// Upload events:
case mediaLibraryAdded(source: MediaUploadSource, kind: MediaKind)
case mediaLibraryUploadRetried
}

public enum MediaUploadSource: Sendable {
case photoLibrary
case camera
case otherApps
case stockPhotos
case imagePlayground
}

/// No-op tracker for previews and module-internal default-construction.
Expand Down
16 changes: 16 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

struct FailedUpload: Identifiable, Sendable {
let id: UUID
let displayName: String
let kind: MediaKind
/// Localized error message. The uploader stores
/// `(error as NSError).localizedDescription` for HTTP failures and a
/// localized materializer-error message for pre-upload failures.
let errorMessage: String
/// True when the actor can rerun the upload from the stored params +
/// temp file. False for materialization failures, where the original
/// `MediaCreateParams` / temp file were never produced — the
/// Uploads-screen row should offer Dismiss only.
let isRetryable: Bool
}
17 changes: 17 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import UniformTypeIdentifiers
import WordPressAPI
import WordPressAPIInternal

Expand All @@ -16,6 +17,22 @@ public enum MediaKind: String, CaseIterable, Hashable, Sendable {
case .document: self = .document
}
}

/// Coarse, best-effort classification of a content type before an upload
/// is materialized. Defaults to `.document` for anything that isn't
/// recognizably image, video, or audio. The materializer derives the
/// authoritative kind from the post-transform content type.
init(estimating contentType: UTType) {
if contentType.conforms(to: .image) {
self = .image
} else if contentType.conforms(to: .movie) {
self = .video
} else if contentType.conforms(to: .audio) {
self = .audio
} else {
self = .document
}
}
}

// MARK: - UI helpers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation
import UniformTypeIdentifiers

/// Upload policy injected by the app target. The module honors this struct
/// but never derives it — `Blog.allowedFileTypes`, user-media settings, etc.
/// stay on the app side. Picker affordance and upload validation are split
/// because the materializer validates the effective post-transform type and
/// extension, not just the source file the picker exposed.
public struct MediaUploadPolicy: Sendable {
/// UTTypes the document picker (`.fileImporter`) offers. May include
/// broad fallbacks like `.content` when the server allow-list is empty.
/// **Not** the upload validator. Photos and camera pickers do not read
/// this field — they have their own hard-coded image/video filters.
let filePickerContentTypes: [UTType]

/// Real upload allow/deny gate. Called by the materializer just before
/// enqueue with the *effective* `(UTType, file-extension)` pair after
/// any transform. App target typically backs this with
/// `Blog.allowedFileTypes` + the default mobile-allowed-extensions list.
let isAllowedForUpload: @Sendable (_ contentType: UTType, _ fileExtension: String) -> Bool

/// Resize the longest edge of images to at most this many pixels. `nil`
/// means no cap. Applied before JPEG re-encode.
let imageMaxDimension: Int?

/// JPEG quality for re-encoded images (0.0...1.0). Used both when
/// resizing and when converting HEIC → JPEG.
let imageJpegQuality: Double

/// If true, HEIC sources are converted to JPEG before upload.
let convertHEICToJPEG: Bool

/// Video duration cap in seconds. Over-duration videos are rejected
/// (V1 parity, no trim).
let videoMaxDurationSeconds: TimeInterval?

/// `AVAssetExportSession` preset name. Controls quality only.
let videoExportPreset: String

/// Output container UTType for re-exported videos. Default
/// `.mpeg4Movie`. Drives the file extension of the materialized temp
/// file and the effective MIME type the validator checks against.
let videoOutputContentType: UTType

/// If true, strip GPS EXIF before upload.
let stripImageLocation: Bool

public init(
filePickerContentTypes: [UTType],
isAllowedForUpload: @escaping @Sendable (UTType, String) -> Bool,
imageMaxDimension: Int? = nil,
imageJpegQuality: Double = 0.9,
convertHEICToJPEG: Bool = true,
videoMaxDurationSeconds: TimeInterval? = nil,
videoExportPreset: String,
videoOutputContentType: UTType = .mpeg4Movie,
stripImageLocation: Bool = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit – I think this defaulted to true before, and it probably still should?

Follow-on nit: should this apply to videos as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed these default values in 158d784

) {
self.filePickerContentTypes = filePickerContentTypes
self.isAllowedForUpload = isAllowedForUpload
self.imageMaxDimension = imageMaxDimension
self.imageJpegQuality = imageJpegQuality
self.convertHEICToJPEG = convertHEICToJPEG
self.videoMaxDurationSeconds = videoMaxDurationSeconds
self.videoExportPreset = videoExportPreset
self.videoOutputContentType = videoOutputContentType
self.stripImageLocation = stripImageLocation
}
}
11 changes: 11 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation
import WordPressAPI

/// View-model-facing surface of an in-flight upload. The actor stores a
/// richer internal value with the `Task` handle and owned temp-file URL.
struct PendingUpload: Identifiable, Sendable {
let id: UUID
let displayName: String // basename of the temp file
let kind: MediaKind // for icon + Uploads-row rendering
let progress: Progress // bound to ProgressView directly
}
88 changes: 88 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Foundation
import UIKit
import UniformTypeIdentifiers

/// Picker-output payload that the materializer consumes. Variants carry the
/// source-of-origin needed for analytics — `MediaLibraryViewModel` reads
/// the case to fire `.mediaLibraryAdded(source:kind:)` *before* enqueueing,
/// so the actor never has to derive analytics from picker shape.
enum UploadSource: @unchecked Sendable {
/// `PHPickerResult.itemProvider` plus its `suggestedName` (typically
/// "IMG_1234" or nil) and a UTType hint from the picker selection.
case photoLibrary(itemProvider: NSItemProvider, suggestedName: String?, hint: UTType)

/// Captured image from the camera. `Date` is the capture moment used
/// for the filename pattern `IMG_<yyyy-MM-dd HH-mm-ss>.jpg`.
case cameraImage(UIImage, capturedAt: Date)

/// Captured video file from the camera, already at a temp URL.
case cameraVideo(URL, capturedAt: Date)

/// File-importer URL. Materializer reads it under
/// `startAccessingSecurityScopedResource()`.
case file(URL)

/// Remote-URL source for external pickers (Stock Photos). The
/// materializer downloads bytes via `RemoteDownloader` before dispatching
/// to the image / GIF / disallowed branches.
case remoteURL(RemoteURL)

/// Image Playground (iOS 18.1+) returns a local file URL in our app
/// sandbox. The materializer copies bytes without security-scoped access
/// and dispatches to `materializeFileImage`.
case imagePlayground(URL, suggestedName: String)
}

extension UploadSource {
/// Internal carrier for `.remoteURL`. The public boundary type
/// `ExternalRemoteMedia` is converted to this in the view model before
/// enqueueing — keeps `UploadSource` module-internal.
struct RemoteURL: Sendable {
let url: URL
let suggestedName: String
let contentType: UTType
let caption: String?
}
}

extension UploadSource {
/// Fraction of the overall upload progress allocated to the
/// materialization stage. On-device sources are fast to materialize
/// relative to the upload itself.
var materializationProgressWeight: Double {
switch self {
case .photoLibrary, .cameraImage, .cameraVideo, .file, .imagePlayground:
return 0.05
case .remoteURL:
// Remote sources download the full file during materialization, then
// upload the same bytes, so split the bar evenly between the two.
// The real download-vs-upload time ratio varies by network, so this
// weight only affects how smoothly the row advances, not correctness.
return 0.5
}
}

/// Best-effort media kind derived from the picker payload before the
/// upload is materialized, used for the pre-enqueue analytics event and
/// the initial Uploads-row icon. The materializer later derives the
/// authoritative kind from the post-transform content type.
var estimatedKind: MediaKind {
switch self {
case .photoLibrary(_, _, let hint):
return MediaKind(estimating: hint)
case .cameraImage:
return .image
case .cameraVideo:
return .video
case .file(let url):
let contentType =
(try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType
?? UTType(filenameExtension: url.pathExtension)
return contentType.map { MediaKind(estimating: $0) } ?? .document
case .remoteURL(let remote):
return MediaKind(estimating: remote.contentType)
case .imagePlayground:
return .image
}
}
}
41 changes: 41 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/UploaderState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

/// One row in the upload queue, in submission order. Failing in-flight
/// keeps the row at its original position so the Uploads screen does not
/// reshuffle when an upload transitions to failed (or back to pending
/// after Retry).
enum UploadEntry: Identifiable, Sendable {
case pending(PendingUpload)
case failed(FailedUpload)

var id: UUID {
switch self {
case .pending(let p): return p.id
case .failed(let f): return f.id
}
}
}

/// Snapshot of the uploader's queue. Emitted whenever any entry changes.
/// `entries` preserves submission order; `pendingCount` / `failedCount`
/// are derived for the banner.
struct UploaderState: Sendable {
let entries: [UploadEntry]

init(entries: [UploadEntry] = []) {
self.entries = entries
}

var isEmpty: Bool { entries.isEmpty }

var pendingCount: Int { pending.count }
var failedCount: Int { failed.count }

var pending: [PendingUpload] {
entries.compactMap { if case .pending(let p) = $0 { return p } else { return nil } }
}

var failed: [FailedUpload] {
entries.compactMap { if case .failed(let f) = $0 { return f } else { return nil } }
}
}
Loading