diff --git a/Experiments/Experiments/DefaultFeatureFlagService.swift b/Experiments/Experiments/DefaultFeatureFlagService.swift index bf54a4582b4..4446ead84d7 100644 --- a/Experiments/Experiments/DefaultFeatureFlagService.swift +++ b/Experiments/Experiments/DefaultFeatureFlagService.swift @@ -94,7 +94,7 @@ public struct DefaultFeatureFlagService: FeatureFlagService { case .filterHistoryOnOrderAndProductLists: return true case .backgroundProductImageUpload: - return buildConfig == .localDeveloper || buildConfig == .alpha + return false case .notificationSettings: return true case .allowMerchantAIAPIKey: diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index e63d033fe45..41263e7f549 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -513,6 +513,7 @@ 45B204B82489095100FE6526 /* ProductCategoryMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B204B72489095100FE6526 /* ProductCategoryMapper.swift */; }; 45B204BA24890A8C00FE6526 /* ProductCategoryMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B204B924890A8C00FE6526 /* ProductCategoryMapperTests.swift */; }; 45B204BC24890B1200FE6526 /* category.json in Resources */ = {isa = PBXBuildFile; fileRef = 45B204BB24890B1200FE6526 /* category.json */; }; + 45B383252D5CC00700F8CB1A /* MediaUploadSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B383242D5CC00700F8CB1A /* MediaUploadSessionManager.swift */; }; 45B79AC62C9355F800DCCB2C /* meta-data-products-and-orders_nested_in_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 45B79AC52C9355F800DCCB2C /* meta-data-products-and-orders_nested_in_data.json */; }; 45C6D0E429B9F327009CE29C /* CookieNonceAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45C6D0E329B9F327009CE29C /* CookieNonceAuthenticatorTests.swift */; }; 45CCFCE227A2C9BF0012E8CB /* InboxNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CCFCE127A2C9BF0012E8CB /* InboxNote.swift */; }; @@ -1741,6 +1742,7 @@ 45B204B72489095100FE6526 /* ProductCategoryMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryMapper.swift; sourceTree = ""; }; 45B204B924890A8C00FE6526 /* ProductCategoryMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCategoryMapperTests.swift; sourceTree = ""; }; 45B204BB24890B1200FE6526 /* category.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = category.json; sourceTree = ""; }; + 45B383242D5CC00700F8CB1A /* MediaUploadSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadSessionManager.swift; sourceTree = ""; }; 45B79AC52C9355F800DCCB2C /* meta-data-products-and-orders_nested_in_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "meta-data-products-and-orders_nested_in_data.json"; sourceTree = ""; }; 45C6D0E329B9F327009CE29C /* CookieNonceAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieNonceAuthenticatorTests.swift; sourceTree = ""; }; 45CCFCE127A2C9BF0012E8CB /* InboxNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxNote.swift; sourceTree = ""; }; @@ -2745,6 +2747,7 @@ 4587D1132D64CB70001971E4 /* ProductImageInBackground */ = { isa = PBXGroup; children = ( + 45B383242D5CC00700F8CB1A /* MediaUploadSessionManager.swift */, 4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */, 4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */, 452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */, @@ -5548,6 +5551,7 @@ DE50295D28C6068B00551736 /* JetpackUserMapper.swift in Sources */, B524194121AC60A700D6FC0A /* DotcomDevice.swift in Sources */, D8EDFE2225EE88C9003D2213 /* ReaderConnectionToken.swift in Sources */, + 45B383252D5CC00700F8CB1A /* MediaUploadSessionManager.swift in Sources */, 4599FC5824A624BD0056157A /* ProductTagListMapper.swift in Sources */, 45A4B86225D3086600776FB4 /* ShippingLabelAddressValidationError.swift in Sources */, D823D90D2237784A00C90817 /* ShipmentTrackingProvider.swift in Sources */, diff --git a/Networking/Networking/Model/Media/WordPressMedia.swift b/Networking/Networking/Model/Media/WordPressMedia.swift index d6c9ace76af..6d6305af8e1 100644 --- a/Networking/Networking/Model/Media/WordPressMedia.swift +++ b/Networking/Networking/Model/Media/WordPressMedia.swift @@ -125,3 +125,27 @@ private extension WordPressMedia { case title } } + +extension WordPressMedia { + /// Converts a `WordPressMedia` to `Media`. + public func toMedia() -> Media { + .init(mediaID: mediaID, + date: date, + fileExtension: fileExtension, + filename: details?.fileName ?? title?.rendered ?? slug, + mimeType: mimeType, + src: src, + thumbnailURL: details?.sizes?["thumbnail"]?.src, + name: slug, + alt: alt, + height: details?.height, + width: details?.width) + } + + private var fileExtension: String { + guard let fileName = details?.fileName else { + return "" + } + return URL(fileURLWithPath: fileName).pathExtension + } +} diff --git a/Networking/Networking/Model/Product/ProductImage.swift b/Networking/Networking/Model/Product/ProductImage.swift index fbdbe6c0f3d..05b998b6192 100644 --- a/Networking/Networking/Model/Product/ProductImage.swift +++ b/Networking/Networking/Model/Product/ProductImage.swift @@ -3,7 +3,7 @@ import Codegen /// Represents a ProductImage entity. /// -public struct ProductImage: Codable, Equatable, Sendable, GeneratedCopiable, GeneratedFakeable { +public struct ProductImage: Codable, Sendable, GeneratedCopiable, GeneratedFakeable { public let imageID: Int64 public let dateCreated: Date // gmt public let dateModified: Date? // gmt @@ -55,6 +55,20 @@ public struct ProductImage: Codable, Equatable, Sendable, GeneratedCopiable, Gen } } +extension ProductImage: Equatable { + public static func == (lhs: ProductImage, rhs: ProductImage) -> Bool { + // Convert timestamps to integers to ignore fractional seconds, ensuring date comparisons + // are accurate to the nearest second and avoiding test discrepancies from millisecond differences. + let lhsTimestamp = Int(lhs.dateCreated.timeIntervalSince1970) + let rhsTimestamp = Int(rhs.dateCreated.timeIntervalSince1970) + + return lhs.imageID == rhs.imageID && + lhsTimestamp == rhsTimestamp && + lhs.src == rhs.src && + lhs.name == rhs.name && + lhs.alt == rhs.alt + } +} /// Defines all the ProductImage CodingKeys. /// diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index 8089bd61d91..4541614f674 100644 --- a/Networking/Networking/Network/AlamofireNetwork.swift +++ b/Networking/Networking/Network/AlamofireNetwork.swift @@ -27,10 +27,13 @@ public class AlamofireNetwork: Network { public var session: URLSession { Session.default.session } + public let credentials: Credentials? + /// Public Initializer /// /// public required init(credentials: Credentials?, sessionManager: Alamofire.Session? = nil) { + self.credentials = credentials self.requestConverter = RequestConverter(credentials: credentials) self.requestAuthenticator = RequestProcessor(requestAuthenticator: DefaultRequestAuthenticator(credentials: credentials)) if let sessionManager { diff --git a/Networking/Networking/ProductImageInBackground/MediaUploadSessionManager.swift b/Networking/Networking/ProductImageInBackground/MediaUploadSessionManager.swift new file mode 100644 index 00000000000..39f806211ad --- /dev/null +++ b/Networking/Networking/ProductImageInBackground/MediaUploadSessionManager.swift @@ -0,0 +1,172 @@ +import Foundation + +public protocol MediaUploadSessionManagerDelegate: AnyObject { + func mediaUploadSessionManager(_ manager: MediaUploadSessionManager, + didCompleteUpload uploadID: String, + withResult result: Result) +} + +/// Background upload specific errors +enum BackgroundUploadError: Error { + case invalidRequestBody + case invalidResponse + case decodingError +} + +/// Session Manager for media upload in background +/// +public final class MediaUploadSessionManager: NSObject { + + public let backgroundSessionIdentifier: String + private lazy var backgroundSession: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: backgroundSessionIdentifier) + config.sharedContainerIdentifier = "group.com.automattic.woocommerce" + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.allowsCellularAccess = true + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + private var completionHandlers: [String: (Result) -> Void] = [:] + private var taskResponseData: [Int: Data] = [:] + private var backgroundCompletionHandler: (() -> Void)? + weak var delegate: MediaUploadSessionManagerDelegate? + + + public init(sessionIdentifier: String? = nil) { + // Use bundle based identifiers as suggested here + // https://www.avanderlee.com/swift/urlsession-common-pitfalls-with-background-download-upload-tasks/#bundle-based-identifiers + let appBundleName = Bundle.main.bundleURL.lastPathComponent + .lowercased() + .replacingOccurrences(of: " ", with: ".") + let defaultSessionIdentifier = "com.background.\(appBundleName)" + self.backgroundSessionIdentifier = sessionIdentifier ?? defaultSessionIdentifier + super.init() + } + + public func uploadMedia(request: URLRequest, + mediaItem: UploadableMedia, + uploadID: String, + completion: @escaping (Result) -> Void) { + completionHandlers[uploadID] = completion + + guard let httpBody = request.httpBody else { + let error = BackgroundUploadError.invalidRequestBody + completion(.failure(error)) + return + } + + do { + // Create temp file with proper extension from mediaItem + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileURL = tempDirectory.appendingPathComponent(mediaItem.filename) + try httpBody.write(to: tempFileURL) + + // Upload using temp file + var modifiedRequest = request + modifiedRequest.httpBody = nil + + // Upload tasks in background works only if we use a file reference + let task = backgroundSession.uploadTask(with: modifiedRequest, fromFile: tempFileURL) + task.taskDescription = uploadID + task.resume() + + // Cleanup temp file + DispatchQueue.main.async { + try? FileManager.default.removeItem(at: tempFileURL) + } + } catch { + DDLogError("⛔️ MediaUploadSessionManager- Failed image upload while creating temp file: \(error)") + completion(.failure(error)) + } + } + + public func handleBackgroundSessionCompletion(_ completionHandler: @escaping () -> Void) { + backgroundCompletionHandler = completionHandler + } +} + +extension MediaUploadSessionManager: URLSessionDataDelegate { + public func urlSession(_ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data) { + if let existingData = taskResponseData[dataTask.taskIdentifier] { + taskResponseData[dataTask.taskIdentifier] = existingData + data + } else { + taskResponseData[dataTask.taskIdentifier] = data + } + } + + public func urlSession(_ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error?) { + guard let uploadID = task.taskDescription else { + DDLogDebug("MediaUploadSessionManager- task completed without an upload identifier. Task identifier: \(task.taskIdentifier)") + return + } + defer { + taskResponseData.removeValue(forKey: task.taskIdentifier) + } + + if let error = error { + DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): encountered error: \(error.localizedDescription)") + notifyCompletion(.failure(error), for: uploadID) + return + } + + guard let httpResponse = task.response as? HTTPURLResponse else { + DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): " + + "response is not a valid HTTPURLResponse. Actual response: " + + "\(String(describing: task.response))") + notifyCompletion(.failure(BackgroundUploadError.invalidResponse), for: uploadID) + return + } + + guard let data = taskResponseData[task.taskIdentifier] else { + DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): " + + "missing response data for task with identifier \(task.taskIdentifier)") + notifyCompletion(.failure(BackgroundUploadError.invalidResponse), for: uploadID) + return + } + + if !(200...299).contains(httpResponse.statusCode) { + DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): " + + "unexpected HTTP status code \(httpResponse.statusCode). " + + "Full response: \(httpResponse) Headers: \(httpResponse.allHeaderFields)") + notifyCompletion(.failure(BackgroundUploadError.invalidResponse), for: uploadID) + return + } + + // Use MediaMapper to parse response + if let jsonString = String(data: data, encoding: .utf8) { + } else { + DDLogError("⛔️ MediaUploadSessionManager- Failed to convert response data to JSON string for task (\(uploadID))") + } + let mapper = WordPressMediaMapper() + do { + let media = try mapper.map(response: data) + notifyCompletion(.success(media.toMedia()), for: uploadID) + } catch { + DDLogError("⛔️ MediaUploadSessionManager- Upload failure for task (\(uploadID)): error mapping media: \(error.localizedDescription)") + notifyCompletion(.failure(error), for: uploadID) + } + } + + public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { [weak self] in + DDLogDebug("MediaUploadSessionManager- Background URL session did finish events. Invoking completion handler.") + self?.backgroundCompletionHandler?() + self?.backgroundCompletionHandler = nil + } + } + + private func notifyCompletion(_ result: Result, for uploadID: String) { + DispatchQueue.main.async { [weak self] in + DDLogError("⛔️ MediaUploadSessionManager- Notifying completion for task (\(uploadID)) with result: \(result)") + guard let self = self else { return } + self.completionHandlers[uploadID]?(result) + self.completionHandlers.removeValue(forKey: uploadID) + self.delegate?.mediaUploadSessionManager(self, didCompleteUpload: uploadID, withResult: result) + } + } +} diff --git a/Networking/Networking/Remote/MediaRemote.swift b/Networking/Networking/Remote/MediaRemote.swift index 0d02950a91d..907e28751f8 100644 --- a/Networking/Networking/Remote/MediaRemote.swift +++ b/Networking/Networking/Remote/MediaRemote.swift @@ -1,4 +1,5 @@ import Foundation +import Alamofire /// Protocol for `MediaRemote` mainly used for mocking. public protocol MediaRemoteProtocol { @@ -19,6 +20,11 @@ public protocol MediaRemoteProtocol { productID: Int64, mediaID: Int64, completion: @escaping (Result) -> Void) + + /// Creates a URLRequest for uploading media in background + func uploadMediaRequest(siteID: Int64, + productID: Int64, + mediaItem: UploadableMedia) async throws -> URLRequest } /// Media: Remote Endpoints @@ -163,6 +169,68 @@ public class MediaRemote: Remote, MediaRemoteProtocol { completion(.failure(error)) } } + + public func uploadMediaRequest(siteID: Int64, + productID: Int64, + mediaItem: UploadableMedia) async throws -> URLRequest { + + let boundary = UUID().uuidString + let path = "sites/\(siteID)/media" + + let dotcomRequest = try DotcomRequest(wordpressApiVersion: .wpMark2, + method: .post, + path: path, + parameters: nil, + availableAsRESTRequest: true) + + guard let network = network as? AlamofireNetwork else { + throw NetworkError.unacceptableStatusCode(statusCode: 500, response: nil) + } + + let converter = RequestConverter(credentials: network.credentials) + var request = try converter.convert(dotcomRequest).asURLRequest() + + // Authenticate the request if we have credentials + if let credentials = network.credentials { + request = try DefaultRequestAuthenticator(credentials: credentials).authenticate(request) + } else { + throw NetworkError.unacceptableStatusCode(statusCode: 401, response: nil) + } + + // Add multipart content type + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // Add form data + var body = Data() + func append(_ string: String) { + body.append(string.data(using: .utf8)!) + } + + let params: [String: String] = [ + ParameterKey.wordPressMediaPostID: "\(productID)", + ParameterKey.fieldsWordPressSite: ParameterValue.wordPressMediaFields, + ParameterKey.wordPressAltText: mediaItem.altText ?? "" + ] + + //Add parameters + for (key, value) in params { + append("--\(boundary)\r\n") + append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n") + append("\(value)\r\n") + } + + // Add file data + append("--\(boundary)\r\n") + append("Content-Disposition: form-data; name=\"file\"; filename=\"\(mediaItem.filename)\"\r\n") + append("Content-Type: \(mediaItem.mimeType)\r\n\r\n") + body.append(try Data(contentsOf: mediaItem.localURL)) + append("\r\n") + append("--\(boundary)--\r\n") + + request.httpBody = body + request.httpMethod = "POST" + return request + } } diff --git a/WooCommerce/Classes/AppDelegate.swift b/WooCommerce/Classes/AppDelegate.swift index 1e9a2fb5a16..ba484cd652f 100644 --- a/WooCommerce/Classes/AppDelegate.swift +++ b/WooCommerce/Classes/AppDelegate.swift @@ -2,6 +2,7 @@ import UIKit import Combine import Storage import class Networking.UserAgent +import class Networking.MediaUploadSessionManager import Experiments import class WidgetKit.WidgetCenter import protocol WooFoundation.Analytics @@ -265,6 +266,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DDLogDebug("Received memory warning: Available memory - \(size)") ServiceLocator.imageService.clearMemoryCache() } + + func application(_ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void) { + if identifier == ServiceLocator.backgroundMediaUploadSessionManager.backgroundSessionIdentifier { + ServiceLocator.backgroundMediaUploadSessionManager.handleBackgroundSessionCompletion(completionHandler) + } + } } // MARK: - Initialization Methods diff --git a/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift b/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift index 72557e165a8..084d39884dc 100644 --- a/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift +++ b/WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift @@ -7,6 +7,8 @@ import protocol Yosemite.StoresManager import enum Yosemite.ProductImageStatus import enum Yosemite.ProductImageAssetType import enum Yosemite.ProductOrVariationID +import class Networking.ProductImageStatusStorage +import protocol Experiments.FeatureFlagService /// Information about a background product image upload error. struct ProductImageUploadErrorInfo { @@ -87,12 +89,65 @@ protocol ProductImageUploaderProtocol { /// Supports background image upload and product images update after the user leaves the product form. final class ProductImageUploader: ProductImageUploaderProtocol { + + let imageStatusStorage: ProductImageStatusStorage + var errors: AnyPublisher { - errorsSubject.eraseToAnyPublisher() + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + return imageStatusStorage.errorsPublisher + .flatMap { errorItems in + errorItems.publisher + .compactMap { errorItem in + guard let productOrVariationID = errorItem.productOrVariationID, + let assetType = errorItem.assetType else { return nil } + + // Create key to check against excluded keys + let key = Key(siteID: errorItem.siteID, + productOrVariationID: productOrVariationID, + isLocalID: productOrVariationID.id == 0) + + // Skip error if it's for a product being edited + guard !self.statusUpdatesExcludedProductKeys.contains(key) else { + return nil + } + + return ProductImageUploadErrorInfo( + siteID: errorItem.siteID, + productOrVariationID: productOrVariationID, + error: .failedUploadingImage(asset: assetType, error: errorItem.error) + ) + } + } + .eraseToAnyPublisher() + } else { + return errorsSubject + .filter { info in + let key = Key(siteID: info.siteID, + productOrVariationID: info.productOrVariationID, + isLocalID: info.productOrVariationID.id == 0) + return !self.statusUpdatesExcludedProductKeys.contains(key) + } + .eraseToAnyPublisher() + } } var activeUploads: AnyPublisher<[ProductImageUploaderKey], Never> { - $activeUploadsPublisher.eraseToAnyPublisher() + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + return imageStatusStorage.statusesPublisher + .map { statuses in + statuses.compactMap { status -> ProductImageUploaderKey? in + if status.isUploading { + return ProductImageUploaderKey(siteID: status.siteID, + productOrVariationID: status.productOrVariationID, + isLocalID: status.isLocalID) + } + return nil + } + } + .eraseToAnyPublisher() + } else { + return $activeUploadsPublisher.eraseToAnyPublisher() + } } typealias Key = ProductImageUploaderKey @@ -108,12 +163,20 @@ final class ProductImageUploader: ProductImageUploaderProtocol { @Published private var activeUploadsPublisher: [ProductImageUploaderKey] = [] private let stores: StoresManager + private let featureFlagService: FeatureFlagService private let imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol + private var cancellables = Set() + init(stores: StoresManager = ServiceLocator.stores, - imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol = ProductImagesProductIDUpdater()) { + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol = ProductImagesProductIDUpdater(), + imageStatusStorage: ProductImageStatusStorage = ProductImageStatusStorage()) { self.stores = stores + self.featureFlagService = featureFlagService self.imagesProductIDUpdater = imagesProductIDUpdater + self.imageStatusStorage = imageStatusStorage + // Observe when the app enters background. NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), @@ -170,9 +233,17 @@ final class ProductImageUploader: ProductImageUploaderProtocol { } func sendBackgroundUploadNoticeIfNeeded(key: ProductImageUploaderKey, using noticePresenter: NoticePresenter) { - if activeUploadsPublisher.contains(key) { - let notice = Notice(title: Localization.backgroundUploadNoticeTitle) - noticePresenter.enqueue(notice: notice) + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + let statuses = imageStatusStorage.getAllStatuses(for: key.siteID, productID: key.productOrVariationID) + if statuses.contains(where: { $0.isUploading }) { + let notice = Notice(title: Localization.backgroundUploadNoticeTitle) + noticePresenter.enqueue(notice: notice) + } + } else { + if activeUploadsPublisher.contains(key) { + let notice = Notice(title: Localization.backgroundUploadNoticeTitle) + noticePresenter.enqueue(notice: notice) + } } } @@ -243,18 +314,34 @@ final class ProductImageUploader: ProductImageUploaderProtocol { imageUploadSubscriptions = [] activeUploadsPublisher = [] + imageStatusStorage.clearAllStatuses() + actionHandlersByProduct = [:] imagesSaverByProduct = [:] } private func scheduleUploadInProgressNotificationIfNeeded() { - guard !activeUploadsPublisher.isEmpty else { return } + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + let statuses = imageStatusStorage.getAllStatuses() + let hasUploadingStatuses = statuses.contains { $0.isUploading } + + if hasUploadingStatuses { + let notification = LocalNotification(scenario: .productImageBackgroundUpload) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + Task { + await LocalNotificationScheduler(pushNotesManager: ServiceLocator.pushNotesManager).schedule(notification: notification, + trigger: trigger, remoteFeatureFlag: nil) + } + } + } else { + guard !activeUploadsPublisher.isEmpty else { return } - let notification = LocalNotification(scenario: .productImageBackgroundUpload) - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) - Task { - await LocalNotificationScheduler(pushNotesManager: ServiceLocator.pushNotesManager).schedule(notification: notification, - trigger: trigger, remoteFeatureFlag: nil) + let notification = LocalNotification(scenario: .productImageBackgroundUpload) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + Task { + await LocalNotificationScheduler(pushNotesManager: ServiceLocator.pushNotesManager).schedule(notification: notification, + trigger: trigger, remoteFeatureFlag: nil) + } } } @@ -282,12 +369,18 @@ private extension ProductImageUploader { let observationToken = actionHandler.addUpdateObserver(self) { [weak self] productImageStatuses in guard let self = self else { return } - if !activeUploadsPublisher.contains(key), productImageStatuses.hasPendingUpload { - activeUploadsPublisher.append(key) - } else if activeUploadsPublisher.contains(key), !productImageStatuses.hasPendingUpload { - /// When all pending uploads are completed or removed, - /// remove the key from active uploads - removeProductFromActiveUploads(key: key) + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + // Update the states in userDefaultsStatuses + self.imageStatusStorage.appendStatuses(productImageStatuses, for: key.siteID, productID: key.productOrVariationID) + } + else { + if !activeUploadsPublisher.contains(key), productImageStatuses.hasPendingUpload { + activeUploadsPublisher.append(key) + } else if activeUploadsPublisher.contains(key), !productImageStatuses.hasPendingUpload { + /// When all pending uploads are completed or removed, + /// remove the key from active uploads + removeProductFromActiveUploads(key: key) + } } } statusUpdatesSubscriptions.insert(observationToken) @@ -302,7 +395,13 @@ private extension ProductImageUploader { productOrVariationID: key.productOrVariationID, error: .failedUploadingImage(asset: asset, error: error)) if statusUpdatesExcludedProductKeys.contains(key) == false { - errorsSubject.send(infoError) + if !self.featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + // Only send the error directly if `backgroundProductImageUpload` feature flag is disabled + errorsSubject.send(infoError) + } + // To keep in mind + // Do not update storage here as the action handler will update its status + // which will trigger `observeStatusUpdates` to do the storage update } } } @@ -310,7 +409,15 @@ private extension ProductImageUploader { } func removeProductFromActiveUploads(key: Key) { - activeUploadsPublisher.removeAll(where: { $0 == key }) + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + imageStatusStorage.removeStatus(where: { status in + status.siteID == key.siteID && + status.productOrVariationID == key.productOrVariationID && + status.isUploading + }) + } else { + activeUploadsPublisher.removeAll(where: { $0 == key }) + } } } diff --git a/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift b/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift index 6fec03c0396..ea0f8997f29 100644 --- a/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift +++ b/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift @@ -4,6 +4,7 @@ import Experiments import Storage import Yosemite import Hardware +import class Networking.MediaUploadSessionManager import WooFoundation import WordPressShared @@ -97,6 +98,10 @@ final class ServiceLocator { /// private static var _generalAppSettings: GeneralAppSettingsStorage = GeneralAppSettingsStorage() + /// Background image service + /// + private static var _backgroundMediaUploadSessionManager = MediaUploadSessionManager() + private static var _cardPresentPaymentsOnboardingIPPUsersRefresher: CardPresentPaymentsOnboardingIPPUsersRefresher = CardPresentPaymentsOnboardingIPPUsersRefresher() @@ -266,6 +271,12 @@ final class ServiceLocator { static var startupWaitingTimeTracker: AppStartupWaitingTimeTracker { _startupWaitingTimeTracker } + + /// Provides access point to the `MediaUploadSessionManager`. + /// + static var backgroundMediaUploadSessionManager: MediaUploadSessionManager { + return _backgroundMediaUploadSessionManager + } } diff --git a/WooCommerce/Classes/ViewRelated/Products/Media/ProductImageActionHandler.swift b/WooCommerce/Classes/ViewRelated/Products/Media/ProductImageActionHandler.swift index 01ad80d8490..3caf8adc089 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Media/ProductImageActionHandler.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Media/ProductImageActionHandler.swift @@ -1,6 +1,7 @@ import Combine import Photos import Yosemite +import protocol Experiments.FeatureFlagService /// Interface of `ProductImageActionHandler` to allow mocking in unit tests. protocol ProductImageActionHandlerProtocol { @@ -45,6 +46,8 @@ final class ProductImageActionHandler: ProductImageActionHandlerProtocol { private let stores: StoresManager + private let featureFlagService: FeatureFlagService + private(set) var productImageStatuses: [ProductImageStatus] { didSet { queue.async { [weak self] in @@ -73,11 +76,13 @@ final class ProductImageActionHandler: ProductImageActionHandlerProtocol { productID: ProductOrVariationID, imageStatuses: [ProductImageStatus], queue: DispatchQueue = .main, - stores: StoresManager = ServiceLocator.stores) { + stores: StoresManager = ServiceLocator.stores, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { self.siteID = siteID self.productOrVariationID = productID self.queue = queue self.stores = stores + self.featureFlagService = featureFlagService self.productImageStatuses = imageStatuses } @@ -217,21 +222,43 @@ final class ProductImageActionHandler: ProductImageActionHandlerProtocol { DispatchQueue.main.async { [weak self] in guard let self = self else { return } let action: MediaAction + let uploadID = UUID().uuidString + switch asset { case .phAsset(let asset): + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + action = MediaAction.uploadMediaInBackground(siteID: self.siteID, + productID: self.productOrVariationID.id, + mediaAsset: asset, + altText: nil, + filename: nil, + uploadID: uploadID, + onCompletion: onCompletion) + } else { action = MediaAction.uploadMedia(siteID: self.siteID, productID: self.productOrVariationID.id, mediaAsset: asset, altText: nil, filename: nil, onCompletion: onCompletion) + } case .uiImage(let image, let filename, let altText): + if featureFlagService.isFeatureFlagEnabled(.backgroundProductImageUpload) { + action = MediaAction.uploadMediaInBackground(siteID: self.siteID, + productID: self.productOrVariationID.id, + mediaAsset: image, + altText: altText, + filename: filename, + uploadID: uploadID, + onCompletion: onCompletion) + } else { action = MediaAction.uploadMedia(siteID: self.siteID, productID: self.productOrVariationID.id, mediaAsset: image, altText: altText, filename: filename, onCompletion: onCompletion) + } } self.stores.dispatch(action) } diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index f9672e09c89..7c8a5a4383f 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -46,7 +46,10 @@ class AuthenticatedState: StoresManagerState { InboxNotesStore(dispatcher: dispatcher, storageManager: storageManager, network: network), JetpackSettingsStore(dispatcher: dispatcher, storageManager: storageManager, network: network), JustInTimeMessageStore(dispatcher: dispatcher, storageManager: storageManager, network: network), - MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network), + MediaStore(dispatcher: dispatcher, + storageManager: storageManager, + network: network, + backgroundUploader: ServiceLocator.backgroundMediaUploadSessionManager), NotificationStore(dispatcher: dispatcher, storageManager: storageManager, network: network), NotificationCountStore(dispatcher: dispatcher, storageManager: storageManager, fileStorage: PListFileStorage()), OrderCardPresentPaymentEligibilityStore(dispatcher: dispatcher, storageManager: storageManager, network: network), diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index e612a1a20e1..a8dae1e55ff 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -23,6 +23,7 @@ final class MockFeatureFlagService: FeatureFlagService { var favoriteProducts: Bool var isProductGlobalUniqueIdentifierSupported: Bool var hideSitesInStorePicker: Bool + var backgroundProductImageUpload: Bool var notificationSettings: Bool var allowMerchantAIAPIKey: Bool @@ -47,6 +48,7 @@ final class MockFeatureFlagService: FeatureFlagService { favoriteProducts: Bool = false, isProductGlobalUniqueIdentifierSupported: Bool = false, hideSitesInStorePicker: Bool = false, + backgroundProductImageUpload: Bool = false, notificationSettings: Bool = false, allowMerchantAIAPIKey: Bool = false) { self.isInboxOn = isInboxOn @@ -70,6 +72,7 @@ final class MockFeatureFlagService: FeatureFlagService { self.favoriteProducts = favoriteProducts self.isProductGlobalUniqueIdentifierSupported = isProductGlobalUniqueIdentifierSupported self.hideSitesInStorePicker = hideSitesInStorePicker + self.backgroundProductImageUpload = backgroundProductImageUpload self.notificationSettings = notificationSettings self.allowMerchantAIAPIKey = allowMerchantAIAPIKey } @@ -118,6 +121,8 @@ final class MockFeatureFlagService: FeatureFlagService { return isProductGlobalUniqueIdentifierSupported case .hideSitesInStorePicker: return hideSitesInStorePicker + case .backgroundProductImageUpload: + return backgroundProductImageUpload case .notificationSettings: return notificationSettings case .allowMerchantAIAPIKey: diff --git a/WooCommerce/WooCommerceTests/Mocks/MockMediaStoresManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockMediaStoresManager.swift index 570921d45f9..5c1ac0d3570 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockMediaStoresManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockMediaStoresManager.swift @@ -56,6 +56,12 @@ final class MockMediaStoresManager: DefaultStoresManager { return } onCompletion(.success(media)) + case .uploadMediaInBackground(_, _, _, _, _, _, let onCompletion): + guard let media = media else { + onCompletion(.failure(MediaActionError.unknown)) + return + } + onCompletion(.success(media)) } } } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift b/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift index d5527014b9f..e2ed95874cc 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockProductImageUploader.swift @@ -64,7 +64,6 @@ extension MockProductImageUploader: ProductImageUploaderProtocol { } func sendBackgroundUploadNoticeIfNeeded(key: ProductImageUploaderKey, using noticePresenter: NoticePresenter) { - // no-op } func reset() { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModel+UpdatesTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModel+UpdatesTests.swift index 6da4aac2ff8..c168855e502 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModel+UpdatesTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewModel+UpdatesTests.swift @@ -358,7 +358,11 @@ final class ProductFormViewModel_UpdatesTests: XCTestCase { } extension ProductImageActionHandler { - convenience init(siteID: Int64, product: ProductFormDataModel) { - self.init(siteID: siteID, productID: .product(id: product.productID), imageStatuses: product.imageStatuses) + convenience init(siteID: Int64, product: ProductFormDataModel, featureFlag: MockFeatureFlagService? = nil) { + guard let featureFlagUnwrapped = featureFlag else { + self.init(siteID: siteID, productID: .product(id: product.productID), imageStatuses: product.imageStatuses) + return + } + self.init(siteID: siteID, productID: .product(id: product.productID), imageStatuses: product.imageStatuses, featureFlagService: featureFlagUnwrapped) } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageActionHandlerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageActionHandlerTests.swift index fb6e8cf0c8b..32e153ed8cd 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageActionHandlerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageActionHandlerTests.swift @@ -4,12 +4,29 @@ import TestKit import XCTest @testable import WooCommerce @testable import Yosemite +import Networking final class ProductImageActionHandlerTests: XCTestCase { private var productImageStatusesSubscription: AnyCancellable? private var assetUploadSubscription: AnyCancellable? private let siteID: Int64 = 1234 private let productID = ProductOrVariationID.product(id: 5678) + private var mockFeatureFlagService: MockFeatureFlagService! + private var storage: ProductImageStatusStorage! + + override func setUp() { + super.setUp() + mockFeatureFlagService = MockFeatureFlagService() + UserDefaults.standard.removeObject(forKey: "savedProductUploadImageStatuses") + storage = ProductImageStatusStorage(userDefaults: .standard) + } + + override func tearDown() { + mockFeatureFlagService = nil + UserDefaults.standard.removeObject(forKey: "savedProductUploadImageStatuses") + storage = nil + super.tearDown() + } func test_uploading_media_successfully() { let mockMedia = createMockMedia() @@ -31,7 +48,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let model = EditableProductModel(product: mockProduct) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) let mockAsset = PHAsset() let expectedStatusUpdates: [[ProductImageStatus]] = [ @@ -87,7 +105,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let model = EditableProductModel(product: mockProduct) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) let mockAsset = PHAsset() let expectedStatusUpdates: [[ProductImageStatus]] = [ @@ -129,7 +148,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let model = EditableProductModel(product: .fake()) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) // When let mediaMetadata: (filename: String?, altText: String?) = waitFor { promise in @@ -156,7 +176,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let model = EditableProductModel(product: Product.fake().copy(siteID: siteID, productID: productID.id)) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) let mockImage = UIImage() // When @@ -184,7 +205,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let model = EditableProductModel(product: mockProduct) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) let expectedStatusUpdates: [[ProductImageStatus]] = [ mockRemoteProductImageStatuses, @@ -225,7 +247,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let model = EditableProductModel(product: mockProduct) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) // Media items to upload to site media library. let mockMedia1 = Media(mediaID: 134, date: Date(), @@ -275,7 +298,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let mockProduct = Product.fake().copy(images: []) let model = EditableProductModel(product: mockProduct) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) let mockProductImages = [ ProductImage(imageID: 1, dateCreated: Date(), dateModified: Date(), src: "", name: "", alt: ""), ProductImage(imageID: 2, dateCreated: Date(), dateModified: Date(), src: "", name: "", alt: "") @@ -312,7 +336,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let mockProduct = Product.fake().copy(images: []) let model = EditableProductModel(product: mockProduct) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) let mockProductImages = [ ProductImage(imageID: 1, dateCreated: Date(), dateModified: Date(), src: "", name: "", alt: ""), ProductImage(imageID: 2, dateCreated: Date(), dateModified: Date(), src: "", name: "", alt: "") @@ -357,7 +382,8 @@ final class ProductImageActionHandlerTests: XCTestCase { let model = EditableProductModel(product: Product.fake().copy(siteID: siteID, productID: productID.id)) let productImageActionHandler = ProductImageActionHandler(siteID: siteID, - product: model) + product: model, + featureFlag: mockFeatureFlagService) let expectation = self.expectation(description: "Wait for status update") expectation.expectedFulfillmentCount = 1 @@ -405,7 +431,9 @@ final class ProductImageActionHandlerTests: XCTestCase { let dummyImage = ProductImage(imageID: 1, dateCreated: Date(), dateModified: Date(), src: "", name: "", alt: "") let mockProduct = Product.fake().copy(siteID: siteID, productID: localProductID.id, images: [dummyImage]) let model = EditableProductModel(product: mockProduct) - let handler = ProductImageActionHandler(siteID: siteID, product: model) + let handler = ProductImageActionHandler(siteID: siteID, + product: model, + featureFlag: mockFeatureFlagService) // Simulate an upload with a status .uploading, with the current productID (localProductID) let mockAsset = PHAsset() diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift index 138d5e027f3..dd5373645c0 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/ProductImageUploaderTests.swift @@ -3,6 +3,7 @@ import Combine import Photos import XCTest import Yosemite +import Networking final class ProductImageUploaderTests: XCTestCase { private let siteID: Int64 = 134 @@ -10,13 +11,66 @@ final class ProductImageUploaderTests: XCTestCase { private var errorsSubscription: AnyCancellable? private var assetUploadSubscription: AnyCancellable? private var activeUploadsSubscription: AnyCancellable? + private var mockFeatureFlagService: MockFeatureFlagService! + private var storage: ProductImageStatusStorage! + private var testDefaults: UserDefaults! + private var testUserDefaultsName: String! + + override func setUp() { + super.setUp() + mockFeatureFlagService = MockFeatureFlagService() + + // Create a truly unique UserDefaults instance for each test run + testUserDefaultsName = "test.\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: testUserDefaultsName)! + storage = ProductImageStatusStorage(userDefaults: testDefaults, key: testUserDefaultsName) + } + + override func tearDown() { + // Cancel all subscriptions + errorsSubscription?.cancel() + errorsSubscription = nil + + assetUploadSubscription?.cancel() + assetUploadSubscription = nil + + activeUploadsSubscription?.cancel() + activeUploadsSubscription = nil + + // Clean up storage + storage.clearAllStatuses() + storage = nil + + mockFeatureFlagService = nil + + // Remove the UserDefaults suite + testDefaults.removeSuite(named: testUserDefaultsName) + testDefaults = nil + testUserDefaultsName = nil + + super.tearDown() + } + + private func createImageUploader(stores: StoresManager, + featureFlag: MockFeatureFlagService, + productIDUpdater: ProductImagesProductIDUpdaterProtocol = MockProductImagesProductIDUpdater()) -> ProductImageUploader { + return ProductImageUploader( + stores: stores, + featureFlagService: featureFlag, + imagesProductIDUpdater: productIDUpdater, + imageStatusStorage: storage + ) + } + + // MARK: - Tests with Feature Flag Disabled func test_hasUnsavedChangesOnImages_becomes_false_after_uploading_and_saving() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, + featureFlag: mockFeatureFlagService, + productIDUpdater: mockProductIDUpdater) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -54,7 +108,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_hasUnsavedChangesOnImages_stays_false_after_uploading_and_saving_successfully() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -113,7 +167,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_when_saving_product_twice_the_latest_images_are_saved() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -188,8 +242,7 @@ final class ProductImageUploaderTests: XCTestCase { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) let localProductID: Int64 = 0 let remoteProductID = productID.id let originalStatuses: [ProductImageStatus] = [.remote(image: ProductImage.fake(), siteID: siteID, productID: productID), @@ -224,8 +277,7 @@ final class ProductImageUploaderTests: XCTestCase { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) let localProductID: Int64 = 0 let nonExistentProductID: Int64 = 999 let remoteProductID = productID.id @@ -252,8 +304,7 @@ final class ProductImageUploaderTests: XCTestCase { // Given let stores = MockStoresManager(sessionManager: .testingInstance) let mockProductIDUpdater = MockProductImagesProductIDUpdater() - let imageUploader = ProductImageUploader(stores: stores, - imagesProductIDUpdater: mockProductIDUpdater) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -295,7 +346,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_actionHandler_error_is_emitted_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -328,7 +379,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_savingProductImages_error_is_emitted_when_saving_images_fails() throws { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: false), @@ -374,7 +425,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_errors_are_not_emitted_when_image_upload_succeeds() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -402,7 +453,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_error_is_emitted_after_stopEmittingErrors_with_a_different_product_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -439,7 +490,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_error_is_not_emitted_after_stopEmittingErrors_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -470,7 +521,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_calling_replaceLocalID_updates_excluded_product_from_status_updates() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let localProductID: Int64 = 0 let nonExistentProductID: Int64 = 999 let remoteProductID = productID @@ -508,7 +559,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_error_is_emitted_after_stop_and_startEmittingErrors_when_image_upload_fails() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -550,7 +601,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_image_upload_error_is_not_emitted_after_reset() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let actionHandler = imageUploader.actionHandler(key: .init(siteID: siteID, productOrVariationID: productID, isLocalID: true), @@ -592,7 +643,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_product_is_removed_from_activeUploads_when_upload_completes() { let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let key = ProductImageUploaderKey(siteID: siteID, productOrVariationID: productID, isLocalID: false) @@ -624,7 +675,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_product_is_removed_from_activeUploads_when_upload_is_cancelled() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let key = ProductImageUploaderKey(siteID: siteID, productOrVariationID: productID, isLocalID: false) @@ -658,7 +709,7 @@ final class ProductImageUploaderTests: XCTestCase { func test_background_upload_notice_is_sent_when_there_are_active_uploads() { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - let imageUploader = ProductImageUploader(stores: stores) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) let key = ProductImageUploaderKey(siteID: siteID, productOrVariationID: productID, isLocalID: false) @@ -693,6 +744,288 @@ final class ProductImageUploaderTests: XCTestCase { imageUploader.sendBackgroundUploadNoticeIfNeeded(key: key, using: noticePresenter) XCTAssertTrue(isNoticeTriggered) } + + // MARK: - Tests with background image upload feature flag enabled + + func test_hasUnsavedChangesOnImages_becomes_false_after_uploading_and_saving_with_flag_enabled() throws { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let mockProductIDUpdater = MockProductImagesProductIDUpdater() + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService, productIDUpdater: mockProductIDUpdater) + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + let actionHandler = imageUploader.actionHandler(key: key, originalStatuses: []) + let asset = PHAsset() + + // Initial state - no unsaved changes + XCTAssertFalse(imageUploader.hasUnsavedChangesOnImages(key: key, originalImages: []), + "Should not have unsaved changes initially") + + // When - Upload an image + let uploadedMedia = Media.fake().copy(mediaID: 645) + stores.whenReceivingAction(ofType: MediaAction.self) { action in + if case let .uploadMedia(_, _, _, _, _, onCompletion) = action { + onCompletion(.success(uploadedMedia)) + } + } + + actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .phAsset(asset: asset)) + + // Wait for the upload to be processed + let _ = waitFor { promise in + actionHandler.addUpdateObserver(self) { statuses in + if statuses.count > 0 { + promise(statuses) + } + } + } + + // Verify upload created unsaved changes + XCTAssertTrue(imageUploader.hasUnsavedChangesOnImages(key: key, originalImages: []), + "Should have unsaved changes after uploading an image") + + // When - Save the product with new images + stores.whenReceivingAction(ofType: ProductAction.self) { action in + if case let .updateProductImages(_, _, images, onCompletion) = action { + onCompletion(.success(.fake().copy(siteID: self.siteID, productID: self.productID.id, images: images))) + } + } + + let saveResult: Result<[ProductImage], Error> = waitFor { promise in + imageUploader.saveProductImagesWhenNoneIsPendingUploadAnymore(key: key) { result in + promise(result) + } + } + + // Then - Verify save succeeded and changes are no longer unsaved + XCTAssertTrue(saveResult.isSuccess, "Product save should succeed") + if case .success(let images) = saveResult { + XCTAssertEqual(images.count, 1, "Should have saved one image") + XCTAssertEqual(images.first?.imageID, uploadedMedia.mediaID, "Saved image should match uploaded media") + } + + XCTAssertFalse(imageUploader.hasUnsavedChangesOnImages(key: key, originalImages: [.fake().copy(imageID: 645)]), + "Should not have unsaved changes after saving") + } + + func test_error_is_published_through_storage_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + + let asset = ProductImageAssetType.phAsset(asset: PHAsset()) + let error = NSError(domain: "test", code: 123) + let expectedError = ProductImageUploadErrorInfo( + siteID: siteID, + productOrVariationID: productID, + error: .failedUploadingImage(asset: asset, error: error) + ) + + let errorsSubject = PassthroughSubject() + let mockImageUploader = MockProductImageUploader(errors: errorsSubject.eraseToAnyPublisher()) + + var receivedErrors: [ProductImageUploadErrorInfo] = [] + errorsSubscription = mockImageUploader.errors.sink { error in + receivedErrors.append(error) + } + + // When - simulate an error in the storage + errorsSubject.send(expectedError) + + // Then + XCTAssertEqual(receivedErrors.count, 1) + XCTAssertEqual(receivedErrors.first?.siteID, siteID) + XCTAssertEqual(receivedErrors.first?.productOrVariationID, productID) + if case let .failedUploadingImage(receivedAsset, receivedError) = receivedErrors.first?.error { + XCTAssertEqual(receivedAsset, asset) + XCTAssertEqual((receivedError as NSError).domain, error.domain) + XCTAssertEqual((receivedError as NSError).code, error.code) + } else { + XCTFail("Expected failedUploadingImage error") + } + } + + func test_activeUploads_are_tracked_through_storage_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) + + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + + // Ensure storage is empty at the start + XCTAssertEqual(storage.getAllStatuses().count, 0, "Storage should be empty at test start") + + // Set up subscription to track active uploads + var activeUploads: [ProductImageUploaderKey] = [] + activeUploadsSubscription = imageUploader.activeUploads.sink { keys in + activeUploads = keys + } + + // Initially there should be no active uploads + XCTAssertTrue(activeUploads.isEmpty) + + // When - Upload an image to create an active upload + let actionHandler = imageUploader.actionHandler(key: key, originalStatuses: []) + + // Configure the stores manager to not complete the upload immediately + var uploadCompletion: ((Result) -> Void)? + stores.whenReceivingAction(ofType: MediaAction.self) { action in + if case let .uploadMedia(_, _, _, _, _, onCompletion) = action { + uploadCompletion = onCompletion + // Don't call completion yet to keep the upload "in progress" + } + } + + // Start the upload + let mockAsset = PHAsset() + actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .phAsset(asset: mockAsset)) + + // Wait for the upload to be reflected in the active uploads + let uploadDetectedExpectation = expectation(description: "Upload detected in active uploads") + var uploadCheckCancellable: AnyCancellable? + uploadCheckCancellable = imageUploader.activeUploads + .sink { currentUploads in + if currentUploads.contains(key) { + uploadDetectedExpectation.fulfill() + uploadCheckCancellable?.cancel() + } + } + wait(for: [uploadDetectedExpectation], timeout: 3.0) + + // Verify the upload is being tracked + XCTAssertEqual(activeUploads.count, 1) + XCTAssertEqual(activeUploads.first?.siteID, key.siteID) + XCTAssertEqual(activeUploads.first?.productOrVariationID, key.productOrVariationID) + + // When - Complete the upload + uploadCompletion?(.success(.fake().copy(mediaID: 999))) + + // Then - Wait for the active upload to be removed + let uploadCompletedExpectation = expectation(description: "Upload removed from active uploads") + var completionCheckCancellable: AnyCancellable? + completionCheckCancellable = imageUploader.activeUploads + .sink { currentUploads in + if currentUploads.isEmpty { + uploadCompletedExpectation.fulfill() + completionCheckCancellable?.cancel() + } + } + wait(for: [uploadCompletedExpectation], timeout: 3.0) + + // Verify all uploads are finished + XCTAssertTrue(activeUploads.isEmpty) + } + + func test_background_upload_notice_is_sent_when_there_are_active_uploads_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let imageUploader = createImageUploader(stores: stores, + featureFlag: mockFeatureFlagService) + + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + + let noticePresenter = MockNoticePresenter() + var isNoticeTriggered = false + noticePresenter.onNoticeQueued = { _ in + isNoticeTriggered = true + } + + // Monitor active uploads + var activeUploads: [ProductImageUploaderKey] = [] + activeUploadsSubscription = imageUploader.activeUploads.sink { keys in + activeUploads = keys + } + + // Verify that there are no uploads at the beginning + XCTAssertEqual(storage.getAllStatuses().count, 0, "Storage should be empty at the beginning") + + // When - No active uploads + imageUploader.sendBackgroundUploadNoticeIfNeeded(key: key, using: noticePresenter) + + // Then + XCTAssertFalse(isNoticeTriggered, "No notice should be triggered when there are no active uploads") + + // Reset the notice flag + isNoticeTriggered = false + + // Create an uploading status directly in storage + let uploadingStatus = ProductImageStatus.uploading( + asset: .uiImage(image: .checkmark, filename: "test", altText: "alt_test"), + siteID: siteID, + productID: productID + ) + storage.addStatus(uploadingStatus) + + // Wait for the active upload to be registered + waitUntil(timeout: 3) { + activeUploads.contains(key) + } + + // When - With active uploads + imageUploader.sendBackgroundUploadNoticeIfNeeded(key: key, using: noticePresenter) + + // Then + XCTAssertTrue(isNoticeTriggered, "Notice should be triggered when there are active uploads") + } + + func test_reset_clears_storage_state_with_flag_enabled() { + // Given + mockFeatureFlagService = MockFeatureFlagService(backgroundProductImageUpload: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + let imageUploader = createImageUploader(stores: stores, featureFlag: mockFeatureFlagService) + + let key = ProductImageUploaderKey(siteID: siteID, + productOrVariationID: productID, + isLocalID: false) + + // Configure the store to keep uploads in progress. Don't call completion to keep it in uploading state. + stores.whenReceivingAction(ofType: MediaAction.self) { action in + if case .uploadMedia = action { + } + } + + // Add a status to storage by uploading an image + let actionHandler = imageUploader.actionHandler(key: key, originalStatuses: []) + actionHandler.uploadMediaAssetToSiteMediaLibrary(asset: .uiImage(image: .checkmark, filename: "test", altText: "alt_test")) + + // Wait for the upload to be registered + let uploadStatus = waitFor { promise in + actionHandler.addUpdateObserver(self) { statuses in + if statuses.hasPendingUpload { + promise(statuses) + } + } + } + + // Verify we have a pending upload + XCTAssertTrue(uploadStatus.hasPendingUpload) + + // Set up subscription to track active uploads + var activeUploads: [ProductImageUploaderKey] = [] + activeUploadsSubscription = imageUploader.activeUploads.sink { keys in + activeUploads = keys + } + + // Wait for the active upload to be registered + waitUntil() { + activeUploads.contains { $0 == key } + } + + // When + imageUploader.reset() + + // Then - Active uploads should be cleared + waitUntil() { + activeUploads.isEmpty + } + } } extension ProductImageUploadErrorInfo: @retroactive Equatable { diff --git a/Yosemite/Yosemite/Actions/MediaAction.swift b/Yosemite/Yosemite/Actions/MediaAction.swift index 0ac2a1f72d8..b7733acba4a 100644 --- a/Yosemite/Yosemite/Actions/MediaAction.swift +++ b/Yosemite/Yosemite/Actions/MediaAction.swift @@ -40,6 +40,16 @@ public enum MediaAction: Action { filename: String?, onCompletion: (Result) -> Void) + /// Uploads an exportable media asset to the site's WP Media Library using background URLSession. + /// + case uploadMediaInBackground(siteID: Int64, + productID: Int64, + mediaAsset: ExportableAsset, + altText: String?, + filename: String?, + uploadID: String, + onCompletion: (Result) -> Void) + /// Uploads a local file to the site's WP Media Library. /// case uploadFile(siteID: Int64, diff --git a/Yosemite/Yosemite/Stores/MediaStore.swift b/Yosemite/Yosemite/Stores/MediaStore.swift index 7c0f7413b11..308acce5f41 100644 --- a/Yosemite/Yosemite/Stores/MediaStore.swift +++ b/Yosemite/Yosemite/Stores/MediaStore.swift @@ -6,32 +6,53 @@ import Storage // public final class MediaStore: Store { private let remote: MediaRemoteProtocol + private let backgroundUploader: MediaUploadSessionManager private lazy var mediaExportService: MediaExportService = DefaultMediaExportService() public convenience override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { let remote = MediaRemote(network: network) - self.init(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + let backgroundUploader = MediaUploadSessionManager() + self.init(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote, backgroundUploader: backgroundUploader) } - init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network, remote: MediaRemoteProtocol) { + init(dispatcher: Dispatcher, + storageManager: StorageManagerType, + network: Network, + remote: MediaRemoteProtocol, + backgroundUploader: MediaUploadSessionManager) { self.remote = remote + self.backgroundUploader = backgroundUploader + super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) + } + + public init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network, backgroundUploader: MediaUploadSessionManager) { + self.remote = MediaRemote(network: network) + self.backgroundUploader = backgroundUploader super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) } convenience init(mediaExportService: MediaExportService, - dispatcher: Dispatcher, - storageManager: StorageManagerType, - network: Network) { + dispatcher: Dispatcher, + storageManager: StorageManagerType, + network: Network, + backgroundUploader: MediaUploadSessionManager) { let remote = MediaRemote(network: network) - self.init(mediaExportService: mediaExportService, dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + self.init(mediaExportService: mediaExportService, + dispatcher: dispatcher, + storageManager: storageManager, + network: network, + remote: remote, + backgroundUploader: backgroundUploader) } init(mediaExportService: MediaExportService, dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network, - remote: MediaRemoteProtocol) { + remote: MediaRemoteProtocol, + backgroundUploader: MediaUploadSessionManager) { self.remote = remote + self.backgroundUploader = backgroundUploader super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) self.mediaExportService = mediaExportService } @@ -64,6 +85,15 @@ public final class MediaStore: Store { onCompletion: onCompletion) case .uploadMedia(let siteID, let productID, let mediaAsset, let altText, let filename, let onCompletion): uploadMedia(siteID: siteID, productID: productID, mediaAsset: mediaAsset, altText: altText, filename: filename, onCompletion: onCompletion) + case .uploadMediaInBackground(let siteID, let productID, let mediaAsset, let altText, let filename, let uploadID, let onCompletion): + uploadMediaInBackground(siteID: siteID, + productID: productID, + mediaAsset: mediaAsset, + altText: altText, + filename: filename, + uploadID: uploadID, + shouldRemoveFileUponCompletion: true, + onCompletion: onCompletion) case let .uploadFile(siteID, productID, localURL, altText, onCompletion): uploadFile(siteID: siteID, productID: productID, @@ -190,6 +220,45 @@ private extension MediaStore { onCompletion(result.map { $0.toMedia() }) } } + + /// Uploads an exportable media asset to the site's WP Media Library using background URLSession + /// + func uploadMediaInBackground(siteID: Int64, + productID: Int64, + mediaAsset: ExportableAsset, + altText: String?, + filename: String?, + uploadID: String, + shouldRemoveFileUponCompletion: Bool = true, + onCompletion: @escaping (Result) -> Void) { + Task { @MainActor in + do { + let uploadableMedia = try await mediaExportService.export(mediaAsset, filename: filename, altText: altText) + + let request = try await remote.uploadMediaRequest(siteID: siteID, + productID: productID, + mediaItem: uploadableMedia) + + // Start background upload + backgroundUploader.uploadMedia(request: request, + mediaItem: uploadableMedia, + uploadID: uploadID) { result in + // Removes local media after the upload API request. + if shouldRemoveFileUponCompletion { + do { + try MediaFileManager().removeLocalMedia(at: uploadableMedia.localURL) + } catch { + onCompletion(.failure(error)) + return + } + } + onCompletion(result) + } + } catch { + onCompletion(.failure(error)) + } + } + } } // MARK: Helpers @@ -212,27 +281,3 @@ public enum MediaActionError: Error { case unexpectedMediaCount(count: Int) case unknown } - -extension WordPressMedia { - /// Converts a `WordPressMedia` to `Media`. - func toMedia() -> Media { - .init(mediaID: mediaID, - date: date, - fileExtension: fileExtension, - filename: details?.fileName ?? title?.rendered ?? slug, - mimeType: mimeType, - src: src, - thumbnailURL: details?.sizes?["thumbnail"]?.src, - name: slug, - alt: alt, - height: details?.height, - width: details?.width) - } - - private var fileExtension: String { - guard let fileName = details?.fileName else { - return "" - } - return URL(fileURLWithPath: fileName).pathExtension - } -} diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift index 794d4604da6..e8636cba8f1 100644 --- a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift @@ -49,6 +49,7 @@ extension MockMediaRemote { case uploadMedia(siteID: Int64) case updateProductID(siteID: Int64) case updateProductIDToWordPressSite(siteID: Int64) + case uploadMediaRequest(siteID: Int64, productID: Int64) } } @@ -101,4 +102,17 @@ extension MockMediaRemote: MediaRemoteProtocol { } completion(result) } + + func uploadMediaRequest(siteID: Int64, productID: Int64, mediaItem: Networking.UploadableMedia) async throws -> URLRequest { + invocations.append(.uploadMediaRequest(siteID: siteID, productID: productID)) + let boundary = UUID().uuidString + let urlString = "https://example.com/wp/v2/sites/\(siteID)/media" + guard let url = URL(string: urlString) else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + return request + } } diff --git a/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift b/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift index 3f247db2b43..4644e89fa8e 100644 --- a/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift @@ -50,7 +50,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) // When let result: Result = waitFor { promise in @@ -76,7 +77,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) insertJCPSiteToStorage(siteID: sampleSiteID) @@ -228,7 +230,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) // When let _: Result<[Media], Error> = waitFor { promise in @@ -255,7 +258,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) // When let result: Result<[Media], Error> = waitFor { promise in @@ -284,7 +288,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) insertJCPSiteToStorage(siteID: sampleSiteID) @@ -314,7 +319,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) insertJCPSiteToStorage(siteID: sampleSiteID) @@ -645,7 +651,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) // When let result: Result = waitFor { promise in let action = MediaAction.updateProductID(siteID: self.sampleSiteID, @@ -670,7 +677,8 @@ final class MediaStoreTests: XCTestCase { let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) // When let result: Result = waitFor { promise in @@ -740,12 +748,14 @@ private extension MediaStoreTests { dispatcher: dispatcher, storageManager: storageManager, network: network, - remote: remote) + remote: remote, + backgroundUploader: MediaUploadSessionManager()) } else { return MediaStore(mediaExportService: mediaExportService, dispatcher: dispatcher, storageManager: storageManager, - network: network) + network: network, + backgroundUploader: MediaUploadSessionManager()) } } diff --git a/docs/NETWORKING.md b/docs/NETWORKING.md index 4cd90a3b20d..d7abc113602 100644 --- a/docs/NETWORKING.md +++ b/docs/NETWORKING.md @@ -37,6 +37,15 @@ There are three implementations of this protocol: * [`WordPressOrgNetwork`](../Networking/Networking/Network/WordPressOrgNetwork.swift) also uses Alamofire to manage network requests, but with cookie-based authentication for working with the WordPress.org REST API. * [`MockNetwork`](../Networking/Networking/Network/MockNetwork.swift): a mock networking stack that does not actually hit the network, to be used in the unit tests. +## Background Uploads +The [`MediaUploadSessionManager`](../Networking/Networking/Network/MediaUploadSessionManager.swift) provides support for media background upload tasks that continue even when the app is suspended or terminated. + +Key features: +* Uses URLSession background configuration +* Handles upload completion/failure after app relaunch +* Provides delegate callbacks for upload status +* Integrates with MediaRemote for WordPress media uploads + ## `URLRequestConvertible` A protocol the abstracts the actual URL requests.