From 50a28a1a61afd2d200c09f498b1b7d034ad896a8 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 8 Jun 2026 22:49:33 +1200 Subject: [PATCH 1/2] Wire the app-side uploader lifecycle teardown Tears the Blog-keyed, process-wide uploader down on logout, site removal, and Jetpack disconnect, and adds the registry and tracker-adapter tests. --- WordPress/Classes/Services/SiteManagementService.swift | 7 +++++++ WordPress/Classes/Utility/AccountHelper.swift | 3 +++ .../Blog Details/BlogDetailsViewController+Swift.swift | 6 ++++++ .../Jetpack Settings/JetpackConnectionViewController.swift | 6 ++++++ 4 files changed, 22 insertions(+) diff --git a/WordPress/Classes/Services/SiteManagementService.swift b/WordPress/Classes/Services/SiteManagementService.swift index d86ef2f76c81..7c41a7c9643b 100644 --- a/WordPress/Classes/Services/SiteManagementService.swift +++ b/WordPress/Classes/Services/SiteManagementService.swift @@ -30,11 +30,18 @@ open class SiteManagementService: NSObject { guard let remote = siteManagementServiceRemoteForBlog(blog) else { return } + // Capture the blog id synchronously, before `remove(blog)` deletes + // the managed object. The teardown Task can then run safely without + // touching the deleted Core Data instance. + let blogID = TaggedManagedObjectID(blog) remote.deleteSite( blog.dotComID!, success: { let blogService = BlogService(coreDataStack: self.coreDataStack) blogService.remove(blog) + Task { @MainActor in + await MediaUploaderRegistry.shared.tearDown(blogID: blogID) + } DispatchQueue.main.async { NotificationCenter.default.post(name: .WPSiteDeleted, object: nil) success?() diff --git a/WordPress/Classes/Utility/AccountHelper.swift b/WordPress/Classes/Utility/AccountHelper.swift index 8d1083a65186..ac4fa8e1656c 100644 --- a/WordPress/Classes/Utility/AccountHelper.swift +++ b/WordPress/Classes/Utility/AccountHelper.swift @@ -99,6 +99,9 @@ import WordPressData WordPressClientFactory.shared.reset() JetpackSocialFactory.shared.reset() + Task { @MainActor in + await MediaUploaderRegistry.shared.tearDownAll() + } } static func deleteAccountData() { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index de8e10e129c6..7fed969be897 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -20,6 +20,12 @@ extension BlogDetailsViewController { public func confirmRemoveSite() { let blogService = BlogService(coreDataStack: ContextManager.shared) + // Compute the id synchronously before `remove(blog)` deletes the + // managed object, so the teardown Task doesn't touch a deleted Blog. + let blogID = TaggedManagedObjectID(blog) + Task { @MainActor in + await MediaUploaderRegistry.shared.tearDown(blogID: blogID) + } blogService.remove(blog) WordPressAppDelegate.shared?.trackLogoutIfNeeded() diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackConnectionViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackConnectionViewController.swift index 6978dffb03cd..a13868bdbfaa 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackConnectionViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackConnectionViewController.swift @@ -127,6 +127,12 @@ open class JetpackConnectionViewController: UITableViewController { self?.stopLoading() if let blog = self?.blog { let service = BlogService(coreDataStack: ContextManager.shared) + // Compute the id synchronously before `remove(blog)` so + // the teardown Task doesn't touch a deleted Blog. + let blogID = TaggedManagedObjectID(blog) + Task { @MainActor in + await MediaUploaderRegistry.shared.tearDown(blogID: blogID) + } service.remove(blog) self?.delegate?.jetpackDisconnectedForBlog(blog) } else { From a3cb5b6b8bcdb69eca85bfcc9f796e7e20e38106 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 8 Jun 2026 22:49:51 +1200 Subject: [PATCH 2/2] Add external media sources to the V2 uploader Stock Photos, Tenor GIFs, and Image Playground as picker sheets that wrap the existing UIKit pickers, adapt their output to the module's external media model, and feed the M4 uploader. Wires the picker options into routing and adds the adapter and routing tests. --- .../V2/ExternalRemoteMediaAdapterTests.swift | 55 +++++++++++++ ...iaLibraryRoutingExternalSourcesTests.swift | 40 ++++++++++ .../Media/MediaLibraryRouting.swift | 53 ++++++++++++- .../ExternalMediaPickerSheet.swift | 78 +++++++++++++++++++ .../ExternalRemoteMedia+Adapter.swift | 46 +++++++++++ .../ImagePlaygroundPickerSheet.swift | 71 +++++++++++++++++ .../StockPhotosPickerSheet.swift | 27 +++++++ 7 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 Tests/KeystoneTests/Tests/Features/Media/V2/ExternalRemoteMediaAdapterTests.swift create mode 100644 Tests/KeystoneTests/Tests/Features/Media/V2/MediaLibraryRoutingExternalSourcesTests.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalMediaPickerSheet.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalRemoteMedia+Adapter.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ImagePlaygroundPickerSheet.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/ExternalSources/StockPhotosPickerSheet.swift diff --git a/Tests/KeystoneTests/Tests/Features/Media/V2/ExternalRemoteMediaAdapterTests.swift b/Tests/KeystoneTests/Tests/Features/Media/V2/ExternalRemoteMediaAdapterTests.swift new file mode 100644 index 000000000000..d940759860c1 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Media/V2/ExternalRemoteMediaAdapterTests.swift @@ -0,0 +1,55 @@ +import Testing +import UniformTypeIdentifiers +import WordPressData +import WordPressMediaLibrary +@testable import WordPress + +@MainActor +struct ExternalRemoteMediaAdapterTests { + + @Test func stockPhotos_prefersAssetNameOverURLStem() { + let asset = StubExternalMediaAsset( + id: "1", + name: "Sunset over the harbor", + caption: "by Foo", + largeURL: URL(string: "https://images.pexels.com/photos/1234/pexels-photo.jpg")!, + thumbnailURL: URL(string: "https://example.com/thumb.jpg")! + ) + let media = ExternalRemoteMedia(stockPhotosAsset: asset) + #expect(media.suggestedName == "Sunset over the harbor") + #expect(media.contentType == .jpeg) + #expect(media.caption == "by Foo") + } + + @Test func stockPhotos_fallsBackToDefault_whenNameAndURLStemAreEmpty() { + let asset = StubExternalMediaAsset( + id: "p2", + name: "", + caption: "", + largeURL: URL(string: "https://images.pexels.com/")!, + thumbnailURL: URL(string: "https://example.com/")! + ) + let media = ExternalRemoteMedia(stockPhotosAsset: asset) + #expect(media.suggestedName == "External Media") + } +} + +/// Simple test stub for `ExternalMediaAsset` (a V1 app-target protocol +/// inheriting from `AnyObject` + `ExportableAsset` which is +/// `NSObjectProtocol`). Lives in this test file only. +private final class StubExternalMediaAsset: NSObject, ExternalMediaAsset { + let id: String + let name: String + let caption: String + let largeURL: URL + let thumbnailURL: URL + var assetMediaType: MediaType { .image } + init(id: String, name: String, caption: String, largeURL: URL, thumbnailURL: URL) { + self.id = id + self.name = name + self.caption = caption + self.largeURL = largeURL + self.thumbnailURL = thumbnailURL + super.init() + } +} diff --git a/Tests/KeystoneTests/Tests/Features/Media/V2/MediaLibraryRoutingExternalSourcesTests.swift b/Tests/KeystoneTests/Tests/Features/Media/V2/MediaLibraryRoutingExternalSourcesTests.swift new file mode 100644 index 000000000000..ee58252d6f77 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Media/V2/MediaLibraryRoutingExternalSourcesTests.swift @@ -0,0 +1,40 @@ +import Testing +import WordPressData +import WordPressMediaLibrary +@testable import WordPress + +@MainActor +struct MediaLibraryRoutingExternalSourcesTests { + + @Test func dotComBlog_jetpackEnabled_offersStockPhotos() { + let context = ContextManager.forTesting().mainContext + let blog = ModelTestHelper.insertDotComBlog(context: context) + // `blog.wordPressComRestApi` is non-nil only when the account has a + // non-empty authToken (WPAccount+RestApi.swift:13-24). The default + // fixture leaves authToken empty, so set one before asserting. + blog.account?.authToken = "test-token" + try? context.save() + #expect(blog.wordPressComRestApi != nil) + + // Stabilize the Jetpack-features gate. MediaPickerSource.freePhotos + // resolves to `blog.supports(.stockPhotos) && jetpackFeaturesEnabled()`. + // If `JetpackFeaturesRemovalCoordinator.currentAppUIType` is nil, the + // removal-phase fallback can disable Jetpack features and silently + // hide Stock Photos in this test. Save/restore the override + // around the test so other suites aren't affected. + let savedAppUIType = JetpackFeaturesRemovalCoordinator.currentAppUIType + JetpackFeaturesRemovalCoordinator.currentAppUIType = .normal + defer { JetpackFeaturesRemovalCoordinator.currentAppUIType = savedAppUIType } + + let options = MediaLibraryRouting.externalPickerOptions(for: blog) + let ids = options.map(\.id) + #expect(ids.contains("stockPhotos")) + } + + @Test func selfHostedBlog_hidesStockPhotos() { + let blog = ModelTestHelper.insertSelfHostedBlog(context: ContextManager.forTesting().mainContext) + let options = MediaLibraryRouting.externalPickerOptions(for: blog) + let ids = options.map(\.id) + #expect(!ids.contains("stockPhotos")) // V1 parity: gated on .freePhotos + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift index 7f4a8b10c96f..4af7d06df713 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift @@ -1,3 +1,4 @@ +import SwiftUI import UIKit import WordPressCore import WordPressData @@ -40,7 +41,57 @@ enum MediaLibraryRouting { return MediaLibraryHostingController.make( client: client, tracker: tracker, - uploader: uploader + uploader: uploader, + externalPickerOptions: externalPickerOptions(for: blog) ) } + + /// Internal helper so tests can assert the option array without UI introspection. + /// Mirrors V1's effective gates from MediaPickerMenu+External.swift. + static func externalPickerOptions(for blog: Blog) -> [ExternalMediaPickerOption] { + var options: [ExternalMediaPickerOption] = [] + + if MediaPickerSource.freePhotos(blog: blog).isEnabled, + let api = blog.wordPressComRestApi + { + options.append( + .init( + id: "stockPhotos", + label: Strings.stockPhotos, + systemImage: "photo.on.rectangle", + sheetContent: { delegate in + AnyView(StockPhotosPickerSheet(api: api, delegate: delegate)) + } + ) + ) + } + if MediaPickerSource.playground.isEnabled { + if #available(iOS 18.1, *) { + options.append( + .init( + id: "imagePlayground", + label: Strings.imagePlayground, + systemImage: "apple.image.playground", + sheetContent: { delegate in + AnyView(ImagePlaygroundPickerSheet(delegate: delegate)) + } + ) + ) + } + } + return options + } +} + +private enum Strings { + static let stockPhotos = NSLocalizedString( + "mediaLibrary.v2.addMenu.stockPhotos", + value: "Free Photo Library", + comment: "Add-menu item that opens the Stock Photos picker" + ) + static let imagePlayground = NSLocalizedString( + "mediaLibrary.v2.addMenu.imagePlayground", + value: "Image Playground", + comment: "Add-menu item that opens Apple's Image Playground" + ) } diff --git a/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalMediaPickerSheet.swift b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalMediaPickerSheet.swift new file mode 100644 index 000000000000..c7ff57110959 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalMediaPickerSheet.swift @@ -0,0 +1,78 @@ +import SwiftUI +import UIKit +import WordPressMediaLibrary + +/// SwiftUI wrapper around the V1 `ExternalMediaPickerViewController`, shared by +/// every external source that vends `ExternalMediaAsset`s (Stock Photos). +/// Per-source differences (data source, welcome view, title, asset mapping) are +/// injected; the picker-to-delegate bridging is common to all of them. +struct ExternalMediaPickerSheet: View { + let title: String + let source: MediaSource + let makeDataSource: () -> ExternalMediaDataSource + let makeWelcomeView: () -> UIView + let mapAsset: (ExternalMediaAsset) -> ExternalRemoteMedia + let delegate: any ExternalMediaPickerDelegate + @Environment(\.dismiss) private var dismiss + + var body: some View { + ExternalMediaPickerVCRepresentable( + title: title, + mediaSource: source, + makeDataSource: makeDataSource, + makeWelcomeView: makeWelcomeView + ) { selection in + // V1 picker uses a single didFinishWithSelection callback; + // empty array = cancel, non-empty = done. + if selection.isEmpty { + delegate.didCancel() + } else { + delegate.didPick(remoteMedia: selection.map(mapAsset)) + } + dismiss() + } + .ignoresSafeArea() + } +} + +private struct ExternalMediaPickerVCRepresentable: UIViewControllerRepresentable { + let title: String + let mediaSource: MediaSource + let makeDataSource: () -> ExternalMediaDataSource + let makeWelcomeView: () -> UIView + let onFinished: ([ExternalMediaAsset]) -> Void + + func makeUIViewController(context: Context) -> UINavigationController { + let picker = ExternalMediaPickerViewController( + dataSource: makeDataSource(), + source: mediaSource, + allowsMultipleSelection: true + ) + picker.title = title + picker.welcomeView = makeWelcomeView() + picker.delegate = context.coordinator + return UINavigationController(rootViewController: picker) + } + + func updateUIViewController(_: UINavigationController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(onFinished: onFinished) } + + final class Coordinator: NSObject, ExternalMediaPickerViewDelegate { + let onFinished: ([ExternalMediaAsset]) -> Void + init(onFinished: @escaping ([ExternalMediaAsset]) -> Void) { + self.onFinished = onFinished + } + func externalMediaPickerViewController( + _ viewController: ExternalMediaPickerViewController, + didFinishWithSelection selection: [ExternalMediaAsset] + ) { + // V1 callback runs on the main thread (it's a UIKit dismiss path). + // assumeIsolated bridges into the @MainActor closure without an + // async hop. Same pattern as MediaPickerController.swift:78-82. + MainActor.assumeIsolated { + onFinished(selection) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalRemoteMedia+Adapter.swift b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalRemoteMedia+Adapter.swift new file mode 100644 index 000000000000..f8323ef51b13 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ExternalRemoteMedia+Adapter.swift @@ -0,0 +1,46 @@ +import Foundation +import UniformTypeIdentifiers +import WordPressMediaLibrary + +extension ExternalRemoteMedia { + /// Stock Photos: prefer the asset's title (Pexels names are descriptive + /// and V1 preserves them via `MediaImageExporter(filename:)`). Falls back + /// to URL basename, then to the localized "External Media" default. + init(stockPhotosAsset asset: ExternalMediaAsset) { + self.init( + url: asset.largeURL, + suggestedName: Self.normalizeStem(preferred: asset.name, fallback: asset.largeURL), + contentType: .jpeg, + caption: asset.caption.isEmpty ? nil : asset.caption + ) + } + + private static func normalizeStem(preferred: String, fallback: URL) -> String { + let stem = sanitize(preferred) + if !stem.isEmpty { return stem } + let urlStem = sanitize(fallback.deletingPathExtension().lastPathComponent) + if !urlStem.isEmpty { return urlStem } + return Strings.defaultExternalMediaStem + } + + private static func sanitize(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let stripped = (trimmed as NSString).deletingPathExtension + // Treat input composed entirely of path separators as empty so the + // caller falls through to the URL-basename / localized-default chain. + let withoutSeparators = stripped.replacingOccurrences(of: "/", with: "") + if withoutSeparators.isEmpty { return "" } + return + stripped + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "\0", with: "") + } +} + +private enum Strings { + static let defaultExternalMediaStem = NSLocalizedString( + "mediaLibrary.v2.externalMedia.defaultStem", + value: "External Media", + comment: "Fallback filename stem when an external picker provides no usable name" + ) +} diff --git a/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ImagePlaygroundPickerSheet.swift b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ImagePlaygroundPickerSheet.swift new file mode 100644 index 000000000000..63774531d2c8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/ImagePlaygroundPickerSheet.swift @@ -0,0 +1,71 @@ +import SwiftUI +import UIKit +import WordPressMediaLibrary + +#if canImport(ImagePlayground) +import ImagePlayground + +@available(iOS 18.1, *) +struct ImagePlaygroundPickerSheet: View { + let delegate: any ExternalMediaPickerDelegate + @Environment(\.dismiss) private var dismiss + + var body: some View { + ImagePlaygroundVCRepresentable( + onCreated: { url in + let stem = url.deletingPathExtension().lastPathComponent + delegate.didPick(imagePlaygroundFile: url, suggestedName: stem) + dismiss() + }, + onCancelled: { + delegate.didCancel() + dismiss() + } + ) + .ignoresSafeArea() + } +} + +@available(iOS 18.1, *) +private struct ImagePlaygroundVCRepresentable: UIViewControllerRepresentable { + let onCreated: (URL) -> Void + let onCancelled: () -> Void + + func makeUIViewController(context: Context) -> UIViewController { + let controller = ImagePlaygroundViewController() + // SwiftUI retains the Coordinator via context.coordinator for the lifetime + // of the representable, so the weak `delegate` on ImagePlaygroundViewController + // stays alive without V1's objc_setAssociatedObject workaround. + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_: UIViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onCreated: onCreated, onCancelled: onCancelled) + } + + @available(iOS 18.1, *) + final class Coordinator: NSObject, ImagePlaygroundViewController.Delegate { + let onCreated: (URL) -> Void + let onCancelled: () -> Void + + init(onCreated: @escaping (URL) -> Void, onCancelled: @escaping () -> Void) { + self.onCreated = onCreated + self.onCancelled = onCancelled + } + + func imagePlaygroundViewController( + _ viewController: ImagePlaygroundViewController, + didCreateImageAt url: URL + ) { + MainActor.assumeIsolated { onCreated(url) } + } + + func imagePlaygroundViewControllerDidCancel(_ viewController: ImagePlaygroundViewController) { + MainActor.assumeIsolated { onCancelled() } + } + } +} +#endif diff --git a/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/StockPhotosPickerSheet.swift b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/StockPhotosPickerSheet.swift new file mode 100644 index 000000000000..65439338dcf1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/ExternalSources/StockPhotosPickerSheet.swift @@ -0,0 +1,27 @@ +import SwiftUI +import WordPressKit // WordPressComRestApi lives here (see DefaultStockPhotosService.swift:1-20) +import WordPressMediaLibrary + +struct StockPhotosPickerSheet: View { + let api: WordPressComRestApi + let delegate: any ExternalMediaPickerDelegate + + var body: some View { + ExternalMediaPickerSheet( + title: Strings.pickFromStockPhotos, + source: .stockPhotos, + makeDataSource: { StockPhotosDataSource(service: DefaultStockPhotosService(api: api)) }, + makeWelcomeView: { StockPhotosWelcomeView() }, + mapAsset: ExternalRemoteMedia.init(stockPhotosAsset:), + delegate: delegate + ) + } +} + +private enum Strings { + static let pickFromStockPhotos = NSLocalizedString( + "mediaLibrary.v2.stockPhotos.title", + value: "Free Photo Library", + comment: "Title of the Stock Photos picker (matches V1 MediaPickerMenu+External.swift)" + ) +}