Skip to content

Commit de0286a

Browse files
committed
Add Media Library upload data types and tracker events
Module-side foundation for the V2 upload pipeline: the upload source and policy value types, the in-flight/failed/state models, the transport protocol over the wp_mobile upload call, and the new tracker event cases. Bumps wordpress-rs to 0.4.0 for the create-media API the transport calls.
1 parent 236a51c commit de0286a

13 files changed

Lines changed: 665 additions & 4 deletions

File tree

Modules/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Modules/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ let package = Package(
6464
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.15.0"),
6565
.package(
6666
url: "https://github.com/automattic/wordpress-rs",
67-
exact: "0.3.0"
67+
exact: "0.4.0"
6868
),
6969
.package(
7070
url: "https://github.com/Automattic/color-studio",

Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ public enum MediaTrackerEvent: Sendable {
1616
case mediaLibraryFilterChanged(kind: MediaKind?) // nil = "All"
1717
case mediaLibrarySearched(queryLength: Int) // fires AFTER 300ms debounce trailing edge; non-empty only
1818
case mediaLibraryGridModeToggled(isAspectRatio: Bool)
19+
20+
// Upload events:
21+
case mediaLibraryAdded(source: MediaUploadSource, kind: MediaKind)
22+
case mediaLibraryUploadRetried
23+
}
24+
25+
public enum MediaUploadSource: Sendable {
26+
case photoLibrary
27+
case camera
28+
case otherApps
29+
case stockPhotos
30+
case imagePlayground
1931
}
2032

2133
/// No-op tracker for previews and module-internal default-construction.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Foundation
2+
3+
struct FailedUpload: Identifiable, Sendable {
4+
let id: UUID
5+
let displayName: String
6+
let kind: MediaKind
7+
/// Localized error message. The uploader stores
8+
/// `(error as NSError).localizedDescription` for HTTP failures and a
9+
/// localized materializer-error message for pre-upload failures.
10+
let errorMessage: String
11+
/// True when the actor can rerun the upload from the stored params +
12+
/// temp file. False for materialization failures, where the original
13+
/// `MediaCreateParams` / temp file were never produced — the
14+
/// Uploads-screen row should offer Dismiss only.
15+
let isRetryable: Bool
16+
}

Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import UniformTypeIdentifiers
23
import WordPressAPI
34
import WordPressAPIInternal
45

@@ -16,6 +17,22 @@ public enum MediaKind: String, CaseIterable, Hashable, Sendable {
1617
case .document: self = .document
1718
}
1819
}
20+
21+
/// Coarse, best-effort classification of a content type before an upload
22+
/// is materialized. Defaults to `.document` for anything that isn't
23+
/// recognizably image, video, or audio. The materializer derives the
24+
/// authoritative kind from the post-transform content type.
25+
init(estimating contentType: UTType) {
26+
if contentType.conforms(to: .image) {
27+
self = .image
28+
} else if contentType.conforms(to: .movie) {
29+
self = .video
30+
} else if contentType.conforms(to: .audio) {
31+
self = .audio
32+
} else {
33+
self = .document
34+
}
35+
}
1936
}
2037

