diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 095cbdddce..e5a78af0c8 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -205,6 +205,10 @@ 1E2BEAE52C8B00B5002741A3 /* SubscriptionPagesUseSubscriptionFeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2BEAE32C8B00B5002741A3 /* SubscriptionPagesUseSubscriptionFeatureTests.swift */; }; 1E559BB12BBCA9F1002B4AF6 /* RedirectNavigationResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E559BB02BBCA9F1002B4AF6 /* RedirectNavigationResponder.swift */; }; 1E559BB22BBCA9F1002B4AF6 /* RedirectNavigationResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E559BB02BBCA9F1002B4AF6 /* RedirectNavigationResponder.swift */; }; + 1E5921BF2CF479E600E15CCA /* FeatureFlags in Frameworks */ = {isa = PBXBuildFile; productRef = 1E5921BE2CF479E600E15CCA /* FeatureFlags */; }; + 1E5921C12CF479EE00E15CCA /* FeatureFlags in Frameworks */ = {isa = PBXBuildFile; productRef = 1E5921C02CF479EE00E15CCA /* FeatureFlags */; }; + 1E5921C32CF47A0700E15CCA /* FeatureFlags in Frameworks */ = {isa = PBXBuildFile; productRef = 1E5921C22CF47A0700E15CCA /* FeatureFlags */; }; + 1E5921C52CF47A0F00E15CCA /* FeatureFlags in Frameworks */ = {isa = PBXBuildFile; productRef = 1E5921C42CF47A0F00E15CCA /* FeatureFlags */; }; 1E7E2E9029029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7E2E8F29029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift */; }; 1E7E2E942902AC0E00C01B54 /* PrivacyDashboardPermissionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7E2E932902AC0E00C01B54 /* PrivacyDashboardPermissionHandler.swift */; }; 1E950E3F2912A10D0051A99B /* ContentBlocking in Frameworks */ = {isa = PBXBuildFile; productRef = 1E950E3E2912A10D0051A99B /* ContentBlocking */; }; @@ -5085,6 +5089,7 @@ buildActionMask = 2147483647; files = ( 4B5235452C7BB14D00AFAF64 /* WireGuard in Frameworks */, + 1E5921C12CF479EE00E15CCA /* FeatureFlags in Frameworks */, 37269F012B332FC8005E8E46 /* Common in Frameworks */, 9D9DE57B2C63AA1F00D20B15 /* AppKitExtensions in Frameworks */, EE7295E92A545BC4008C0991 /* NetworkProtection in Frameworks */, @@ -5160,6 +5165,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1E5921BF2CF479E600E15CCA /* FeatureFlags in Frameworks */, 02589D9F2C88E8270093940D /* Persistence in Frameworks */, F1DF95E52BD1807C0045E591 /* Subscription in Frameworks */, 4B5235472C7BB15700AFAF64 /* WireGuard in Frameworks */, @@ -5225,6 +5231,7 @@ 9DC5FAC52C6B8A010011F068 /* AppKitExtensions in Frameworks */, 020807B22C6CFF95006F94C4 /* Configuration in Frameworks */, F1D0428E2BFB9F9C00A31506 /* Subscription in Frameworks */, + 1E5921C32CF47A0700E15CCA /* FeatureFlags in Frameworks */, C18BF9D02C736C9100ED6B8A /* Freemium in Frameworks */, 9D9AE8F92AAA3AD00026E7DC /* DataBrokerProtection in Frameworks */, ); @@ -5239,6 +5246,7 @@ 315A023F2B6421AE00BFA577 /* Networking in Frameworks */, 9DC5FAC72C6B8A080011F068 /* AppKitExtensions in Frameworks */, F1D042902BFB9FA300A31506 /* Subscription in Frameworks */, + 1E5921C52CF47A0F00E15CCA /* FeatureFlags in Frameworks */, C18BF9D22C736C9700ED6B8A /* Freemium in Frameworks */, 9D9AE8FB2AAA3AD90026E7DC /* DataBrokerProtection in Frameworks */, ); @@ -10039,6 +10047,7 @@ 4B5235442C7BB14D00AFAF64 /* WireGuard */, 02589DA02C88EB570093940D /* Configuration */, 02589DA22C88EB5D0093940D /* Persistence */, + 1E5921C02CF479EE00E15CCA /* FeatureFlags */, ); productName = NetworkProtectionSystemExtension; productReference = 4B25375A2A11BE7300610219 /* com.duckduckgo.macos.vpn.network-extension.debug.systemextension */; @@ -10172,6 +10181,7 @@ 4B5235462C7BB15700AFAF64 /* WireGuard */, 02589D9C2C88E8210093940D /* Configuration */, 02589D9E2C88E8270093940D /* Persistence */, + 1E5921BE2CF479E600E15CCA /* FeatureFlags */, ); productName = NetworkProtectionAppExtension; productReference = 4B4D603D2A0B290200BCD287 /* NetworkProtectionAppExtension.appex */; @@ -10288,6 +10298,7 @@ 020807B12C6CFF95006F94C4 /* Configuration */, C18BF9CF2C736C9100ED6B8A /* Freemium */, 02A15D8F2C88D773001A4237 /* Persistence */, + 1E5921C22CF47A0700E15CCA /* FeatureFlags */, ); productName = DuckDuckGoAgent; productReference = 9D9AE8D12AAA39A70026E7DC /* DuckDuckGo Personal Information Removal.app */; @@ -10315,6 +10326,7 @@ C18BF9D12C736C9700ED6B8A /* Freemium */, 02A15D8B2C88D763001A4237 /* Configuration */, 02A15D8D2C88D76A001A4237 /* Persistence */, + 1E5921C42CF47A0F00E15CCA /* FeatureFlags */, ); productName = DuckDuckGoAgent; productReference = 9D9AE8F22AAA39D30026E7DC /* DuckDuckGo Personal Information Removal App Store.app */; @@ -15205,7 +15217,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 211.1.3; + version = "211.1.3-1"; }; }; 9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = { @@ -15360,6 +15372,22 @@ package = FAE06B199CA1F209B55B34E9 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Crashes; }; + 1E5921BE2CF479E600E15CCA /* FeatureFlags */ = { + isa = XCSwiftPackageProductDependency; + productName = FeatureFlags; + }; + 1E5921C02CF479EE00E15CCA /* FeatureFlags */ = { + isa = XCSwiftPackageProductDependency; + productName = FeatureFlags; + }; + 1E5921C22CF47A0700E15CCA /* FeatureFlags */ = { + isa = XCSwiftPackageProductDependency; + productName = FeatureFlags; + }; + 1E5921C42CF47A0F00E15CCA /* FeatureFlags */ = { + isa = XCSwiftPackageProductDependency; + productName = FeatureFlags; + }; 1E950E3E2912A10D0051A99B /* ContentBlocking */ = { isa = XCSwiftPackageProductDependency; package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ea1c934f56..b5d80977b2 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "f83b1f5ebd328bc2447d1a3793149bb21037d685", - "version" : "211.1.3" + "revision" : "114cdbfcfae15ad8c7d5e502832e94061aef7cff", + "version" : "211.1.3-1" } }, { @@ -75,7 +75,7 @@ { "identity" : "lottie-spm", "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-spm.git", + "location" : "https://github.com/airbnb/lottie-spm", "state" : { "revision" : "1d29eccc24cc8b75bff9f6804155112c0ffc9605", "version" : "4.4.3" diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index e52d0e4ce7..780b46d395 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -285,7 +285,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { onboardingStateMachine = ContextualOnboardingStateMachine() // Configure Subscription - subscriptionManager = DefaultSubscriptionManager() + subscriptionManager = DefaultSubscriptionManager(featureFlagger: featureFlagger) subscriptionUIHandler = SubscriptionUIHandler(windowControllersManagerProvider: { return WindowControllersManager.shared }) diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index e27993350e..37cc5cd778 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -726,7 +726,8 @@ final class MainMenu: NSMenu { updatePurchasingPlatform: updatePurchasingPlatform, currentViewController: { WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController }, openSubscriptionTab: { WindowControllersManager.shared.showTab(with: .subscription($0)) }, - subscriptionManager: Application.appDelegate.subscriptionManager) + subscriptionManager: Application.appDelegate.subscriptionManager, + subscriptionUserDefaults: subscriptionUserDefaults) NSMenuItem(title: "Logging").submenu(setupLoggingMenu()) NSMenuItem(title: "AI Chat").submenu(AIChatDebugMenu()) diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index d419d26dc7..4672503b4d 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -431,7 +431,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { } else { privacyProItem.submenu = SubscriptionSubMenu(targeting: self, subscriptionFeatureAvailability: DefaultSubscriptionFeatureAvailability(), - accountManager: accountManager) + subscriptionManager: subscriptionManager) addItem(privacyProItem) } } @@ -878,7 +878,7 @@ final class HelpSubMenu: NSMenu { final class SubscriptionSubMenu: NSMenu, NSMenuDelegate { var subscriptionFeatureAvailability: SubscriptionFeatureAvailability - var accountManager: AccountManager + var subscriptionManager: SubscriptionManager var networkProtectionItem: NSMenuItem! var dataBrokerProtectionItem: NSMenuItem! @@ -887,10 +887,10 @@ final class SubscriptionSubMenu: NSMenu, NSMenuDelegate { init(targeting target: AnyObject, subscriptionFeatureAvailability: SubscriptionFeatureAvailability, - accountManager: AccountManager) { + subscriptionManager: SubscriptionManager) { self.subscriptionFeatureAvailability = subscriptionFeatureAvailability - self.accountManager = accountManager + self.subscriptionManager = subscriptionManager super.init(title: "") @@ -901,17 +901,27 @@ final class SubscriptionSubMenu: NSMenu, NSMenuDelegate { delegate = self - addMenuItems() + Task { + await addMenuItems() + } } required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func addMenuItems() { - addItem(networkProtectionItem) - addItem(dataBrokerProtectionItem) - addItem(identityTheftRestorationItem) + private func addMenuItems() async { + let features = await subscriptionManager.currentSubscriptionFeatures() + + if features.contains(.networkProtection) { + addItem(networkProtectionItem) + } + if features.contains(.dataBrokerProtection) { + addItem(dataBrokerProtectionItem) + } + if features.contains(.identityTheftRestoration) || features.contains(.identityTheftRestorationGlobal) { + addItem(identityTheftRestorationItem) + } addItem(NSMenuItem.separator()) addItem(subscriptionSettingsItem) } @@ -948,10 +958,10 @@ final class SubscriptionSubMenu: NSMenu, NSMenuDelegate { } private func refreshAvailabilityBasedOnEntitlements() { - guard subscriptionFeatureAvailability.isFeatureAvailable, accountManager.isUserAuthenticated else { return } + guard subscriptionFeatureAvailability.isFeatureAvailable, subscriptionManager.accountManager.isUserAuthenticated else { return } @Sendable func hasEntitlement(for productName: Entitlement.ProductName) async -> Bool { - switch await self.accountManager.hasEntitlement(forProductName: productName) { + switch await self.subscriptionManager.accountManager.hasEntitlement(forProductName: productName) { case let .success(result): return result case .failure: @@ -964,7 +974,10 @@ final class SubscriptionSubMenu: NSMenu, NSMenuDelegate { let isNetworkProtectionItemEnabled = await hasEntitlement(for: .networkProtection) let isDataBrokerProtectionItemEnabled = await hasEntitlement(for: .dataBrokerProtection) - let isIdentityTheftRestorationItemEnabled = await hasEntitlement(for: .identityTheftRestoration) + + let hasIdentityTheftRestoration = await hasEntitlement(for: .identityTheftRestoration) + let hasIdentityTheftRestorationGlobal = await hasEntitlement(for: .identityTheftRestorationGlobal) + let isIdentityTheftRestorationItemEnabled = hasIdentityTheftRestoration || hasIdentityTheftRestorationGlobal Task { @MainActor in self.networkProtectionItem.isEnabled = isNetworkProtectionItemEnabled diff --git a/DuckDuckGo/Preferences/View/PreferencesRootView.swift b/DuckDuckGo/Preferences/View/PreferencesRootView.swift index 195309d2d8..6001f387ca 100644 --- a/DuckDuckGo/Preferences/View/PreferencesRootView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesRootView.swift @@ -201,7 +201,8 @@ enum Preferences { return PreferencesSubscriptionModel(openURLHandler: openURL, userEventHandler: handleUIEvent, sheetActionHandler: sheetActionHandler, - subscriptionManager: subscriptionManager) + subscriptionManager: subscriptionManager, + featureFlagger: NSApp.delegateTyped.featureFlagger) } } } diff --git a/DuckDuckGo/Subscription/SubscriptionManager+StandardConfiguration.swift b/DuckDuckGo/Subscription/SubscriptionManager+StandardConfiguration.swift index 360ddb541e..8dde58203e 100644 --- a/DuckDuckGo/Subscription/SubscriptionManager+StandardConfiguration.swift +++ b/DuckDuckGo/Subscription/SubscriptionManager+StandardConfiguration.swift @@ -20,11 +20,13 @@ import Foundation import Subscription import Common import PixelKit +import BrowserServicesKit +import FeatureFlags extension DefaultSubscriptionManager { // Init the SubscriptionManager using the standard dependencies and configuration, to be used only in the dependencies tree root - public convenience init() { + public convenience init(featureFlagger: FeatureFlagger? = nil) { // MARK: - Configure Subscription let subscriptionAppGroup = Bundle.main.appGroup(bundle: .subs) let subscriptionUserDefaults = UserDefaults(suiteName: subscriptionAppGroup)! @@ -36,23 +38,53 @@ extension DefaultSubscriptionManager { let accessTokenStorage = SubscriptionTokenKeychainStorage(keychainType: .dataProtection(.named(subscriptionAppGroup))) let subscriptionEndpointService = DefaultSubscriptionEndpointService(currentServiceEnvironment: subscriptionEnvironment.serviceEnvironment) let authEndpointService = DefaultAuthEndpointService(currentServiceEnvironment: subscriptionEnvironment.serviceEnvironment) + let subscriptionFeatureMappingCache = DefaultSubscriptionFeatureMappingCache(subscriptionEndpointService: subscriptionEndpointService, + userDefaults: subscriptionUserDefaults) + let accountManager = DefaultAccountManager(accessTokenStorage: accessTokenStorage, entitlementsCache: entitlementsCache, subscriptionEndpointService: subscriptionEndpointService, authEndpointService: authEndpointService) + let subscriptionFeatureFlagger: FeatureFlaggerMapping = FeatureFlaggerMapping { feature in + guard let featureFlagger else { + // With no featureFlagger provided there is no gating of features + return feature.defaultState + } + + switch feature { + case .isLaunchedROW: + return featureFlagger.isFeatureOn(.isPrivacyProLaunchedROW) + case .isLaunchedROWOverride: + return featureFlagger.isFeatureOn(.isPrivacyProLaunchedROWOverride) + case .usePrivacyProUSARegionOverride: + return (featureFlagger.internalUserDecider.isInternalUser && + subscriptionEnvironment.serviceEnvironment == .staging && + subscriptionUserDefaults.storefrontRegionOverride == .usa) + case .usePrivacyProROWRegionOverride: + return (featureFlagger.internalUserDecider.isInternalUser && + subscriptionEnvironment.serviceEnvironment == .staging && + subscriptionUserDefaults.storefrontRegionOverride == .restOfWorld) + } + } + if #available(macOS 12.0, *) { - let storePurchaseManager = DefaultStorePurchaseManager() + let storePurchaseManager = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) self.init(storePurchaseManager: storePurchaseManager, accountManager: accountManager, subscriptionEndpointService: subscriptionEndpointService, authEndpointService: authEndpointService, - subscriptionEnvironment: subscriptionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) } else { self.init(accountManager: accountManager, subscriptionEndpointService: subscriptionEndpointService, authEndpointService: authEndpointService, - subscriptionEnvironment: subscriptionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) } accountManager.delegate = self diff --git a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift index 204d9228c5..29354841b7 100644 --- a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift @@ -167,25 +167,27 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { } func getSubscriptionOptions(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed else { return SubscriptionOptions.empty } + var subscriptionOptions = SubscriptionOptions.empty switch subscriptionPlatform { case .appStore: if #available(macOS 12.0, *) { - if let subscriptionOptions = await subscriptionManager.storePurchaseManager().subscriptionOptions() { - return subscriptionOptions + if let appStoreSubscriptionOptions = await subscriptionManager.storePurchaseManager().subscriptionOptions() { + subscriptionOptions = appStoreSubscriptionOptions } } case .stripe: switch await stripePurchaseFlow.subscriptionOptions() { - case .success(let subscriptionOptions): - return subscriptionOptions + case .success(let stripeSubscriptionOptions): + subscriptionOptions = stripeSubscriptionOptions case .failure: break } } - return SubscriptionOptions.empty + guard subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed else { return subscriptionOptions.withoutPurchaseOptions() } + + return subscriptionOptions } func subscriptionSelected(params: Any, original: WKScriptMessage) async throws -> Encodable? { @@ -342,7 +344,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { func featureSelected(params: Any, original: WKScriptMessage) async throws -> Encodable? { struct FeatureSelection: Codable { - let feature: String + let productFeature: Entitlement.ProductName } guard let featureSelection: FeatureSelection = DecodableHelper.decode(from: params) else { @@ -350,31 +352,20 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { return nil } - guard let subscriptionFeatureName = SubscriptionFeatureName(rawValue: featureSelection.feature) else { - assertionFailure("SubscriptionPagesUserScript: feature name does not matches mapping") - return nil - } - - switch subscriptionFeatureName { - case .privateBrowsing: - notificationCenter.post(name: .openPrivateBrowsing, object: self, userInfo: nil) - case .privateSearch: - notificationCenter.post(name: .openPrivateSearch, object: self, userInfo: nil) - case .emailProtection: - notificationCenter.post(name: .openEmailProtection, object: self, userInfo: nil) - case .appTrackingProtection: - notificationCenter.post(name: .openAppTrackingProtection, object: self, userInfo: nil) - case .vpn: + switch featureSelection.productFeature { + case .networkProtection: PixelKit.fire(PrivacyProPixel.privacyProWelcomeVPN, frequency: .unique) notificationCenter.post(name: .ToggleNetworkProtectionInMainWindow, object: self, userInfo: nil) - case .personalInformationRemoval: + case .dataBrokerProtection: PixelKit.fire(PrivacyProPixel.privacyProWelcomePersonalInformationRemoval, frequency: .unique) notificationCenter.post(name: .openPersonalInformationRemoval, object: self, userInfo: nil) await uiHandler.showTab(with: .dataBrokerProtection) - case .identityTheftRestoration: + case .identityTheftRestoration, .identityTheftRestorationGlobal: PixelKit.fire(PrivacyProPixel.privacyProWelcomeIdentityRestoration, frequency: .unique) let url = subscriptionManager.url(for: .identityTheftRestoration) await uiHandler.showTab(with: .identityTheftRestoration(url)) + case .unknown: + break } return nil diff --git a/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift index 011f00efc9..9635ae26d7 100644 --- a/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift +++ b/DuckDuckGo/UnifiedFeedbackForm/UnifiedFeedbackFormView.swift @@ -74,7 +74,7 @@ private struct FeedbackFormBodyView: View { @EnvironmentObject var viewModel: UnifiedFeedbackFormViewModel var body: some View { - CategoryPicker(sources: UnifiedFeedbackReportType.self, selection: $viewModel.selectedReportType) { + CategoryPicker(options: UnifiedFeedbackReportType.allCases, selection: $viewModel.selectedReportType) { switch UnifiedFeedbackReportType(rawValue: viewModel.selectedReportType) { case .selectReportType, nil: EmptyView() @@ -94,24 +94,24 @@ private struct FeedbackFormBodyView: View { @ViewBuilder func reportProblemView() -> some View { - CategoryPicker(sources: UnifiedFeedbackCategory.self, selection: $viewModel.selectedCategory) { + CategoryPicker(options: viewModel.availableCategories, selection: $viewModel.selectedCategory) { switch UnifiedFeedbackCategory(rawValue: viewModel.selectedCategory) { case .selectFeature, nil: EmptyView() case .subscription: - CategoryPicker(sources: PrivacyProFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + CategoryPicker(options: PrivacyProFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { issueDescriptionView() } case .vpn: - CategoryPicker(sources: VPNFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + CategoryPicker(options: VPNFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { issueDescriptionView() } case .pir: - CategoryPicker(sources: PIRFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + CategoryPicker(options: PIRFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { issueDescriptionView() } case .itr: - CategoryPicker(sources: ITRFeedbackSubcategory.self, selection: $viewModel.selectedSubcategory) { + CategoryPicker(options: ITRFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { issueDescriptionView() } } @@ -140,14 +140,14 @@ private struct FeedbackFormBodyView: View { } private struct CategoryPicker: View where Category.AllCases == [Category], Category.RawValue == String { - let sources: Category.Type + let options: [Category] let selection: Binding let content: () -> Content - init(sources: Category.Type, + init(options: [Category], selection: Binding, @ViewBuilder content: @escaping () -> Content) { - self.sources = sources + self.options = options self.selection = selection self.content = content } @@ -155,7 +155,7 @@ private struct CategoryPicker Void private var purchasePlatformItem: NSMenuItem? + private var regionOverrideItem: NSMenuItem? var currentViewController: () -> NSViewController? let subscriptionManager: SubscriptionManager + let subscriptionUserDefaults: UserDefaults var accountManager: AccountManager { subscriptionManager.accountManager } - private var _purchaseManager: Any? - @available(macOS 12.0, *) - fileprivate var purchaseManager: DefaultStorePurchaseManager { - if _purchaseManager == nil { - _purchaseManager = DefaultStorePurchaseManager() - } - // swiftlint:disable:next force_cast - return _purchaseManager as! DefaultStorePurchaseManager - } - required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -54,13 +46,15 @@ public final class SubscriptionDebugMenu: NSMenuItem { updatePurchasingPlatform: @escaping (SubscriptionEnvironment.PurchasePlatform) -> Void, currentViewController: @escaping () -> NSViewController?, openSubscriptionTab: @escaping (URL) -> Void, - subscriptionManager: SubscriptionManager) { + subscriptionManager: SubscriptionManager, + subscriptionUserDefaults: UserDefaults) { self.currentEnvironment = currentEnvironment self.updateServiceEnvironment = updateServiceEnvironment self.updatePurchasingPlatform = updatePurchasingPlatform self.currentViewController = currentViewController self.openSubscriptionTab = openSubscriptionTab self.subscriptionManager = subscriptionManager + self.subscriptionUserDefaults = subscriptionUserDefaults super.init(title: "Subscription", action: nil, keyEquivalent: "") self.submenu = makeSubmenu() } @@ -99,6 +93,10 @@ public final class SubscriptionDebugMenu: NSMenuItem { let storefrontCountryCode = SKPaymentQueue.default().storefront?.countryCode ?? "nil" menu.addItem(NSMenuItem(title: "Storefront Country Code: \(storefrontCountryCode)", action: nil, target: nil)) + let regionOverrideItem = NSMenuItem(title: "Region override for App Store Sandbox", action: nil, target: nil) + menu.addItem(regionOverrideItem) + self.regionOverrideItem = regionOverrideItem + menu.delegate = self return menu @@ -161,6 +159,37 @@ public final class SubscriptionDebugMenu: NSMenuItem { return menu } + private func makeRegionOverrideItemSubmenu() -> NSMenu { + let menu = NSMenu(title: "") + + let currentRegionOverride = subscriptionUserDefaults.storefrontRegionOverride + + let usaItem = NSMenuItem(title: "USA", action: #selector(setRegionOverrideToUSA), target: self) + if currentRegionOverride == .usa { + usaItem.state = .on + usaItem.isEnabled = false + usaItem.action = nil + usaItem.target = nil + } + menu.addItem(usaItem) + + let rowItem = NSMenuItem(title: "Rest of World", action: #selector(setRegionOverrideToROW), target: self) + if currentRegionOverride == .restOfWorld { + rowItem.state = .on + rowItem.isEnabled = false + rowItem.action = nil + rowItem.target = nil + } + menu.addItem(rowItem) + + menu.addItem(.separator()) + + let clearItem = NSMenuItem(title: "Clear storefront region override", action: #selector(clearRegionOverride), target: self) + menu.addItem(clearItem) + + return menu + } + private func refreshSubmenu() { self.submenu = makeSubmenu() } @@ -233,13 +262,12 @@ public final class SubscriptionDebugMenu: NSMenuItem { @objc func syncAppleIDAccount() { Task { @MainActor in - try? await purchaseManager.syncAppleIDAccount() + try? await subscriptionManager.storePurchaseManager().syncAppleIDAccount() } } @IBAction func showPurchaseView(_ sender: Any?) { if #available(macOS 12.0, *) { - let storePurchaseManager = DefaultStorePurchaseManager() let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: subscriptionManager.accountManager, storePurchaseManager: subscriptionManager.storePurchaseManager(), subscriptionEndpointService: subscriptionManager.subscriptionEndpointService, @@ -249,7 +277,8 @@ public final class SubscriptionDebugMenu: NSMenuItem { accountManager: subscriptionManager.accountManager, appStoreRestoreFlow: appStoreRestoreFlow, authEndpointService: subscriptionManager.authEndpointService) - let vc = DebugPurchaseViewController(storePurchaseManager: storePurchaseManager, appStorePurchaseFlow: appStorePurchaseFlow) + // swiftlint:disable:next force_cast + let vc = DebugPurchaseViewController(storePurchaseManager: subscriptionManager.storePurchaseManager() as! DefaultStorePurchaseManager, appStorePurchaseFlow: appStorePurchaseFlow) currentViewController()?.presentAsSheet(vc) } } @@ -302,6 +331,30 @@ public final class SubscriptionDebugMenu: NSMenuItem { NSApp.terminate(self) } + // MARK: - Region override + + @IBAction func clearRegionOverride(_ sender: Any?) { + updateRegionOverride(to: nil) + } + + @IBAction func setRegionOverrideToUSA(_ sender: Any?) { + updateRegionOverride(to: .usa) + } + + @IBAction func setRegionOverrideToROW(_ sender: Any?) { + updateRegionOverride(to: .restOfWorld) + } + + private func updateRegionOverride(to region: SubscriptionRegion?) { + self.subscriptionUserDefaults.storefrontRegionOverride = region + + if #available(macOS 12.0, *) { + Task { + await subscriptionManager.storePurchaseManager().updateAvailableProducts() + } + } + } + // MARK: - @objc @@ -352,5 +405,6 @@ extension SubscriptionDebugMenu: NSMenuDelegate { public func menuWillOpen(_ menu: NSMenu) { purchasePlatformItem?.submenu = makePurchasePlatformSubmenu() + regionOverrideItem?.submenu = makeRegionOverrideItemSubmenu() } } diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift index 53dcbb131e..50f7114fa7 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionModel.swift @@ -20,6 +20,8 @@ import AppKit import Subscription import struct Combine.AnyPublisher import enum Combine.Publishers +import FeatureFlags +import BrowserServicesKit public final class PreferencesSubscriptionModel: ObservableObject { @@ -27,6 +29,12 @@ public final class PreferencesSubscriptionModel: ObservableObject { @Published var subscriptionDetails: String? @Published var subscriptionStatus: Subscription.Status? + @Published var subscriptionStorefrontRegion: SubscriptionRegion = .usa + + @Published var shouldShowVPN: Bool = false + @Published var shouldShowDBP: Bool = false + @Published var shouldShowITR: Bool = false + @Published var hasAccessToVPN: Bool = false @Published var hasAccessToDBP: Bool = false @Published var hasAccessToITR: Bool = false @@ -34,10 +42,13 @@ public final class PreferencesSubscriptionModel: ObservableObject { @Published var email: String? var hasEmail: Bool { !(email?.isEmpty ?? true) } + let featureFlagger: FeatureFlagger + var isROWLaunched: Bool = false + private var subscriptionPlatform: Subscription.Platform? lazy var sheetModel = SubscriptionAccessViewModel(actionHandlers: sheetActionHandler, - purchasePlatform: subscriptionManager.currentEnvironment.purchasePlatform) + purchasePlatform: subscriptionManager.currentEnvironment.purchasePlatform) private let subscriptionManager: SubscriptionManager private var accountManager: AccountManager { @@ -95,17 +106,21 @@ public final class PreferencesSubscriptionModel: ObservableObject { public init(openURLHandler: @escaping (URL) -> Void, userEventHandler: @escaping (UserEvent) -> Void, sheetActionHandler: SubscriptionAccessActionHandlers, - subscriptionManager: SubscriptionManager) { + subscriptionManager: SubscriptionManager, + featureFlagger: FeatureFlagger) { self.subscriptionManager = subscriptionManager self.openURLHandler = openURLHandler self.userEventHandler = userEventHandler self.sheetActionHandler = sheetActionHandler + self.featureFlagger = featureFlagger + self.subscriptionStorefrontRegion = currentStorefrontRegion() self.isUserAuthenticated = accountManager.isUserAuthenticated if accountManager.isUserAuthenticated { Task { await self.updateSubscription(cachePolicy: .returnCacheDataElseLoad) + await self.updateAvailableSubscriptionFeatures() await self.loadCachedEntitlements() } @@ -123,6 +138,7 @@ public final class PreferencesSubscriptionModel: ObservableObject { subscriptionChangeObserver = NotificationCenter.default.addObserver(forName: .subscriptionDidChange, object: nil, queue: .main) { _ in Task { [weak self] in await self?.updateSubscription(cachePolicy: .returnCacheDataDontLoad) + await self?.updateAvailableSubscriptionFeatures() } } } @@ -141,6 +157,17 @@ public final class PreferencesSubscriptionModel: ObservableObject { } } + @MainActor + func didAppear() { + if isUserAuthenticated { + userEventHandler(.activeSubscriptionSettingsClick) + fetchAndUpdateSubscriptionDetails() + } else { + self.subscriptionStorefrontRegion = currentStorefrontRegion() + } + isROWLaunched = featureFlagger.isFeatureOn(.isPrivacyProLaunchedROW) || featureFlagger.isFeatureOn(.isPrivacyProLaunchedROWOverride) + } + private func updateUserAuthenticatedState(_ isUserAuthenticated: Bool) { self.isUserAuthenticated = isUserAuthenticated self.email = accountManager.email @@ -307,7 +334,7 @@ public final class PreferencesSubscriptionModel: ObservableObject { } @MainActor - func fetchAndUpdateSubscriptionDetails() { + private func fetchAndUpdateSubscriptionDetails() { self.isUserAuthenticated = accountManager.isUserAuthenticated guard fetchSubscriptionDetailsTask == nil else { return } @@ -322,6 +349,38 @@ public final class PreferencesSubscriptionModel: ObservableObject { } } + private func currentStorefrontRegion() -> SubscriptionRegion { + var region: SubscriptionRegion? + + switch subscriptionManager.currentEnvironment.purchasePlatform { + case .appStore: + if #available(macOS 12.0, *) { + region = subscriptionManager.storePurchaseManager().currentStorefrontRegion + } + case .stripe: + region = .usa + } + + return region ?? .usa + } + + @MainActor + private func updateAvailableSubscriptionFeatures() async { + let features = await currentSubscriptionFeatures() + + shouldShowVPN = features.contains(.networkProtection) + shouldShowDBP = features.contains(.dataBrokerProtection) + shouldShowITR = features.contains(.identityTheftRestoration) || features.contains(.identityTheftRestorationGlobal) + } + + private func currentSubscriptionFeatures() async -> [Entitlement.ProductName] { + if subscriptionManager.currentEnvironment.purchasePlatform == .appStore { + return await subscriptionManager.currentSubscriptionFeatures() + } else { + return [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] + } + } + @MainActor private func loadCachedEntitlements() async { switch await self.accountManager.hasEntitlement(forProductName: .networkProtection, cachePolicy: .returnCacheDataDontLoad) { @@ -338,12 +397,23 @@ public final class PreferencesSubscriptionModel: ObservableObject { hasAccessToDBP = false } + var hasITR = false switch await self.accountManager.hasEntitlement(forProductName: .identityTheftRestoration, cachePolicy: .returnCacheDataDontLoad) { case let .success(result): - hasAccessToITR = result + hasITR = result + case .failure: + hasITR = false + } + + var hasITRGlobal = false + switch await self.accountManager.hasEntitlement(forProductName: .identityTheftRestorationGlobal, cachePolicy: .returnCacheDataDontLoad) { + case let .success(result): + hasITRGlobal = result case .failure: - hasAccessToITR = false + hasITRGlobal = false } + + hasAccessToITR = hasITR || hasITRGlobal } @MainActor func fetchEmailAndRemoteEntitlements() async { @@ -358,7 +428,7 @@ public final class PreferencesSubscriptionModel: ObservableObject { let entitlements = response.account.entitlements.compactMap { $0.product } hasAccessToVPN = entitlements.contains(.networkProtection) hasAccessToDBP = entitlements.contains(.dataBrokerProtection) - hasAccessToITR = entitlements.contains(.identityTheftRestoration) + hasAccessToITR = entitlements.contains(.identityTheftRestoration) || entitlements.contains(.identityTheftRestorationGlobal) accountManager.updateCache(with: response.account.entitlements) } } diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift index 58a10ae9c0..ec66c0b134 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/Preferences/PreferencesSubscriptionView.swift @@ -108,10 +108,7 @@ public struct PreferencesSubscriptionView: View { } } .onAppear(perform: { - if model.isUserAuthenticated { - model.userEventHandler(.activeSubscriptionSettingsClick) - model.fetchAndUpdateSubscriptionDetails() - } + model.didAppear() }) .onReceive(model.statePublisher, perform: updateState(state:)) } @@ -129,7 +126,12 @@ public struct PreferencesSubscriptionView: View { .cornerRadius(4) } content: { TextMenuItemHeader(UserText.preferencesSubscriptionInactiveHeader) - TextMenuItemCaption(UserText.preferencesSubscriptionInactiveCaption) + switch model.subscriptionStorefrontRegion { + case .usa: + TextMenuItemCaption(UserText.preferencesSubscriptionInactiveUSCaption) + case .restOfWorld: + TextMenuItemCaption(UserText.preferencesSubscriptionInactiveROWCaption) + } } buttons: { Button(UserText.purchaseButton) { model.purchaseAction() } .buttonStyle(DefaultActionButtonStyle(enabled: true)) @@ -175,53 +177,74 @@ public struct PreferencesSubscriptionView: View { @ViewBuilder private var featureRowsForNoSubscriptionView: some View { - SectionView(iconName: "VPN-Icon", - title: UserText.vpnServiceTitle, - description: UserText.vpnServiceDescription) + switch model.subscriptionStorefrontRegion { + case .usa: + SectionView(iconName: "VPN-Icon", + title: UserText.vpnServiceTitle, + description: UserText.vpnServiceDescription) + + Divider() + .foregroundColor(Color.secondary) + + SectionView(iconName: "PIR-Icon", + title: UserText.personalInformationRemovalServiceTitle, + description: UserText.personalInformationRemovalServiceDescription) - Divider() - .foregroundColor(Color.secondary) + Divider() + .foregroundColor(Color.secondary) - SectionView(iconName: "PIR-Icon", - title: UserText.personalInformationRemovalServiceTitle, - description: UserText.personalInformationRemovalServiceDescription) + SectionView(iconName: "ITR-Icon", + title: UserText.identityTheftRestorationServiceTitle, + description: UserText.identityTheftRestorationServiceDescription) - Divider() - .foregroundColor(Color.secondary) + case .restOfWorld: + SectionView(iconName: "VPN-Icon", + title: UserText.vpnServiceTitle, + description: UserText.vpnServiceDescription) - SectionView(iconName: "ITR-Icon", - title: UserText.identityTheftRestorationServiceTitle, - description: UserText.identityTheftRestorationServiceDescription) + Divider() + .foregroundColor(Color.secondary) + + SectionView(iconName: "ITR-Icon", + title: UserText.identityTheftRestorationServiceTitle, + description: UserText.identityTheftRestorationServiceDescription) + } } @ViewBuilder private var featureRowsForActiveSubscription: some View { - SectionView(iconName: "VPN-Icon", - title: UserText.vpnServiceTitle, - description: UserText.vpnServiceDescription, - buttonName: UserText.vpnServiceButtonTitle, - buttonAction: { model.openVPN() }, - enabled: model.hasAccessToVPN) - - Divider() - .foregroundColor(Color.secondary) - - SectionView(iconName: "PIR-Icon", - title: UserText.personalInformationRemovalServiceTitle, - description: UserText.personalInformationRemovalServiceDescription, - buttonName: UserText.personalInformationRemovalServiceButtonTitle, - buttonAction: { model.openPersonalInformationRemoval() }, - enabled: model.hasAccessToDBP) - - Divider() - .foregroundColor(Color.secondary) - - SectionView(iconName: "ITR-Icon", - title: UserText.identityTheftRestorationServiceTitle, - description: UserText.identityTheftRestorationServiceDescription, - buttonName: UserText.identityTheftRestorationServiceButtonTitle, - buttonAction: { model.openIdentityTheftRestoration() }, - enabled: model.hasAccessToITR) + if model.shouldShowVPN { + SectionView(iconName: "VPN-Icon", + title: UserText.vpnServiceTitle, + description: UserText.vpnServiceDescription, + buttonName: UserText.vpnServiceButtonTitle, + buttonAction: { model.openVPN() }, + enabled: model.hasAccessToVPN) + + Divider() + .foregroundColor(Color.secondary) + } + + if model.shouldShowDBP { + SectionView(iconName: "PIR-Icon", + title: UserText.personalInformationRemovalServiceTitle, + description: UserText.personalInformationRemovalServiceDescription, + buttonName: UserText.personalInformationRemovalServiceButtonTitle, + buttonAction: { model.openPersonalInformationRemoval() }, + enabled: model.hasAccessToDBP) + + Divider() + .foregroundColor(Color.secondary) + } + + if model.shouldShowITR { + SectionView(iconName: "ITR-Icon", + title: UserText.identityTheftRestorationServiceTitle, + description: UserText.identityTheftRestorationServiceDescription, + buttonName: UserText.identityTheftRestorationServiceButtonTitle, + buttonAction: { model.openIdentityTheftRestoration() }, + enabled: model.hasAccessToITR) + } } @ViewBuilder @@ -281,7 +304,11 @@ public struct PreferencesSubscriptionView: View { PreferencePaneSection { TextMenuItemHeader(UserText.preferencesSubscriptionFooterTitle, bottomPadding: 0) HStack(alignment: .top, spacing: 6) { - TextMenuItemCaption(UserText.preferencesSubscriptionFooterCaption) + if !model.isROWLaunched { + TextMenuItemCaption(UserText.preferencesSubscriptionFooterCaption) + } else { + TextMenuItemCaption(UserText.preferencesSubscriptionHelpFooterCaption) + } Button(UserText.viewFaqsButton) { model.openFAQ() } } } diff --git a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift index 0a22093283..17480c4234 100644 --- a/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift +++ b/LocalPackages/SubscriptionUI/Sources/SubscriptionUI/UserText.swift @@ -33,7 +33,7 @@ enum UserText { static let personalInformationRemovalServiceButtonTitle = NSLocalizedString("subscription.preferences.services.personal.information.removal.button.title", value: "Open", comment: "Title for the Personal Information Removal service button to open its settings") static let identityTheftRestorationServiceTitle = NSLocalizedString("subscription.preferences.services.identity.theft.restoration.title", value: "Identity Theft Restoration", comment: "Title for the Identity Theft Restoration service listed in the subscription preferences pane") - static let identityTheftRestorationServiceDescription = NSLocalizedString("subscription.preferences.services.identity.theft.restoration.description", value: "Restore stolen accounts and financial losses in the event of identity theft.", comment: "Description for the Identity Theft Restoration service listed in the subscription preferences pane") + static let identityTheftRestorationServiceDescription = NSLocalizedString("subscription.preferences.services.identity.theft.restoration.description", value: "Get help restoring stolen accounts and financial losses in the event of identity theft.", comment: "Description for the Identity Theft Restoration service listed in the subscription preferences pane") static let identityTheftRestorationServiceButtonTitle = NSLocalizedString("subscription.preferences.services.identity.theft.restoration.button.title", value: "View", comment: "Title for the Identity Theft Restoration service button to open its settings") // MARK: Preferences activate section @@ -49,6 +49,7 @@ enum UserText { // MARK: Preferences footer static let preferencesSubscriptionFooterTitle = NSLocalizedString("subscription.preferences.subscription.footer.title", value: "Need help with Privacy Pro?", comment: "Title for the subscription preferences pane footer") static let preferencesSubscriptionFooterCaption = NSLocalizedString("subscription.preferences.subscription.footer.caption", value: "Get answers to frequently asked questions or contact Privacy Pro support from our help pages.", comment: "Caption for the subscription preferences pane footer") + static let preferencesSubscriptionHelpFooterCaption = NSLocalizedString("subscription.preferences.subscription.help.footer.caption", value: "Get answers to frequently asked questions or contact Privacy Pro support from our help pages. Feature availability varies by country.", comment: "Caption for the subscription preferences pane footer") static let viewFaqsButton = NSLocalizedString("subscription.preferences.view.faqs.button", value: "FAQs and Support", comment: "Button to open page for FAQs") static let preferencesSubscriptionFeedbackTitle = NSLocalizedString("subscription.preferences.feedback.title", value: "Send Feedback", comment: "Title for the subscription feedback section") static let preferencesSubscriptionFeedbackCaption = NSLocalizedString("subscription.preferences.feedback.caption", value: "Help improve Privacy Pro. Your feedback matters to us. Feel free to report any issues or provide general feedback.", comment: "Caption for the subscription feedback section") @@ -77,8 +78,9 @@ enum UserText { static let removeFromThisDeviceButton = NSLocalizedString("subscription.preferences.remove.from.this.device.button", value: "Remove From This Device", comment: "Button to remove subscription from this device") // MARK: Preferences when subscription is inactive - static let preferencesSubscriptionInactiveHeader = NSLocalizedString("subscription.preferences.subscription.inactive.header", value: "Subscribe to Privacy Pro", comment: "Header for the subscription preferences pane when the subscription is inactive") - static let preferencesSubscriptionInactiveCaption = NSLocalizedString("subscription.preferences.subscription.inactive.caption", value: "More seamless privacy with three new protections.", comment: "Caption for the subscription preferences pane when the subscription is inactive") + static let preferencesSubscriptionInactiveHeader = NSLocalizedString("subscription.preferences.subscription.inactive.header", value: "Protect your connection and identity with Privacy Pro", comment: "Header for the subscription preferences pane when the subscription is inactive") + static let preferencesSubscriptionInactiveUSCaption = NSLocalizedString("subscription.preferences.subscription.inactive.us.caption", value: "Three premium protections in one subscription.", comment: "Caption for the subscription preferences pane when the subscription is inactive") + static let preferencesSubscriptionInactiveROWCaption = NSLocalizedString("subscription.preferences.subscription.inactive.row.caption", value: "Two premium protections in one subscription.", comment: "Caption for the subscription preferences pane when the subscription is inactive") static let purchaseButton = NSLocalizedString("subscription.preferences.purchase.button", value: "Get Privacy Pro", comment: "Button to open a page where user can learn more and purchase the subscription") static let haveSubscriptionButton = NSLocalizedString("subscription.preferences.i.have.a.subscription.button", value: "I Have a Subscription", comment: "Button enabling user to activate a subscription user bought earlier or on another device") diff --git a/UnitTests/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManagingTests.swift b/UnitTests/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManagingTests.swift index a0f6038443..00d1583b3e 100644 --- a/UnitTests/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManagingTests.swift +++ b/UnitTests/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManagingTests.swift @@ -34,6 +34,7 @@ final class FreemiumDBPPixelExperimentManagingTests: XCTestCase { let mockSubscriptionService = SubscriptionEndpointServiceMock() let mockAuthService = AuthEndpointServiceMock() let mockStorePurchaseManager = StorePurchaseManagerMock() + let mockSubscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() let currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) @@ -43,7 +44,8 @@ final class FreemiumDBPPixelExperimentManagingTests: XCTestCase { authEndpointService: mockAuthService, storePurchaseManager: mockStorePurchaseManager, currentEnvironment: currentEnvironment, - canPurchase: false) + canPurchase: false, + subscriptionFeatureMappingCache: mockSubscriptionFeatureMappingCache) mockUserDefaults = MockUserDefaults() let testLocale = Locale(identifier: "en_US") sut = FreemiumDBPPixelExperimentManager(subscriptionManager: mockSubscriptionManager, userDefaults: mockUserDefaults, locale: testLocale) diff --git a/UnitTests/Freemium/DBP/FreemiumDBPFeatureTests.swift b/UnitTests/Freemium/DBP/FreemiumDBPFeatureTests.swift index 04ace9f251..e8b84142ba 100644 --- a/UnitTests/Freemium/DBP/FreemiumDBPFeatureTests.swift +++ b/UnitTests/Freemium/DBP/FreemiumDBPFeatureTests.swift @@ -44,6 +44,7 @@ final class FreemiumDBPFeatureTests: XCTestCase { let mockSubscriptionService = SubscriptionEndpointServiceMock() let mockAuthService = AuthEndpointServiceMock() let mockStorePurchaseManager = StorePurchaseManagerMock() + let mockSubscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() let currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) @@ -53,7 +54,8 @@ final class FreemiumDBPFeatureTests: XCTestCase { authEndpointService: mockAuthService, storePurchaseManager: mockStorePurchaseManager, currentEnvironment: currentEnvironment, - canPurchase: false) + canPurchase: false, + subscriptionFeatureMappingCache: mockSubscriptionFeatureMappingCache) mockFreemiumDBPUserStateManagerManager = MockFreemiumDBPUserStateManager() mockFeatureDisabler = MockFeatureDisabler() diff --git a/UnitTests/Menus/MoreOptionsMenuTests.swift b/UnitTests/Menus/MoreOptionsMenuTests.swift index f622fe79cc..f25dcdd5ae 100644 --- a/UnitTests/Menus/MoreOptionsMenuTests.swift +++ b/UnitTests/Menus/MoreOptionsMenuTests.swift @@ -67,7 +67,8 @@ final class MoreOptionsMenuTests: XCTestCase { storePurchaseManager: storePurchaseManager, currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore), - canPurchase: false) + canPurchase: false, + subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock()) mockFreemiumDBPFeature = MockFreemiumDBPFeature() diff --git a/UnitTests/Subscription/SubscriptionAppStoreRestorerTests.swift b/UnitTests/Subscription/SubscriptionAppStoreRestorerTests.swift index f0efe70f31..bd26652802 100644 --- a/UnitTests/Subscription/SubscriptionAppStoreRestorerTests.swift +++ b/UnitTests/Subscription/SubscriptionAppStoreRestorerTests.swift @@ -45,6 +45,7 @@ final class SubscriptionAppStoreRestorerTests: XCTestCase { var subscriptionService: SubscriptionEndpointServiceMock! var authService: AuthEndpointServiceMock! var storePurchaseManager: StorePurchaseManagerMock! + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! var subscriptionEnvironment: SubscriptionEnvironment! var subscriptionManager: SubscriptionManagerMock! @@ -75,6 +76,8 @@ final class SubscriptionAppStoreRestorerTests: XCTestCase { subscriptionService = SubscriptionEndpointServiceMock() authService = AuthEndpointServiceMock() storePurchaseManager = StorePurchaseManagerMock() + subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) @@ -83,7 +86,8 @@ final class SubscriptionAppStoreRestorerTests: XCTestCase { authEndpointService: authService, storePurchaseManager: storePurchaseManager, currentEnvironment: subscriptionEnvironment, - canPurchase: true) + canPurchase: true, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) appStoreRestoreFlow = AppStoreRestoreFlowMock() subscriptionAppStoreRestorer = DefaultSubscriptionAppStoreRestorer(subscriptionManager: subscriptionManager, diff --git a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift index 65d57d5dd6..c339e89c3f 100644 --- a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift +++ b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift @@ -46,7 +46,7 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" - static let subscriptionOptions = SubscriptionOptions(platform: SubscriptionPlatformName.ios.rawValue, + static let subscriptionOptions = SubscriptionOptions(platform: SubscriptionPlatformName.macos, options: [ SubscriptionOption(id: "1", cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly")), @@ -54,9 +54,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly")) ], features: [ - SubscriptionFeature(name: "vpn"), - SubscriptionFeature(name: "personal-information-removal"), - SubscriptionFeature(name: "identity-theft-restoration") + SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration) ]) static let validateTokenResponse = ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, @@ -84,6 +84,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { var storePurchaseManager: StorePurchaseManagerMock! var subscriptionEnvironment: SubscriptionEnvironment! + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! + var subscriptionFeatureFlagger: FeatureFlaggerMapping! + var appStorePurchaseFlow: AppStorePurchaseFlow! var appStoreRestoreFlow: AppStoreRestoreFlow! var appStoreAccountManagementFlow: AppStoreAccountManagementFlow! @@ -135,6 +138,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { key: UserDefaultsCacheKey.subscriptionEntitlements, settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + subscriptionFeatureFlagger = FeatureFlaggerMapping(mapping: { $0.defaultState }) + // Real AccountManager accountManager = DefaultAccountManager(storage: accountStorage, accessTokenStorage: accessTokenStorage, @@ -173,7 +179,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, - subscriptionEnvironment: subscriptionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) mockFreemiumDBPExperimentManager = MockFreemiumDBPExperimentManager() mockPixelHandler = MockFreemiumDBPExperimentPixelHandler() @@ -870,83 +878,15 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { // MARK: - Tests for featureSelected - func testFeatureSelectedSuccessForPrivateBrowsing() async throws { - // Given - ensureUserAuthenticatedState() - let selectedFeature = SubscriptionFeatureName.privateBrowsing - - let notificationPostedExpectation = expectation(forNotification: .openPrivateBrowsing, object: nil) - - // When - let featureSelectionParams = ["feature": selectedFeature.rawValue] - let result = try await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) - - // Then - await fulfillment(of: [notificationPostedExpectation], timeout: 0.5) - XCTAssertNil(result) - XCTAssertPrivacyPixelsFired([]) - } - - func testFeatureSelectedSuccessForPrivateSearch() async throws { - // Given - ensureUserAuthenticatedState() - let selectedFeature = SubscriptionFeatureName.privateSearch - - let notificationPostedExpectation = expectation(forNotification: .openPrivateSearch, object: nil) - - // When - let featureSelectionParams = ["feature": selectedFeature.rawValue] - let result = try await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) - - // Then - await fulfillment(of: [notificationPostedExpectation], timeout: 0.5) - XCTAssertNil(result) - XCTAssertPrivacyPixelsFired([]) - } - - func testFeatureSelectedSuccessForEmailProtection() async throws { - // Given - ensureUserAuthenticatedState() - let selectedFeature = SubscriptionFeatureName.emailProtection - - let notificationPostedExpectation = expectation(forNotification: .openEmailProtection, object: nil) - - // When - let featureSelectionParams = ["feature": selectedFeature.rawValue] - let result = try await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) - - // Then - await fulfillment(of: [notificationPostedExpectation], timeout: 0.5) - XCTAssertNil(result) - XCTAssertPrivacyPixelsFired([]) - } - - func testFeatureSelectedSuccessForAppTrackingProtection() async throws { - // Given - ensureUserAuthenticatedState() - let selectedFeature = SubscriptionFeatureName.appTrackingProtection - - let notificationPostedExpectation = expectation(forNotification: .openAppTrackingProtection, object: nil) - - // When - let featureSelectionParams = ["feature": selectedFeature.rawValue] - let result = try await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) - - // Then - await fulfillment(of: [notificationPostedExpectation], timeout: 0.5) - XCTAssertNil(result) - XCTAssertPrivacyPixelsFired([]) - } - func testFeatureSelectedSuccessForNetworkProtection() async throws { // Given ensureUserAuthenticatedState() - let selectedFeature = SubscriptionFeatureName.vpn + let selectedFeature = Entitlement.ProductName.networkProtection let notificationPostedExpectation = expectation(forNotification: .ToggleNetworkProtectionInMainWindow, object: nil) // When - let featureSelectionParams = ["feature": selectedFeature.rawValue] + let featureSelectionParams = ["productFeature": selectedFeature.rawValue] let result = try await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) // Then @@ -958,7 +898,7 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { func testFeatureSelectedSuccessForPersonalInformationRemoval() async throws { // Given ensureUserAuthenticatedState() - let selectedFeature = SubscriptionFeatureName.personalInformationRemoval + let selectedFeature = Entitlement.ProductName.dataBrokerProtection let notificationPostedExpectation = expectation(forNotification: .openPersonalInformationRemoval, object: nil) let uiHandlerCalledExpectation = expectation(description: "uiHandlerCalled") @@ -970,7 +910,7 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { } // When - let featureSelectionParams = ["feature": selectedFeature.rawValue] + let featureSelectionParams = ["productFeature": selectedFeature.rawValue] let result = try await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) // Then @@ -982,7 +922,7 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { func testFeatureSelectedSuccessForIdentityTheftRestoration() async throws { // Given ensureUserAuthenticatedState() - let selectedFeature = SubscriptionFeatureName.identityTheftRestoration + let selectedFeature = Entitlement.ProductName.identityTheftRestoration let uiHandlerCalledExpectation = expectation(description: "uiHandlerCalled") @@ -995,7 +935,7 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { } // When - let featureSelectionParams = ["feature": selectedFeature.rawValue] + let featureSelectionParams = ["productFeature": selectedFeature.rawValue] let result = try await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) // Then diff --git a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift index 272b32ad5a..c7a35c81c6 100644 --- a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift +++ b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift @@ -56,7 +56,7 @@ final class SubscriptionPagesUseSubscriptionFeatureTestsForStripe: XCTestCase { price: "99", currency: "USD")] - static let subscriptionOptions = SubscriptionOptions(platform: SubscriptionPlatformName.stripe.rawValue, + static let subscriptionOptions = SubscriptionOptions(platform: SubscriptionPlatformName.stripe, options: [ SubscriptionOption(id: "1", cost: SubscriptionOptionCost(displayPrice: "$9.00", recurrence: "monthly")), @@ -64,9 +64,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTestsForStripe: XCTestCase { cost: SubscriptionOptionCost(displayPrice: "$99.00", recurrence: "yearly")) ], features: [ - SubscriptionFeature(name: "vpn"), - SubscriptionFeature(name: "personal-information-removal"), - SubscriptionFeature(name: "identity-theft-restoration") + SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration) ]) static let validateTokenResponse = ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, @@ -94,6 +94,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTestsForStripe: XCTestCase { var storePurchaseManager: StorePurchaseManagerMock! var subscriptionEnvironment: SubscriptionEnvironment! + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! + var subscriptionFeatureFlagger: FeatureFlaggerMapping! + var appStorePurchaseFlow: AppStorePurchaseFlow! var appStoreRestoreFlow: AppStoreRestoreFlow! var appStoreAccountManagementFlow: AppStoreAccountManagementFlow! @@ -141,6 +144,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTestsForStripe: XCTestCase { key: UserDefaultsCacheKey.subscriptionEntitlements, settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + subscriptionFeatureFlagger = FeatureFlaggerMapping(mapping: { $0.defaultState }) + // Real AccountManager accountManager = DefaultAccountManager(storage: accountStorage, accessTokenStorage: accessTokenStorage, @@ -177,7 +183,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTestsForStripe: XCTestCase { accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, - subscriptionEnvironment: subscriptionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) mockFreemiumDBPExperimentManager = MockFreemiumDBPExperimentManager() diff --git a/UnitTests/UnifiedFeedbackForm/UnifiedFeedbackFormViewModelTests.swift b/UnitTests/UnifiedFeedbackForm/UnifiedFeedbackFormViewModelTests.swift index 2e7dae8803..051a6019d3 100644 --- a/UnitTests/UnifiedFeedbackForm/UnifiedFeedbackFormViewModelTests.swift +++ b/UnitTests/UnifiedFeedbackForm/UnifiedFeedbackFormViewModelTests.swift @@ -17,6 +17,8 @@ // import XCTest +import Subscription +import SubscriptionTestingUtilities @testable import DuckDuckGo_Privacy_Browser final class UnifiedFeedbackFormViewModelTests: XCTestCase { @@ -24,7 +26,8 @@ final class UnifiedFeedbackFormViewModelTests: XCTestCase { func testWhenCreatingViewModel_ThenInitialStateIsFeedbackPending() throws { let collector = MockVPNMetadataCollector() let sender = MockVPNFeedbackSender() - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + let subscriptionManager = SubscriptionManagerMock() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender, subscriptionManager: subscriptionManager) XCTAssertEqual(viewModel.viewState, .feedbackPending) } @@ -32,7 +35,8 @@ final class UnifiedFeedbackFormViewModelTests: XCTestCase { func testWhenSendingFeedbackSucceeds_ThenFeedbackIsSent() async throws { let collector = MockVPNMetadataCollector() let sender = MockVPNFeedbackSender() - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + let subscriptionManager = SubscriptionManagerMock() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender, subscriptionManager: subscriptionManager) viewModel.selectedReportType = UnifiedFeedbackReportType.reportIssue.rawValue let text = "Some feedback report text" viewModel.feedbackFormText = text @@ -46,7 +50,8 @@ final class UnifiedFeedbackFormViewModelTests: XCTestCase { func testWhenSendingFeedbackFails_ThenFeedbackIsNotSent() async throws { let collector = MockVPNMetadataCollector() let sender = MockVPNFeedbackSender() - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + let subscriptionManager = SubscriptionManagerMock() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender, subscriptionManager: subscriptionManager) viewModel.selectedReportType = UnifiedFeedbackReportType.reportIssue.rawValue let text = "Some feedback report text" viewModel.feedbackFormText = text @@ -62,7 +67,8 @@ final class UnifiedFeedbackFormViewModelTests: XCTestCase { let collector = MockVPNMetadataCollector() let sender = MockVPNFeedbackSender() let delegate = MockVPNFeedbackFormViewModelDelegate() - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender) + let subscriptionManager = SubscriptionManagerMock() + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: collector, feedbackSender: sender, subscriptionManager: subscriptionManager) viewModel.delegate = delegate XCTAssertFalse(delegate.receivedDismissedViewCallback) @@ -193,3 +199,17 @@ private class MockVPNFeedbackFormViewModelDelegate: UnifiedFeedbackFormViewModel } } + +extension SubscriptionManagerMock { + + convenience init() { + self.init(accountManager: AccountManagerMock(), + subscriptionEndpointService: SubscriptionEndpointServiceMock(), + authEndpointService: AuthEndpointServiceMock(), + storePurchaseManager: StorePurchaseManagerMock(), + currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore), + canPurchase: false, + subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock()) + } +}