Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import Combine
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we're using Combine here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, removed in b6854d4.

import Foundation
import UIKit
import class WooFoundation.CurrencySettings
import enum WooFoundation.CountryCode
import enum WooFoundation.CurrencyCode
import protocol Experiments.FeatureFlagService
import struct Yosemite.SiteSetting
import protocol Yosemite.POSEligibilityServiceProtocol
import protocol Yosemite.StoresManager
import class Yosemite.POSEligibilityService
import struct Yosemite.SystemPlugin
import enum Yosemite.FeatureFlagAction
import enum Yosemite.SettingAction
import protocol Yosemite.PluginsServiceProtocol
import class Yosemite.PluginsService

final class POSTabEligibilityCheckerI2: POSEntryPointEligibilityCheckerProtocol {
private let siteID: Int64
private let userInterfaceIdiom: UIUserInterfaceIdiom
private let siteSettings: SelectedSiteSettingsProtocol
private let pluginsService: PluginsServiceProtocol
private let eligibilityService: POSEligibilityServiceProtocol
private let stores: StoresManager
private let featureFlagService: FeatureFlagService

init(siteID: Int64,
userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom,
siteSettings: SelectedSiteSettingsProtocol = ServiceLocator.selectedSiteSettings,
pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager),
eligibilityService: POSEligibilityServiceProtocol = POSEligibilityService(),
stores: StoresManager = ServiceLocator.stores,
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
self.siteID = siteID
self.userInterfaceIdiom = userInterfaceIdiom
self.siteSettings = siteSettings
self.pluginsService = pluginsService
self.eligibilityService = eligibilityService
self.stores = stores
self.featureFlagService = featureFlagService
}

/// Checks the initial visibility of the POS tab without dependance on network requests.
func checkInitialVisibility() -> Bool {
eligibilityService.loadCachedPOSTabVisibility(siteID: siteID) ?? false
}

/// Determines whether the POS entry point can be shown based on the selected store and feature gates.
func checkEligibility() async -> POSEligibilityState {
guard #available(iOS 17.0, *) else {
return .ineligible(reason: .unsupportedIOSVersion)
}

async let siteSettingsEligibility = checkSiteSettingsEligibility()
async let pluginEligibility = checkPluginEligibility()

switch await siteSettingsEligibility {
case .eligible:
break
case .ineligible(let reason):
return .ineligible(reason: reason)
}

switch await pluginEligibility {
case .eligible:
return .eligible
case .ineligible(let reason):
return .ineligible(reason: reason)
}
}

/// Checks the final visibility of the POS tab.
func checkVisibility() async -> Bool {
guard userInterfaceIdiom == .pad else {
return false
}

async let siteSettingsEligibility = waitAndCheckSiteSettingsEligibility()
async let featureFlagEligibility = checkRemoteFeatureEligibility()

switch await siteSettingsEligibility {
case .ineligible(.unsupportedCountry):
return false
default:
break
}

return await featureFlagEligibility == .eligible
}
}

// MARK: - WC Plugin Related Eligibility Check

private extension POSTabEligibilityCheckerI2 {
func checkPluginEligibility() async -> POSEligibilityState {
let wcPlugin = await fetchWooCommercePlugin(siteID: siteID)

guard VersionHelpers.isVersionSupported(version: wcPlugin.version,
minimumRequired: Constants.wcPluginMinimumVersion) else {
return .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: Constants.wcPluginMinimumVersion))
}

// For versions below 10.0.0, the feature is enabled by default.
let isFeatureSwitchSupported = VersionHelpers.isVersionSupported(version: wcPlugin.version,
minimumRequired: Constants.wcPluginMinimumVersionWithFeatureSwitch,
includesDevAndBetaVersions: true)
if !isFeatureSwitchSupported {
return .eligible
}

// For versions that support the feature switch, checks if the feature switch is enabled.
return await checkFeatureSwitchEnabled(siteID: siteID)
}

@MainActor
func fetchWooCommercePlugin(siteID: Int64) async -> SystemPlugin {
await pluginsService.waitForPluginInStorage(siteID: siteID, pluginName: Constants.wcPluginName, isActive: true)
}

@MainActor
func checkFeatureSwitchEnabled(siteID: Int64) async -> POSEligibilityState {
await withCheckedContinuation { [weak self] continuation in
guard let self else {
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
}
let action = SettingAction.isFeatureEnabled(siteID: siteID, feature: .pointOfSale) { result in
switch result {
case .success(let isEnabled):
continuation.resume(returning: isEnabled ? .eligible : .ineligible(reason: .featureSwitchDisabled))
case .failure:
continuation.resume(returning: .ineligible(reason: .featureSwitchSyncFailure))
}
}
stores.dispatch(action)
}
}
}

// MARK: - Site Settings Related Eligibility Check

