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 eb49cb186880..5431d82e5c90 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -63,7 +63,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..ddc4b628e7af 100644 --- a/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift +++ b/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift @@ -16,6 +16,19 @@ 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 tenor + 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..d35688c01818 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift @@ -0,0 +1,86 @@ +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, Tenor). 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: + // Network download dominates progress; raise the materialization + // share so the row doesn't sit near 0% during a multi-MB download. + return 0.7 + } + } + + /// 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..4ee7f59415e9 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 (Stock Photos / Tenor) 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/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift index f20496925cac..7a27d9ab4a1e 100644 --- a/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift +++ b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift @@ -30,8 +30,66 @@ 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 .tenor: + guard kind == .image else { return } + var props = baseProperties + props["media_origin"] = "full_screen_picker" + // Bare selection-time call — matches V1's + // SiteMediaAddMediaMenuController.swift:129 (no properties / blog). + WPAnalytics.track(.tenorUploaded) + WPAnalytics.track(.mediaLibraryAddedPhotoViaTenor, 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, _), (.tenor, _), (.imagePlayground, _): return nil + case (_, .audio), (_, .document): return nil + } + } }