diff --git a/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift b/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift index 6c0479952f8..a60e01b3658 100644 --- a/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift +++ b/Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift @@ -57,21 +57,25 @@ public class AlamofireNetwork: Network { public var session: URLSession { Session.default.session } - private var subscription: AnyCancellable? + private var siteSubscription: AnyCancellable? /// Thread-safe error handler for failure tracking and retry logic private let errorHandler: AlamofireNetworkErrorHandler + private var appPasswordSupportSubscription: AnyCancellable? + /// Public Initializer /// /// - Parameters: /// - credentials: Authentication credentials for requests. /// - selectedSite: Publisher for site selection changes. + /// This is necessary if you wish to enable network switching to direct requests while authenticated with WPCOM for better performance. /// - sessionManager: Optional pre-configured session manager. /// - ensuresSessionManagerIsInitialized: If true, the session is always set during initialization immediately to avoid lazy initialization race conditions. /// Defaults to false for backward compatibility. Set to true when making concurrent requests immediately after initialization. public required init(credentials: Credentials?, - selectedSite: AnyPublisher? = nil, + selectedSite: AnyPublisher?, + appPasswordSupportState: AnyPublisher?, userDefaults: UserDefaults = .standard, sessionManager: Alamofire.Session? = nil, ensuresSessionManagerIsInitialized: Bool = false) { @@ -110,17 +114,8 @@ public class AlamofireNetwork: Network { } }() updateAuthenticationMode(authenticationMode) - } - - public func updateAppPasswordSwitching(enabled: Bool) { - guard let credentials, case .wpcom = credentials else { return } - if enabled, let selectedSite { - observeSelectedSite(selectedSite) - } else { - requestConverter = RequestConverter(siteAddress: nil) - requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials)) - requestAuthenticator.delegate = nil - updateAuthenticationMode(.jetpackTunnel) + if let appPasswordSupportState { + observeAppPasswordSupportState(appPasswordSupportState) } } @@ -277,6 +272,28 @@ public class AlamofireNetwork: Network { } private extension AlamofireNetwork { + + func observeAppPasswordSupportState(_ appPasswordSupportState: AnyPublisher) { + appPasswordSupportSubscription = appPasswordSupportState + .removeDuplicates() + .sink { [weak self] enabled in + self?.updateAppPasswordSwitching(enabled: enabled) + } + } + + func updateAppPasswordSwitching(enabled: Bool) { + guard let credentials, case .wpcom = credentials else { return } + if enabled, let selectedSite { + observeSelectedSite(selectedSite) + } else { + requestConverter = RequestConverter(siteAddress: nil) + requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials)) + requestAuthenticator.delegate = nil + updateAuthenticationMode(.jetpackTunnel) + siteSubscription = nil + } + } + /// Creates a session manager with request retrier and adapter /// func makeSession(configuration sessionConfiguration: URLSessionConfiguration) -> Alamofire.Session { @@ -286,7 +303,7 @@ private extension AlamofireNetwork { /// Updates `requestConverter` and `requestAuthenticator` when selected site changes /// func observeSelectedSite(_ selectedSite: AnyPublisher) { - subscription = selectedSite + siteSubscription = selectedSite .removeDuplicates() .combineLatest(userDefaults.publisher(for: \.applicationPasswordUnsupportedList)) .sink { [weak self] site, unsupportedList in diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategyFactory.swift index dabb35cecaa..4f656844be4 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategyFactory.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategyFactory.swift @@ -3,6 +3,8 @@ import class WooFoundation.CurrencySettings import protocol Storage.StorageManagerType import class Networking.CouponsRemote import class Networking.AlamofireNetwork +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public struct PointOfSaleCouponFetchStrategyFactory { private let siteID: Int64 @@ -13,8 +15,12 @@ public struct PointOfSaleCouponFetchStrategyFactory { public init(siteID: Int64, currencySettings: CurrencySettings, credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, storage: StorageManagerType) { - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) let remote = CouponsRemote(network: network) self.siteID = siteID self.currencySettings = currencySettings diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponService.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponService.swift index 20edcd69553..36463c7b9cb 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponService.swift @@ -5,6 +5,8 @@ import class WooFoundation.CurrencyFormatter import class WooFoundation.CurrencySettings import Storage import enum Alamofire.AFError +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public protocol PointOfSaleCouponServiceProtocol { func provideLocalPointOfSaleCoupons(fetchStrategy: PointOfSaleCouponFetchStrategy) async throws -> [POSItem] @@ -30,8 +32,12 @@ public final class PointOfSaleCouponService: PointOfSaleCouponServiceProtocol { public convenience init(siteID: Int64, currencySettings: CurrencySettings, credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, storage: StorageManagerType) { - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.init(siteID: siteID, currencySettings: currencySettings, settingStoreMethods: SettingStoreMethods(storageManager: storage, network: network), diff --git a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift index e94bb60cbcd..f7c6f65399b 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift @@ -1,6 +1,7 @@ import Foundation import Networking import Storage +import struct Combine.AnyPublisher public protocol POSSystemStatusServiceProtocol { /// Loads WooCommerce plugin and POS feature switch value remotely for eligibility checks. @@ -27,8 +28,13 @@ public final class POSSystemStatusService: POSSystemStatusServiceProtocol { private let storageManager: StorageManagerType private let pluginsService: PluginsServiceProtocol - public init(credentials: Credentials?, storageManager: StorageManagerType) { - let network = AlamofireNetwork(credentials: credentials) + public init(credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, + storageManager: StorageManagerType) { + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.remote = SystemStatusRemote(network: network) self.storageManager = storageManager self.pluginsService = PluginsService(storageManager: storageManager) diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift index a21813d090a..ff59863e051 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift @@ -4,6 +4,8 @@ import class Networking.ProductsRemote import class WooFoundation.CurrencySettings import class Networking.AlamofireNetwork import enum Networking.NetworkError +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public protocol PointOfSaleBarcodeScanServiceProtocol { func getItem(barcode: String) async throws(PointOfSaleBarcodeScanError) -> POSItem @@ -40,8 +42,12 @@ public final class PointOfSaleBarcodeScanService: PointOfSaleBarcodeScanServiceP public convenience init(siteID: Int64, credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, currencySettings: CurrencySettings) { - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.init(siteID: siteID, productsRemote: ProductsRemote(network: network), currencySettings: currencySettings) diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift index 95d36bbad7a..b047d47207d 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift @@ -2,6 +2,8 @@ import Foundation import class Networking.ProductsRemote import class Networking.ProductVariationsRemote import class Networking.AlamofireNetwork +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public protocol PointOfSaleItemFetchStrategyFactoryProtocol { func defaultStrategy(analytics: POSItemFetchAnalyticsTracking) -> PointOfSalePurchasableItemFetchStrategy @@ -18,9 +20,13 @@ public final class PointOfSaleItemFetchStrategyFactory: PointOfSaleItemFetchStra private let variationsRemote: ProductVariationsRemote public init(siteID: Int64, - credentials: Credentials?) { + credentials: Credentials?, + selectedSite: AnyPublisher? = nil, + appPasswordSupportState: AnyPublisher? = nil) { self.siteID = siteID - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.productsRemote = ProductsRemote(network: network) self.variationsRemote = ProductVariationsRemote(network: network) } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderListFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderListFetchStrategyFactory.swift index 47da00588c8..63acfc85ea4 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderListFetchStrategyFactory.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderListFetchStrategyFactory.swift @@ -2,6 +2,8 @@ import Foundation import class Networking.AlamofireNetwork import class Networking.OrdersRemote import class WooFoundationCore.CurrencyFormatter +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public protocol POSOrderListFetchStrategyFactoryProtocol { func defaultStrategy() -> POSOrderListFetchStrategy @@ -15,9 +17,13 @@ public final class POSOrderListFetchStrategyFactory: POSOrderListFetchStrategyFa public init(siteID: Int64, credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, currencyFormatter: CurrencyFormatter) { self.siteID = siteID - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.ordersRemote = OrdersRemote(network: network) self.currencyFormatter = currencyFormatter } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 462523bc997..288eeeadac5 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -3,6 +3,8 @@ import protocol Networking.POSCatalogSyncRemoteProtocol import class Networking.AlamofireNetwork import class Networking.POSCatalogSyncRemote import Storage +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite // TODO - remove the periphery ignore comment when the catalog is integrated with POS. // periphery:ignore @@ -30,12 +32,19 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol private let persistenceService: POSCatalogPersistenceServiceProtocol private let batchedLoader: BatchedRequestLoader - public convenience init?(credentials: Credentials?, batchSize: Int = 2, grdbManager: GRDBManagerProtocol) { + public convenience init?(credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, + batchSize: Int = 2, + grdbManager: GRDBManagerProtocol) { guard let credentials else { DDLogError("⛔️ Could not create POSCatalogFullSyncService due missing credentials") return nil } - let network = AlamofireNetwork(credentials: credentials, ensuresSessionManagerIsInitialized: true) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState, + ensuresSessionManagerIsInitialized: true) let syncRemote = POSCatalogSyncRemote(network: network) let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager) self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index e9d8573fd47..55c2f8bc578 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -3,6 +3,8 @@ import protocol Networking.POSCatalogSyncRemoteProtocol import class Networking.AlamofireNetwork import class Networking.POSCatalogSyncRemote import protocol Storage.GRDBManagerProtocol +import struct NetworkingCore.JetpackSite +import struct Combine.AnyPublisher // TODO - remove the periphery ignore comment when the service is integrated with POS. // periphery:ignore @@ -22,12 +24,19 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe private let persistenceService: POSCatalogPersistenceServiceProtocol private let batchedLoader: BatchedRequestLoader - public convenience init?(credentials: Credentials?, batchSize: Int = 1, grdbManager: GRDBManagerProtocol) { + public convenience init?(credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, + batchSize: Int = 1, + grdbManager: GRDBManagerProtocol) { guard let credentials else { DDLogError("⛔️ Could not create POSCatalogIncrementalSyncService due missing credentials") return nil } - let network = AlamofireNetwork(credentials: credentials, ensuresSessionManagerIsInitialized: true) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState, + ensuresSessionManagerIsInitialized: true) let syncRemote = POSCatalogSyncRemote(network: network) let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager) self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift index cddca87befa..270e2436dbb 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSOrderService.swift @@ -2,6 +2,8 @@ import Foundation import Networking import class WooFoundation.CurrencyFormatter import enum WooFoundation.CurrencyCode +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public protocol POSOrderServiceProtocol { /// Syncs order based on the cart. @@ -17,12 +19,17 @@ public final class POSOrderService: POSOrderServiceProtocol { private let siteID: Int64 private let ordersRemote: POSOrdersRemoteProtocol - public convenience init?(siteID: Int64, credentials: Credentials?) { + public convenience init?(siteID: Int64, + credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher) { guard let credentials else { DDLogError("⛔️ Could not create POSOrderService due to not finding credentials") return nil } - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.init(siteID: siteID, ordersRemote: OrdersRemote(network: network)) } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift index 3a9eaa0f3e4..1a020d25afe 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSReceiptService.swift @@ -1,5 +1,6 @@ import SwiftUI import Networking +import struct Combine.AnyPublisher public protocol POSReceiptServiceProtocol { func sendReceipt(orderID: Int64, recipientEmail: String, isEligibleForPOSReceipt: Bool) async throws @@ -9,12 +10,17 @@ public final class POSReceiptService: POSReceiptServiceProtocol { private let siteID: Int64 private let receiptsRemote: POSReceiptsRemoteProtocol - public convenience init?(siteID: Int64, credentials: Credentials?) { + public convenience init?(siteID: Int64, + credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher) { guard let credentials else { DDLogError("⛔️ Could not create POSReceiptService due to not finding credentials") return nil } - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.init(siteID: siteID, receiptsRemote: ReceiptRemote(network: network)) } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSSiteSettingService.swift b/Modules/Sources/Yosemite/Tools/POS/POSSiteSettingService.swift index 71f95cec48b..d5a817fcffd 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSSiteSettingService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSSiteSettingService.swift @@ -1,5 +1,6 @@ import Foundation import Networking +import struct Combine.AnyPublisher /// Protocol for POS site setting service that manages WooCommerce feature flags. public protocol POSSiteSettingServiceProtocol { @@ -17,8 +18,12 @@ public protocol POSSiteSettingServiceProtocol { public final class POSSiteSettingService: POSSiteSettingServiceProtocol { private let remote: SiteSettingsRemoteProtocol - public init(credentials: Credentials?) { - let network = AlamofireNetwork(credentials: credentials) + public init(credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher) { + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.remote = SiteSettingsRemote(network: network) } diff --git a/Modules/Sources/Yosemite/Tools/POS/PointOfSaleSettingsService.swift b/Modules/Sources/Yosemite/Tools/POS/PointOfSaleSettingsService.swift index f706639be27..8f18fb67540 100644 --- a/Modules/Sources/Yosemite/Tools/POS/PointOfSaleSettingsService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/PointOfSaleSettingsService.swift @@ -1,6 +1,8 @@ import Foundation import Networking import Storage +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public protocol PointOfSaleSettingsServiceProtocol { func retrievePointOfSaleSettings() async throws -> POSReceiptInformation @@ -18,8 +20,12 @@ public final class PointOfSaleSettingsService: PointOfSaleSettingsServiceProtoco public convenience init(siteID: Int64, credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher, storage: StorageManagerType) { - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState) self.init(siteID: siteID, settingStoreMethods: SettingStoreMethods(storageManager: storage, network: network)) } diff --git a/Modules/Sources/Yosemite/Tools/Payments/WooPaymentsPayoutService.swift b/Modules/Sources/Yosemite/Tools/Payments/WooPaymentsPayoutService.swift index 518c6045a19..951394a7562 100644 --- a/Modules/Sources/Yosemite/Tools/Payments/WooPaymentsPayoutService.swift +++ b/Modules/Sources/Yosemite/Tools/Payments/WooPaymentsPayoutService.swift @@ -1,6 +1,8 @@ import Foundation import Networking import WooFoundation +import struct Combine.AnyPublisher +import struct NetworkingCore.JetpackSite public protocol WooPaymentsPayoutServiceProtocol { func fetchPayoutsOverview() async throws -> [WooPaymentsPayoutsOverviewByCurrency] @@ -14,12 +16,17 @@ public final class WooPaymentsPayoutService: WooPaymentsPayoutServiceProtocol { // MARK: - Initialization - public convenience init?(siteID: Int64, credentials: Credentials?) { + public convenience init?(siteID: Int64, + credentials: Credentials?, + selectedSite: AnyPublisher, + appPasswordSupportState: AnyPublisher) { guard let credentials else { DDLogError("⛔️ Could not create payouts service due to not finding credentials") return nil } - self.init(siteID: siteID, network: AlamofireNetwork(credentials: credentials)) + self.init(siteID: siteID, network: AlamofireNetwork(credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState)) } public init(siteID: Int64, network: Network) { diff --git a/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift b/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift index c7efed76e25..0e8e349b704 100644 --- a/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift +++ b/Modules/Tests/NetworkingTests/Network/AlamofireNetworkTests.swift @@ -28,7 +28,7 @@ final class AlamofireNetworkTests: XCTestCase { MockURLProtocol.Mocks.mockResponse(["error": "http_request_failed"], statusCode: 401, for: urlRequest) // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) let error = waitFor { promise in network.responseData(for: request) { data, error in promise(error) @@ -50,7 +50,7 @@ final class AlamofireNetworkTests: XCTestCase { MockURLProtocol.Mocks.mockResponse(["error": "not_found"], statusCode: 404, for: urlRequest) // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) let error = waitFor { promise in network.responseData(for: request) { data, error in promise(error) @@ -72,7 +72,7 @@ final class AlamofireNetworkTests: XCTestCase { MockURLProtocol.Mocks.mockResponse(["error": "http_request_failed"], statusCode: 200, for: urlRequest) // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) let error = waitFor { promise in network.responseData(for: request) { data, error in promise(error) @@ -95,7 +95,7 @@ final class AlamofireNetworkTests: XCTestCase { MockURLProtocol.Mocks.mockResponse(["error": "http_request_failed"], statusCode: 500, for: urlRequest) // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) let result = waitFor { promise in network.responseData(for: request) { result in promise(result) @@ -117,7 +117,7 @@ final class AlamofireNetworkTests: XCTestCase { MockURLProtocol.Mocks.mockResponse(["error": "http_request_failed"], statusCode: 200, for: urlRequest) // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) let result = waitFor { promise in network.responseData(for: request) { result in promise(result) @@ -140,7 +140,7 @@ final class AlamofireNetworkTests: XCTestCase { MockURLProtocol.Mocks.mockResponse(["error": "http_request_failed"], statusCode: 500, for: urlRequest) // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) let result = waitFor { promise in self.responseDataSubscription = network.responseDataPublisher(for: request) .sink { result in @@ -163,7 +163,7 @@ final class AlamofireNetworkTests: XCTestCase { MockURLProtocol.Mocks.mockResponse(["error": "http_request_failed"], statusCode: 200, for: urlRequest) // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) let result = waitFor { promise in self.responseDataSubscription = network.responseDataPublisher(for: request) .sink { result in @@ -180,7 +180,7 @@ final class AlamofireNetworkTests: XCTestCase { func test_didFailToAuthenticateRequestWithAppPassword_adds_siteID_to_unsupported_list() { // Given let siteID: Int64 = 123 - let network = AlamofireNetwork(credentials: nil, userDefaults: userDefaults) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, userDefaults: userDefaults) XCTAssertTrue(userDefaults.applicationPasswordUnsupportedList.isEmpty) // When @@ -195,7 +195,7 @@ final class AlamofireNetworkTests: XCTestCase { let existingSiteID: Int64 = 456 let newSiteID: Int64 = 123 userDefaults.applicationPasswordUnsupportedList = [String(existingSiteID): Date()] - let network = AlamofireNetwork(credentials: nil, userDefaults: userDefaults) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, userDefaults: userDefaults) // When network.didFailToAuthenticateRequestWithAppPassword(siteID: newSiteID, error: NetworkError.notFound(response: nil)) @@ -210,7 +210,7 @@ final class AlamofireNetworkTests: XCTestCase { func test_concurrent_requests_do_not_fail_with_sessionDeinitialized_error_when_ensuresSessionManagerIsInitialized_is_true() async throws { // Given let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: -1, path: "test") - let network = AlamofireNetwork(credentials: nil, ensuresSessionManagerIsInitialized: true) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, ensuresSessionManagerIsInitialized: true) // When async let request1 = network.responseDataAndHeaders(for: request) @@ -231,7 +231,7 @@ final class AlamofireNetworkTests: XCTestCase { func test_concurrent_requests_fail_with_sessionDeinitialized_error_when_ensuresSessionManagerIsInitialized_is_false() async throws { // Given let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 1, path: "test") - let network = AlamofireNetwork(credentials: nil, ensuresSessionManagerIsInitialized: false) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, ensuresSessionManagerIsInitialized: false) // When async let request1 = network.responseDataAndHeaders(for: request) @@ -650,7 +650,10 @@ final class AlamofireNetworkTests: XCTestCase { let siteID: Int64 = 333 let jetpackRequest = createJetpackRequest(siteID: siteID, path: "test") let wpcomCredentials = createWPComCredentials() - let network = AlamofireNetwork(credentials: wpcomCredentials, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: wpcomCredentials, + selectedSite: nil, + appPasswordSupportState: nil, + sessionManager: createSessionWithMockURLProtocol()) // Mock Jetpack request to fail let jetpackUrlRequest = try XCTUnwrap(try? jetpackRequest.asURLRequest()) @@ -676,7 +679,10 @@ final class AlamofireNetworkTests: XCTestCase { let wporgCredentials = Credentials.wporg(username: "user", password: "pass", siteAddress: "https://example.com") // When - let network = AlamofireNetwork(credentials: wporgCredentials, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: wporgCredentials, + selectedSite: nil, + appPasswordSupportState: nil, + sessionManager: createSessionWithMockURLProtocol()) // Then let expectation = XCTestExpectation(description: "Authentication mode should be set") @@ -692,7 +698,10 @@ final class AlamofireNetworkTests: XCTestCase { let appPasswordCredentials = Credentials.applicationPassword(username: "user", password: "pass", siteAddress: "https://example.com") // When - let network = AlamofireNetwork(credentials: appPasswordCredentials, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: appPasswordCredentials, + selectedSite: nil, + appPasswordSupportState: nil, + sessionManager: createSessionWithMockURLProtocol()) // Then let expectation = XCTestExpectation(description: "Authentication mode should be set") @@ -708,7 +717,10 @@ final class AlamofireNetworkTests: XCTestCase { let wpcomCredentials = createWPComCredentials() // When - let network = AlamofireNetwork(credentials: wpcomCredentials, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: wpcomCredentials, + selectedSite: nil, + appPasswordSupportState: nil, + sessionManager: createSessionWithMockURLProtocol()) // Then let expectation = XCTestExpectation(description: "Authentication mode should be set") @@ -721,7 +733,7 @@ final class AlamofireNetworkTests: XCTestCase { func test_authenticationMode_is_nil_for_no_credentials() { // When - let network = AlamofireNetwork(credentials: nil, sessionManager: createSessionWithMockURLProtocol()) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil, sessionManager: createSessionWithMockURLProtocol()) // Then let expectation = XCTestExpectation(description: "Authentication mode should be set") @@ -736,10 +748,16 @@ final class AlamofireNetworkTests: XCTestCase { // Given let siteID: Int64 = 123 let wpcomCredentials = createWPComCredentials() - let network = createNetworkWithSelectedSite(siteID: siteID, credentials: wpcomCredentials, userDefaults: userDefaults) + let appPasswordSupportStream = CurrentValueSubject(false) + let network = createNetworkWithSelectedSite( + siteID: siteID, + credentials: wpcomCredentials, + userDefaults: userDefaults, + appPasswordSupport: appPasswordSupportStream.eraseToAnyPublisher() + ) // When - Enable app password switching - network.updateAppPasswordSwitching(enabled: true) + appPasswordSupportStream.send(true) // Then let expectation = XCTestExpectation(description: "Authentication mode should change") @@ -754,11 +772,17 @@ final class AlamofireNetworkTests: XCTestCase { // Given let siteID: Int64 = 456 let wpcomCredentials = createWPComCredentials() - let network = createNetworkWithSelectedSite(siteID: siteID, credentials: wpcomCredentials, userDefaults: userDefaults) + let appPasswordSupportStream = CurrentValueSubject(false) + let network = createNetworkWithSelectedSite( + siteID: siteID, + credentials: wpcomCredentials, + userDefaults: userDefaults, + appPasswordSupport: appPasswordSupportStream.eraseToAnyPublisher() + ) // When - Enable then disable app password switching - network.updateAppPasswordSwitching(enabled: true) - network.updateAppPasswordSwitching(enabled: false) + appPasswordSupportStream.send(true) + appPasswordSupportStream.send(false) // Then let expectation = XCTestExpectation(description: "Authentication mode should revert") @@ -773,11 +797,17 @@ final class AlamofireNetworkTests: XCTestCase { // Given let siteID: Int64 = 789 let wpcomCredentials = createWPComCredentials() + let appPasswordSupportStream = CurrentValueSubject(false) userDefaults.applicationPasswordUnsupportedList = [String(siteID): Date()] - let network = createNetworkWithSelectedSite(siteID: siteID, credentials: wpcomCredentials, userDefaults: userDefaults) + let network = createNetworkWithSelectedSite( + siteID: siteID, + credentials: wpcomCredentials, + userDefaults: userDefaults, + appPasswordSupport: appPasswordSupportStream.eraseToAnyPublisher() + ) // When - Enable app password switching for an unsupported site - network.updateAppPasswordSwitching(enabled: true) + appPasswordSupportStream.send(true) // Then let expectation = XCTestExpectation(description: "Authentication mode should remain jetpackTunnel") @@ -791,10 +821,10 @@ final class AlamofireNetworkTests: XCTestCase { func test_authenticationMode_does_not_change_for_non_wpcom_credentials() { // Given let wporgCredentials = Credentials.wporg(username: "user", password: "pass", siteAddress: "https://example.com") - let network = AlamofireNetwork(credentials: wporgCredentials, sessionManager: createSessionWithMockURLProtocol()) - - // When - Try to enable app password switching (should have no effect) - network.updateAppPasswordSwitching(enabled: true) + let network = AlamofireNetwork(credentials: wporgCredentials, + selectedSite: nil, + appPasswordSupportState: nil, + sessionManager: createSessionWithMockURLProtocol()) // Then let expectation = XCTestExpectation(description: "Authentication mode should remain unchanged") @@ -839,16 +869,21 @@ private extension AlamofireNetworkTests { return Just(site).eraseToAnyPublisher() } - func createNetworkWithSelectedSite(siteID: Int64, credentials: Credentials? = nil, userDefaults: UserDefaults? = nil) -> AlamofireNetwork { + func createNetworkWithSelectedSite( + siteID: Int64, + credentials: Credentials? = nil, + userDefaults: UserDefaults? = nil, + appPasswordSupport: AnyPublisher = Just(true).eraseToAnyPublisher() + ) -> AlamofireNetwork { let networkCredentials = credentials ?? createWPComCredentials() let selectedSite = createSelectedSitePublisher(siteID: siteID) let network = AlamofireNetwork( credentials: networkCredentials, selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupport, userDefaults: userDefaults ?? .standard, sessionManager: createSessionWithMockURLProtocol() ) - network.updateAppPasswordSwitching(enabled: true) return network } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift index 1c57b8b43aa..8b271c3eb81 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import Combine @testable import Networking @testable import Yosemite @testable import Storage @@ -137,7 +138,12 @@ struct POSCatalogFullSyncServiceTests { let grdbManager = try GRDBManager() // When - let service = POSCatalogFullSyncService(credentials: credentials, grdbManager: grdbManager) + let service = POSCatalogFullSyncService( + credentials: credentials, + selectedSite: Just(Site.fake()).map { $0.toJetpackSite() }.eraseToAnyPublisher(), + appPasswordSupportState: Just(false).eraseToAnyPublisher(), + grdbManager: grdbManager + ) // Then #expect(service != nil) @@ -148,7 +154,12 @@ struct POSCatalogFullSyncServiceTests { let grdbManager = try GRDBManager() // When - let service = POSCatalogFullSyncService(credentials: nil, grdbManager: grdbManager) + let service = POSCatalogFullSyncService( + credentials: nil, + selectedSite: Just(Site.fake()).map { $0.toJetpackSite() }.eraseToAnyPublisher(), + appPasswordSupportState: Just(false).eraseToAnyPublisher(), + grdbManager: grdbManager + ) // Then #expect(service == nil) diff --git a/WooCommerce/Classes/Authentication/Jetpack Setup/Native Jetpack Setup/JetpackSetupViewModel.swift b/WooCommerce/Classes/Authentication/Jetpack Setup/Native Jetpack Setup/JetpackSetupViewModel.swift index 4d291dedd06..d38ea3fdcac 100644 --- a/WooCommerce/Classes/Authentication/Jetpack Setup/Native Jetpack Setup/JetpackSetupViewModel.swift +++ b/WooCommerce/Classes/Authentication/Jetpack Setup/Native Jetpack Setup/JetpackSetupViewModel.swift @@ -431,7 +431,7 @@ private extension JetpackSetupViewModel { } func finalizeSiteConnection(blogID: Int64, provisionResponse: JetpackConnectionProvisionResponse) { - let network = AlamofireNetwork(credentials: wpcomCredentials) + let network = AlamofireNetwork(credentials: wpcomCredentials, selectedSite: nil, appPasswordSupportState: nil) stores.dispatch(JetpackConnectionAction.finalizeConnection( siteID: blogID, siteURL: siteURL, diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 8168ad9472f..a08d1b6348f 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -5,6 +5,8 @@ import Yosemite import class WooFoundation.CurrencySettings import protocol Storage.StorageManagerType import class WooFoundationCore.CurrencyFormatter +import struct NetworkingCore.JetpackSite +import struct Combine.AnyPublisher /// View controller that provides the tab bar item for the Point of Sale tab. /// It is never visible on the screen, only used to provide the tab bar item as all POS UI is full-screen. @@ -32,7 +34,10 @@ final class POSTabCoordinator { private let eligibilityChecker: POSEntryPointEligibilityCheckerProtocol private lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { - PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials) + PointOfSaleItemFetchStrategyFactory(siteID: siteID, + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported) }() private lazy var posPopularItemFetchStrategyFactory: PointOfSaleFixedItemFetchStrategyFactory = { @@ -43,6 +48,8 @@ final class POSTabCoordinator { PointOfSaleCouponFetchStrategyFactory(siteID: siteID, currencySettings: currencySettings, credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, storage: storageManager) }() @@ -50,15 +57,27 @@ final class POSTabCoordinator { return PointOfSaleCouponService(siteID: siteID, currencySettings: currencySettings, credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, storage: storageManager) }() private lazy var barcodeScanService: PointOfSaleBarcodeScanService = { PointOfSaleBarcodeScanService(siteID: siteID, credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, currencySettings: currencySettings) }() + /// Publisher to send to `AlamofireNetwork` for request authentication mode switching. + private let defaultSitePublisher: AnyPublisher + + private let appPasswordSupportState: ApplicationPasswordsExperimentState + + /// Publisher to send to `AlamofireNetwork` the state of app password support for JP sites + private let isAppPasswordSupported: AnyPublisher + init(siteID: Int64, tabContainerController: TabContainerController, viewControllerToPresent: UIViewController, @@ -69,6 +88,13 @@ final class POSTabCoordinator { eligibilityChecker: POSEntryPointEligibilityCheckerProtocol) { self.siteID = siteID self.storesManager = storesManager + self.defaultSitePublisher = storesManager.sessionManager.defaultSitePublisher + .map { $0?.toJetpackSite() } + .eraseToAnyPublisher() + self.appPasswordSupportState = ApplicationPasswordsExperimentState() + self.isAppPasswordSupported = appPasswordSupportState + .$isAvailableAndEnabled + .eraseToAnyPublisher() self.tabContainerController = tabContainerController self.viewControllerToPresent = viewControllerToPresent self.credentials = storesManager.sessionManager.defaultCredentials @@ -95,14 +121,20 @@ private extension POSTabCoordinator { collectOrderPaymentAnalyticsTracker: collectOrderPaymentAnalyticsTracker) let settingsService = PointOfSaleSettingsService(siteID: siteID, credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, storage: storageManager) let pluginsService = PluginsService(storageManager: storageManager) let siteTimezone = storesManager.sessionManager.defaultSite?.siteTimezone ?? .current if let receiptService = POSReceiptService(siteID: siteID, - credentials: credentials), + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported), let orderService = POSOrderService(siteID: siteID, - credentials: credentials), + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported), #available(iOS 17.0, *) { let receiptSender = POSReceiptSender(siteID: siteID, orderService: orderService, @@ -126,6 +158,8 @@ private extension POSTabCoordinator { orderListFetchStrategyFactory: POSOrderListFetchStrategyFactory( siteID: siteID, credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported, currencyFormatter: CurrencyFormatter(currencySettings: currencySettings) ) ), diff --git a/WooCommerce/Classes/System/SessionManager.swift b/WooCommerce/Classes/System/SessionManager.swift index 51bd8dec01c..0053ef59a8c 100644 --- a/WooCommerce/Classes/System/SessionManager.swift +++ b/WooCommerce/Classes/System/SessionManager.swift @@ -247,7 +247,7 @@ final class SessionManager: SessionManagerProtocol { guard let siteID = defaultStoreID else { return nil } - let network = AlamofireNetwork(credentials: credentials) + let network = AlamofireNetwork(credentials: credentials, selectedSite: nil, appPasswordSupportState: nil) return DefaultApplicationPasswordUseCase(type: .wpcom(siteID: siteID), network: network) case .none: return nil diff --git a/WooCommerce/Classes/ViewRelated/Connectivity Tool/ConnectivityToolViewModel.swift b/WooCommerce/Classes/ViewRelated/Connectivity Tool/ConnectivityToolViewModel.swift index b89b7a8a790..0daee2c642e 100644 --- a/WooCommerce/Classes/ViewRelated/Connectivity Tool/ConnectivityToolViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Connectivity Tool/ConnectivityToolViewModel.swift @@ -28,7 +28,7 @@ final class ConnectivityToolViewModel { init(session: SessionManagerProtocol = ServiceLocator.stores.sessionManager) { - let network = AlamofireNetwork(credentials: session.defaultCredentials) + let network = AlamofireNetwork(credentials: session.defaultCredentials, selectedSite: nil, appPasswordSupportState: nil) self.announcementsRemote = AnnouncementsRemote(network: network) self.systemStatusRemote = SystemStatusRemote(network: network) self.orderRemote = OrdersRemote(network: network) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/ApplicationPasswordsExperimentState.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/ApplicationPasswordsExperimentState.swift index 03b178fd721..bff04ed9f0e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/ApplicationPasswordsExperimentState.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/ApplicationPasswordsExperimentState.swift @@ -1,25 +1,27 @@ +import Combine import Foundation import Yosemite +import struct Storage.GeneralAppSettingsStorage final class ApplicationPasswordsExperimentState { private let stores: StoresManager private let availabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerProtocol + private let generalAppSettings: GeneralAppSettingsStorage + + private var experimentalFlagSubscription: AnyCancellable? init( stores: StoresManager = ServiceLocator.stores, - availabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerProtocol = ApplicationPasswordsExperimentAvailabilityChecker() + availabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerProtocol = ApplicationPasswordsExperimentAvailabilityChecker(), + generalAppSettings: GeneralAppSettingsStorage = ServiceLocator.generalAppSettings ) { self.stores = stores self.availabilityChecker = availabilityChecker + self.generalAppSettings = generalAppSettings + observeExperimentalFlag() } - var isAvailableAndEnabled: Bool { - get async { - let isAvailable = await availabilityChecker.fetchAvailability() - let isEnabled = await isEnabled - return isAvailable && isEnabled - } - } + @Published private(set) var isAvailableAndEnabled: Bool = true @MainActor private var isEnabled: Bool { @@ -33,6 +35,25 @@ final class ApplicationPasswordsExperimentState { } } } + + private func updateAvailability() { + Task { @MainActor in + let isAvailable = await availabilityChecker.fetchAvailability() + let isEnabled = await isEnabled + isAvailableAndEnabled = isAvailable && isEnabled + } + } + + private func observeExperimentalFlag() { + experimentalFlagSubscription = generalAppSettings + .betaFeatureEnabledPublisher( + .applicationPasswords + ) + .removeDuplicates() + .sink { [weak self] _ in + self?.updateAvailability() + } + } } protocol ApplicationPasswordsExperimentAvailabilityCheckerProtocol { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift index 9f6c9cbb825..7e4f6a9a623 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift @@ -334,7 +334,18 @@ struct InPersonPaymentsMenu_Previews: PreviewProvider { cardPresentPaymentsConfiguration: .init(country: .US), onboardingUseCase: CardPresentPaymentsOnboardingUseCase(), cardReaderSupportDeterminer: CardReaderSupportDeterminer(siteID: 0), - wooPaymentsPayoutService: WooPaymentsPayoutService(siteID: 0, credentials: .init(authToken: "")))) + wooPaymentsPayoutService: WooPaymentsPayoutService( + siteID: 0, + credentials: .init(authToken: ""), + selectedSite: ServiceLocator.stores.sessionManager.defaultSitePublisher + .map { $0?.toJetpackSite() } + .eraseToAnyPublisher(), + appPasswordSupportState: ApplicationPasswordsExperimentState() + .$isAvailableAndEnabled + .eraseToAnyPublisher() + ) + ) + ) static var previews: some View { NavigationStack { InPersonPaymentsMenu(viewModel: viewModel) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift index 04ba3334b3f..a377934b66a 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -54,6 +54,7 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { private let featureFlagService: FeatureFlagService private let systemStatusService: POSSystemStatusServiceProtocol private let siteSettingService: POSSiteSettingServiceProtocol + private let appPasswordSupportState: ApplicationPasswordsExperimentState init(siteID: Int64, userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, @@ -61,17 +62,30 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(), stores: StoresManager = ServiceLocator.stores, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, - systemStatusService: POSSystemStatusServiceProtocol = POSSystemStatusService(credentials: ServiceLocator.stores.sessionManager.defaultCredentials, - storageManager: ServiceLocator.storageManager), - siteSettingService: POSSiteSettingServiceProtocol = POSSiteSettingService(credentials: ServiceLocator.stores.sessionManager.defaultCredentials)) { + systemStatusService: POSSystemStatusServiceProtocol? = nil, + siteSettingService: POSSiteSettingServiceProtocol? = nil) { self.siteID = siteID self.userInterfaceIdiom = userInterfaceIdiom self.siteSettings = siteSettings self.eligibilityService = eligibilityService self.stores = stores self.featureFlagService = featureFlagService - self.systemStatusService = systemStatusService - self.siteSettingService = siteSettingService + self.appPasswordSupportState = ApplicationPasswordsExperimentState() + + let credentials = stores.sessionManager.defaultCredentials + let selectedSite = stores.sessionManager.defaultSitePublisher.map { $0?.toJetpackSite() }.eraseToAnyPublisher() + let appPasswordSupportState = appPasswordSupportState.$isAvailableAndEnabled.eraseToAnyPublisher() + self.systemStatusService = systemStatusService ?? POSSystemStatusService( + credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState, + storageManager: ServiceLocator.storageManager + ) + self.siteSettingService = siteSettingService ?? POSSiteSettingService( + credentials: credentials, + selectedSite: selectedSite, + appPasswordSupportState: appPasswordSupportState + ) } /// Checks the initial visibility of the POS tab without dependance on network requests. diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index c45795497ac..d638ea8fa46 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -86,6 +86,7 @@ final class HubMenuViewModel: ObservableObject { private let blazeEligibilityChecker: BlazeEligibilityCheckerProtocol private let googleAdsEligibilityChecker: GoogleAdsEligibilityChecker private let siteCIABEligibilityChecker: CIABEligibilityCheckerProtocol + private let appPasswordSupportState = ApplicationPasswordsExperimentState() private(set) lazy var inboxViewModel = InboxViewModel(siteID: siteID) @@ -111,8 +112,18 @@ final class HubMenuViewModel: ObservableObject { cardPresentPaymentsConfiguration: CardPresentConfigurationLoader().configuration, onboardingUseCase: CardPresentPaymentsOnboardingUseCase(), cardReaderSupportDeterminer: CardReaderSupportDeterminer(siteID: siteID), - wooPaymentsPayoutService: WooPaymentsPayoutService(siteID: siteID, - credentials: credentials))) + wooPaymentsPayoutService: WooPaymentsPayoutService( + siteID: siteID, + credentials: credentials, + selectedSite: stores.sessionManager.defaultSitePublisher + .map { $0?.toJetpackSite() } + .eraseToAnyPublisher(), + appPasswordSupportState: appPasswordSupportState + .$isAvailableAndEnabled + .eraseToAnyPublisher() + ) + ) + ) }() private(set) var cardPresentPaymentService: CardPresentPaymentFacade? diff --git a/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift b/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift index ae0efd0b632..29ea47fc75b 100644 --- a/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/JetpackSetup/JetpackSetupCoordinator.swift @@ -310,7 +310,7 @@ private extension JetpackSetupCoordinator { @MainActor func loadWPComAccountUsername(authToken: String) async -> String? { await withCheckedContinuation { continuation in - let network = AlamofireNetwork(credentials: Credentials(authToken: authToken)) + let network = AlamofireNetwork(credentials: Credentials(authToken: authToken), selectedSite: nil, appPasswordSupportState: nil) let accountAction = JetpackConnectionAction.loadWPComAccount(network: network) { account in continuation.resume(returning: account?.username) } diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index a3ad1c7c5d6..5177f8695a6 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -37,6 +37,10 @@ class AuthenticatedState: StoresManagerState { /// private(set) var posCatalogSyncCoordinator: POSCatalogSyncCoordinator? + // periphery:ignore - keep strong reference to keep the state publisher alive + private var appPasswordSupportStateHandler: ApplicationPasswordsExperimentState? + private var appPasswordSupportState: PassthroughSubject + /// Designated Initializer /// init(credentials: Credentials, sessionManager: SessionManagerProtocol) { @@ -46,7 +50,12 @@ class AuthenticatedState: StoresManagerState { .map { $0?.toJetpackSite() } .eraseToAnyPublisher() - self.network = AlamofireNetwork(credentials: credentials, selectedSite: site) + self.appPasswordSupportState = .init() + self.network = AlamofireNetwork( + credentials: credentials, + selectedSite: site, + appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher() + ) var services: [ActionsProcessor] = [ AppSettingsStore(dispatcher: dispatcher, @@ -147,8 +156,16 @@ class AuthenticatedState: StoresManagerState { // Initialize POS catalog sync coordinator if feature flag is enabled if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1), - let fullSyncService = POSCatalogFullSyncService(credentials: credentials, grdbManager: ServiceLocator.grdbManager), - let incrementalSyncService = POSCatalogIncrementalSyncService(credentials: credentials, grdbManager: ServiceLocator.grdbManager) { + let fullSyncService = POSCatalogFullSyncService(credentials: credentials, + selectedSite: site, + appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher(), + grdbManager: ServiceLocator.grdbManager), + let incrementalSyncService = POSCatalogIncrementalSyncService( + credentials: credentials, + selectedSite: site, + appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher(), + grdbManager: ServiceLocator.grdbManager + ) { let syncRemote = POSCatalogSyncRemote(network: network) let catalogSizeChecker = POSCatalogSizeChecker(syncRemote: syncRemote) posCatalogSyncCoordinator = POSCatalogSyncCoordinator( @@ -164,11 +181,7 @@ class AuthenticatedState: StoresManagerState { trackEventRequestNotificationHandler = TrackEventRequestNotificationHandler() startListeningToNotifications() - observeExperimentFeatureSettings() - - DispatchQueue.main.async { - self.checkApplicationPasswordExperimentFeatureState() - } + observeAppPasswordSupportState() } /// Convenience Initializer @@ -222,16 +235,6 @@ private extension AuthenticatedState { func tunnelTimeoutWasReceived(note: Notification) { ServiceLocator.analytics.track(.jetpackTunnelTimeout) } - - func checkApplicationPasswordExperimentFeatureState() { - Task { - let isAvailableAndEnabled = await ApplicationPasswordsExperimentState().isAvailableAndEnabled - - await MainActor.run { - network.updateAppPasswordSwitching(enabled: isAvailableAndEnabled) - } - } - } } @@ -248,19 +251,20 @@ private extension AuthenticatedState { } } -/// Observe beta experiment settings private extension AuthenticatedState { - func observeExperimentFeatureSettings() { - ServiceLocator - .generalAppSettings - .betaFeatureEnabledPublisher( - .applicationPasswords - ) - .dropFirst() - .removeDuplicates() - .sink { [weak self] _ in - self?.checkApplicationPasswordExperimentFeatureState() - } - .store(in: &cancellables) + func observeAppPasswordSupportState() { + DispatchQueue.main.async { [self] in + /// The state needs to be created on the main thread to avoid creating a new ServiceLocator.stores in a different thread. + /// Without this, race condition can happen. + let appPasswordSupportStateHandler = ApplicationPasswordsExperimentState() + self.appPasswordSupportStateHandler = appPasswordSupportStateHandler // strong ref to keep the stream alive + appPasswordSupportStateHandler + .$isAvailableAndEnabled + .receive(on: DispatchQueue.main) + .sink { [weak self] enabled in + self?.appPasswordSupportState.send(enabled) + } + .store(in: &cancellables) + } } } diff --git a/WooCommerce/Classes/Yosemite/DeauthenticatedState.swift b/WooCommerce/Classes/Yosemite/DeauthenticatedState.swift index d78bbccf276..90aaadebd17 100644 --- a/WooCommerce/Classes/Yosemite/DeauthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/DeauthenticatedState.swift @@ -15,7 +15,7 @@ class DeauthenticatedState: StoresManagerState { init() { // Used for logged-out state without a WPCOM auth token. - let network = AlamofireNetwork(credentials: nil) + let network = AlamofireNetwork(credentials: nil, selectedSite: nil, appPasswordSupportState: nil) services = [ JetpackConnectionStore(dispatcher: dispatcher), AccountCreationStore(dotcomClientID: ApiCredentials.dotcomAppId, diff --git a/WooCommerce/NotificationExtension/OrderNotificationDataService.swift b/WooCommerce/NotificationExtension/OrderNotificationDataService.swift index 7fe95472c55..a0d7ab1887d 100644 --- a/WooCommerce/NotificationExtension/OrderNotificationDataService.swift +++ b/WooCommerce/NotificationExtension/OrderNotificationDataService.swift @@ -26,7 +26,7 @@ final class OrderNotificationDataService { private let network: AlamofireNetwork init(credentials: Credentials) { - network = AlamofireNetwork(credentials: credentials) + network = AlamofireNetwork(credentials: credentials, selectedSite: nil, appPasswordSupportState: nil) // opt out from network switching ordersRemote = OrdersRemote(network: network) notesRemote = NotificationsRemote(network: network) } diff --git a/WooCommerce/StoreWidgets/StoreInfoDataService.swift b/WooCommerce/StoreWidgets/StoreInfoDataService.swift index b8d6729cf73..1855ebf1e2e 100644 --- a/WooCommerce/StoreWidgets/StoreInfoDataService.swift +++ b/WooCommerce/StoreWidgets/StoreInfoDataService.swift @@ -38,7 +38,7 @@ final class StoreInfoDataService { private let isAuthenticatedWithoutWPCom: Bool init(credentials: Credentials) { - network = AlamofireNetwork(credentials: credentials) + network = AlamofireNetwork(credentials: credentials, selectedSite: nil, appPasswordSupportState: nil) // opt out from network switching orderStatsRemoteV4 = OrderStatsRemoteV4(network: network) siteStatsRemote = SiteStatsRemote(network: network) if case .wpcom = credentials { diff --git a/WooCommerce/Woo Watch App/Orders/OrdersDataService.swift b/WooCommerce/Woo Watch App/Orders/OrdersDataService.swift index f4fbb07a33a..d9a29be8420 100644 --- a/WooCommerce/Woo Watch App/Orders/OrdersDataService.swift +++ b/WooCommerce/Woo Watch App/Orders/OrdersDataService.swift @@ -13,7 +13,7 @@ final class OrdersDataService { private let network: AlamofireNetwork init(credentials: Credentials) { - network = AlamofireNetwork(credentials: credentials) + network = AlamofireNetwork(credentials: credentials, selectedSite: nil, appPasswordSupportState: nil) // opt out from network switching ordersRemote = OrdersRemote(network: network) } diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift index 19a3162b953..bfae866df05 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleCouponsControllerTests.swift @@ -3,15 +3,21 @@ import Testing import Foundation import struct Yosemite.PointOfSaleCouponFetchStrategyFactory import class WooFoundation.CurrencySettings +import struct Yosemite.Site +import Combine struct PointOfSaleCouponsControllerTests { private let fetchStrategyFactory: PointOfSaleCouponFetchStrategyFactory init() { - fetchStrategyFactory = PointOfSaleCouponFetchStrategyFactory(siteID: 123, - currencySettings: CurrencySettings(), - credentials: nil, - storage: MockStorageManager()) + fetchStrategyFactory = PointOfSaleCouponFetchStrategyFactory( + siteID: 123, + currencySettings: CurrencySettings(), + credentials: nil, + selectedSite: Just(Site.fake()).map { $0.toJetpackSite() }.eraseToAnyPublisher(), + appPasswordSupportState: Just(false).eraseToAnyPublisher(), + storage: MockStorageManager() + ) } @Test func loadItems_when_empty_coupons_then_results_in_empty_state() async throws { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/ApplicationPasswordsExperimentStateTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/ApplicationPasswordsExperimentStateTests.swift index 79202d23308..5ac3b8978d1 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/ApplicationPasswordsExperimentStateTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/ApplicationPasswordsExperimentStateTests.swift @@ -1,11 +1,13 @@ import XCTest import Yosemite +import Combine @testable import WooCommerce final class ApplicationPasswordsExperimentStateTests: XCTestCase { private var sut: ApplicationPasswordsExperimentState! private var availabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerMock! private var stores: MockStoresManager! + private var cancellables: AnyCancellable? override func setUp() { super.setUp() @@ -18,10 +20,11 @@ final class ApplicationPasswordsExperimentStateTests: XCTestCase { sut = nil availabilityChecker = nil stores = nil + cancellables = nil super.tearDown() } - func test_when_available_and_enabled_then_isAvailableAndEnabled_returns_true() async { + func test_when_available_and_enabled_then_isAvailableAndEnabled_stream_returns_true() { // Given availabilityChecker.mockedAvailability = true stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in @@ -31,13 +34,19 @@ final class ApplicationPasswordsExperimentStateTests: XCTestCase { } // When - let result = await sut.isAvailableAndEnabled + var values: [Bool] = [] + cancellables = sut.$isAvailableAndEnabled + .sink { result in + values.append(result) + } // Then - XCTAssertTrue(result) + waitUntil { + values == [true, true] + } } - func test_when_available_and_disabled_then_isAvailableAndEnabled_returns_false() async { + func test_when_available_and_disabled_then_isAvailableAndEnabled_stream_returns_false() { // Given availabilityChecker.mockedAvailability = true stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in @@ -47,13 +56,19 @@ final class ApplicationPasswordsExperimentStateTests: XCTestCase { } // When - let result = await sut.isAvailableAndEnabled + var values: [Bool] = [] + cancellables = sut.$isAvailableAndEnabled + .sink { result in + values.append(result) + } // Then - XCTAssertFalse(result) + waitUntil { + values == [true, false] + } } - func test_when_unavailable_and_enabled_then_isAvailableAndEnabled_returns_false() async { + func test_when_unavailable_and_enabled_then_isAvailableAndEnabled_stream_returns_false() { // Given availabilityChecker.mockedAvailability = false stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in @@ -63,13 +78,19 @@ final class ApplicationPasswordsExperimentStateTests: XCTestCase { } // When - let result = await sut.isAvailableAndEnabled + var values: [Bool] = [] + cancellables = sut.$isAvailableAndEnabled + .sink { result in + values.append(result) + } // Then - XCTAssertFalse(result) + waitUntil { + values == [true, false] + } } - func test_when_unavailable_and_disabled_then_isAvailableAndEnabled_returns_false() async { + func test_when_unavailable_and_disabled_then_isAvailableAndEnabled_stream_returns_false() { // Given availabilityChecker.mockedAvailability = false stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in @@ -79,10 +100,16 @@ final class ApplicationPasswordsExperimentStateTests: XCTestCase { } // When - let result = await sut.isAvailableAndEnabled + var values: [Bool] = [] + cancellables = sut.$isAvailableAndEnabled + .sink { result in + values.append(result) + } // Then - XCTAssertFalse(result) + waitUntil { + values == [true, false] + } } }