diff --git a/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift index a455cdc212c..5bf17686464 100644 --- a/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift @@ -57,6 +57,7 @@ extension Storage.GeneralAppSettings { installationDate: NullableCopiableProp = .copy, feedbacks: CopiableProp<[FeedbackType: FeedbackSettings]> = .copy, isViewAddOnsSwitchEnabled: CopiableProp = .copy, + isApplicationPasswordsSwitchEnabled: CopiableProp = .copy, knownCardReaders: CopiableProp<[String]> = .copy, lastEligibilityErrorInfo: NullableCopiableProp = .copy, lastJetpackBenefitsBannerDismissedTime: NullableCopiableProp = .copy, @@ -68,6 +69,7 @@ extension Storage.GeneralAppSettings { let installationDate = installationDate ?? self.installationDate let feedbacks = feedbacks ?? self.feedbacks let isViewAddOnsSwitchEnabled = isViewAddOnsSwitchEnabled ?? self.isViewAddOnsSwitchEnabled + let isApplicationPasswordsSwitchEnabled = isApplicationPasswordsSwitchEnabled ?? self.isApplicationPasswordsSwitchEnabled let knownCardReaders = knownCardReaders ?? self.knownCardReaders let lastEligibilityErrorInfo = lastEligibilityErrorInfo ?? self.lastEligibilityErrorInfo let lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime ?? self.lastJetpackBenefitsBannerDismissedTime @@ -80,6 +82,7 @@ extension Storage.GeneralAppSettings { installationDate: installationDate, feedbacks: feedbacks, isViewAddOnsSwitchEnabled: isViewAddOnsSwitchEnabled, + isApplicationPasswordsSwitchEnabled: isApplicationPasswordsSwitchEnabled, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, lastJetpackBenefitsBannerDismissedTime: lastJetpackBenefitsBannerDismissedTime, diff --git a/Modules/Sources/Storage/Model/GeneralAppSettings.swift b/Modules/Sources/Storage/Model/GeneralAppSettings.swift index 6c10b6fd1a8..09bc1f56357 100644 --- a/Modules/Sources/Storage/Model/GeneralAppSettings.swift +++ b/Modules/Sources/Storage/Model/GeneralAppSettings.swift @@ -24,6 +24,10 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { /// public var isViewAddOnsSwitchEnabled: Bool + /// The state(`true` or `false`) for the application passwords feature switch. + /// + public var isApplicationPasswordsSwitchEnabled: Bool + /// A list (possibly empty) of known card reader IDs - i.e. IDs of card readers that should be reconnected to automatically /// e.g. ["CHB204909005931"] /// @@ -55,6 +59,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { public init(installationDate: Date?, feedbacks: [FeedbackType: FeedbackSettings], isViewAddOnsSwitchEnabled: Bool, + isApplicationPasswordsSwitchEnabled: Bool, knownCardReaders: [String], lastEligibilityErrorInfo: EligibilityErrorInfo? = nil, lastJetpackBenefitsBannerDismissedTime: Date? = nil, @@ -65,6 +70,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { self.installationDate = installationDate self.feedbacks = feedbacks self.isViewAddOnsSwitchEnabled = isViewAddOnsSwitchEnabled + self.isApplicationPasswordsSwitchEnabled = isApplicationPasswordsSwitchEnabled self.knownCardReaders = knownCardReaders self.lastEligibilityErrorInfo = lastEligibilityErrorInfo self.lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime @@ -78,6 +84,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { .init(installationDate: nil, feedbacks: [:], isViewAddOnsSwitchEnabled: false, + isApplicationPasswordsSwitchEnabled: true, knownCardReaders: [], lastEligibilityErrorInfo: nil, featureAnnouncementCampaignSettings: [:], @@ -107,6 +114,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { installationDate: installationDate, feedbacks: updatedFeedbacks, isViewAddOnsSwitchEnabled: isViewAddOnsSwitchEnabled, + isApplicationPasswordsSwitchEnabled: isApplicationPasswordsSwitchEnabled, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, featureAnnouncementCampaignSettings: featureAnnouncementCampaignSettings, @@ -127,6 +135,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { installationDate: installationDate, feedbacks: feedbacks, isViewAddOnsSwitchEnabled: isViewAddOnsSwitchEnabled, + isApplicationPasswordsSwitchEnabled: isApplicationPasswordsSwitchEnabled, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, featureAnnouncementCampaignSettings: updatedSettings, @@ -147,6 +156,7 @@ extension GeneralAppSettings { self.installationDate = try container.decodeIfPresent(Date.self, forKey: .installationDate) self.feedbacks = try container.decodeIfPresent([FeedbackType: FeedbackSettings].self, forKey: .feedbacks) ?? [:] self.isViewAddOnsSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isViewAddOnsSwitchEnabled) ?? false + self.isApplicationPasswordsSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isApplicationPasswordsSwitchEnabled) ?? false self.knownCardReaders = try container.decodeIfPresent([String].self, forKey: .knownCardReaders) ?? [] self.lastEligibilityErrorInfo = try container.decodeIfPresent(EligibilityErrorInfo.self, forKey: .lastEligibilityErrorInfo) self.lastJetpackBenefitsBannerDismissedTime = try container.decodeIfPresent(Date.self, forKey: .lastJetpackBenefitsBannerDismissedTime) diff --git a/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift b/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift index 69d99dfa6f4..4b62544136b 100644 --- a/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift +++ b/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift @@ -356,4 +356,14 @@ public enum AppSettingsAction: Action { /// Loads the favorite products. /// case loadFavoriteProductIDs(siteID: Int64, onCompletion: ([Int64]) -> Void) + + // MARK: - Application passwords Experiment feature + + /// Sets the state of the App Passwords Experiment feature + /// + case setAppPasswordsExperimentSettingState(isOn: Bool, onCompletion: (Result) -> Void) + + /// Loads Loads the state of the App Passwords Experiment feature + /// + case getAppPasswordsExperimentSettingState(onCompletion: (Bool) -> Void) } diff --git a/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift b/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift index 7b9ba97f944..10963d12b5f 100644 --- a/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift +++ b/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift @@ -289,6 +289,10 @@ public class AppSettingsStore: Store { dismissCustomFieldsTopBanner(onCompletion: onCompletion) case .loadCustomFieldsTopBannerDismissState(let onCompletion): loadCustomFieldsTopBannerDismissState(onCompletion: onCompletion) + case .setAppPasswordsExperimentSettingState(let value, let onCompletion): + setAppPasswordsExperimentSettingEnabled(isOn: value, onCompletion: onCompletion) + case .getAppPasswordsExperimentSettingState(let onCompletion): + getAppPasswordsExperimentSettingEnabled(onCompletion: onCompletion) } } } @@ -1269,6 +1273,23 @@ private extension AppSettingsStore { } } +// MARK: - Application Passwords Experiment Feature +// +private extension AppSettingsStore { + func setAppPasswordsExperimentSettingEnabled(isOn: Bool, onCompletion: (Result) -> Void) { + do { + try generalAppSettings.setValue(isOn, for: \.isApplicationPasswordsSwitchEnabled) + onCompletion(.success(())) + } catch { + onCompletion(.failure(error)) + } + } + + func getAppPasswordsExperimentSettingEnabled(onCompletion: (Bool) -> Void) { + onCompletion(generalAppSettings.value(for: \.isApplicationPasswordsSwitchEnabled)) + } +} + // MARK: - Errors /// Errors diff --git a/Modules/Tests/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift b/Modules/Tests/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift index 952d3dc8ff9..a19ab4b1233 100644 --- a/Modules/Tests/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift +++ b/Modules/Tests/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift @@ -66,6 +66,7 @@ final class GeneralAppSettingsTests: XCTestCase { let previousSettings = GeneralAppSettings(installationDate: installationDate, feedbacks: feedbackSettings, isViewAddOnsSwitchEnabled: true, + isApplicationPasswordsSwitchEnabled: false, knownCardReaders: readers, lastEligibilityErrorInfo: eligibilityInfo, lastJetpackBenefitsBannerDismissedTime: jetpackBannerDismissedDate, @@ -114,6 +115,7 @@ private extension GeneralAppSettingsTests { GeneralAppSettings(installationDate: installationDate, feedbacks: feedbacks, isViewAddOnsSwitchEnabled: isViewAddOnsSwitchEnabled, + isApplicationPasswordsSwitchEnabled: false, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, lastJetpackBenefitsBannerDismissedTime: lastJetpackBenefitsBannerDismissedTime, diff --git a/Modules/Tests/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift b/Modules/Tests/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift index 9bb98fa7e14..96cce9ca379 100644 --- a/Modules/Tests/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift @@ -239,6 +239,7 @@ private extension InAppFeedbackCardVisibilityUseCaseTests { installationDate: installationDate, feedbacks: [feedback.name: feedback], isViewAddOnsSwitchEnabled: false, + isApplicationPasswordsSwitchEnabled: false, knownCardReaders: [], featureAnnouncementCampaignSettings: [:], sitesWithAtLeastOneIPPTransactionFinished: [], diff --git a/Modules/Tests/YosemiteTests/Stores/AppSettingsStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/AppSettingsStoreTests.swift index 5b00cc9f682..93c9bd9ec9a 100644 --- a/Modules/Tests/YosemiteTests/Stores/AppSettingsStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/AppSettingsStoreTests.swift @@ -1522,6 +1522,7 @@ private extension AppSettingsStoreTests { installationDate: installationDate, feedbacks: [feedback.name: feedback], isViewAddOnsSwitchEnabled: false, + isApplicationPasswordsSwitchEnabled: false, knownCardReaders: [], featureAnnouncementCampaignSettings: [:], sitesWithAtLeastOneIPPTransactionFinished: [], @@ -1536,6 +1537,7 @@ private extension AppSettingsStoreTests { installationDate: Date(), feedbacks: [:], isViewAddOnsSwitchEnabled: false, + isApplicationPasswordsSwitchEnabled: false, knownCardReaders: [], featureAnnouncementCampaignSettings: featureAnnouncementCampaignSettings, sitesWithAtLeastOneIPPTransactionFinished: [], diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift index d5c73ef4202..4f6f4b33616 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift @@ -299,6 +299,7 @@ enum WooAnalyticsStat: String { case settingsBetaFeaturesButtonTapped = "settings_beta_features_button_tapped" case settingsBetaFeaturesProductsToggled = "settings_beta_features_products_toggled" case settingsBetaFeaturesOrderAddOnsToggled = "settings_beta_features_order_addons_toggled" + case settingsBetaFeaturesApplicationPasswordsToggled = "settings_beta_features_application_passwords_toggled" case settingsBetaFeatureToggled = "settings_beta_feature_toggled" case settingsPrivacySettingsTapped = "settings_privacy_settings_button_tapped" diff --git a/WooCommerce/Classes/Extensions/AttributedString+Helpers.swift b/WooCommerce/Classes/Extensions/AttributedString+Helpers.swift index b2bc2cefbe4..f8e10da1e5f 100644 --- a/WooCommerce/Classes/Extensions/AttributedString+Helpers.swift +++ b/WooCommerce/Classes/Extensions/AttributedString+Helpers.swift @@ -16,12 +16,19 @@ extension AttributedString { static func withEmbeddedLink( mainContent: String, linkText: String, - link: String + link: String, + font: Font? = .body, + foregroundColor: Color? = Color(uiColor: .text) ) -> AttributedString { let content = String.localizedStringWithFormat(mainContent, linkText) var attributedText = AttributedString(content) - attributedText.font = .body - attributedText.foregroundColor = Color(uiColor: .text) + + if let font { + attributedText.font = font + } + if let foregroundColor { + attributedText.foregroundColor = foregroundColor + } if let range = attributedText.range(of: linkText), let url = URL(string: link) { diff --git a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift index 9530c158d83..f0ad820d4ea 100644 --- a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift +++ b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift @@ -70,6 +70,9 @@ extension UserDefaults { // Hide stores from store picker case hiddenStoreIDs + + // Application passwords experiment remote FF cached value + case applicationPasswordsExperimentRemoteFFValue } } diff --git a/WooCommerce/Classes/Model/BetaFeature.swift b/WooCommerce/Classes/Model/BetaFeature.swift index 7a1723a5026..65eed07cf3d 100644 --- a/WooCommerce/Classes/Model/BetaFeature.swift +++ b/WooCommerce/Classes/Model/BetaFeature.swift @@ -5,6 +5,7 @@ import protocol WooFoundation.WooAnalyticsEventPropertyType enum BetaFeature: String, CaseIterable { case viewAddOns + case applicationPasswords } extension BetaFeature { @@ -12,6 +13,8 @@ extension BetaFeature { switch self { case .viewAddOns: return Localization.viewAddOnsTitle + case .applicationPasswords: + return Localization.applicationPasswordsTitle } } @@ -19,6 +22,8 @@ extension BetaFeature { switch self { case .viewAddOns: return Localization.viewAddOnsDescription + case .applicationPasswords: + return Localization.applicationPasswordsDescription } } @@ -26,6 +31,8 @@ extension BetaFeature { switch self { case .viewAddOns: return \.isViewAddOnsSwitchEnabled + case .applicationPasswords: + return \.isApplicationPasswordsSwitchEnabled } } @@ -35,6 +42,8 @@ extension BetaFeature { switch self { case .viewAddOns: return .settingsBetaFeaturesOrderAddOnsToggled + case .applicationPasswords: + return .settingsBetaFeaturesApplicationPasswordsToggled } } @@ -47,6 +56,26 @@ extension BetaFeature { } } +extension BetaFeature { + typealias DescriptionLink = (text: String, url: URL) + + var descriptionLink: DescriptionLink? { + switch self { + case .viewAddOns: + return nil + case .applicationPasswords: + guard let url = URL(string: Constants.applicationPasswordsDocURL) else { + return nil + } + + return DescriptionLink( + text: Localization.applicationPasswordsDescriptionLinkText, + url: url + ) + } + } +} + extension GeneralAppSettingsStorage { func betaFeatureEnabled(_ feature: BetaFeature) -> Bool { value(for: feature.settingsKey) @@ -86,5 +115,26 @@ private extension BetaFeature { static let viewAddOnsDescription = NSLocalizedString( "Test out viewing Order Add-Ons as we get ready to launch", comment: "Cell description on the beta features screen to enable the order add-ons feature") + + static let applicationPasswordsTitle = NSLocalizedString( + "experimentalFeatures.applicationPasswords.title", + value: "Application Passwords", + comment: "Cell title on the beta features screen to enable the application passwords feature") + static let applicationPasswordsDescription = NSLocalizedString( + "experimentalFeatures.applicationPasswords.description", + value: "Enable %@ to let the app fetch data directly from your WooCommerce site rather than via Jetpack connections", + comment: "Cell description on the beta features screen to enable application passwords feature. The placeholder will be replaced by a link title." + ) + + static let applicationPasswordsDescriptionLinkText = NSLocalizedString( + "experimentalFeatures.applicationPasswords.description.linkText", + value: "Application Passwords", + comment: "Link text to open Application Passwords documentation page" + ) + } + + enum Constants { + static let applicationPasswordsDocURL = + "https://wordpress.com/support/security/two-step-authentication/application-specific-passwords/" } } diff --git a/WooCommerce/Classes/System/SessionManager.swift b/WooCommerce/Classes/System/SessionManager.swift index 5da78e2e235..51bd8dec01c 100644 --- a/WooCommerce/Classes/System/SessionManager.swift +++ b/WooCommerce/Classes/System/SessionManager.swift @@ -226,6 +226,7 @@ final class SessionManager: SessionManagerProtocol { defaults[.wpcomSiteSuspended] = nil defaults[.tapToPayAwarenessMomentFirstLaunchCompleted] = nil defaults[.applicationPasswordUnsupportedList] = nil + defaults[.applicationPasswordsExperimentRemoteFFValue] = nil resetTimestampsValues() imageCache.clearCache() } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/ApplicationPasswordsExperimentState.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/ApplicationPasswordsExperimentState.swift new file mode 100644 index 00000000000..1ad27581851 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/ApplicationPasswordsExperimentState.swift @@ -0,0 +1,70 @@ +import Foundation +import Yosemite + +final class ApplicationPasswordsExperimentState { + private let stores: StoresManager + private let availabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerProtocol + + init( + stores: StoresManager = ServiceLocator.stores, + availabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerProtocol = ApplicationPasswordsExperimentAvailabilityChecker() + ) { + self.stores = stores + self.availabilityChecker = availabilityChecker + } + + var isAvailableAndEnabled: Bool { + get async { + let isAvailable = await availabilityChecker.fetchAvailability() + let isEnabled = await isEnabled + return isAvailable && isEnabled + } + } + + @MainActor + private var isEnabled: Bool { + get async { + return await withCheckedContinuation { continuation in + stores.dispatch( + AppSettingsAction.getAppPasswordsExperimentSettingState { isOn in + continuation.resume(with: .success(isOn)) + } + ) + } + } + } +} + +protocol ApplicationPasswordsExperimentAvailabilityCheckerProtocol { + var cachedValue: Bool { get } + func fetchAvailability() async -> Bool +} + +final class ApplicationPasswordsExperimentAvailabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerProtocol { + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + var cachedValue: Bool { + get { + userDefaults[.applicationPasswordsExperimentRemoteFFValue] ?? false + } set { + userDefaults[.applicationPasswordsExperimentRemoteFFValue] = newValue + } + } + + func fetchAvailability() async -> Bool { + await withCheckedContinuation { continuation in + //TODO: - put the remote FF checking here + let mockResultValue = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + continuation.resume(returning: mockResultValue) + } + + cachedValue = mockResultValue + } + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift index da2c77b3390..c435cd7a9ae 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfiguration.swift @@ -14,6 +14,7 @@ final class BetaFeaturesConfigurationViewController: UIHostingController Binding { appSettings.betaFeatureEnabledBinding(feature) } } + +private extension BetaFeaturesConfigurationViewModel { + func isVisible(feature: BetaFeature) -> Bool { + switch feature { + case .viewAddOns: + return true + case .applicationPasswords: + return appPasswordsExperimentAvailabilityChecker.cachedValue + } + } + + func setupInitialFeaturesVisibility() { + availableFeatures = betaFeatures.filter { betaFeature in + isVisible(feature: betaFeature) + } + } + + func updateFeaturesAvailability() { + Task { + let fetchedAvailableFeatures = await fetchFeaturesAvailability() + + await MainActor.run { + availableFeatures = fetchedAvailableFeatures + } + } + } + + func fetchFeaturesAvailability() async -> [BetaFeature] { + var results = [BetaFeature]() + for feature in betaFeatures { + switch feature { + case .viewAddOns: + results.append(feature) + case .applicationPasswords: + if await appPasswordsExperimentAvailabilityChecker.fetchAvailability() { + results.append(feature) + } + } + } + + return results + } +} diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 3d53e1d2682..7b5fbe82627 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -2,6 +2,7 @@ import Foundation import Yosemite import Networking import Storage +import Combine // MARK: - AuthenticatedState // @@ -25,13 +26,14 @@ class AuthenticatedState: StoresManagerState { private let network: AlamofireNetwork + private var cancellables: Set = [] + /// Designated Initializer /// init(credentials: Credentials, sessionManager: SessionManagerProtocol) { let storageManager = ServiceLocator.storageManager let site = sessionManager.defaultSitePublisher - .prepend(sessionManager.defaultSite) // needed to emit the initial value upon subscription .map { $0?.toJetpackSite() } .eraseToAnyPublisher() @@ -137,7 +139,11 @@ class AuthenticatedState: StoresManagerState { trackEventRequestNotificationHandler = TrackEventRequestNotificationHandler() startListeningToNotifications() - checkApplicationPasswordExperimentFeatureFlag() + observeExperimentFeatureSettings() + + DispatchQueue.main.async { + self.checkApplicationPasswordExperimentFeatureState() + } } /// Convenience Initializer @@ -192,11 +198,14 @@ private extension AuthenticatedState { ServiceLocator.analytics.track(.jetpackTunnelTimeout) } - /// Uses local feature flag for development phase. - /// TODO: Switch to remote feature flag before release. - func checkApplicationPasswordExperimentFeatureFlag() { - let enabled = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.applicationPasswordExperiment) - network.updateAppPasswordSwitching(enabled: enabled) + func checkApplicationPasswordExperimentFeatureState() { + Task { + let isAvailableAndEnabled = await ApplicationPasswordsExperimentState().isAvailableAndEnabled + + await MainActor.run { + network.updateAppPasswordSwitching(enabled: isAvailableAndEnabled) + } + } } } @@ -213,3 +222,20 @@ private extension AuthenticatedState { resetGeneralStoreSettings]) } } + +/// Observe beta experiment settings +private extension AuthenticatedState { + func observeExperimentFeatureSettings() { + ServiceLocator + .generalAppSettings + .betaFeatureEnabledPublisher( + .applicationPasswords + ) + .dropFirst() + .removeDuplicates() + .sink { [weak self] _ in + self?.checkApplicationPasswordExperimentFeatureState() + } + .store(in: &cancellables) + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index cc02e4233ae..567757393c5 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1229,6 +1229,8 @@ 26FE09E124DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26FE09E024DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift */; }; 26FFC50C2BED7C5A0067B3A4 /* WatchDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B249702BEC801400730730 /* WatchDependencies.swift */; }; 26FFC50D2BED7C5B0067B3A4 /* WatchDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B249702BEC801400730730 /* WatchDependencies.swift */; }; + 2D09E0D12E61BC7F005C26F3 /* ApplicationPasswordsExperimentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D09E0D02E61BC7D005C26F3 /* ApplicationPasswordsExperimentState.swift */; }; + 2D09E0D52E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D09E0D42E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift */; }; 2D880B492DFB2F3F00A6FB2C /* OptionalBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D880B482DFB2F3D00A6FB2C /* OptionalBinding.swift */; }; 2D88C1112DF883C300A6FB2C /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D88C1102DF883BD00A6FB2C /* AttributedString+Helpers.swift */; }; 2DB877522E25466C0001B175 /* ShippingItemRowAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB877512E25466B0001B175 /* ShippingItemRowAccessibility.swift */; }; @@ -4413,6 +4415,8 @@ 26FE09E024DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyCoordinatorControllerTests.swift; sourceTree = ""; }; 26FFD32628C6A0A4002E5E5E /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 26FFD32928C6A0F4002E5E5E /* UIImage+Widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Widgets.swift"; sourceTree = ""; }; + 2D09E0D02E61BC7D005C26F3 /* ApplicationPasswordsExperimentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordsExperimentState.swift; sourceTree = ""; }; + 2D09E0D42E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordsExperimentStateTests.swift; sourceTree = ""; }; 2D880B482DFB2F3D00A6FB2C /* OptionalBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalBinding.swift; sourceTree = ""; }; 2D88C1102DF883BD00A6FB2C /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = ""; }; 2DB877512E25466B0001B175 /* ShippingItemRowAccessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingItemRowAccessibility.swift; sourceTree = ""; }; @@ -7825,6 +7829,7 @@ 02D4564A231D059E008CF0A9 /* Beta features */ = { isa = PBXGroup; children = ( + 2D09E0D02E61BC7D005C26F3 /* ApplicationPasswordsExperimentState.swift */, E1E649EA28461EDF0070B194 /* BetaFeaturesConfiguration.swift */, 023BD5832BFDCBF800A10D7B /* BetaFeaturesConfigurationViewModel.swift */, ); @@ -11522,6 +11527,7 @@ BA143222273662DE00E4B3AB /* Settings */ = { isa = PBXGroup; children = ( + 2D09E0D42E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift */, 023BD5892BFDCF9500A10D7B /* POS */, BAFEF51D273C2151005F94CC /* SettingsViewModelTests.swift */, 02AA586528531D0E0068B6F0 /* CloseAccountCoordinatorTests.swift */, @@ -16115,6 +16121,7 @@ EECB6D1E2AFBFE0000040BC9 /* WooSubscriptionProductsEligibilityChecker.swift in Sources */, CEA455C72BB5CA5E00D932CF /* AnalyticsSessionsUnavailableCard.swift in Sources */, EE45E29D2A381A250085F227 /* ProductDescriptionGenerationCelebrationView.swift in Sources */, + 2D09E0D12E61BC7F005C26F3 /* ApplicationPasswordsExperimentState.swift in Sources */, 20C3DB232E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupViews.swift in Sources */, 20C3DB242E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetupFlowManager.swift in Sources */, 20C3DB252E1E69CF00CF7D3B /* PointOfSaleBarcodeScannerSetup.swift in Sources */, @@ -17736,6 +17743,7 @@ 0247F510286E7D26009C177E /* ProductVariationFormViewModel+ImageUploaderTests.swift in Sources */, 020B2F9123BDD71500BD79AD /* IntegerInputFormatterTests.swift in Sources */, DECEA4492C81C1A800C28C10 /* ProductImagePickerViewModelTests.swift in Sources */, + 2D09E0D52E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift in Sources */, D816DDBC22265DA300903E59 /* OrderTrackingTableViewCellTests.swift in Sources */, 579CDF01274D811D00E8903D /* StoreStatsUsageTracksEventEmitterTests.swift in Sources */, CE4AFE482CD239B90013C52B /* WooShippingPostPurchaseViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/ApplicationPasswordsExperimentStateTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/ApplicationPasswordsExperimentStateTests.swift new file mode 100644 index 00000000000..7138f0a6d0e --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/ApplicationPasswordsExperimentStateTests.swift @@ -0,0 +1,99 @@ +import XCTest +import Yosemite +@testable import WooCommerce + +final class ApplicationPasswordsExperimentStateTests: XCTestCase { + private var sut: ApplicationPasswordsExperimentState! + private var availabilityChecker: ApplicationPasswordsExperimentAvailabilityCheckerMock! + private var stores: MockStoresManager! + + override func setUp() { + super.setUp() + availabilityChecker = ApplicationPasswordsExperimentAvailabilityCheckerMock() + stores = MockStoresManager(sessionManager: .makeForTesting()) + sut = ApplicationPasswordsExperimentState(stores: stores, availabilityChecker: availabilityChecker) + } + + override func tearDown() { + sut = nil + availabilityChecker = nil + stores = nil + super.tearDown() + } + + func test_when_available_and_enabled_then_isAvailableAndEnabled_returns_true() async { + // Given + availabilityChecker.mockedAvailability = true + stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in + if case let .getAppPasswordsExperimentSettingState(onCompletion) = action { + onCompletion(true) + } + } + + // When + let result = await sut.isAvailableAndEnabled + + // Then + XCTAssertTrue(result) + } + + func test_when_available_and_disabled_then_isAvailableAndEnabled_returns_false() async { + // Given + availabilityChecker.mockedAvailability = true + stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in + if case let .getAppPasswordsExperimentSettingState(onCompletion) = action { + onCompletion(false) + } + } + + // When + let result = await sut.isAvailableAndEnabled + + // Then + XCTAssertFalse(result) + } + + func test_when_unavailable_and_enabled_then_isAvailableAndEnabled_returns_false() async { + // Given + availabilityChecker.mockedAvailability = false + stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in + if case let .getAppPasswordsExperimentSettingState(onCompletion) = action { + onCompletion(true) + } + } + + // When + let result = await sut.isAvailableAndEnabled + + // Then + XCTAssertFalse(result) + } + + func test_when_unavailable_and_disabled_then_isAvailableAndEnabled_returns_false() async { + // Given + availabilityChecker.mockedAvailability = false + stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in + if case let .getAppPasswordsExperimentSettingState(onCompletion) = action { + onCompletion(false) + } + } + + // When + let result = await sut.isAvailableAndEnabled + + // Then + XCTAssertFalse(result) + } +} + +private final class ApplicationPasswordsExperimentAvailabilityCheckerMock: ApplicationPasswordsExperimentAvailabilityCheckerProtocol { + var mockedAvailability = false + + var cachedValue: Bool { + mockedAvailability + } + + func fetchAvailability() async -> Bool { + mockedAvailability + } +}