From 4b7111e11ff5d573bd9a17d63c9c4dbabe48e34d Mon Sep 17 00:00:00 2001 From: Paolo Musolino Date: Wed, 26 Feb 2025 14:33:45 +0100 Subject: [PATCH] Feat: Created `ProductImageStatusStorage` for storing image statuses in User Defaults. This file has been created in another branch, but given the size of the branch/PR, I'm splitting them, and I renamed the class from `ProductImagesUserDefaultsStatuses` to `ProductImageStatusStorage`. --- .../Networking.xcodeproj/project.pbxproj | 4 + .../ProductImageStatusStorage.swift | 183 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 Networking/Networking/ProductImageInBackground/ProductImageStatusStorage.swift diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index 60e1159c60b..6320725c4c8 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -459,6 +459,7 @@ 451A9836260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451A9835260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift */; }; 4523FB872D596F8000FD1328 /* order-shipping-labels-with-error-in-labels-data.json in Resources */ = {isa = PBXBuildFile; fileRef = 4523FB862D596F6300FD1328 /* order-shipping-labels-with-error-in-labels-data.json */; }; 4524CD9C242CEFAB00B2F20A /* product-on-sale-with-empty-sale-price.json in Resources */ = {isa = PBXBuildFile; fileRef = 4524CD9B242CEFAB00B2F20A /* product-on-sale-with-empty-sale-price.json */; }; + 452EDBD42D6F4F47003A96BC /* ProductImageStatusStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */; }; 453305E92459DF2100264E50 /* PostMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453305E82459DF2100264E50 /* PostMapper.swift */; }; 453305EB2459E01A00264E50 /* PostMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453305EA2459E01A00264E50 /* PostMapperTests.swift */; }; 453305ED2459E1AA00264E50 /* site-post.json in Resources */ = {isa = PBXBuildFile; fileRef = 453305EC2459E1AA00264E50 /* site-post.json */; }; @@ -1676,6 +1677,7 @@ 451A9835260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPackagesMapperTests.swift; sourceTree = ""; }; 4523FB862D596F6300FD1328 /* order-shipping-labels-with-error-in-labels-data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "order-shipping-labels-with-error-in-labels-data.json"; sourceTree = ""; }; 4524CD9B242CEFAB00B2F20A /* product-on-sale-with-empty-sale-price.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "product-on-sale-with-empty-sale-price.json"; sourceTree = ""; }; + 452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageStatusStorage.swift; sourceTree = ""; }; 453305E82459DF2100264E50 /* PostMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMapper.swift; sourceTree = ""; }; 453305EA2459E01A00264E50 /* PostMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMapperTests.swift; sourceTree = ""; }; 453305EC2459E1AA00264E50 /* site-post.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-post.json"; sourceTree = ""; }; @@ -2710,6 +2712,7 @@ children = ( 4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */, 4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */, + 452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */, ); path = ProductImageInBackground; sourceTree = ""; @@ -5501,6 +5504,7 @@ DE8BEF0B2D141DD9008B3A3F /* NotificationSettings.swift in Sources */, D88D5A49230BC8C7007B6E01 /* ProductReviewStatus.swift in Sources */, 036563DB2906938600D84BFD /* JustInTimeMessage.swift in Sources */, + 452EDBD42D6F4F47003A96BC /* ProductImageStatusStorage.swift in Sources */, CCA1D60429437B2C00B40560 /* SiteSummaryStats.swift in Sources */, 451A97C92609FF050059D135 /* ShippingLabelPackagesResponse.swift in Sources */, 029BA4F0255D7282006171FD /* ShippingLabelRemote.swift in Sources */, diff --git a/Networking/Networking/ProductImageInBackground/ProductImageStatusStorage.swift b/Networking/Networking/ProductImageInBackground/ProductImageStatusStorage.swift new file mode 100644 index 00000000000..16c8ba424f4 --- /dev/null +++ b/Networking/Networking/ProductImageInBackground/ProductImageStatusStorage.swift @@ -0,0 +1,183 @@ +import Foundation +import Combine + +/// Save product image upload statuses in User Defaults. +/// This class is declared in the Networking layer because it will also be accessed by the background URLSession operations. +/// This class avoid KVO, and uses Combine to handle notifications, and efficiently checks for actual data changes. +/// We're not using KVO, because `ProductImageStatus` use a swift type (enum), not compatible with Obj-C. +/// +public final class ProductImageStatusStorage { + private let key: String + private let userDefaults: UserDefaults + + // Private internal instance for static methods + private static let internalInstance = ProductImageStatusStorage() + + // Publisher for observing changes + private let statusesSubject: CurrentValueSubject<[ProductImageStatus], Never> + private var cancellables = Set() + + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public var statusesPublisher: AnyPublisher<[ProductImageStatus], Never> { + statusesSubject + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public var errorsPublisher: AnyPublisher<(siteID: Int64, + productOrVariationID: ProductOrVariationID?, + assetType: ProductImageAssetType?, + error: Error), Never> { + statusesSubject + .compactMap { statuses in + statuses.first { $0.isUploadFailure } + } + .compactMap { status in + if let error = status.error { + return (siteID: status.siteID, + productOrVariationID: status.productOrVariationID, + assetType: status.assetType, + error: error) + } + return nil + } + .eraseToAnyPublisher() + } + + public init(userDefaults: UserDefaults = .standard, + key: String = "savedProductUploadImageStatuses", + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder()) { + self.key = key + self.userDefaults = userDefaults + self.encoder = encoder + self.decoder = decoder + self.encoder.dateEncodingStrategy = .iso8601 + self.decoder.dateDecodingStrategy = .iso8601 + self.statusesSubject = CurrentValueSubject(Self.loadStatuses(from: userDefaults, key: key, decoder: decoder)) + setupObservers() + } + + public func addStatus(_ status: ProductImageStatus) { + var current = statusesSubject.value + current.append(status) + saveStatuses(current) + } + + public func removeStatus(_ status: ProductImageStatus) { + let current = statusesSubject.value.filter { $0 != status } + saveStatuses(current) + } + + public func removeStatus(where predicate: (ProductImageStatus) -> Bool) { + let current = statusesSubject.value.filter { !predicate($0) } + saveStatuses(current) + } + + public func updateStatus(_ status: ProductImageStatus) { + var current = statusesSubject.value + if let index = current.firstIndex(where: { $0 == status }) { + current[index] = status + saveStatuses(current) + } else { + addStatus(status) + } + } + + public func findStatus(where predicate: (ProductImageStatus) -> Bool) -> ProductImageStatus? { + statusesSubject.value.first(where: predicate) + } + + public func getAllStatuses() -> [ProductImageStatus] { + statusesSubject.value + } + + public func getAllStatuses(for siteID: Int64, productID: ProductOrVariationID?) -> [ProductImageStatus] { + statusesSubject.value.filter { + switch $0 { + case .uploading(_, let sID, let pID), + .uploadFailure(_, _, let sID, let pID): + return sID == siteID && (productID == nil || pID == productID) + case .remote(_, let sID, let pID): + return sID == siteID && pID == productID + } + } + } + + public func clearAllStatuses() { + userDefaults.removeObject(forKey: key) + userDefaults.synchronize() + statusesSubject.send([]) + } + + public func setAllStatuses(_ statuses: [ProductImageStatus]) { + saveStatuses(statuses) + } + + public func setAllStatuses(_ statuses: [ProductImageStatus], for siteID: Int64, productID: ProductOrVariationID?) { + var filtered = statusesSubject.value.filter { + switch $0 { + case .uploading(_, let sID, let pID), + .uploadFailure(_, _, let sID, let pID): + return sID != siteID || (productID != nil && pID != productID) + case .remote(_, let sID, let pID): + return sID != siteID || pID != productID + } + } + filtered.append(contentsOf: statuses) + saveStatuses(filtered) + } + + private func saveStatuses(_ statuses: [ProductImageStatus]) { + do { + let data = try encoder.encode(statuses) + userDefaults.set(data, forKey: key) + userDefaults.synchronize() + statusesSubject.send(statuses) + } catch { + print("Encoding error in ProductImageStatusStorage: \(error)") + } + } + + private static func loadStatuses(from userDefaults: UserDefaults, key: String, decoder: JSONDecoder = JSONDecoder()) -> [ProductImageStatus] { + guard let data = userDefaults.data(forKey: key) else { return [] } + do { + return try decoder.decode([ProductImageStatus].self, from: data) + } catch { + print("Decoding error in ProductImageStatusStorage: \(error)") + return [] + } + } +} + +// Private utility methods to observe changes in UserDefaults +private extension ProductImageStatusStorage { + func setupObservers() { + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification, object: userDefaults) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.checkForExternalUpdates() + } + .store(in: &cancellables) + } + + func checkForExternalUpdates() { + let newStatuses = Self.loadStatuses(from: userDefaults, key: key, decoder: decoder) + + // Deep comparison + let hasChanges = !newStatuses.elementsEqual(statusesSubject.value) { lhs, rhs in + switch (lhs, rhs) { + case (.remote(let lImg, let lSite, let lProd), .remote(let rImg, let rSite, let rProd)): + return lImg == rImg && lSite == rSite && lProd == rProd + default: + return lhs == rhs + } + } + + if hasChanges { + statusesSubject.send(newStatuses) + } + } +}