private extension POSTabEligibilityCheckerI2 {
enum SiteSettingsEligibilityState {
case eligible
case ineligible(reason: SiteSettingsIneligibleReason)
}

enum SiteSettingsIneligibleReason {
case siteSettingsNotAvailable
case unsupportedCountry(supportedCountries: [CountryCode])
case unsupportedCurrency(supportedCurrencies: [CurrencyCode])
}

func checkSiteSettingsEligibility() async -> POSEligibilityState {
let siteSettingsEligibility = await waitAndCheckSiteSettingsEligibility()
switch siteSettingsEligibility {
case .eligible:
return .eligible
case .ineligible(reason: let reason):
switch reason {
case .siteSettingsNotAvailable, .unsupportedCountry:
// This is an edge case where the store country is expected to be eligible from the visilibity check, but site settings might have
// changed to an unsupported country during the session. In this case, we return an ineligible reason that prompts the merchant to
// relaunch the app.
return .ineligible(reason: .siteSettingsNotAvailable)
case let .unsupportedCurrency(supportedCurrencies: supportedCurrencies):
return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrencies))
}
}
}

func waitAndCheckSiteSettingsEligibility() async -> SiteSettingsEligibilityState {
// Waits for the first site settings that matches the given site ID.
let siteSettings = await waitForSiteSettingsRefresh()
guard siteSettings.isNotEmpty else {
return .ineligible(reason: .siteSettingsNotAvailable)
}

// Conditions that can change if site settings are synced during the lifetime.
let countryCode = SiteAddress(siteSettings: siteSettings).countryCode
let currencyCode = CurrencySettings(siteSettings: siteSettings).currencyCode

return isEligibleFromCountryAndCurrencyCode(countryCode: countryCode, currencyCode: currencyCode)
}

func waitForSiteSettingsRefresh() async -> [SiteSetting] {
for await siteSettings in siteSettings.settingsStream.values {
guard siteSettings.siteID == siteID, siteSettings.settings.isNotEmpty, siteSettings.source != .initialLoad else {
continue
}
return siteSettings.settings
}
// If we get here, the stream completed without yielding any values for our site ID which is unexpected.
return []
}

func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> SiteSettingsEligibilityState {
let supportedCountries: [CountryCode] = [.US, .GB]
let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD],
.GB: [.GBP]]

// Checks country first.
guard supportedCountries.contains(countryCode) else {
return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries))
}

let supportedCurrenciesForCountry = supportedCurrencies[countryCode] ?? []
guard supportedCurrenciesForCountry.contains(currencyCode) else {
return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrenciesForCountry))
}
return .eligible
}
}

// MARK: - Remote Feature Flag Eligibility Check

private extension POSTabEligibilityCheckerI2 {
enum RemoteFeatureFlagEligibilityState: Equatable {
case eligible
case ineligible(reason: RemoteFeatureFlagIneligibleReason)
}

enum RemoteFeatureFlagIneligibleReason: Equatable {
case selfDeallocated
case featureFlagDisabled
}

@MainActor
func checkRemoteFeatureEligibility() async -> RemoteFeatureFlagEligibilityState {
// Only whitelisted accounts in WPCOM have the Point of Sale remote feature flag enabled. These can be found at D159901-code
// If the account is whitelisted, then the remote value takes preference over the local feature flag configuration
await withCheckedContinuation { [weak self] continuation in
guard let self else {
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
}
let action = FeatureFlagAction.isRemoteFeatureFlagEnabled(.pointOfSale, defaultValue: false) { [weak self] result in
guard let self else {
return continuation.resume(returning: .ineligible(reason: .selfDeallocated))
}
switch result {
case true:
// The site is whitelisted.
continuation.resume(returning: .eligible)
case false:
// When the site is not whitelisted, check the local feature flag configuration.
let localFeatureFlag = featureFlagService.isFeatureFlagEnabled(.pointOfSale)
continuation.resume(returning: localFeatureFlag ? .eligible : .ineligible(reason: .featureFlagDisabled))
}
}
self.stores.dispatch(action)
}
}
}

private extension POSTabEligibilityCheckerI2 {
enum Constants {
static let wcPluginName = "WooCommerce"
static let wcPluginMinimumVersion = "9.6.0-beta"
static let wcPluginMinimumVersionWithFeatureSwitch = "10.0.0"
}
}
15 changes: 12 additions & 3 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,20 +149,29 @@ final class MainTabBarController: UITabBarController {
self.analytics = analytics
self.stores = stores
self.posEligibilityCheckerFactory = posEligibilityCheckerFactory ?? { siteID in
POSTabEligibilityChecker(siteID: siteID)
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) {
POSTabEligibilityCheckerI2(siteID: siteID)
} else {
POSTabEligibilityChecker(siteID: siteID)
}
}
self.posEligibilityService = posEligibilityService
super.init(coder: coder)
}

