Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ final class ExternalMediaPickerViewController: UIViewController, UICollectionVie

switch source {
case .stockPhotos:
WPAnalytics.track(.tenorAccessed)
case .tenor:
WPAnalytics.track(.stockMediaAccessed)
case .tenor:
WPAnalytics.track(.tenorAccessed)
default:
assertionFailure("Unsupported source: \(source)")
}
Expand Down
53 changes: 52 additions & 1 deletion WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SwiftUI
import UIKit
import WordPressCore
import WordPressData
Expand Down Expand Up @@ -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"
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
)
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)"
)
}