From cc2998a87ac3ecee16138bddbb2cea0754aa2454 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Sat, 4 Jul 2026 17:41:45 +1200 Subject: [PATCH 1/2] Wire the app-side uploader lifecycle teardown Tear down uploaders by observing Core Data blog deletions on the main context instead of hooking individual removal call sites. This covers every deletion path with one mechanism, including sync-driven cleanup of dead blogs and the account cascade on logout, and it keeps self-hosted uploaders alive through a WordPress.com-only logout. --- .../Media/MediaUploaderRegistryTests.swift | 23 +++++++-- .../Media/V2/MediaUploaderRegistry.swift | 48 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift b/Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift index b277db213a7c..4cf1f6b5a634 100644 --- a/Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift @@ -26,7 +26,7 @@ struct MediaUploaderRegistryTests { @Test("Returns the same uploader for the same blog") func sameBlogReturnsSameUploader() throws { let blog = makeBlog(siteId: 1) - let registry = MediaUploaderRegistry() + let registry = MediaUploaderRegistry(mainContext: mainContext) let first = try registry.uploader(for: blog) let second = try registry.uploader(for: blog) #expect(first === second) @@ -36,7 +36,7 @@ struct MediaUploaderRegistryTests { func tearDownIsTargeted() async throws { let blogA = makeBlog(siteId: 1) let blogB = makeBlog(siteId: 2) - let registry = MediaUploaderRegistry() + let registry = MediaUploaderRegistry(mainContext: mainContext) _ = try registry.uploader(for: blogA) let bUploader = try registry.uploader(for: blogB) await registry.tearDown(blogID: TaggedManagedObjectID(blogA)) @@ -48,11 +48,28 @@ struct MediaUploaderRegistryTests { func tearDownAllClears() async throws { let blogA = makeBlog(siteId: 1) let blogB = makeBlog(siteId: 2) - let registry = MediaUploaderRegistry() + let registry = MediaUploaderRegistry(mainContext: mainContext) let aFirst = try registry.uploader(for: blogA) _ = try registry.uploader(for: blogB) await registry.tearDownAll() let aSecond = try registry.uploader(for: blogA) #expect(aFirst !== aSecond) } + + @Test("Deleting a blog tears down its uploader") + func blogDeletionTearsDownUploader() throws { + let blogA = makeBlog(siteId: 1) + let blogB = makeBlog(siteId: 2) + let blogAID = TaggedManagedObjectID(blogA) + let blogBID = TaggedManagedObjectID(blogB) + let registry = MediaUploaderRegistry(mainContext: mainContext) + _ = try registry.uploader(for: blogA) + _ = try registry.uploader(for: blogB) + + mainContext.delete(blogA) + contextManager.saveContextAndWait(mainContext) + + #expect(!registry.hasUploader(blogID: blogAID)) + #expect(registry.hasUploader(blogID: blogBID)) + } } diff --git a/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift b/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift index 5bd1883757cb..103d6207479f 100644 --- a/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift +++ b/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation import WordPressCore import WordPressData @@ -9,9 +10,43 @@ final class MediaUploaderRegistry { private var uploaders: [TaggedManagedObjectID: MediaUploader] = [:] private let clientFactory: WordPressClientFactory + private var deletionObserver: NSObjectProtocol? - init(clientFactory: WordPressClientFactory = .shared) { + /// Uploader teardown keys off actual Core Data deletion rather than + /// explicit removal call sites, so every path that deletes a `Blog` + /// (site deletion, Jetpack disconnect, sync-driven cleanup of dead + /// blogs, and the account cascade on logout) is covered, while blogs + /// that survive (self-hosted sites after a WordPress.com-only logout) + /// keep their uploaders and any in-flight work. + init( + clientFactory: WordPressClientFactory = .shared, + mainContext: NSManagedObjectContext = ContextManager.shared.mainContext + ) { self.clientFactory = clientFactory + deletionObserver = NotificationCenter.default.addObserver( + forName: .NSManagedObjectContextObjectsDidChange, + object: mainContext, + queue: nil + ) { [weak self] notification in + guard let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set else { + return + } + let blogIDs = deleted.compactMap { ($0 as? Blog)?.objectID } + if blogIDs.isEmpty { + return + } + // The main context posts its change notifications on the main + // thread, so hop into the @MainActor handler without an async gap. + MainActor.assumeIsolated { + self?.tearDownUploaders(forDeletedBlogIDs: blogIDs) + } + } + } + + deinit { + if let deletionObserver { + NotificationCenter.default.removeObserver(deletionObserver) + } } func uploader(for blog: Blog) throws -> MediaUploader { @@ -35,6 +70,10 @@ final class MediaUploaderRegistry { return uploader } + func hasUploader(blogID: TaggedManagedObjectID) -> Bool { + uploaders[blogID] != nil + } + /// Removal call sites should derive the `TaggedManagedObjectID` from /// the `Blog` while still on its managed object context, then pass it /// here. Capturing the `Blog` itself across the launched `Task`'s @@ -51,4 +90,11 @@ final class MediaUploaderRegistry { await uploader.tearDown() } } + + private func tearDownUploaders(forDeletedBlogIDs blogIDs: [NSManagedObjectID]) { + for key in uploaders.keys where blogIDs.contains(key.objectID) { + guard let uploader = uploaders.removeValue(forKey: key) else { continue } + Task { await uploader.tearDown() } + } + } } From 9b8eb9eeafe1d6e65c160f62fca982af0aa459c6 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 | 68 +++++++++++++++- .../ExternalMediaPickerSheet.swift | 78 +++++++++++++++++++ .../ExternalRemoteMedia+Adapter.swift | 46 +++++++++++ .../ImagePlaygroundPickerSheet.swift | 71 +++++++++++++++++ .../StockPhotosPickerSheet.swift | 27 +++++++ 7 files changed, 384 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..fb1b31e49cb7 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,72 @@ 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, + blog.wordPressComRestApi != nil + { + // Capture the blog id, not the api instance: WordPressComRestApi + // freezes its bearer token at init, so an instance captured at + // screen creation goes stale if the account re-authenticates + // while the screen is alive. Resolving at presentation time + // mirrors V1's tap-time lookup (MediaPickerMenu+External.swift). + let blogID = TaggedManagedObjectID(blog) + options.append( + .init( + id: "stockPhotos", + label: Strings.stockPhotos, + systemImage: "photo.on.rectangle", + sheetContent: { delegate in + guard + let blog = try? ContextManager.shared.mainContext.existingObject(with: blogID), + let api = blog.wordPressComRestApi + else { + // A nil api posts the fix-auth-token notification + // from the getter itself, which triggers the + // re-sign-in flow, so this is not a dead end. + return AnyView(EmptyView()) + } + return 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)" + ) +}