From de0286ab3c4a3854403fe763f5ad79c897be5b70 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 8 Jun 2026 22:46:36 +1200 Subject: [PATCH 1/4] 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. --- Modules/Package.resolved | 6 +- Modules/Package.swift | 2 +- .../Analytics/MediaTracker.swift | 12 ++ .../Models/FailedUpload.swift | 16 ++ .../Models/MediaKind.swift | 17 ++ .../Models/MediaUploadPolicy.swift | 69 +++++++ .../Models/PendingUpload.swift | 11 ++ .../Models/UploadSource.swift | 88 +++++++++ .../Models/UploaderState.swift | 41 ++++ .../Strings/Strings.swift | 176 ++++++++++++++++++ .../Upload/MediaUploadTransport.swift | 27 +++ ...MediaTrackerAdapterUploadEventsTests.swift | 155 +++++++++++++++ .../Analytics/MediaTrackerAdapter.swift | 49 +++++ 13 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/UploaderState.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Upload/MediaUploadTransport.swift create mode 100644 Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 854e1f730863..3f399e95f411 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "84257158eee34b797c4f5c81ad33c2f508fe93818c99642c468f31136376cda1", + "originHash" : "e67cd3477bbcb448662b647b41bbd8ee018749a6c6e9da97b0b64cbe1ff83807", "pins" : [ { "identity" : "alamofire", @@ -345,8 +345,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/automattic/wordpress-rs", "state" : { - "revision" : "7065171801606268e9ad0554cf8ec3b7de8ff04d", - "version" : "0.3.0" + "revision" : "2bd38f23211917de08dac22a16423adbbedab48d", + "version" : "0.4.0" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index e3dbf2d02e42..b41253b9807b 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -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", diff --git a/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift b/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift index a0fd069fbeaf..1df5c8388a08 100644 --- a/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift +++ b/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift @@ -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. diff --git a/Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift b/Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift new file mode 100644 index 000000000000..9e5a1871e15a --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift @@ -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 +} diff --git a/Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift b/Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift index 5ca477268e29..9af193b58d95 100644 --- a/Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift +++ b/Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift @@ -1,4 +1,5 @@ import Foundation +import UniformTypeIdentifiers import WordPressAPI import WordPressAPIInternal @@ -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 diff --git a/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift b/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift new file mode 100644 index 000000000000..30a97474e7c5 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift @@ -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 + ) { + 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 + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift b/Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift new file mode 100644 index 000000000000..d51eca8516df --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift @@ -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 +} diff --git a/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift b/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift new file mode 100644 index 000000000000..cf51be032915 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift @@ -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_.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 + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Models/UploaderState.swift b/Modules/Sources/WordPressMediaLibrary/Models/UploaderState.swift new file mode 100644 index 000000000000..25f01aa03390 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/UploaderState.swift @@ -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 } } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift index 1ce8d57a05ca..9fe2245a689c 100644 --- a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift +++ b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift @@ -117,4 +117,180 @@ enum Strings { value: "Media failed to load", comment: "Accessibility label for a cell whose underlying media couldn't be loaded" ) + + // MARK: - Upload error messages + + static let uploadErrorSecurityScopedAccess = NSLocalizedString( + "mediaLibrary.upload.error.securityScopedAccess", + value: "Couldn't access the selected file.", + comment: "Error shown when iOS denies access to a file picked via Files." + ) + static let uploadErrorFileNotFound = NSLocalizedString( + "mediaLibrary.upload.error.fileNotFound", + value: "The selected file could not be found.", + comment: "Error shown when a picked file no longer exists on disk." + ) + static let uploadErrorDurationCap = NSLocalizedString( + "mediaLibrary.upload.error.durationCap", + value: "This video is longer than your site allows.", + comment: "Error shown when a picked video exceeds the duration cap configured for the blog." + ) + static let uploadErrorDisallowedType = NSLocalizedString( + "mediaLibrary.upload.error.disallowedType", + value: "This file type isn't allowed for upload on your site.", + comment: "Error shown when a picked file's type is not in the blog's allowed list." + ) + static let uploadErrorHEICConversion = NSLocalizedString( + "mediaLibrary.upload.error.heicConversion", + value: "Couldn't convert the photo for upload.", + comment: "Error shown when HEIC-to-JPEG conversion fails before upload." + ) + static let uploadErrorVideoExport = NSLocalizedString( + "mediaLibrary.upload.error.videoExport", + value: "Couldn't prepare the video for upload: %1$@", + comment: + "Error shown when AVAssetExportSession fails before upload. %1$@ is the underlying error description." + ) + static let uploadErrorVideoExportNoExporter = NSLocalizedString( + "mediaLibrary.upload.error.videoExport.noExporter", + value: "No exporter is available for the selected video quality.", + comment: + "Error shown when no AVAssetExportSession can be created for the configured export preset." + ) + static let uploadErrorUnknownContentType = NSLocalizedString( + "mediaLibrary.upload.error.unknownContentType", + value: "Couldn't determine the file type.", + comment: "Error shown when no UTType can be derived from the picker output." + ) + static let materializerErrorRemoteDownloadFailed = NSLocalizedString( + "mediaLibrary.materializer.remoteDownloadFailed", + value: "Couldn't download the selected media: %1$@", + comment: + "Failed-row label when a remote media download (e.g. Stock Photos) failed before upload. %1$@ is the underlying error description." + ) + + // MARK: - Upload fallback display names + + static let uploadFallbackPhotoName = NSLocalizedString( + "mediaLibrary.upload.fallback.photo", + value: "Photo", + comment: "Display name used when a picked photo has no source filename." + ) + static let uploadFallbackCameraImageName = NSLocalizedString( + "mediaLibrary.upload.fallback.cameraImage", + value: "Camera photo", + comment: "Display name used for camera-captured photos in the Uploads queue." + ) + static let uploadFallbackCameraVideoName = NSLocalizedString( + "mediaLibrary.upload.fallback.cameraVideo", + value: "Camera video", + comment: "Display name used for camera-captured videos in the Uploads queue." + ) + static let defaultExternalMediaStem = NSLocalizedString( + "mediaLibrary.externalMedia.defaultStem", + value: "External Media", + comment: + "Fallback filename stem for external media when the picker provides no usable name" + ) + + // MARK: - Upload banner and uploads screen + + static let uploadBannerUploadingOnly = NSLocalizedString( + "mediaLibrary.upload.banner.uploadingOnly", + value: "Uploading %1$d items", + comment: "Banner shown above the grid while uploads are in flight. %1$d is the count." + ) + static let uploadBannerMixed = NSLocalizedString( + "mediaLibrary.upload.banner.mixed", + value: "Uploading %1$d · %2$d failed", + comment: "Banner shown when both pending and failed uploads exist. %1$d pending, %2$d failed." + ) + static let uploadBannerFailedOnly = NSLocalizedString( + "mediaLibrary.upload.banner.failedOnly", + value: "%1$d upload failed", + comment: "Banner shown when only failed uploads remain. %1$d is the count." + ) + static let uploadsScreenTitle = NSLocalizedString( + "mediaLibrary.uploads.title", + value: "Uploads", + comment: "Navigation title for the Uploads queue screen." + ) + static let uploadsScreenAllDone = NSLocalizedString( + "mediaLibrary.uploads.allDone", + value: "All uploaded", + comment: "Empty-state label shown on the Uploads screen after the last item resolves." + ) + static let uploadsScreenClose = NSLocalizedString( + "mediaLibrary.uploads.close", + value: "Close", + comment: "Button to dismiss the modally-presented Uploads queue screen." + ) + static let uploadBulkCancelAllConfirm = NSLocalizedString( + "mediaLibrary.uploads.bulk.cancelAll.confirm", + value: "Cancel uploads", + comment: "Destructive button title in the confirmation alert for canceling every in-flight upload." + ) + static let uploadBulkCancelAllMessage = NSLocalizedString( + "mediaLibrary.uploads.bulk.cancelAll.message", + value: "All in-progress uploads will be cancelled. This can't be undone.", + comment: "Body of the confirmation alert shown before canceling every in-flight upload." + ) + static let cancel = NSLocalizedString( + "mediaLibrary.uploads.alert.cancel", + value: "Keep uploading", + comment: "Cancel-the-alert button on the bulk-cancel confirmation dialog. Keeps uploads running." + ) + static let uploadActionRetry = NSLocalizedString( + "mediaLibrary.uploads.retry", + value: "Retry", + comment: "Per-row action: retry a failed upload." + ) + static let uploadActionDismiss = NSLocalizedString( + "mediaLibrary.uploads.dismiss", + value: "Dismiss", + comment: "Per-row action: remove a failed upload from the queue." + ) + static let uploadBulkRetryAll = NSLocalizedString( + "mediaLibrary.uploads.bulk.retryAll", + value: "Retry all failed", + comment: "Bulk action: retry every failed upload." + ) + static let uploadBulkDismissAll = NSLocalizedString( + "mediaLibrary.uploads.bulk.dismissAll", + value: "Dismiss all failed", + comment: "Bulk action: dismiss every failed upload." + ) + static let uploadBulkCancelAll = NSLocalizedString( + "mediaLibrary.uploads.bulk.cancelAll", + value: "Cancel all uploading", + comment: "Bulk action: cancel every in-flight upload." + ) + + // MARK: - Add menu + + static let addMenuTitle = NSLocalizedString( + "mediaLibrary.addMenu.title", + value: "Add", + comment: "Accessibility label for the toolbar + button that opens the Add menu." + ) + static let addMenuPhotoLibrary = NSLocalizedString( + "mediaLibrary.addMenu.photoLibrary", + value: "Photo Library", + comment: "Add-menu item that opens the system photo library picker." + ) + static let addMenuTakePhoto = NSLocalizedString( + "mediaLibrary.addMenu.takePhoto", + value: "Take Photo", + comment: "Add-menu item that opens the camera in photo mode." + ) + static let addMenuTakeVideo = NSLocalizedString( + "mediaLibrary.addMenu.takeVideo", + value: "Take Video", + comment: "Add-menu item that opens the camera in video mode." + ) + static let addMenuChooseFile = NSLocalizedString( + "mediaLibrary.addMenu.chooseFile", + value: "Choose File", + comment: "Add-menu item that opens the system file picker." + ) } diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploadTransport.swift b/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploadTransport.swift new file mode 100644 index 000000000000..b7be83593593 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploadTransport.swift @@ -0,0 +1,27 @@ +import Foundation +import WordPressAPI +import WordPressCore + +/// Module-internal seam over the wp_mobile-side upload call. +protocol MediaUploadTransport: Sendable { + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext +} + +struct DefaultMediaUploadTransport: MediaUploadTransport { + let client: WordPressClient + + init(client: WordPressClient) { + self.client = client + } + + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext { + let service = try await client.service + return try await service.uploadMedia(params: params, fulfilling: progress) + } +} diff --git a/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift b/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift new file mode 100644 index 000000000000..b7328f56f717 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift @@ -0,0 +1,155 @@ +import Foundation +import CoreData +import Testing +import WordPressData +@testable import WordPress +@testable import WordPressMediaLibrary + +@Suite("MediaTrackerAdapter upload events", .serialized) +@MainActor +struct MediaTrackerAdapterUploadEventsTests { + let contextManager = ContextManager.forTesting() + var mainContext: NSManagedObjectContext { contextManager.mainContext } + + private func makeAdapter() -> MediaTrackerAdapter { + let blog = ModelTestHelper.insertDotComBlog(context: mainContext) + contextManager.saveContextAndWait(mainContext) + return MediaTrackerAdapter(blog: blog, baseProperties: ["is_v2": "1"]) + } + + @Test("Photo from PHPicker maps to AddedPhotoViaDeviceLibrary") + func photoLibraryImage() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .photoLibrary, kind: .image)) + + #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedPhotoViaDeviceLibrary) + } + + @Test("Video from PHPicker maps to AddedVideoViaDeviceLibrary") + func photoLibraryVideo() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .photoLibrary, kind: .video)) + + #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedVideoViaDeviceLibrary) + } + + @Test("Photo from camera maps to AddedPhotoViaCamera") + func cameraImage() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .camera, kind: .image)) + + #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedPhotoViaCamera) + } + + @Test("Video from camera maps to AddedVideoViaCamera") + func cameraVideo() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .camera, kind: .video)) + + #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedVideoViaCamera) + } + + @Test("Photo from file picker maps to AddedPhotoViaOtherApps") + func otherAppsImage() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .otherApps, kind: .image)) + + #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedPhotoViaOtherApps) + } + + @Test("Video from file picker maps to AddedVideoViaOtherApps") + func otherAppsVideo() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .otherApps, kind: .video)) + + #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedVideoViaOtherApps) + } + + @Test("Document is silently dropped") + func documentDropped() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .otherApps, kind: .document)) + + #expect(TestAnalyticsTracker.tracked.isEmpty) + } + + @Test("Audio is silently dropped") + func audioDropped() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .photoLibrary, kind: .audio)) + + #expect(TestAnalyticsTracker.tracked.isEmpty) + } + + @Test("Retry maps to mediaLibraryUploadMediaRetried") + func retryEvent() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryUploadRetried) + + #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryUploadMediaRetried) + } + + @Test("Stock Photos image fires PhotoViaStockPhotos + StockMediaUploaded") + func stockPhotos_imageAdded_firesPhotoViaStockPhotos_andStockMediaUploaded() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + makeAdapter().track(.mediaLibraryAdded(source: .stockPhotos, kind: .image)) + + let events = TestAnalyticsTracker.tracked + #expect(events.contains { $0.stat == .mediaLibraryAddedPhotoViaStockPhotos }) + #expect(events.contains { $0.stat == .stockMediaUploaded }) + let photoViaEvent = events.first { $0.stat == .mediaLibraryAddedPhotoViaStockPhotos } + #expect(photoViaEvent?.properties["is_v2"] as? String == "1") + #expect(photoViaEvent?.properties["media_origin"] as? String == "full_screen_picker") + } + + @Test("Stock Photos non-image kinds fire no external-source events") + func stockPhotos_videoAdded_doesNotFireExternalSourceEvents() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + let blog = ModelTestHelper.insertDotComBlog(context: mainContext) + contextManager.saveContextAndWait(mainContext) + let adapter = MediaTrackerAdapter(blog: blog, baseProperties: [:]) + adapter.track(.mediaLibraryAdded(source: .stockPhotos, kind: .video)) + + let trackedStats = TestAnalyticsTracker.tracked.map(\.stat) + #expect(!trackedStats.contains(.stockMediaUploaded)) + #expect(!trackedStats.contains(.mediaLibraryAddedPhotoViaStockPhotos)) + } + + @Test("Image Playground image fires no added-photo event") + func imagePlayground_imageAdded_firesNoAddedPhotoEvent() { + TestAnalyticsTracker.setup() + defer { TestAnalyticsTracker.tearDown() } + + let blog = ModelTestHelper.insertDotComBlog(context: mainContext) + contextManager.saveContextAndWait(mainContext) + let adapter = MediaTrackerAdapter(blog: blog, baseProperties: [:]) + adapter.track(.mediaLibraryAdded(source: .imagePlayground, kind: .image)) + + let trackedEventNames = TestAnalyticsTracker.tracked.map(\.event) + let trackedStats = TestAnalyticsTracker.tracked.map(\.stat) + #expect(!trackedEventNames.contains("media_library_photo_added")) + #expect(!trackedStats.contains(.mediaLibraryAddedPhotoViaStockPhotos)) + } +} diff --git a/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift index f20496925cac..4874b89eb70d 100644 --- a/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift +++ b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift @@ -30,8 +30,57 @@ struct MediaTrackerAdapter: MediaTracker { case .mediaLibraryGridModeToggled(let isAspectRatio): stat = .siteMediaGridModeToggled properties["mode"] = isAspectRatio ? "aspect_ratio" : "square" + + case .mediaLibraryAdded(let source, let kind): + handleAddedMedia(source: source, kind: kind) + return + + case .mediaLibraryUploadRetried: + stat = .mediaLibraryUploadMediaRetried } WPAppAnalytics.track(stat, properties: properties, blog: blog) } + + private func handleAddedMedia(source: MediaUploadSource, kind: MediaKind) { + switch source { + case .photoLibrary, .camera, .otherApps: + guard let resolvedStat = uploadAddedStat(source: source, kind: kind) else { + // .audio / .document map to no event — V1 parity. + return + } + WPAppAnalytics.track(resolvedStat, properties: baseProperties, blog: blog) + + case .stockPhotos: + // External sources fire only for image kind — non-image .remoteURL + // (which the materializer rejects) must NOT log a photo-added event + // at enqueue time. + guard kind == .image else { return } + var props = baseProperties + props["media_origin"] = "full_screen_picker" + // Bare selection-time call — matches V1's + // SiteMediaAddMediaMenuController.swift:127 (no properties / blog). + WPAnalytics.track(.stockMediaUploaded) + // Contextual ...ViaStockPhotos with baseProperties + media_origin + blog. + WPAppAnalytics.track(.mediaLibraryAddedPhotoViaStockPhotos, properties: props, blog: blog) + + case .imagePlayground: + // V1 doesn't emit a ...ViaImagePlayground event; preserved for parity. + return + } + } + + private func uploadAddedStat(source: MediaUploadSource, kind: MediaKind) -> WPAnalyticsStat? { + switch (source, kind) { + case (.photoLibrary, .image): return .mediaLibraryAddedPhotoViaDeviceLibrary + case (.photoLibrary, .video): return .mediaLibraryAddedVideoViaDeviceLibrary + case (.camera, .image): return .mediaLibraryAddedPhotoViaCamera + case (.camera, .video): return .mediaLibraryAddedVideoViaCamera + case (.otherApps, .image): return .mediaLibraryAddedPhotoViaOtherApps + case (.otherApps, .video): return .mediaLibraryAddedVideoViaOtherApps + // Unreachable from `handleAddedMedia`; kept for switch exhaustiveness. + case (.stockPhotos, _), (.imagePlayground, _): return nil + case (_, .audio), (_, .document): return nil + } + } } From 655ecccb16988cdedb7aca35381d826258353a7a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 18 Jun 2026 11:13:18 +1200 Subject: [PATCH 2/4] Address review feedback on upload analytics and strings Attach media_origin to device/camera/file-picker added events for V1 parity, tighten the Image Playground analytics test, and fix the upload banner pluralization and the bulk-cancel button name. --- .../WordPressMediaLibrary/Strings/Strings.swift | 16 +++++++++++++--- .../MediaTrackerAdapterUploadEventsTests.swift | 5 +---- .../Utility/Analytics/MediaTrackerAdapter.swift | 13 ++++++++++++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift index 9fe2245a689c..394781fe5bae 100644 --- a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift +++ b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift @@ -195,6 +195,11 @@ enum Strings { // MARK: - Upload banner and uploads screen + static let uploadBannerUploadingOnlySingle = NSLocalizedString( + "mediaLibrary.upload.banner.uploadingOnly.single", + value: "Uploading %1$d item", + comment: "Banner shown above the grid while a single upload is in flight. %1$d is the count (1)." + ) static let uploadBannerUploadingOnly = NSLocalizedString( "mediaLibrary.upload.banner.uploadingOnly", value: "Uploading %1$d items", @@ -205,9 +210,14 @@ enum Strings { value: "Uploading %1$d · %2$d failed", comment: "Banner shown when both pending and failed uploads exist. %1$d pending, %2$d failed." ) + static let uploadBannerFailedOnlySingle = NSLocalizedString( + "mediaLibrary.upload.banner.failedOnly.single", + value: "%1$d upload failed", + comment: "Banner shown when a single failed upload remains. %1$d is the count (1)." + ) static let uploadBannerFailedOnly = NSLocalizedString( "mediaLibrary.upload.banner.failedOnly", - value: "%1$d upload failed", + value: "%1$d uploads failed", comment: "Banner shown when only failed uploads remain. %1$d is the count." ) static let uploadsScreenTitle = NSLocalizedString( @@ -235,8 +245,8 @@ enum Strings { value: "All in-progress uploads will be cancelled. This can't be undone.", comment: "Body of the confirmation alert shown before canceling every in-flight upload." ) - static let cancel = NSLocalizedString( - "mediaLibrary.uploads.alert.cancel", + static let keepUploading = NSLocalizedString( + "mediaLibrary.uploads.alert.keepUploading", value: "Keep uploading", comment: "Cancel-the-alert button on the bulk-cancel confirmation dialog. Keeps uploads running." ) diff --git a/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift b/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift index b7328f56f717..2d5f8552f16e 100644 --- a/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift @@ -147,9 +147,6 @@ struct MediaTrackerAdapterUploadEventsTests { let adapter = MediaTrackerAdapter(blog: blog, baseProperties: [:]) adapter.track(.mediaLibraryAdded(source: .imagePlayground, kind: .image)) - let trackedEventNames = TestAnalyticsTracker.tracked.map(\.event) - let trackedStats = TestAnalyticsTracker.tracked.map(\.stat) - #expect(!trackedEventNames.contains("media_library_photo_added")) - #expect(!trackedStats.contains(.mediaLibraryAddedPhotoViaStockPhotos)) + #expect(TestAnalyticsTracker.tracked.isEmpty) } } diff --git a/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift index 4874b89eb70d..fbc7eef4d382 100644 --- a/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift +++ b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift @@ -49,7 +49,18 @@ struct MediaTrackerAdapter: MediaTracker { // .audio / .document map to no event — V1 parity. return } - WPAppAnalytics.track(resolvedStat, properties: baseProperties, blog: blog) + var props = baseProperties + // V1 attaches media_origin via selectionMethod: full_screen_picker for + // the photo library / camera, document_picker for the Files app. + switch source { + case .photoLibrary, .camera: + props["media_origin"] = "full_screen_picker" + case .otherApps: + props["media_origin"] = "document_picker" + case .stockPhotos, .imagePlayground: + break + } + WPAppAnalytics.track(resolvedStat, properties: props, blog: blog) case .stockPhotos: // External sources fire only for image kind — non-image .remoteURL From 158d78423dd52408782ff9a7a837ad58cf5232e7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 19 Jun 2026 12:05:50 +1200 Subject: [PATCH 3/4] Remove MediaUploadPolicy.init default values --- .../Models/MediaUploadPolicy.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift b/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift index 30a97474e7c5..ee56b2dd24d0 100644 --- a/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift +++ b/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift @@ -48,13 +48,13 @@ public struct MediaUploadPolicy: Sendable { public init( filePickerContentTypes: [UTType], isAllowedForUpload: @escaping @Sendable (UTType, String) -> Bool, - imageMaxDimension: Int? = nil, - imageJpegQuality: Double = 0.9, - convertHEICToJPEG: Bool = true, - videoMaxDurationSeconds: TimeInterval? = nil, + imageMaxDimension: Int?, + imageJpegQuality: Double, + convertHEICToJPEG: Bool, + videoMaxDurationSeconds: TimeInterval?, videoExportPreset: String, - videoOutputContentType: UTType = .mpeg4Movie, - stripImageLocation: Bool = false + videoOutputContentType: UTType, + stripImageLocation: Bool ) { self.filePickerContentTypes = filePickerContentTypes self.isAllowedForUpload = isAllowedForUpload From 2e38b102045e041504e2f9cd02779a9268c23dc3 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 19 Jun 2026 12:14:18 +1200 Subject: [PATCH 4/4] Tighten MediaTrackerAdapter upload-event test assertions --- ...MediaTrackerAdapterUploadEventsTests.swift | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift b/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift index 2d5f8552f16e..4d26a1013542 100644 --- a/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Media/MediaTrackerAdapterUploadEventsTests.swift @@ -24,7 +24,11 @@ struct MediaTrackerAdapterUploadEventsTests { makeAdapter().track(.mediaLibraryAdded(source: .photoLibrary, kind: .image)) - #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedPhotoViaDeviceLibrary) + let events = TestAnalyticsTracker.tracked + #expect(events.count == 1) + #expect(events.first?.stat == .mediaLibraryAddedPhotoViaDeviceLibrary) + #expect(events.first?.properties["media_origin"] as? String == "full_screen_picker") + #expect(events.first?.properties["is_v2"] as? String == "1") } @Test("Video from PHPicker maps to AddedVideoViaDeviceLibrary") @@ -34,7 +38,8 @@ struct MediaTrackerAdapterUploadEventsTests { makeAdapter().track(.mediaLibraryAdded(source: .photoLibrary, kind: .video)) - #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedVideoViaDeviceLibrary) + #expect(TestAnalyticsTracker.tracked.count == 1) + #expect(TestAnalyticsTracker.tracked.first?.stat == .mediaLibraryAddedVideoViaDeviceLibrary) } @Test("Photo from camera maps to AddedPhotoViaCamera") @@ -44,7 +49,8 @@ struct MediaTrackerAdapterUploadEventsTests { makeAdapter().track(.mediaLibraryAdded(source: .camera, kind: .image)) - #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedPhotoViaCamera) + #expect(TestAnalyticsTracker.tracked.count == 1) + #expect(TestAnalyticsTracker.tracked.first?.stat == .mediaLibraryAddedPhotoViaCamera) } @Test("Video from camera maps to AddedVideoViaCamera") @@ -54,7 +60,8 @@ struct MediaTrackerAdapterUploadEventsTests { makeAdapter().track(.mediaLibraryAdded(source: .camera, kind: .video)) - #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedVideoViaCamera) + #expect(TestAnalyticsTracker.tracked.count == 1) + #expect(TestAnalyticsTracker.tracked.first?.stat == .mediaLibraryAddedVideoViaCamera) } @Test("Photo from file picker maps to AddedPhotoViaOtherApps") @@ -64,7 +71,11 @@ struct MediaTrackerAdapterUploadEventsTests { makeAdapter().track(.mediaLibraryAdded(source: .otherApps, kind: .image)) - #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedPhotoViaOtherApps) + let events = TestAnalyticsTracker.tracked + #expect(events.count == 1) + #expect(events.first?.stat == .mediaLibraryAddedPhotoViaOtherApps) + #expect(events.first?.properties["media_origin"] as? String == "document_picker") + #expect(events.first?.properties["is_v2"] as? String == "1") } @Test("Video from file picker maps to AddedVideoViaOtherApps") @@ -74,7 +85,8 @@ struct MediaTrackerAdapterUploadEventsTests { makeAdapter().track(.mediaLibraryAdded(source: .otherApps, kind: .video)) - #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryAddedVideoViaOtherApps) + #expect(TestAnalyticsTracker.tracked.count == 1) + #expect(TestAnalyticsTracker.tracked.first?.stat == .mediaLibraryAddedVideoViaOtherApps) } @Test("Document is silently dropped") @@ -104,7 +116,10 @@ struct MediaTrackerAdapterUploadEventsTests { makeAdapter().track(.mediaLibraryUploadRetried) - #expect(TestAnalyticsTracker.tracked.last?.stat == .mediaLibraryUploadMediaRetried) + let events = TestAnalyticsTracker.tracked + #expect(events.count == 1) + #expect(events.first?.stat == .mediaLibraryUploadMediaRetried) + #expect(events.first?.properties["is_v2"] as? String == "1") } @Test("Stock Photos image fires PhotoViaStockPhotos + StockMediaUploaded")