Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ProductImageStatusStorage class for managing stored images statuses in User Defaults #15256

Open
wants to merge 5 commits into
base: feat/save-product-image-upload-statuses-in-user-defaults-codable-conformance-unit-tests
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Experiments/Experiments/DefaultFeatureFlagService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
return true
case .filterHistoryOnOrderAndProductLists:
return buildConfig == .localDeveloper || buildConfig == .alpha
case .backgroundProductImageUpload:
return buildConfig == .localDeveloper || buildConfig == .alpha
default:
return true
}
Expand Down
4 changes: 4 additions & 0 deletions Experiments/Experiments/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,8 @@ public enum FeatureFlag: Int {
/// Supports managing filer history on order and product lists
///
case filterHistoryOnOrderAndProductLists

/// Supports uploading product images in background
///
case backgroundProductImageUpload
}
4 changes: 4 additions & 0 deletions Networking/Networking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1676,6 +1677,7 @@
451A9835260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPackagesMapperTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
4524CD9B242CEFAB00B2F20A /* product-on-sale-with-empty-sale-price.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "product-on-sale-with-empty-sale-price.json"; sourceTree = "<group>"; };
452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageStatusStorage.swift; sourceTree = "<group>"; };
453305E82459DF2100264E50 /* PostMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMapper.swift; sourceTree = "<group>"; };
453305EA2459E01A00264E50 /* PostMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMapperTests.swift; sourceTree = "<group>"; };
453305EC2459E1AA00264E50 /* site-post.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-post.json"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2710,6 +2712,7 @@
children = (
4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */,
4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */,
452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */,
);
path = ProductImageInBackground;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,62 @@ public enum ProductImageStatus: Equatable, Codable {
}
}

public var isUploading: Bool {
if case .uploading = self {
return true
}
return false
}

public var isUploadFailure: Bool {
if case .uploadFailure = self {
return true
}
return false
}

public var siteID: Int64 {
switch self {
case .uploading(_, let siteID, _),
.remote(_, let siteID, _),
.uploadFailure(_, _, let siteID, _):
return siteID
}
}

public var productOrVariationID: ProductOrVariationID {
switch self {
case .uploading(_, _, let productID),
.uploadFailure(_, _, _, let productID),
.remote(_, _, let productID):
return productID
}
}

public var assetType: ProductImageAssetType? {
switch self {
case .uploading(let asset, _, _),
.uploadFailure(let asset, _, _, _):
return asset
default:
return nil
}
}

public var error: Error? {
if case .uploadFailure(_, let errorDescription, _, _) = self {
return errorDescription
}
return nil
}

public var isLocalID: Bool {
if productOrVariationID.id == 0 {
return true
}
return false
}

public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case let (.uploading(lAsset, lSiteID, lProductID), .uploading(rAsset, rSiteID, rProductID)):
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()

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)
}
}
}
4 changes: 0 additions & 4 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2414,7 +2414,6 @@
CEE113952CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE113942CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift */; };
CEE125512CC66C8700D3183D /* WooShippingServiceCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE125502CC66C8100D3183D /* WooShippingServiceCardViewModel.swift */; };
CEE482D52B83A9A300FAC8C5 /* AnalyticsCard+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE482D42B83A9A300FAC8C5 /* AnalyticsCard+UI.swift */; };
CEEC9B6021E79CAA0055EEF0 /* FeatureFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEC9B5F21E79CAA0055EEF0 /* FeatureFlagTests.swift */; };
CEEC9B6421E7AB850055EEF0 /* AppRatingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEC9B6221E79EE00055EEF0 /* AppRatingManager.swift */; };
CEEC9B6621E7C5200055EEF0 /* AppRatingManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEC9B6521E7C5200055EEF0 /* AppRatingManagerTests.swift */; };
CEEF74222B99EC5800B03948 /* AnalyticsReportCardProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEF74212B99EC5800B03948 /* AnalyticsReportCardProtocol.swift */; };
Expand Down Expand Up @@ -5613,7 +5612,6 @@
CEE113942CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingSelectedPackageView.swift; sourceTree = "<group>"; };
CEE125502CC66C8100D3183D /* WooShippingServiceCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingServiceCardViewModel.swift; sourceTree = "<group>"; };
CEE482D42B83A9A300FAC8C5 /* AnalyticsCard+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnalyticsCard+UI.swift"; sourceTree = "<group>"; };
CEEC9B5F21E79CAA0055EEF0 /* FeatureFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagTests.swift; sourceTree = "<group>"; };
CEEC9B6221E79EE00055EEF0 /* AppRatingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRatingManager.swift; sourceTree = "<group>"; };
CEEC9B6521E7C5200055EEF0 /* AppRatingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRatingManagerTests.swift; sourceTree = "<group>"; };
CEEF74212B99EC5800B03948 /* AnalyticsReportCardProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsReportCardProtocol.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -10526,7 +10524,6 @@
B53B898A20D4606400EDB467 /* System */ = {
isa = PBXGroup;
children = (
CEEC9B5F21E79CAA0055EEF0 /* FeatureFlagTests.swift */,
B53B898820D450AF00EDB467 /* SessionManagerTests.swift */,
934CB124224EAB540005CCB9 /* TestingAppDelegate.swift */,
9379E1A22255365F006A6BE4 /* TestingMode.storyboard */,
Expand Down Expand Up @@ -17452,7 +17449,6 @@
D88D5A3D230B5E85007B6E01 /* ServiceLocatorTests.swift in Sources */,
269098BA27D6922E001FEB07 /* FeesInputTransformerTests.swift in Sources */,
02A275C223FE590A005C560F /* MockKingfisherImageDownloader.swift in Sources */,
CEEC9B6021E79CAA0055EEF0 /* FeatureFlagTests.swift in Sources */,
20DB185D2CF5E7630018D3E1 /* PointOfSaleOrderControllerTests.swift in Sources */,
D80254822655267A001B2CC1 /* CardPresentModalErrorTests.swift in Sources */,
02AC822C2498BC9700A615FB /* ProductFormViewModel+UpdatesTests.swift in Sources */,
Expand Down
5 changes: 0 additions & 5 deletions WooCommerce/WooCommerceTests/System/FeatureFlagTests.swift

This file was deleted.