required init?(coder: NSCoder) {
self.featureFlagService = ServiceLocator.featureFlagService
let featureFlagService = ServiceLocator.featureFlagService
self.featureFlagService = featureFlagService
self.noticePresenter = ServiceLocator.noticePresenter
self.productImageUploader = ServiceLocator.productImageUploader
self.analytics = ServiceLocator.analytics
self.stores = ServiceLocator.stores
self.posEligibilityCheckerFactory = { siteID in
POSTabEligibilityChecker(siteID: siteID)
if featureFlagService.isFeatureFlagEnabled(.pointOfSaleAsATabi2) {
POSTabEligibilityCheckerI2(siteID: siteID)
} else {
POSTabEligibilityChecker(siteID: siteID)
}
}
self.posEligibilityService = POSEligibilityService()
super.init(coder: coder)
Expand Down
8 changes: 8 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@
025678052575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678042575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift */; };
025678C125773236009D7E6C /* Collection+ShippingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C025773236009D7E6C /* Collection+ShippingLabel.swift */; };
025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */; };
0256DD0A2E1706B5002FB998 /* POSTabEligibilityCheckerI2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */; };
0256DD0C2E170C46002FB998 /* POSTabEligibilityCheckerI2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */; };
02577A7F2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */; };
0258B4D82B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */; };
0258B4DA2B159A0F008FEA07 /* Publisher+WithPrevious.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */; };
Expand Down Expand Up @@ -3449,6 +3451,8 @@
025678042575EA1B009D7E6C /* ProductDetailsCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsCellViewModelTests.swift; sourceTree = "<group>"; };
025678C025773236009D7E6C /* Collection+ShippingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabel.swift"; sourceTree = "<group>"; };
025678C625773399009D7E6C /* Collection+ShippingLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+ShippingLabelTests.swift"; sourceTree = "<group>"; };
0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabEligibilityCheckerI2.swift; sourceTree = "<group>"; };
0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSTabEligibilityCheckerI2Tests.swift; sourceTree = "<group>"; };
02577A7E2BFC4BB300B63FE6 /* PaymentMethodsWrapperHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsWrapperHostingController.swift; sourceTree = "<group>"; };
0258B4D72B1590A3008FEA07 /* ConfigurableBundleNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurableBundleNoticeView.swift; sourceTree = "<group>"; };
0258B4D92B159A0F008FEA07 /* Publisher+WithPrevious.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+WithPrevious.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6788,6 +6792,7 @@
children = (
023BD58A2BFDCFCB00A10D7B /* POSEligibilityCheckerTests.swift */,
0277889D2DF928E3006F5B8C /* POSTabEligibilityCheckerTests.swift */,
0256DD0B2E170C42002FB998 /* POSTabEligibilityCheckerI2Tests.swift */,
);
path = POS;
sourceTree = "<group>";
Expand Down Expand Up @@ -7753,6 +7758,7 @@
02E4A0842BFB1D1F006D4F87 /* POS */ = {
isa = PBXGroup;
children = (
0256DD092E1706B2002FB998 /* POSTabEligibilityCheckerI2.swift */,
026B2D162DF92290005B8CAA /* POSTabEligibilityChecker.swift */,
02E4A0822BFB1C4F006D4F87 /* POSEligibilityChecker.swift */,
);
Expand Down Expand Up @@ -15951,6 +15957,7 @@
CE35F11B2343F3B1007B2A6B /* TwoColumnHeadlineFootnoteTableViewCell.swift in Sources */,
B9D19A422AE7B4AD00D944D8 /* CustomAmountRowViewModel.swift in Sources */,
D8C251DB230D288A00F49782 /* PushNotesManager.swift in Sources */,
0256DD0A2E1706B5002FB998 /* POSTabEligibilityCheckerI2.swift in Sources */,
09468D9027D5014E0054A751 /* BulkUpdatePriceViewController.swift in Sources */,
024124842AC54C3D0035A247 /* ConfigurableBundleItemView.swift in Sources */,
0279F0DA252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift in Sources */,
Expand Down Expand Up @@ -17318,6 +17325,7 @@
26B3EC622744772A0075EAE6 /* SimplePaymentsSummaryViewModelTests.swift in Sources */,
D85DD1D7257F359800861AA8 /* NotWPErrorViewModelTests.swift in Sources */,
DE3877E4283E35E80075D87E /* DiscountTypeBottomSheetListSelectorCommandTests.swift in Sources */,
0256DD0C2E170C46002FB998 /* POSTabEligibilityCheckerI2Tests.swift in Sources */,
025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */,
02BC5AA624D27F8900C43326 /* ProductVariationFormViewModel+ChangesTests.swift in Sources */,
02CD3BFE2C35D04C00E575C4 /* MockCardPresentPaymentService.swift in Sources */,
Expand Down
Loading
Loading