diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index da04bcfaf6..796c0f56fb 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -56,11 +56,21 @@ public enum FeatureFlag: String { /// https://app.asana.com/0/1208592102886666/1208613627589762/f case crashReportOptInStatusResetting + case isPrivacyProLaunchedROW + case isPrivacyProLaunchedROWOverride } extension FeatureFlag: FeatureFlagDescribing { + + public static var localOverrideStoreName: String = "com.duckduckgo.app.featureFlag.localOverrides" + public var supportsLocalOverriding: Bool { - false + switch self { + case .isPrivacyProLaunchedROWOverride: + return true + default: + return false + } } public var source: FeatureFlagSource { @@ -123,6 +133,10 @@ extension FeatureFlag: FeatureFlagDescribing { return .remoteReleasable(.feature(.adAttributionReporting)) case .crashReportOptInStatusResetting: return .internalOnly + case .isPrivacyProLaunchedROW: + return .remoteReleasable(.subfeature(PrivacyProSubfeature.isLaunchedROW)) + case .isPrivacyProLaunchedROWOverride: + return .remoteReleasable(.subfeature(PrivacyProSubfeature.isLaunchedROWOverride)) } } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 3800ace64b..0250667290 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -11054,7 +11054,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 211.1.3; + version = "211.1.3-1"; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4e271933b0..5ef0b5dc49 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" } }, { @@ -138,7 +138,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", "version" : "1.4.0" diff --git a/DuckDuckGo/AppDependencyProvider.swift b/DuckDuckGo/AppDependencyProvider.swift index 245901da44..b378ab86e9 100644 --- a/DuckDuckGo/AppDependencyProvider.swift +++ b/DuckDuckGo/AppDependencyProvider.swift @@ -94,7 +94,12 @@ final class AppDependencyProvider: DependencyProvider { private init() { featureFlagger = DefaultFeatureFlagger(internalUserDecider: internalUserDecider, - privacyConfigManager: ContentBlocking.shared.privacyConfigurationManager) + privacyConfigManager: ContentBlocking.shared.privacyConfigurationManager, + localOverrides: FeatureFlagLocalOverrides( + keyValueStore: UserDefaults(suiteName: FeatureFlag.localOverrideStoreName)!, + actionHandler: FeatureFlagOverridesPublishingHandler() + ), + for: FeatureFlag.self) configurationManager = ConfigurationManager(store: configurationStore) @@ -109,16 +114,33 @@ final class AppDependencyProvider: DependencyProvider { let accessTokenStorage = SubscriptionTokenKeychainStorage(keychainType: .dataProtection(.named(subscriptionAppGroup))) let subscriptionService = DefaultSubscriptionEndpointService(currentServiceEnvironment: subscriptionEnvironment.serviceEnvironment) let authService = DefaultAuthEndpointService(currentServiceEnvironment: subscriptionEnvironment.serviceEnvironment) + let subscriptionFeatureMappingCache = DefaultSubscriptionFeatureMappingCache(subscriptionEndpointService: subscriptionService, + userDefaults: subscriptionUserDefaults) + let accountManager = DefaultAccountManager(accessTokenStorage: accessTokenStorage, entitlementsCache: entitlementsCache, subscriptionEndpointService: subscriptionService, authEndpointService: authService) - let subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: DefaultStorePurchaseManager(), + let theFeatureFlagger = featureFlagger + let subscriptionFeatureFlagger: FeatureFlaggerMapping = FeatureFlaggerMapping { feature in + switch feature { + case .isLaunchedROW: + return theFeatureFlagger.isFeatureOn(.isPrivacyProLaunchedROW) + case .isLaunchedROWOverride: + return theFeatureFlagger.isFeatureOn(.isPrivacyProLaunchedROWOverride) + default: + return feature.defaultState + } + } + + let subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: DefaultStorePurchaseManager(subscriptionFeatureMappingCache: subscriptionFeatureMappingCache), accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, - subscriptionEnvironment: subscriptionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) accountManager.delegate = subscriptionManager self.subscriptionManager = subscriptionManager diff --git a/DuckDuckGo/NetworkProtectionRootView.swift b/DuckDuckGo/NetworkProtectionRootView.swift index 974c03022c..357e302c3d 100644 --- a/DuckDuckGo/NetworkProtectionRootView.swift +++ b/DuckDuckGo/NetworkProtectionRootView.swift @@ -27,6 +27,7 @@ struct NetworkProtectionRootView: View { let statusViewModel: NetworkProtectionStatusViewModel init() { + let subscriptionManager = AppDependencyProvider.shared.subscriptionManager let accountManager = AppDependencyProvider.shared.subscriptionManager.accountManager let locationListRepository = NetworkProtectionLocationListCompositeRepository(accountManager: accountManager) let usesUnifiedFeedbackForm = accountManager.isUserAuthenticated @@ -35,7 +36,8 @@ struct NetworkProtectionRootView: View { statusObserver: AppDependencyProvider.shared.connectionObserver, serverInfoObserver: AppDependencyProvider.shared.serverInfoObserver, locationListRepository: locationListRepository, - usesUnifiedFeedbackForm: usesUnifiedFeedbackForm) + usesUnifiedFeedbackForm: usesUnifiedFeedbackForm, + subscriptionManager: subscriptionManager) } var body: some View { diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index 88e6768dea..f8a51bf3db 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -309,7 +309,9 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func about() -> some View { - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .vpn) + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), + source: .vpn, + subscriptionManager: statusModel.subscriptionManager) Section { NavigationLink(UserText.netPVPNSettingsFAQ, destination: LazyView(NetworkProtectionFAQView())) diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index becccb79aa..566c697227 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -176,6 +176,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject { @Published public var animationsOn: Bool = false public let usesUnifiedFeedbackForm: Bool + public let subscriptionManager: SubscriptionManager public init(tunnelController: (TunnelController & TunnelSessionProvider), settings: VPNSettings, @@ -183,13 +184,15 @@ final class NetworkProtectionStatusViewModel: ObservableObject { serverInfoObserver: ConnectionServerInfoObserver, errorObserver: ConnectionErrorObserver = ConnectionErrorObserverThroughSession(), locationListRepository: NetworkProtectionLocationListRepository, - usesUnifiedFeedbackForm: Bool) { + usesUnifiedFeedbackForm: Bool, + subscriptionManager: SubscriptionManager) { self.tunnelController = tunnelController self.settings = settings self.statusObserver = statusObserver self.serverInfoObserver = serverInfoObserver self.errorObserver = errorObserver self.usesUnifiedFeedbackForm = usesUnifiedFeedbackForm + self.subscriptionManager = subscriptionManager statusMessage = Self.message(for: statusObserver.recentValue) self.headerTitle = Self.titleText(status: statusObserver.recentValue) diff --git a/DuckDuckGo/SettingsOthersView.swift b/DuckDuckGo/SettingsOthersView.swift index 96f3cab8d1..1e3202ec6e 100644 --- a/DuckDuckGo/SettingsOthersView.swift +++ b/DuckDuckGo/SettingsOthersView.swift @@ -34,9 +34,11 @@ struct SettingsOthersView: View { // Share Feedback if viewModel.usesUnifiedFeedbackForm { - let formViewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .settings) + let formViewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), + source: .settings, + subscriptionManager: viewModel.subscriptionManager) NavigationLink { - UnifiedFeedbackCategoryView(UserText.subscriptionFeedback, sources: UnifiedFeedbackFlowCategory.self, selection: $viewModel.selectedFeedbackFlow) { + UnifiedFeedbackCategoryView(UserText.subscriptionFeedback, options: UnifiedFeedbackFlowCategory.allCases, selection: $viewModel.selectedFeedbackFlow) { if let selectedFeedbackFlow = viewModel.selectedFeedbackFlow { switch UnifiedFeedbackFlowCategory(rawValue: selectedFeedbackFlow) { case nil: diff --git a/DuckDuckGo/SettingsState.swift b/DuckDuckGo/SettingsState.swift index 665077e29d..6d1fb7c43d 100644 --- a/DuckDuckGo/SettingsState.swift +++ b/DuckDuckGo/SettingsState.swift @@ -43,6 +43,7 @@ struct SettingsState { var hasActiveSubscription: Bool var isRestoring: Bool var shouldDisplayRestoreSubscriptionError: Bool + var subscriptionFeatures: [Entitlement.ProductName] var entitlements: [Entitlement.ProductName] var platform: DDGSubscription.Platform var isShowingStripeView: Bool @@ -132,6 +133,7 @@ struct SettingsState { hasActiveSubscription: false, isRestoring: false, shouldDisplayRestoreSubscriptionError: false, + subscriptionFeatures: [], entitlements: [], platform: .unknown, isShowingStripeView: false), diff --git a/DuckDuckGo/SettingsSubscriptionView.swift b/DuckDuckGo/SettingsSubscriptionView.swift index b2414c2855..7c6ce225eb 100644 --- a/DuckDuckGo/SettingsSubscriptionView.swift +++ b/DuckDuckGo/SettingsSubscriptionView.swift @@ -64,9 +64,19 @@ struct SettingsSubscriptionView: View { @ViewBuilder private var purchaseSubscriptionView: some View { Group { + let subtitleText = { + switch subscriptionManager.storePurchaseManager().currentStorefrontRegion { + case .usa: + UserText.settingsPProDescription + case .restOfWorld: + UserText.settingsPProROWDescription + } + }() + SettingsCellView(label: UserText.settingsPProSubscribe, - subtitle: UserText.settingsPProDescription, + subtitle: subtitleText, image: Image("SettingsPrivacyPro")) + .disabled(true) // Get privacy pro SettingsCustomCell(content: { @@ -93,23 +103,33 @@ struct SettingsSubscriptionView: View { @ViewBuilder private var disabledFeaturesView: some View { - SettingsCellView(label: UserText.settingsPProVPNTitle, - image: Image("SettingsPrivacyProVPN"), - statusIndicator: StatusIndicatorView(status: .off), - isGreyedOut: true - ) - SettingsCellView( - label: UserText.settingsPProDBPTitle, - image: Image("SettingsPrivacyProPIR"), - statusIndicator: StatusIndicatorView(status: .off), - isGreyedOut: true - ) - SettingsCellView( - label: UserText.settingsPProITRTitle, - image: Image("SettingsPrivacyProITP"), - statusIndicator: StatusIndicatorView(status: .off), - isGreyedOut: true - ) + let subscriptionFeatures = settingsViewModel.state.subscription.subscriptionFeatures + + if subscriptionFeatures.contains(.networkProtection) { + SettingsCellView(label: UserText.settingsPProVPNTitle, + image: Image("SettingsPrivacyProVPN"), + statusIndicator: StatusIndicatorView(status: .off), + isGreyedOut: true + ) + } + + if subscriptionFeatures.contains(.dataBrokerProtection) { + SettingsCellView( + label: UserText.settingsPProDBPTitle, + image: Image("SettingsPrivacyProPIR"), + statusIndicator: StatusIndicatorView(status: .off), + isGreyedOut: true + ) + } + + if subscriptionFeatures.contains(.identityTheftRestoration) || subscriptionFeatures.contains(.identityTheftRestorationGlobal) { + SettingsCellView( + label: UserText.settingsPProITRTitle, + image: Image("SettingsPrivacyProITP"), + statusIndicator: StatusIndicatorView(status: .off), + isGreyedOut: true + ) + } } @ViewBuilder @@ -155,37 +175,50 @@ struct SettingsSubscriptionView: View { @ViewBuilder private var subscriptionDetailsView: some View { - - if settingsViewModel.state.subscription.entitlements.contains(.networkProtection) { + let subscriptionFeatures = settingsViewModel.state.subscription.subscriptionFeatures + let userEntitlements = settingsViewModel.state.subscription.entitlements + + if subscriptionFeatures.contains(.networkProtection) { + let hasVPNEntitlement = userEntitlements.contains(.networkProtection) + let isVPNConnected = settingsViewModel.state.networkProtectionConnected + NavigationLink(destination: LazyView(NetworkProtectionRootView()), isActive: $isShowingVPN) { SettingsCellView( label: UserText.settingsPProVPNTitle, image: Image("SettingsPrivacyProVPN"), - statusIndicator: StatusIndicatorView(status: settingsViewModel.state.networkProtectionConnected ? .on : .off) + statusIndicator: StatusIndicatorView(status: isVPNConnected ? .on : .off), + isGreyedOut: !hasVPNEntitlement ) } + .disabled(!hasVPNEntitlement) } - - if settingsViewModel.state.subscription.entitlements.contains(.dataBrokerProtection) { + + if subscriptionFeatures.contains(.dataBrokerProtection) { + let hasDBPEntitlement = userEntitlements.contains(.dataBrokerProtection) + NavigationLink(destination: LazyView(SubscriptionPIRView()), isActive: $isShowingDBP) { SettingsCellView( label: UserText.settingsPProDBPTitle, image: Image("SettingsPrivacyProPIR"), - statusIndicator: StatusIndicatorView(status: .on) + statusIndicator: StatusIndicatorView(status: hasDBPEntitlement ? .on : .off), + isGreyedOut: !hasDBPEntitlement ) } + .disabled(!hasDBPEntitlement) } - - if settingsViewModel.state.subscription.entitlements.contains(.identityTheftRestoration) { - NavigationLink( - destination: LazyView(SubscriptionITPView()), - isActive: $isShowingITP) { - SettingsCellView( - label: UserText.settingsPProITRTitle, - image: Image("SettingsPrivacyProITP"), - statusIndicator: StatusIndicatorView(status: .on) - ) + + if subscriptionFeatures.contains(.identityTheftRestoration) || subscriptionFeatures.contains(.identityTheftRestorationGlobal) { + let hasITREntitlement = userEntitlements.contains(.identityTheftRestoration) || userEntitlements.contains(.identityTheftRestorationGlobal) + + NavigationLink(destination: LazyView(SubscriptionITPView()), isActive: $isShowingITP) { + SettingsCellView( + label: UserText.settingsPProITRTitle, + image: Image("SettingsPrivacyProITP"), + statusIndicator: StatusIndicatorView(status: hasITREntitlement ? .on : .off), + isGreyedOut: !hasITREntitlement + ) } + .disabled(!hasITREntitlement) } NavigationLink(destination: LazyView(SubscriptionSettingsView(configuration: .subscribed, settingsViewModel: settingsViewModel)) diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 5f96ce0215..a97700ad72 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -46,7 +46,7 @@ final class SettingsViewModel: ObservableObject { let textZoomCoordinator: TextZoomCoordinating // Subscription Dependencies - private let subscriptionManager: SubscriptionManager + let subscriptionManager: SubscriptionManager let subscriptionFeatureAvailability: SubscriptionFeatureAvailability private var subscriptionSignOutObserver: Any? var duckPlayerContingencyHandler: DuckPlayerContingencyHandler { @@ -750,7 +750,7 @@ extension SettingsViewModel { // Check entitlements and update state var currentEntitlements: [Entitlement.ProductName] = [] - let entitlementsToCheck: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] + let entitlementsToCheck: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration, .identityTheftRestorationGlobal] for entitlement in entitlementsToCheck { if case .success(true) = await subscriptionManager.accountManager.hasEntitlement(forProductName: entitlement) { @@ -759,6 +759,7 @@ extension SettingsViewModel { } self.state.subscription.entitlements = currentEntitlements + self.state.subscription.subscriptionFeatures = await subscriptionManager.currentSubscriptionFeatures() case .failure: break diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift index 6ecfd2aa63..a7c6716fb6 100644 --- a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift @@ -19,6 +19,7 @@ import Combine import SwiftUI +import Subscription final class UnifiedFeedbackFormViewModel: ObservableObject { enum Source: String { @@ -104,16 +105,33 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { let source: String + private(set) var availableCategories: [UnifiedFeedbackCategory] = [.subscription] + init(vpnMetadataCollector: any UnifiedMetadataCollector, defaultMetadatCollector: any UnifiedMetadataCollector = DefaultMetadataCollector(), feedbackSender: any UnifiedFeedbackSender = DefaultFeedbackSender(), - source: Source = .unknown) { + source: Source = .unknown, + subscriptionManager: any SubscriptionManager) { self.viewState = .feedbackPending self.vpnMetadataCollector = vpnMetadataCollector self.defaultMetadataCollector = defaultMetadatCollector self.feedbackSender = feedbackSender self.source = source.rawValue + + Task { + let features = await subscriptionManager.currentSubscriptionFeatures() + + if features.contains(.networkProtection) { + availableCategories.append(.vpn) + } + if features.contains(.dataBrokerProtection) { + availableCategories.append(.pir) + } + if features.contains(.identityTheftRestoration) || features.contains(.identityTheftRestorationGlobal) { + availableCategories.append(.itr) + } + } } @MainActor diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift index 58b43f15d7..8fc22088f3 100644 --- a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackRootView.swift @@ -24,7 +24,7 @@ struct UnifiedFeedbackRootView: View { @StateObject var viewModel: UnifiedFeedbackFormViewModel var body: some View { - UnifiedFeedbackCategoryView(UserText.pproFeedbackFormTitle, sources: UnifiedFeedbackReportType.self, selection: $viewModel.selectedReportType) { + UnifiedFeedbackCategoryView(UserText.pproFeedbackFormTitle, options: UnifiedFeedbackReportType.allCases, selection: $viewModel.selectedReportType) { if let selectedReportType = viewModel.selectedReportType { switch UnifiedFeedbackReportType(rawValue: selectedReportType) { case nil: @@ -54,7 +54,7 @@ struct UnifiedFeedbackRootView: View { @ViewBuilder func reportProblemView() -> some View { UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportProblemTitle, - sources: UnifiedFeedbackCategory.self, + options: viewModel.availableCategories, selection: $viewModel.selectedCategory) { Group { if let selectedCategory = viewModel.selectedCategory { @@ -63,28 +63,28 @@ struct UnifiedFeedbackRootView: View { EmptyView() case .subscription: UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportPProProblemTitle, - sources: PrivacyProFeedbackSubcategory.self, + options: PrivacyProFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { IssueDescriptionFormView(viewModel: viewModel, placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) } case .vpn: UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportVPNProblemTitle, - sources: VPNFeedbackSubcategory.self, + options: VPNFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { IssueDescriptionFormView(viewModel: viewModel, placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) } case .pir: UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportPIRProblemTitle, - sources: PIRFeedbackSubcategory.self, + options: PIRFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { IssueDescriptionFormView(viewModel: viewModel, placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) } case .itr: UnifiedFeedbackCategoryView(UserText.pproFeedbackFormReportITRProblemTitle, - sources: ITRFeedbackSubcategory.self, + options: ITRFeedbackSubcategory.allCases, selection: $viewModel.selectedSubcategory) { IssueDescriptionFormView(viewModel: viewModel, placeholder: UserText.pproFeedbackFormReportProblemPlaceholder) @@ -106,21 +106,21 @@ struct UnifiedFeedbackRootView: View { } } -struct UnifiedFeedbackCategoryView: View where Category.AllCases == [Category], Category.RawValue == String { +struct UnifiedFeedbackCategoryView: View where Category.RawValue == String { let title: String let prompt: String - let sources: Category.Type + let options: [Category] let selection: Binding let destination: () -> Destination init(_ title: String, prompt: String = UserText.pproFeedbackFormSelectCategoryTitle, - sources: Category.Type, + options: [Category], selection: Binding, @ViewBuilder destination: @escaping () -> Destination) { self.title = title self.prompt = prompt - self.sources = sources + self.options = options self.selection = selection self.destination = destination } @@ -129,7 +129,7 @@ struct UnifiedFeedbackCategoryView Void)? var onBackToSettings: (() -> Void)? - var onFeatureSelected: ((SubscriptionFeatureSelection) -> Void)? + var onFeatureSelected: ((Entitlement.ProductName) -> Void)? var onActivateSubscription: (() -> Void)? struct FeatureSelection: Codable { - let feature: String + let productFeature: Entitlement.ProductName } weak var broker: UserScriptMessageBroker? @@ -206,7 +206,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec if subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed { return subscriptionOptions } else { - return SubscriptionOptions.empty + return subscriptionOptions.withoutPurchaseOptions() } } else { Logger.subscription.error("Failed to obtain subscription options") @@ -330,15 +330,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec return nil } - guard let featureSelection = SubscriptionFeatureSelection(featureName: featureSelection.feature) else { - assertionFailure("SubscriptionPagesUserScript: unexpected feature name value") - Logger.subscription.error("SubscriptionPagesUserScript: unexpected feature name value") - setTransactionError(.generalError) - return nil - } + onFeatureSelected?(featureSelection.productFeature) - onFeatureSelected?(featureSelection) - return nil } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift index 75b49918cb..a059bcae64 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift @@ -170,15 +170,17 @@ final class SubscriptionEmailViewModel: ObservableObject { subFeature.onFeatureSelected = { feature in DispatchQueue.main.async { switch feature { - case .netP: + case .networkProtection: UniquePixel.fire(pixel: .privacyProWelcomeVPN) self.state.selectedFeature = .netP - case .itr: + case .dataBrokerProtection: UniquePixel.fire(pixel: .privacyProWelcomePersonalInformationRemoval) self.state.selectedFeature = .itr - case .dbp: + case .identityTheftRestoration, .identityTheftRestorationGlobal: UniquePixel.fire(pixel: .privacyProWelcomeIdentityRestoration) self.state.selectedFeature = .dbp + case .unknown: + break } } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift index 04a59ff21c..5dc11256ab 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift @@ -115,15 +115,17 @@ final class SubscriptionFlowViewModel: ObservableObject { subFeature.onFeatureSelected = { feature in DispatchQueue.main.async { switch feature { - case .netP: + case .networkProtection: UniquePixel.fire(pixel: .privacyProWelcomeVPN) self.state.selectedFeature = .netP - case .dbp: + case .dataBrokerProtection: UniquePixel.fire(pixel: .privacyProWelcomePersonalInformationRemoval) self.state.selectedFeature = .dbp - case .itr: + case .identityTheftRestoration, .identityTheftRestorationGlobal: UniquePixel.fire(pixel: .privacyProWelcomeIdentityRestoration) self.state.selectedFeature = .itr + case .unknown: + break } } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index 82c7b3f04b..5d6f5c57d8 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -243,7 +243,9 @@ struct SubscriptionSettingsView: View { @ViewBuilder private var supportButton: some View { - let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .ppro) + let viewModel = UnifiedFeedbackFormViewModel(vpnMetadataCollector: DefaultVPNMetadataCollector(), + source: .ppro, + subscriptionManager: settingsViewModel.subscriptionManager) NavigationLink(UserText.subscriptionFeedback, destination: UnifiedFeedbackRootView(viewModel: viewModel)) .daxBodyRegular() .foregroundColor(.init(designSystemColor: .textPrimary)) diff --git a/DuckDuckGo/SubscriptionDebugViewController.swift b/DuckDuckGo/SubscriptionDebugViewController.swift index 5bd7505d64..6dbc713030 100644 --- a/DuckDuckGo/SubscriptionDebugViewController.swift +++ b/DuckDuckGo/SubscriptionDebugViewController.swift @@ -23,6 +23,7 @@ import Subscription import Core import NetworkProtection import StoreKit +import BrowserServicesKit final class SubscriptionDebugViewController: UITableViewController { @@ -30,6 +31,9 @@ final class SubscriptionDebugViewController: UITableViewController { private var subscriptionManager: SubscriptionManager { AppDependencyProvider.shared.subscriptionManager } + private var featureFlagger: FeatureFlagger { + AppDependencyProvider.shared.featureFlagger + } // swiftlint:disable:next force_cast private let reporter = (UIApplication.shared.delegate as! AppDelegate).privacyProDataReporter as! PrivacyProDataReporter @@ -40,7 +44,8 @@ final class SubscriptionDebugViewController: UITableViewController { Sections.appstore: "App Store", Sections.environment: "Environment", Sections.pixels: "Promo Pixel Parameters", - Sections.metadata: "StoreKit Metadata" + Sections.metadata: "StoreKit Metadata", + Sections.featureFlags: "Feature flags" ] enum Sections: Int, CaseIterable { @@ -50,6 +55,7 @@ final class SubscriptionDebugViewController: UITableViewController { case environment case pixels case metadata + case featureFlags } enum AuthorizationRows: Int, CaseIterable { @@ -82,6 +88,10 @@ final class SubscriptionDebugViewController: UITableViewController { case countryCode } + enum FeatureFlagRows: Int, CaseIterable { + case isLaunchedROW + } + private var storefrontID = "Loading" private var storefrontCountryCode = "Loading" @@ -173,6 +183,16 @@ final class SubscriptionDebugViewController: UITableViewController { case .none: break } + + case .featureFlags: + switch FeatureFlagRows(rawValue: indexPath.row) { + case .isLaunchedROW: + cell.textLabel?.text = "isPrivacyProLaunchedROWOverride" + cell.accessoryType = featureFlagger.isFeatureOn(.isPrivacyProLaunchedROWOverride) ? .checkmark : .none + case .none: + break + } + case .none: break } @@ -188,6 +208,7 @@ final class SubscriptionDebugViewController: UITableViewController { case .environment: return EnvironmentRows.allCases.count case .pixels: return PixelsRows.allCases.count case .metadata: return MetadataRows.allCases.count + case .featureFlags: return FeatureFlagRows.allCases.count case .none: return 0 } } @@ -223,6 +244,11 @@ final class SubscriptionDebugViewController: UITableViewController { } case .metadata: break + case .featureFlags: + switch FeatureFlagRows(rawValue: indexPath.row) { + case .isLaunchedROW: toggleIsLaunchedROWFlag() + default: break + } case .none: break } @@ -331,6 +357,16 @@ final class SubscriptionDebugViewController: UITableViewController { showAlert(title: "", message: message) } + private func toggleIsLaunchedROWFlag() { + let flag = FeatureFlag.isPrivacyProLaunchedROWOverride + if featureFlagger.localOverrides?.override(for: flag) == nil { + featureFlagger.localOverrides?.toggleOverride(for: flag) + } else { + featureFlagger.localOverrides?.clearOverride(for: flag) + } + tableView.reloadData() + } + private func syncAppleIDAccount() { Task { do { diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index b601eb9f28..b6bb75c759 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1084,6 +1084,7 @@ But if you *do* want a peek under the hood, you can find more information about public static let settingsPProSectionFooter = NSLocalizedString("settings.ppro.footer", value: "Privacy Policy and Terms of Service", comment: "Title for Link in the Footer of Privacy Pro section") public static let settingsPProSubscribe = NSLocalizedString("settings.subscription.subscribe", value: "Protect your connection and identity with Privacy Pro", comment: "Call to action title for Privacy Pro settings") public static let settingsPProDescription = NSLocalizedString("settings.subscription.description", value:"Includes our VPN, Personal Information Removal, and Identity Theft Restoration.", comment: "Privacy pro description subtitle in settings") + public static let settingsPProROWDescription = NSLocalizedString("settings.subscription.row.description", value:"Includes our VPN and Identity Theft Restoration.", comment: "Privacy Pro description subtitle in settings") public static let settingsPProActivating = NSLocalizedString("settings.subscription.activating", value:"Activating", comment: "Privacy pro description subtitle in settings when the is activating") public static let settingsPProLearnMore = NSLocalizedString("settings.subscription.learn.more", value: "Get Privacy Pro", comment: "Get Privacy Pro button text for privacy pro") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index fc7d856f4a..ca98d2afcc 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -2370,6 +2370,9 @@ But if you *do* want a peek under the hood, you can find more information about /* Subscription Settings button text for privacy pro */ "settings.subscription.manage" = "Subscription Settings"; +/* Privacy Pro description subtitle in settings */ +"settings.subscription.row.description" = "Includes our VPN and Identity Theft Restoration."; + /* Call to action title for Privacy Pro settings */ "settings.subscription.subscribe" = "Protect your connection and identity with Privacy Pro"; diff --git a/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift b/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift index 47428ed53d..de2c62cac0 100644 --- a/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift +++ b/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift @@ -22,12 +22,14 @@ import NetworkProtection import NetworkExtension import NetworkProtectionTestUtils import SubscriptionTestingUtilities +import Subscription @testable import DuckDuckGo final class NetworkProtectionStatusViewModelTests: XCTestCase { private var tunnelController: MockTunnelController! private var statusObserver: MockConnectionStatusObserver! private var serverInfoObserver: MockConnectionServerInfoObserver! + private var subscriptionManager: SubscriptionManagerMock! private var viewModel: NetworkProtectionStatusViewModel! private var testError: Error { @@ -40,12 +42,20 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { tunnelController = MockTunnelController() statusObserver = MockConnectionStatusObserver() serverInfoObserver = MockConnectionServerInfoObserver() + subscriptionManager = SubscriptionManagerMock(accountManager: AccountManagerMock(), + subscriptionEndpointService: SubscriptionEndpointServiceMock(), + authEndpointService: AuthEndpointServiceMock(), + storePurchaseManager: StorePurchaseManagerMock(), + currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore), + canPurchase: true, + subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock()) viewModel = NetworkProtectionStatusViewModel(tunnelController: tunnelController, settings: VPNSettings(defaults: .networkProtectionGroupDefaults), statusObserver: statusObserver, serverInfoObserver: serverInfoObserver, locationListRepository: MockNetworkProtectionLocationListRepository(), - usesUnifiedFeedbackForm: false) + usesUnifiedFeedbackForm: false, + subscriptionManager: subscriptionManager) } override func tearDown() { diff --git a/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift b/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift index d8f38e823f..c3004d38fb 100644 --- a/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift +++ b/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift @@ -41,7 +41,8 @@ final class StorePurchaseManagerTests: XCTestCase { session.disableDialogs = true session.clearTransactions() - storePurchaseManager = DefaultStorePurchaseManager() + let subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + storePurchaseManager = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) } override func tearDownWithError() throws { @@ -70,7 +71,7 @@ final class StorePurchaseManagerTests: XCTestCase { // Then XCTAssertEqual(subscriptionOptions.options.count, 2) - XCTAssertEqual(subscriptionOptions.features.count, SubscriptionFeatureName.allCases.count) + XCTAssertEqual(subscriptionOptions.features.count, 3) XCTAssertTrue(storePurchaseManager.areProductsAvailable) let optionIDs = subscriptionOptions.options.map { $0.id } diff --git a/DuckDuckGoTests/Subscription/SubscriptionContainerViewModelTests.swift b/DuckDuckGoTests/Subscription/SubscriptionContainerViewModelTests.swift index 2d4979997f..7efea415d4 100644 --- a/DuckDuckGoTests/Subscription/SubscriptionContainerViewModelTests.swift +++ b/DuckDuckGoTests/Subscription/SubscriptionContainerViewModelTests.swift @@ -30,13 +30,15 @@ final class SubscriptionContainerViewModelTests: XCTestCase { let subscriptionService = SubscriptionEndpointServiceMock() let authService = AuthEndpointServiceMock() let storePurchaseManager = StorePurchaseManagerMock() + let subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() return SubscriptionManagerMock(accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, storePurchaseManager: storePurchaseManager, currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore), - canPurchase: true) + canPurchase: true, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) }() let subscriptionFeatureAvailability = SubscriptionFeatureAvailabilityMock.enabled diff --git a/DuckDuckGoTests/Subscription/SubscriptionFlowViewModelTests.swift b/DuckDuckGoTests/Subscription/SubscriptionFlowViewModelTests.swift index 67235b541e..cc114e794b 100644 --- a/DuckDuckGoTests/Subscription/SubscriptionFlowViewModelTests.swift +++ b/DuckDuckGoTests/Subscription/SubscriptionFlowViewModelTests.swift @@ -29,14 +29,16 @@ final class SubscriptionFlowViewModelTests: XCTestCase { let accountManager = AccountManagerMock() let subscriptionService = DefaultSubscriptionEndpointService(currentServiceEnvironment: .production) let authService = DefaultAuthEndpointService(currentServiceEnvironment: .production) - let storePurchaseManager = DefaultStorePurchaseManager() + let subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + let storePurchaseManager = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) return SubscriptionManagerMock(accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, storePurchaseManager: storePurchaseManager, currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore), - canPurchase: true) + canPurchase: true, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) }() let subscriptionFeatureAvailability = SubscriptionFeatureAvailabilityMock.enabled diff --git a/DuckDuckGoTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift b/DuckDuckGoTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift index 8636921470..0eb8d432a6 100644 --- a/DuckDuckGoTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift +++ b/DuckDuckGoTests/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.ios, 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, @@ -81,6 +81,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! @@ -129,6 +132,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, @@ -156,7 +162,9 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, - subscriptionEnvironment: subscriptionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) feature = SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, subscriptionFeatureAvailability: subscriptionFeatureAvailability, @@ -812,11 +820,11 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { let onFeatureSelectedCalled = expectation(description: "onFeatureSelected") feature.onFeatureSelected = { selection in onFeatureSelectedCalled.fulfill() - XCTAssertEqual(selection, SubscriptionFeatureSelection.itr) + XCTAssertEqual(selection, .identityTheftRestoration) } // When - let featureSelectionParams = ["feature": SubscriptionFeatureName.itr] + let featureSelectionParams = ["productFeature": Entitlement.ProductName.identityTheftRestoration.rawValue] let result = await feature.featureSelected(params: featureSelectionParams, original: Constants.mockScriptMessage) // Then