2138
// MARK: - UI helpers
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Foundation
2+
import UniformTypeIdentifiers
3+
4+
/// Upload policy injected by the app target. The module honors this struct
5+
/// but never derives it — `Blog.allowedFileTypes`, user-media settings, etc.
6+
/// stay on the app side. Picker affordance and upload validation are split
7+
/// because the materializer validates the effective post-transform type and
8+
/// extension, not just the source file the picker exposed.
9+
public struct MediaUploadPolicy: Sendable {
10+
/// UTTypes the document picker (`.fileImporter`) offers. May include
11+
/// broad fallbacks like `.content` when the server allow-list is empty.
12+
/// **Not** the upload validator. Photos and camera pickers do not read
13+
/// this field — they have their own hard-coded image/video filters.
14+
let filePickerContentTypes: [UTType]
15+
16+
/// Real upload allow/deny gate. Called by the materializer just before
17+
/// enqueue with the *effective* `(UTType, file-extension)` pair after
18+
/// any transform. App target typically backs this with
19+
/// `Blog.allowedFileTypes` + the default mobile-allowed-extensions list.
20+
let isAllowedForUpload: @Sendable (_ contentType: UTType, _ fileExtension: String) -> Bool
21+
22+
/// Resize the longest edge of images to at most this many pixels. `nil`
23+
/// means no cap. Applied before JPEG re-encode.
24+
let imageMaxDimension: Int?
25+
26+
/// JPEG quality for re-encoded images (0.0...1.0). Used both when
27+
/// resizing and when converting HEIC → JPEG.
28+
let imageJpegQuality: Double
29+
30+
/// If true, HEIC sources are converted to JPEG before upload.
31+
let convertHEICToJPEG: Bool
32+
33+
/// Video duration cap in seconds. Over-duration videos are rejected
34+
/// (V1 parity, no trim).
35+
let videoMaxDurationSeconds: TimeInterval?
36+
37+
/// `AVAssetExportSession` preset name. Controls quality only.
38+
let videoExportPreset: String
39+
40+
/// Output container UTType for re-exported videos. Default
41+
/// `.mpeg4Movie`. Drives the file extension of the materialized temp
42+
/// file and the effective MIME type the validator checks against.
43+
let videoOutputContentType: UTType
44+
45+
/// If true, strip GPS EXIF before upload.
46+
let stripImageLocation: Bool
47+
48+
public init(
49+
filePickerContentTypes: [UTType],
50+
isAllowedForUpload: @escaping @Sendable (UTType, String) -> Bool,
51+
imageMaxDimension: Int? = nil,
52+
imageJpegQuality: Double = 0.9,
53+
convertHEICToJPEG: Bool = true,
54+
videoMaxDurationSeconds: TimeInterval? = nil,
55+
videoExportPreset: String,
56+
videoOutputContentType: UTType = .mpeg4Movie,
57+
stripImageLocation: Bool = false
58+
) {
59+
self.filePickerContentTypes = filePickerContentTypes
60+
self.isAllowedForUpload = isAllowedForUpload
61+
self.imageMaxDimension = imageMaxDimension
62+
self.imageJpegQuality = imageJpegQuality
63+
self.convertHEICToJPEG = convertHEICToJPEG
64+
self.videoMaxDurationSeconds = videoMaxDurationSeconds
65+
self.videoExportPreset = videoExportPreset
66+
self.videoOutputContentType = videoOutputContentType
67+
self.stripImageLocation = stripImageLocation
68+
}
69+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
import WordPressAPI
3+
4+
/// View-model-facing surface of an in-flight upload. The actor stores a
5+
/// richer internal value with the `Task` handle and owned temp-file URL.
6+
struct PendingUpload: Identifiable, Sendable {
7+
let id: UUID
8+
let displayName: String // basename of the temp file
9+
let kind: MediaKind // for icon + Uploads-row rendering
10+
let progress: Progress // bound to ProgressView directly
11+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import Foundation
2+
import UIKit
3+
import UniformTypeIdentifiers
4+
5+
/// Picker-output payload that the materializer consumes. Variants carry the
6+
/// source-of-origin needed for analytics — `MediaLibraryViewModel` reads
7+
/// the case to fire `.mediaLibraryAdded(source:kind:)` *before* enqueueing,
8+
/// so the actor never has to derive analytics from picker shape.
9+
enum UploadSource: @unchecked Sendable {
10+
/// `PHPickerResult.itemProvider` plus its `suggestedName` (typically
11+
/// "IMG_1234" or nil) and a UTType hint from the picker selection.
12+
case photoLibrary(itemProvider: NSItemProvider, suggestedName: String?, hint: UTType)
13+
14+
/// Captured image from the camera. `Date` is the capture moment used
15+
/// for the filename pattern `IMG_<yyyy-MM-dd HH-mm-ss>.jpg`.
16+
case cameraImage(UIImage, capturedAt: Date)
17+
18+
/// Captured video file from the camera, already at a temp URL.
19+
case cameraVideo(URL, capturedAt: Date)
20+
21+
/// File-importer URL. Materializer reads it under
22+
/// `startAccessingSecurityScopedResource()`.
23+
case file(URL)
24+
25+
/// Remote-URL source for external pickers (Stock Photos). The
26+
/// materializer downloads bytes via `RemoteDownloader` before dispatching
27+
/// to the image / GIF / disallowed branches.
28+
case remoteURL(RemoteURL)
29+
30+
/// Image Playground (iOS 18.1+) returns a local file URL in our app
31+
/// sandbox. The materializer copies bytes without security-scoped access
32+
/// and dispatches to `materializeFileImage`.
33+
case imagePlayground(URL, suggestedName: String)
34+
}
35+
36+
extension UploadSource {
37+
/// Internal carrier for `.remoteURL`. The public boundary type
38+
/// `ExternalRemoteMedia` is converted to this in the view model before
39+
/// enqueueing — keeps `UploadSource` module-internal.
40+
struct RemoteURL: Sendable {
41+
let url: URL
42+
let suggestedName: String
43+
let contentType: UTType
44+
let caption: String?
45+
}
46+
}
47+
48+
extension UploadSource {
49+
/// Fraction of the overall upload progress allocated to the
50+
/// materialization stage. On-device sources are fast to materialize
51+
/// relative to the upload itself.
52+
var materializationProgressWeight: Double {
53+
switch self {
54+
case .photoLibrary, .cameraImage, .cameraVideo, .file, .imagePlayground:
55+
return 0.05
56+
case .remoteURL:
57+
// Remote sources download the full file during materialization, then
58+
// upload the same bytes, so split the bar evenly between the two.
59+
// The real download-vs-upload time ratio varies by network, so this
60+
// weight only affects how smoothly the row advances, not correctness.
61+
return 0.5
62+
}
63+
}
64+
65+
/// Best-effort media kind derived from the picker payload before the
66+
/// upload is materialized, used for the pre-enqueue analytics event and
67+
/// the initial Uploads-row icon. The materializer later derives the
68+
/// authoritative kind from the post-transform content type.
69+
var estimatedKind: MediaKind {
70+
switch self {
71+
case .photoLibrary(_, _, let hint):
72+
return MediaKind(estimating: hint)
73+
case .cameraImage:
74+
return .image
75+
case .cameraVideo:
76+
return .video
77+
case .file(let url):
78+
let contentType =
79+
(try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType
80+
?? UTType(filenameExtension: url.pathExtension)
81+
return contentType.map { MediaKind(estimating: $0) } ?? .document
82+
case .remoteURL(let remote):
83+
return MediaKind(estimating: remote.contentType)
84+
case .imagePlayground:
85+
return .image
86+
}
87+
}
88+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Foundation
2+
3+
/// One row in the upload queue, in submission order. Failing in-flight
4+
/// keeps the row at its original position so the Uploads screen does not
5+
/// reshuffle when an upload transitions to failed (or back to pending
6+
/// after Retry).
7+
enum UploadEntry: Identifiable, Sendable {
8+
case pending(PendingUpload)
9+
case failed(FailedUpload)
10+
11+
var id: UUID {
12+
switch self {
13+
case .pending(let p): return p.id
14+
case .failed(let f): return f.id
15+
}
16+
}
17+
}
18+
19+
/// Snapshot of the uploader's queue. Emitted whenever any entry changes.
20+
/// `entries` preserves submission order; `pendingCount` / `failedCount`
21+
/// are derived for the banner.
22+
struct UploaderState: Sendable {
23+
let entries: [UploadEntry]
24+
25+
init(entries: [UploadEntry] = []) {
26+
self.entries = entries
27+
}
28+
29+
var isEmpty: Bool { entries.isEmpty }
30+
31+
var pendingCount: Int { pending.count }
32+
var failedCount: Int { failed.count }
33+
34+
var pending: [PendingUpload] {
35+
entries.compactMap { if case .pending(let p) = $0 { return p } else { return nil } }
36+
}
37+
38+
var failed: [FailedUpload] {
39+
entries.compactMap { if case .failed(let f) = $0 { return f } else { return nil } }
40+
}
41+
}

0 commit comments

Comments
 (0)