diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index da04bcfaf6..3b2a79bbc3 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -56,17 +56,27 @@ 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 { switch self { case .debugMenu: - return .internalOnly + return .internalOnly() case .sync: return .remoteReleasable(.subfeature(SyncSubfeature.level0ShowSync)) case .autofillCredentialInjecting: @@ -106,9 +116,9 @@ extension FeatureFlag: FeatureFlagDescribing { case .syncPromotionPasswords: return .remoteReleasable(.subfeature(SyncPromotionSubfeature.passwords)) case .onboardingHighlights: - return .internalOnly + return .internalOnly() case .onboardingAddToDock: - return .internalOnly + return .internalOnly() case .autofillSurveys: return .remoteReleasable(.feature(.autofillSurveys)) case .autcompleteTabs: @@ -122,7 +132,11 @@ extension FeatureFlag: FeatureFlagDescribing { case .adAttributionReporting: return .remoteReleasable(.feature(.adAttributionReporting)) case .crashReportOptInStatusResetting: - return .internalOnly + 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 c2fe790d60..574f5d5c53 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -366,7 +366,7 @@ 6FEC0B882C999961006B4F6E /* FavoritesListInteractingAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */; }; 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */; }; 6FF9AD3F2CE63DD800C5A406 /* TabSwitcherOpenDailyPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD3E2CE63DC200C5A406 /* TabSwitcherOpenDailyPixel.swift */; }; - 6FF9AD412CE6610F00C5A406 /* TabSwitcherDailyPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD402CE6610600C5A406 /* TabSwitcherDailyPixelTests.swift */; }; + 6FF9AD412CE6610F00C5A406 /* TabSwitcherOpenDailyPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD402CE6610600C5A406 /* TabSwitcherOpenDailyPixelTests.swift */; }; 6FF9AD452CE766F700C5A406 /* NewTabPageControllerPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */; }; 7B1604E82CB685B400A44EC6 /* Logger+TipKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */; }; 7B1604EC2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */; }; @@ -982,7 +982,6 @@ CBAA195A27BFE15600A4BD49 /* NSManagedObjectContextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */; }; CBC83E3429B631780008E19C /* Configuration in Frameworks */ = {isa = PBXBuildFile; productRef = CBC83E3329B631780008E19C /* Configuration */; }; CBC88EE12C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */; }; - CBC88EE32C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE22C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift */; }; CBC88EE52C8097B500F0F8C5 /* URLCredentialCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */; }; CBCCF96828885DEE006F4A71 /* AppPrivacyConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C4BC3127C3F9B600C40026 /* AppPrivacyConfigurationTests.swift */; }; CBD4F13C279EBF4A00B20FD7 /* HomeMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD4F13B279EBF4A00B20FD7 /* HomeMessage.swift */; }; @@ -1699,7 +1698,7 @@ 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesListInteractingAdapter.swift; sourceTree = ""; }; 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionFetcherTests.swift; sourceTree = ""; }; 6FF9AD3E2CE63DC200C5A406 /* TabSwitcherOpenDailyPixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSwitcherOpenDailyPixel.swift; sourceTree = ""; }; - 6FF9AD402CE6610600C5A406 /* TabSwitcherDailyPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSwitcherDailyPixelTests.swift; sourceTree = ""; }; + 6FF9AD402CE6610600C5A406 /* TabSwitcherOpenDailyPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSwitcherOpenDailyPixelTests.swift; sourceTree = ""; }; 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageControllerPixelTests.swift; sourceTree = ""; }; 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+TipKit.swift"; sourceTree = ""; }; 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TipKitController+ConvenienceInitializers.swift"; sourceTree = ""; }; @@ -2815,7 +2814,6 @@ CBB6B2542AF6D543006B777C /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/InfoPlist.strings; sourceTree = ""; }; CBC7AB542AF6D583008CB798 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScriptTests.swift; sourceTree = ""; }; - CBC88EE22C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageTests.swift; sourceTree = ""; }; CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLCredentialCreator.swift; sourceTree = ""; }; CBC8DC252AF6D4CD00BA681A /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; CBD4F13B279EBF4A00B20FD7 /* HomeMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMessage.swift; sourceTree = ""; }; @@ -5988,7 +5986,7 @@ F13B4BF71F18C9E800814661 /* Tabs */ = { isa = PBXGroup; children = ( - 6FF9AD402CE6610600C5A406 /* TabSwitcherDailyPixelTests.swift */, + 6FF9AD402CE6610600C5A406 /* TabSwitcherOpenDailyPixelTests.swift */, 85010503292FFB080033978F /* FireproofFaviconUpdaterTests.swift */, 8565A34C1FC8DFE400239327 /* LaunchTabNotificationTests.swift */, 984D035F24AF49160066CFB8 /* TabPreviewsSourceTests.swift */, @@ -5997,7 +5995,6 @@ F189AED61F18F6DE001EBAE1 /* TabTests.swift */, D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */, CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */, - CBC88EE22C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift */, ); name = Tabs; sourceTree = ""; @@ -8149,11 +8146,10 @@ C1B7B53428944EFA0098FD6A /* CoreDataTestUtilities.swift in Sources */, 859DB81E2CE62766001F7210 /* TextZoomTests.swift in Sources */, 1DE384E42BC41E2500871AF6 /* PixelExperimentTests.swift in Sources */, - 6FF9AD412CE6610F00C5A406 /* TabSwitcherDailyPixelTests.swift in Sources */, + 6FF9AD412CE6610F00C5A406 /* TabSwitcherOpenDailyPixelTests.swift in Sources */, CBDD5DE129A6741300832877 /* MockBundle.swift in Sources */, C158AC7B297AB5DC0008723A /* MockSecureVault.swift in Sources */, 569437342BE4E41500C0881B /* SyncErrorHandlerSyncErrorsAlertsTests.swift in Sources */, - CBC88EE32C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift in Sources */, 85C11E4120904BBE00BFFEB4 /* VariantManagerTests.swift in Sources */, F1134ECE1F40EA9C00B73467 /* AtbParserTests.swift in Sources */, F189AEE41F18FDAF001EBAE1 /* LinkTests.swift in Sources */, @@ -9365,7 +9361,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -9402,7 +9398,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9492,7 +9488,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9519,7 +9515,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -9665,7 +9661,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9689,7 +9685,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGo.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; INFOPLIST_FILE = DuckDuckGo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9756,7 +9752,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -9790,7 +9786,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -9823,7 +9819,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -9853,7 +9849,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10242,7 +10238,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10273,7 +10269,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = ShareExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10301,7 +10297,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = OpenAction/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -10334,7 +10330,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; @@ -10364,7 +10360,7 @@ CODE_SIGN_ENTITLEMENTS = PacketTunnelProvider/PacketTunnelProviderAlpha.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -10397,11 +10393,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 4; + DYLIB_CURRENT_VERSION = 6; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10631,7 +10627,7 @@ CODE_SIGN_ENTITLEMENTS = DuckDuckGo/DuckDuckGoAlpha.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10659,7 +10655,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10691,7 +10687,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10728,7 +10724,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; @@ -10763,7 +10759,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HKE973VLUW; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -10798,11 +10794,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 4; + DYLIB_CURRENT_VERSION = 6; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -10974,11 +10970,11 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 4; + DYLIB_CURRENT_VERSION = 6; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -11007,10 +11003,10 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 6; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 4; + DYLIB_CURRENT_VERSION = 6; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Core/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -11246,8 +11242,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { - branch = "sam/remove-malformed-error-pixel"; - kind = branch; + kind = exactVersion; + version = 215.0.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 5d96468176..d6698fc14d 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" : { - "branch" : "sam/remove-malformed-error-pixel", - "revision" : "a2f5f25a6ad30edd672bb77699f824e7510fad56" + "revision" : "837dfbfe7a1b2a5e0ec2fb24a47a53dec53444b0", + "version" : "215.0.1" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "dfef00ef77f5181d1d8a4f7cc88f7b7c0514dd34", - "version" : "6.39.0" + "revision" : "c4bb146afdf0c7a93fb9a7d95b1cb255708a470d", + "version" : "6.41.0" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "757bbbae1e2afbb421caee9bfca04ee5c56c3af8", - "version" : "7.2.0" + "revision" : "49db79829dcb166b3524afdbc1c680890452ce1c", + "version" : "7.2.1" } }, { diff --git a/DuckDuckGo/AppDependencyProvider.swift b/DuckDuckGo/AppDependencyProvider.swift index 245901da44..b867f5ea79 100644 --- a/DuckDuckGo/AppDependencyProvider.swift +++ b/DuckDuckGo/AppDependencyProvider.swift @@ -94,7 +94,13 @@ 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() + ), + experimentManager: ExperimentCohortsManager(store: ExperimentsDataStore()), + for: FeatureFlag.self) configurationManager = ConfigurationManager(store: configurationStore) @@ -109,16 +115,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/Debug.storyboard b/DuckDuckGo/Debug.storyboard index 4f26190100..b09aefbf25 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -1001,12 +1001,28 @@ - + + + + + @@ -1031,17 +1047,17 @@ - + - + - + - + @@ -1125,6 +1141,9 @@ + + + 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 761aa4e1fc..fa8ae1ff0a 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -281,7 +281,7 @@ struct NetworkProtectionStatusView: View { @ViewBuilder private func about() -> some View { - let viewModel = UnifiedFeedbackFormViewModel(accountManager: AppDependencyProvider.shared.accountManager, + let viewModel = UnifiedFeedbackFormViewModel(subscriptionManager: statusModel.subscriptionManager, apiService: DefaultAPIService(), vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .vpn) diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 16f0904f33..74732af3fe 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -178,6 +178,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, @@ -185,14 +186,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 bcb4d39b34..b3818cf4b3 100644 --- a/DuckDuckGo/SettingsOthersView.swift +++ b/DuckDuckGo/SettingsOthersView.swift @@ -35,12 +35,12 @@ struct SettingsOthersView: View { // Share Feedback if viewModel.usesUnifiedFeedbackForm { - let formViewModel = UnifiedFeedbackFormViewModel(accountManager: AppDependencyProvider.shared.accountManager, + let formViewModel = UnifiedFeedbackFormViewModel(subscriptionManager: viewModel.subscriptionManager, apiService: DefaultAPIService(), vpnMetadataCollector: DefaultVPNMetadataCollector(), source: .settings) 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 ea36971224..394edeb856 100644 --- a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackFormViewModel.swift @@ -143,7 +143,9 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { let source: String - init(accountManager: any AccountManager, + private(set) var availableCategories: [UnifiedFeedbackCategory] = [.subscription] + + init(subscriptionManager: any SubscriptionManager, apiService: any Networking.APIService, vpnMetadataCollector: any UnifiedMetadataCollector, defaultMetadatCollector: any UnifiedMetadataCollector = DefaultMetadataCollector(), @@ -151,12 +153,26 @@ final class UnifiedFeedbackFormViewModel: ObservableObject { source: Source = .unknown) { self.viewState = .feedbackPending - self.accountManager = accountManager + self.accountManager = subscriptionManager.accountManager self.apiService = apiService 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 615707a755..bd11bbcbdf 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 a06e664b76..70d0639948 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -244,12 +244,10 @@ struct SubscriptionSettingsView: View { @ViewBuilder private var supportButton: some View { - let viewModel = UnifiedFeedbackFormViewModel( - accountManager: AppDependencyProvider.shared.accountManager, - apiService: DefaultAPIService(), - vpnMetadataCollector: DefaultVPNMetadataCollector(), - source: .ppro - ) + let viewModel = UnifiedFeedbackFormViewModel(subscriptionManager: settingsViewModel.subscriptionManager, + apiService: DefaultAPIService(), + vpnMetadataCollector: DefaultVPNMetadataCollector(), + source: .ppro) NavigationLink(UserText.subscriptionFeedback, destination: UnifiedFeedbackRootView(viewModel: viewModel)) .daxBodyRegular() .foregroundColor(.init(designSystemColor: .textPrimary)) diff --git a/DuckDuckGo/SubscriptionDebugViewController.swift b/DuckDuckGo/SubscriptionDebugViewController.swift index 45247ddb76..6dbc713030 100644 --- a/DuckDuckGo/SubscriptionDebugViewController.swift +++ b/DuckDuckGo/SubscriptionDebugViewController.swift @@ -22,6 +22,8 @@ import UIKit import Subscription import Core import NetworkProtection +import StoreKit +import BrowserServicesKit final class SubscriptionDebugViewController: UITableViewController { @@ -29,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 @@ -39,6 +44,8 @@ final class SubscriptionDebugViewController: UITableViewController { Sections.appstore: "App Store", Sections.environment: "Environment", Sections.pixels: "Promo Pixel Parameters", + Sections.metadata: "StoreKit Metadata", + Sections.featureFlags: "Feature flags" ] enum Sections: Int, CaseIterable { @@ -47,6 +54,8 @@ final class SubscriptionDebugViewController: UITableViewController { case appstore case environment case pixels + case metadata + case featureFlags } enum AuthorizationRows: Int, CaseIterable { @@ -74,10 +83,27 @@ final class SubscriptionDebugViewController: UITableViewController { case randomize } + enum MetadataRows: Int, CaseIterable { + case storefrontID + case countryCode + } + + enum FeatureFlagRows: Int, CaseIterable { + case isLaunchedROW + } + + private var storefrontID = "Loading" + private var storefrontCountryCode = "Loading" + override func numberOfSections(in tableView: UITableView) -> Int { return Sections.allCases.count } + override func viewDidLoad() { + super.viewDidLoad() + loadStoreKitMetadata() + } + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { guard let section = Sections(rawValue: section) else { return nil } return titles[section] @@ -145,6 +171,28 @@ final class SubscriptionDebugViewController: UITableViewController { case .none: break } + + case .metadata: + switch MetadataRows(rawValue: indexPath.row) { + case .storefrontID: + cell.textLabel?.text = "Storefront ID" + cell.detailTextLabel?.text = storefrontID + case .countryCode: + cell.textLabel?.text = "Country Code" + cell.detailTextLabel?.text = storefrontCountryCode + 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 } @@ -159,8 +207,9 @@ final class SubscriptionDebugViewController: UITableViewController { case .appstore: return AppStoreRows.allCases.count 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 - } } @@ -193,6 +242,13 @@ final class SubscriptionDebugViewController: UITableViewController { case .randomize: showRandomizedParamters() default: break } + case .metadata: + break + case .featureFlags: + switch FeatureFlagRows(rawValue: indexPath.row) { + case .isLaunchedROW: toggleIsLaunchedROWFlag() + default: break + } case .none: break } @@ -301,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 { @@ -389,6 +455,15 @@ final class SubscriptionDebugViewController: UITableViewController { NetworkProtectionLocationListCompositeRepository.clearCache() } } + + private func loadStoreKitMetadata() { + Task { @MainActor in + let storefront = await Storefront.current + self.storefrontID = storefront?.id ?? "nil" + self.storefrontCountryCode = storefront?.countryCode ?? "nil" + self.tableView.reloadData() + } + } } extension Bool { diff --git a/DuckDuckGo/Tab.swift b/DuckDuckGo/Tab.swift index 04e568eec6..437356124c 100644 --- a/DuckDuckGo/Tab.swift +++ b/DuckDuckGo/Tab.swift @@ -36,12 +36,20 @@ public class Tab: NSObject, NSCoding { static let link = "link" static let viewed = "viewed" static let desktop = "desktop" + static let lastViewedDate = "lastViewedDate" } private var observersHolder = [WeaklyHeldTabObserver]() let uid: String - + + /// The date last time this tab was displayed. + /// + /// - Warning: This value **must not** be used for any other purpose than for inactive tabs buckets aggregation + /// into a daily pixel in `TabSwitcherOpenDailyPixel`. If you plan to do something else, + /// read through https://app.asana.com/0/69071770703008/1208795393823862/f and reopen if necessary. + private(set) var lastViewedDate: Date? + var isDesktop: Bool = false { didSet { notifyObservers() @@ -56,6 +64,9 @@ public class Tab: NSObject, NSCoding { var viewed: Bool = false { didSet { + if viewed { + lastViewedDate = Date() + } notifyObservers() } } @@ -63,11 +74,13 @@ public class Tab: NSObject, NSCoding { public init(uid: String? = nil, link: Link? = nil, viewed: Bool = false, - desktop: Bool = AppWidthObserver.shared.isLargeWidth) { + desktop: Bool = AppWidthObserver.shared.isLargeWidth, + lastViewedDate: Date? = nil) { self.uid = uid ?? UUID().uuidString self.link = link self.viewed = viewed self.isDesktop = desktop + self.lastViewedDate = lastViewedDate } public convenience required init?(coder decoder: NSCoder) { @@ -75,7 +88,8 @@ public class Tab: NSObject, NSCoding { let link = decoder.decodeObject(forKey: NSCodingKeys.link) as? Link let viewed = decoder.containsValue(forKey: NSCodingKeys.viewed) ? decoder.decodeBool(forKey: NSCodingKeys.viewed) : true let desktop = decoder.containsValue(forKey: NSCodingKeys.desktop) ? decoder.decodeBool(forKey: NSCodingKeys.desktop) : false - self.init(uid: uid, link: link, viewed: viewed, desktop: desktop) + let lastViewedDate = decoder.containsValue(forKey: NSCodingKeys.lastViewedDate) ? decoder.decodeObject(forKey: NSCodingKeys.lastViewedDate) as? Date : nil + self.init(uid: uid, link: link, viewed: viewed, desktop: desktop, lastViewedDate: lastViewedDate) } public func encode(with coder: NSCoder) { @@ -83,6 +97,7 @@ public class Tab: NSObject, NSCoding { coder.encode(link, forKey: NSCodingKeys.link) coder.encode(viewed, forKey: NSCodingKeys.viewed) coder.encode(isDesktop, forKey: NSCodingKeys.desktop) + coder.encode(lastViewedDate, forKey: NSCodingKeys.lastViewedDate) } public override func isEqual(_ other: Any?) -> Bool { diff --git a/DuckDuckGo/TabSwitcherOpenDailyPixel.swift b/DuckDuckGo/TabSwitcherOpenDailyPixel.swift index 8b6941ceb2..c46735c497 100644 --- a/DuckDuckGo/TabSwitcherOpenDailyPixel.swift +++ b/DuckDuckGo/TabSwitcherOpenDailyPixel.swift @@ -20,11 +20,19 @@ import Foundation struct TabSwitcherOpenDailyPixel { - func parameters(with tabs: [Tab]) -> [String: String] { + /// Returns parameters with buckets for respective tabs statistics. + /// - Parameters: + /// - tabs: Tabs to be included in the statistics + /// - referenceDate: Date to be used as a reference for calculating inactive tabs. Required for testing. + func parameters(with tabs: [Tab], referenceDate: Date = .now) -> [String: String] { var parameters = [String: String]() parameters[ParameterName.tabCount] = tabCountBucket(for: tabs) parameters[ParameterName.newTabCount] = newTabCountBucket(for: tabs) + parameters[ParameterName.tabActive7dCount] = bucketForInactiveTabs(tabs, within: (-7)..., from: referenceDate) + parameters[ParameterName.tabInactive1wCount] = bucketForInactiveTabs(tabs, within: (-14)...(-8), from: referenceDate) + parameters[ParameterName.tabInactive2wCount] = bucketForInactiveTabs(tabs, within: (-21)...(-15), from: referenceDate) + parameters[ParameterName.tabInactive3wCount] = bucketForInactiveTabs(tabs, within: ...(-22), from: referenceDate) return parameters } @@ -51,6 +59,24 @@ struct TabSwitcherOpenDailyPixel { } + private func bucketForInactiveTabs(_ tabs: [Tab], within daysInterval: Range, from referenceDate: Date) -> String? where Range.Bound == Int { + let dateInterval = AbsoluteDateInterval(daysInterval: daysInterval, basedOn: referenceDate) + + let matchingTabsCount = tabs.count { + guard let lastViewedDate = $0.lastViewedDate else { return false } + + return dateInterval.contains(lastViewedDate) + } + + switch matchingTabsCount { + case 0: return "0" + case 1...5: return "1-5" + case 6...10: return "6-10" + case 11...20: return "11-20" + default: return "21+" + } + } + private func newTabCountBucket(for tabs: [Tab]) -> String? { let count = tabs.count { $0.link == nil } @@ -64,5 +90,42 @@ struct TabSwitcherOpenDailyPixel { private enum ParameterName { static let tabCount = "tab_count" static let newTabCount = "new_tab_count" + + static let tabActive7dCount = "tab_active_7d" + static let tabInactive1wCount = "tab_inactive_1w" + static let tabInactive2wCount = "tab_inactive_2w" + static let tabInactive3wCount = "tab_inactive_3w" + } +} + +private extension TimeInterval { + static let dayInterval: TimeInterval = 86400 +} + +private struct AbsoluteDateInterval where R.Bound == Int { + private let lowerBoundDate: Date + private let upperBoundDate: Date + + init(daysInterval: R, basedOn referenceDate: Date) { + switch daysInterval { + case let daysRange as ClosedRange: + self.lowerBoundDate = referenceDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval) + self.upperBoundDate = referenceDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval) + + case let daysRange as PartialRangeThrough: + self.lowerBoundDate = Date.distantPast + self.upperBoundDate = referenceDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval) + + case let daysRange as PartialRangeFrom: + self.lowerBoundDate = referenceDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval) + self.upperBoundDate = Date.distantFuture + + default: + fatalError("\(R.self) is not supported") + } + } + + func contains(_ date: Date) -> Bool { + lowerBoundDate...upperBoundDate ~= date } } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index f68cd99670..f4d0d7460b 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1087,6 +1087,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 cbc4f3d533..f12ff46fea 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -2376,6 +2376,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/AppUserDefaultsTests.swift b/DuckDuckGoTests/AppUserDefaultsTests.swift index cc12ab3ecb..4aeb208c0b 100644 --- a/DuckDuckGoTests/AppUserDefaultsTests.swift +++ b/DuckDuckGoTests/AppUserDefaultsTests.swift @@ -226,7 +226,7 @@ class AppUserDefaultsTests: XCTestCase { mockManager.privacyConfig = mockConfiguration(subfeatureEnabled: enabled) let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) - return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: mockManager) + return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: mockManager, experimentManager: nil) } private func mockConfiguration(subfeatureEnabled: Bool) -> PrivacyConfiguration { diff --git a/DuckDuckGoTests/DuckPlayerMocks.swift b/DuckDuckGoTests/DuckPlayerMocks.swift index 7806c9d874..0c0844ebcd 100644 --- a/DuckDuckGoTests/DuckPlayerMocks.swift +++ b/DuckDuckGoTests/DuckPlayerMocks.swift @@ -254,6 +254,18 @@ final class MockDuckPlayerFeatureFlagger: FeatureFlagger { func isFeatureOn(for featureFlag: Flag, allowOverride: Bool) -> Bool { return !enabledFeatures.isEmpty } + + func getCohortIfEnabled(_ subfeature: any PrivacySubfeature) -> CohortID? { + return nil + } + + func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? where Flag: FeatureFlagExperimentDescribing { + return nil + } + + func getAllActiveExperiments() -> Experiments { + return [:] + } } final class MockDuckPlayerStorage: DuckPlayerStorage { diff --git a/DuckDuckGoTests/MockFeatureFlagger.swift b/DuckDuckGoTests/MockFeatureFlagger.swift index 7a8f568486..7f6d1a8866 100644 --- a/DuckDuckGoTests/MockFeatureFlagger.swift +++ b/DuckDuckGoTests/MockFeatureFlagger.swift @@ -39,4 +39,16 @@ final class MockFeatureFlagger: FeatureFlagger { } return true } + + func getCohortIfEnabled(_ subfeature: any PrivacySubfeature) -> CohortID? { + return nil + } + + func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? where Flag: FeatureFlagExperimentDescribing { + return nil + } + + func getAllActiveExperiments() -> Experiments { + return [:] + } } diff --git a/DuckDuckGoTests/MockPrivacyConfiguration.swift b/DuckDuckGoTests/MockPrivacyConfiguration.swift index 3066b1c3a6..07c69f3679 100644 --- a/DuckDuckGoTests/MockPrivacyConfiguration.swift +++ b/DuckDuckGoTests/MockPrivacyConfiguration.swift @@ -35,6 +35,18 @@ class MockPrivacyConfiguration: PrivacyConfiguration { return .disabled(.disabledInConfig) } + func stateFor(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + return .disabled(.disabledInConfig) + } + + func cohorts(for subfeature: any PrivacySubfeature) -> [PrivacyConfigurationData.Cohort]? { + return nil + } + + func cohorts(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID) -> [PrivacyConfigurationData.Cohort]? { + return nil + } + var identifier: String = "MockPrivacyConfiguration" var version: String? = "123456789" var userUnprotectedDomains: [String] = [] diff --git a/DuckDuckGoTests/MockTabDelegate.swift b/DuckDuckGoTests/MockTabDelegate.swift index fefb80faa7..acb7db0e53 100644 --- a/DuckDuckGoTests/MockTabDelegate.swift +++ b/DuckDuckGoTests/MockTabDelegate.swift @@ -115,6 +115,14 @@ final class MockTabDelegate: TabDelegate { } +final class MockCredentialCreator: URLCredentialCreating { + + func urlCredentialFrom(trust: SecTrust?) -> URLCredential? { + return URLCredential(user: "", password: "", persistence: .forSession) + } + +} + extension TabViewController { static func fake( diff --git a/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift b/DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift index abc8b0dde6..231ad293f4 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/NewTabPageShortcutsSettingsModelTests.swift b/DuckDuckGoTests/NewTabPageShortcutsSettingsModelTests.swift index 9f3affb257..7561f7c206 100644 --- a/DuckDuckGoTests/NewTabPageShortcutsSettingsModelTests.swift +++ b/DuckDuckGoTests/NewTabPageShortcutsSettingsModelTests.swift @@ -73,4 +73,16 @@ private final class AlwaysTrueFeatureFlagger: FeatureFlagger { func isFeatureOn(for featureFlag: Flag, allowOverride: Bool) -> Bool { true } + + func getCohortIfEnabled(_ subfeature: any PrivacySubfeature) -> CohortID? { + return nil + } + + func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? where Flag: FeatureFlagExperimentDescribing { + return nil + } + + func getAllActiveExperiments() -> Experiments { + return [:] + } } diff --git a/DuckDuckGoTests/PixelTests.swift b/DuckDuckGoTests/PixelTests.swift index 132517dac6..2d4ee61a94 100644 --- a/DuckDuckGoTests/PixelTests.swift +++ b/DuckDuckGoTests/PixelTests.swift @@ -30,7 +30,6 @@ class PixelTests: XCTestCase { let userAgentName = "User-Agent" override func setUpWithError() throws { - throw XCTSkip("Potentially flaky") try super.setUpWithError() Pixel.isDryRun = false @@ -162,7 +161,8 @@ class PixelTests: XCTestCase { wait(for: [expectation], timeout: 5.0) } - func testPixelDebouncePreventsFiringWithinInterval() { + func testPixelDebouncePreventsFiringWithinInterval() throws { + throw XCTSkip("Flaky") let firstFireExpectation = XCTestExpectation(description: "First pixel fire should succeed") let thirdFireExpectation = XCTestExpectation(description: "Third pixel fire should succeed after debounce interval") diff --git a/DuckDuckGoTests/PrivacyConfigurationManagerMock.swift b/DuckDuckGoTests/PrivacyConfigurationManagerMock.swift index d50a621f91..1f3128ec92 100644 --- a/DuckDuckGoTests/PrivacyConfigurationManagerMock.swift +++ b/DuckDuckGoTests/PrivacyConfigurationManagerMock.swift @@ -67,6 +67,18 @@ class PrivacyConfigurationMock: PrivacyConfiguration { return .disabled(.disabledInConfig) // this is not used in platform tests, so mocking this poorly for now } + func stateFor(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + return .disabled(.disabledInConfig) + } + + func cohorts(for subfeature: any PrivacySubfeature) -> [PrivacyConfigurationData.Cohort]? { + return nil + } + + func cohorts(subfeatureID: SubfeatureID, parentFeatureID: ParentFeatureID) -> [PrivacyConfigurationData.Cohort]? { + return nil + } + var protectedDomains = Set() func isProtected(domain: String?) -> Bool { return protectedDomains.contains(domain ?? "") diff --git a/DuckDuckGoTests/SpecialErrorPageTests.swift b/DuckDuckGoTests/SpecialErrorPageTests.swift deleted file mode 100644 index f3ff737d3b..0000000000 --- a/DuckDuckGoTests/SpecialErrorPageTests.swift +++ /dev/null @@ -1,304 +0,0 @@ -// -// SpecialErrorPageTests.swift -// DuckDuckGo -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import XCTest -import WebKit - -@testable import SpecialErrorPages -@testable import DuckDuckGo - -class MockSpecialErrorWebView: WKWebView { - - var loadRequestHandler: ((URLRequest, String) -> Void)? - var currentURL: URL? - - override func loadSimulatedRequest(_ request: URLRequest, responseHTML string: String) -> WKNavigation { - loadRequestHandler?(request, string) - return super.loadSimulatedRequest(request, responseHTML: string) - } - - override var url: URL? { - return currentURL - } - - func setCurrentURL(_ url: URL) { - self.currentURL = url - } - -} - -final class SpecialErrorPageTests: XCTestCase { - - var webView: MockSpecialErrorWebView! - var sut: TabViewController! - - override func setUpWithError() throws { - throw XCTSkip("Potentially Flaky") - - try super.setUpWithError() - let featureFlagger = MockFeatureFlagger() - featureFlagger.enabledFeatureFlags = [.sslCertificatesBypass] - sut = .fake(customWebView: { [weak self] configuration in - guard let self else { fatalError("It has to exist") } - self.webView = MockSpecialErrorWebView(frame: CGRect(), configuration: configuration) - return self.webView - }, featureFlagger: featureFlagger) - WKNavigation.swizzleDealloc() - } - - override func tearDown() async throws { - try await super.tearDown() - WKNavigation.restoreDealloc() - } - - func testWhenCertificateExpiredThenExpectedErrorPageIsShown() { - // GIVEN - let error = NSError(domain: "test", - code: NSURLErrorServerCertificateUntrusted, - userInfo: ["_kCFStreamErrorCodeKey": errSSLCertExpired, - NSURLErrorFailingURLErrorKey: URL(string: "https://expired.badssl.com")!]) - let expectation = self.expectation(description: "Special error page should be loaded") - var didFulfill = false - webView.loadRequestHandler = { request, html in - if !didFulfill { - XCTAssertTrue(html.contains("Warning: This site may be insecure")) - XCTAssertTrue(html.contains("is expired")) - XCTAssertEqual(request.url!.host, URL(string: "https://expired.badssl.com")!.host) - expectation.fulfill() - didFulfill = true - } - } - - // WHEN - sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) - - // THEN - XCTAssertEqual(sut.failedURL, URL(string: "https://expired.badssl.com")!) - XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, - errorType: "expired", - domain: "expired.badssl.com", - eTldPlus1: "badssl.com")) - waitForExpectations(timeout: 1) { error in - XCTAssertNil(error, "Expectation was not fulfilled in time") - } - } - - func testWhenCertificateWrongHostThenExpectedErrorPageIsShown() { - // GIVEN - let error = NSError(domain: "test", - code: NSURLErrorServerCertificateUntrusted, - userInfo: ["_kCFStreamErrorCodeKey": errSSLHostNameMismatch, - NSURLErrorFailingURLErrorKey: URL(string: "https://wrong.host.badssl.com")!]) - let expectation = self.expectation(description: "Special error page should be loaded") - var didFulfill = false - webView.loadRequestHandler = { request, html in - if !didFulfill { - XCTAssertTrue(html.contains("Warning: This site may be insecure")) - XCTAssertTrue(html.contains("does not match")) - XCTAssertEqual(request.url!.host, URL(string: "https://wrong.host.badssl.com")!.host) - expectation.fulfill() - didFulfill = true - } - } - - // WHEN - sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) - - // THEN - XCTAssertEqual(sut.failedURL, URL(string: "https://wrong.host.badssl.com")!) - XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, - errorType: "wrongHost", - domain: "wrong.host.badssl.com", - eTldPlus1: "badssl.com")) - waitForExpectations(timeout: 1) { error in - XCTAssertNil(error, "Expectation was not fulfilled in time") - } - } - - func testWhenCertificateSelfSignedThenExpectedErrorPageIsShown() { - // GIVEN - let error = NSError(domain: "test", - code: NSURLErrorServerCertificateUntrusted, - userInfo: ["_kCFStreamErrorCodeKey": errSSLXCertChainInvalid, - NSURLErrorFailingURLErrorKey: URL(string: "https://self-signed.badssl.com")!]) - let expectation = self.expectation(description: "Special error page should be loaded") - var didFulfill = false - webView.loadRequestHandler = { request, html in - if !didFulfill { - XCTAssertTrue(html.contains("Warning: This site may be insecure")) - XCTAssertTrue(html.contains("is not trusted")) - XCTAssertEqual(request.url!.host, URL(string: "https://self-signed.badssl.com")!.host) - expectation.fulfill() - didFulfill = true - } - } - - // WHEN - sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) - - // THEN - XCTAssertEqual(sut.failedURL, URL(string: "https://self-signed.badssl.com")!) - XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, - errorType: "selfSigned", - domain: "self-signed.badssl.com", - eTldPlus1: "badssl.com")) - waitForExpectations(timeout: 1) { error in - XCTAssertNil(error, "Expectation was not fulfilled in time") - } - } - - func testWhenOtherCertificateIssueThenExpectedErrorPageIsShown() { - // GIVEN - let error = NSError(domain: "test", - code: NSURLErrorServerCertificateUntrusted, - userInfo: ["_kCFStreamErrorCodeKey": errSSLUnknownRootCert, - NSURLErrorFailingURLErrorKey: URL(string: "https://untrusted-root.badssl.com")!]) - let expectation = self.expectation(description: "Special error page should be loaded") - var didFulfill = false - webView.loadRequestHandler = { request, html in - if !didFulfill { - XCTAssertTrue(html.contains("Warning: This site may be insecure")) - XCTAssertTrue(html.contains("is not trusted")) - XCTAssertEqual(request.url!.host, URL(string: "https://untrusted-root.badssl.com")!.host) - expectation.fulfill() - didFulfill = true - } - } - - // WHEN - sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) - - // THEN - XCTAssertEqual(sut.failedURL, URL(string: "https://untrusted-root.badssl.com")!) - XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, - errorType: "invalid", - domain: "untrusted-root.badssl.com", - eTldPlus1: "badssl.com")) - waitForExpectations(timeout: 1) { error in - XCTAssertNil(error, "Expectation was not fulfilled in time") - } - } - - @MainActor - func testWhenNavigationEndedIfNoSSLFailureSSLUserScriptIsNotEnabled() { - // GIVEN - webView.setCurrentURL(URL(string: "https://self-signed.badssl.com")!) - sut.storedSpecialErrorPageUserScript = SpecialErrorPageUserScript(localeStrings: "", languageCode: "") - - // WHEN - sut.webView(webView, didFinish: WKNavigation()) - - // THEN - XCTAssertFalse(sut.specialErrorPageUserScript?.isEnabled ?? true) - } - - @MainActor - func testWhenNavigationEndedIfSSLFailureButURLIsDifferentFromNavigationURLThenSSLUserScriptIsNotEnabled() { - // GIVEN - webView.setCurrentURL(URL(string: "https://self-signed.badssl.com")!) - sut.failedURL = URL(string: "https://different.url.com")! - sut.storedSpecialErrorPageUserScript = SpecialErrorPageUserScript(localeStrings: "", languageCode: "") - - // WHEN - sut.webView(webView, didFinish: WKNavigation()) - - // THEN - XCTAssertFalse(sut.specialErrorPageUserScript?.isEnabled ?? true) - } - - @MainActor - func testWhenNavigationEndedIfSSLFailureAndNavigationURLIsTheSameAsFailingURLThenSSLUserScriptIsEnabled() { - // GIVEN - webView.setCurrentURL(URL(string: "https://self-signed.badssl.com")!) - sut.failedURL = URL(string: "https://self-signed.badssl.com")! - sut.storedSpecialErrorPageUserScript = SpecialErrorPageUserScript(localeStrings: "", languageCode: "") - - // WHEN - sut.webView(webView, didFinish: WKNavigation()) - - // THEN - XCTAssertTrue(sut.specialErrorPageUserScript?.isEnabled ?? false) - } - - func testWhenDidReceiveChallengeIfChallengeForCertificateValidationAndNoBypassThenShouldNotReturnCredentials() async { - let protectionSpace = URLProtectionSpace(host: "", port: 4, protocol: nil, realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust) - let challenge = URLAuthenticationChallenge(protectionSpace: protectionSpace, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: ChallengeSender()) - await sut.webView(webView, didReceive: challenge) { _, credential in - XCTAssertNil(credential) - } - } - - func testWhenDidReceiveChallengeIfChallengeForCertificateValidationAndUserRequestBypassThenReturnsCredentials() async { - let protectionSpace = URLProtectionSpace(host: "", port: 4, protocol: nil, realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust) - let challenge = URLAuthenticationChallenge(protectionSpace: protectionSpace, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: ChallengeSender()) - await sut.visitSite() - await sut.webView(webView, didReceive: challenge) { _, credential in - XCTAssertNotNil(credential) - } - } - -} - -final class ChallengeSender: URLAuthenticationChallengeSender { - func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} - func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} - func cancel(_ challenge: URLAuthenticationChallenge) {} - func isEqual(_ object: Any?) -> Bool { - return false - } - var hash: Int = 0 - var superclass: AnyClass? - func `self`() -> Self { - self - } - func perform(_ aSelector: Selector!) -> Unmanaged! { - return nil - } - func perform(_ aSelector: Selector!, with object: Any!) -> Unmanaged! { - return nil - } - func perform(_ aSelector: Selector!, with object1: Any!, with object2: Any!) -> Unmanaged! { - return nil - } - func isProxy() -> Bool { - return false - } - func isKind(of aClass: AnyClass) -> Bool { - return false - } - func isMember(of aClass: AnyClass) -> Bool { - return false - } - func conforms(to aProtocol: Protocol) -> Bool { - return false - } - func responds(to aSelector: Selector!) -> Bool { - return false - } - var description: String = "" -} - -final class MockCredentialCreator: URLCredentialCreating { - - func urlCredentialFrom(trust: SecTrust?) -> URLCredential? { - return URLCredential(user: "", password: "", persistence: .forSession) - } - -} diff --git a/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift b/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift index 42704f862e..42f5f0c771 100644 --- a/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift +++ b/DuckDuckGoTests/Subscription/StorePurchaseManagerTests.swift @@ -42,7 +42,8 @@ final class StorePurchaseManagerTests: XCTestCase { session.disableDialogs = true session.clearTransactions() - storePurchaseManager = DefaultStorePurchaseManager() + let subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + storePurchaseManager = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) } override func tearDownWithError() throws { @@ -71,7 +72,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 f664d38d41..b350fbd2c6 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! @@ -130,6 +133,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, @@ -157,7 +163,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, @@ -813,11 +821,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 diff --git a/DuckDuckGoTests/TabSwitcherDailyPixelTests.swift b/DuckDuckGoTests/TabSwitcherDailyPixelTests.swift deleted file mode 100644 index 7cc16a6074..0000000000 --- a/DuckDuckGoTests/TabSwitcherDailyPixelTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// TabSwitcherDailyPixelTests.swift -// DuckDuckGo -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import XCTest -import Core -@testable import DuckDuckGo - -final class TabSwitcherDailyPixelTests: XCTestCase { - func testPopulatesParameters() { - let tabs = [Tab(), Tab(), Tab()] - let pixel = TabSwitcherOpenDailyPixel() - - let parameters = pixel.parameters(with: tabs) - - XCTAssertNotNil(parameters[ParameterName.tabCount]) - XCTAssertNotNil(parameters[ParameterName.newTabCount]) - } - - func testIncludesProperCountsForParameters() { - let tabs = [Tab(), Tab(), .mock()] - let pixel = TabSwitcherOpenDailyPixel() - - let parameters = pixel.parameters(with: tabs) - - XCTAssertEqual(parameters[ParameterName.tabCount], "2-5") - XCTAssertEqual(parameters[ParameterName.newTabCount], "2-10") - } - - func testBucketsAggregation() { - let bucketValues = [ - 1...1: "1", - 2...5: "2-5", - 6...10: "6-10", - 11...20: "11-20", - 21...40: "21-40", - 41...60: "41-60", - 61...80: "61-80", - 81...100: "81-100", - 101...125: "101-125", - 126...150: "126-150", - 151...250: "151-250", - 251...500: "251-500", - 501...504: "501+"] - - for bucket in bucketValues { - for value in bucket.key { - let tabs = Array(repeating: Tab.mock(), count: value) - - let countParameter = TabSwitcherOpenDailyPixel().parameters(with: tabs)[ParameterName.tabCount] - - XCTAssertEqual(countParameter, bucket.value) - } - } - } - - func testNewTabBucketsAggregation() { - let bucketValues = [ - 0...1: "0-1", - 2...10: "2-10", - 11...20: "11+"] - - for bucket in bucketValues { - for value in bucket.key { - let tabs = Array(repeating: Tab(), count: value) - - let countParameter = TabSwitcherOpenDailyPixel().parameters(with: tabs)[ParameterName.newTabCount] - - XCTAssertEqual(countParameter, bucket.value) - } - } - } -} - -private extension Tab { - static func mock() -> Tab { - Tab(link: Link(title: nil, url: URL("https://example.com")!)) - } -} - -private enum ParameterName { - static let newTabCount = "new_tab_count" - static let tabCount = "tab_count" -} diff --git a/DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift b/DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift new file mode 100644 index 0000000000..9d5d9c984c --- /dev/null +++ b/DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift @@ -0,0 +1,257 @@ +// +// TabSwitcherOpenDailyPixelTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Core +@testable import DuckDuckGo + +final class TabSwitcherOpenDailyPixelTests: XCTestCase { + func testPopulatesParameters() { + let tabs = [Tab(), Tab(), Tab()] + let pixel = TabSwitcherOpenDailyPixel() + + let parameters = pixel.parameters(with: tabs) + + XCTAssertNotNil(parameters[ParameterName.tabCount]) + XCTAssertNotNil(parameters[ParameterName.newTabCount]) + } + + func testIncludesProperCountsForParameters() { + let tabs = [Tab(), Tab(), .mock()] + let pixel = TabSwitcherOpenDailyPixel() + + let parameters = pixel.parameters(with: tabs) + + XCTAssertEqual(parameters[ParameterName.tabCount], "2-5") + XCTAssertEqual(parameters[ParameterName.newTabCount], "2-10") + } + + func testBucketsAggregation() { + let bucketValues = [ + 1...1: "1", + 2...5: "2-5", + 6...10: "6-10", + 11...20: "11-20", + 21...40: "21-40", + 41...60: "41-60", + 61...80: "61-80", + 81...100: "81-100", + 101...125: "101-125", + 126...150: "126-150", + 151...250: "151-250", + 251...500: "251-500", + 501...504: "501+"] + + for bucket in bucketValues { + for value in bucket.key { + let tabs = Array(repeating: Tab.mock(), count: value) + + let countParameter = TabSwitcherOpenDailyPixel().parameters(with: tabs)[ParameterName.tabCount] + + XCTAssertEqual(countParameter, bucket.value) + } + } + } + + func testNewTabBucketsAggregation() { + let bucketValues = [ + 0...1: "0-1", + 2...10: "2-10", + 11...20: "11+"] + + for bucket in bucketValues { + for value in bucket.key { + let tabs = Array(repeating: Tab(), count: value) + + let countParameter = TabSwitcherOpenDailyPixel().parameters(with: tabs)[ParameterName.newTabCount] + + XCTAssertEqual(countParameter, bucket.value) + } + } + } + + // - MARK: Inactive tabs aggregation tests + + func testTabsWithoutLastVisitValueArentIncludedInBuckets() throws { + let tabs = [Tab.mock(), .mock()] + + let parameters = TabSwitcherOpenDailyPixel().parameters(with: tabs) + + try testBucketParameters(parameters, expectedCount: 0) + } + + func testEdgeCaseBucketParameterForInactiveTabs() throws { + let now = Date() + + let tabs: [Tab] = [ + .mock(lastViewedDate: now.daysAgo(7)), + .mock(lastViewedDate: now.daysAgo(14)), + .mock(lastViewedDate: now.daysAgo(21)), + .mock(lastViewedDate: now.daysAgo(22)) + ] + + let pixelParametersForSecondInterval = TabSwitcherOpenDailyPixel().parameters(with: tabs, referenceDate: now) + + try testBucketParameters(pixelParametersForSecondInterval, expectedCount: 1) + } + + func testBucketParametersForInactiveTabs() throws { + let now = Date() + + let tabsSecondInterval = Tab.stubCollectionForSecondInterval(baseDate: now) + let parametersForSecondInterval = TabSwitcherOpenDailyPixel().parameters(with: tabsSecondInterval, referenceDate: now) + + let tabsThirdInterval = Tab.stubCollectionForThirdInterval(baseDate: now) + let parametersForThirdInterval = TabSwitcherOpenDailyPixel().parameters(with: tabsThirdInterval, referenceDate: now) + + try testBucketParameters(parametersForSecondInterval, expectedCount: 5) + try testBucketParameters(parametersForThirdInterval, expectedCount: 6) + } + + func testBucketNamingForInactiveTabs() throws { + let now = Date() + let expectedBuckets = [ + 0...0: "0", + 1...5: "1-5", + 6...10: "6-10", + 11...20: "11-20", + 21...40: "21+" + ] + + // How many days need to pass for each interval bucket + let parameterDaysOffsetMapping = [ + ParameterName.tabActive7dCount: 0, + ParameterName.tabInactive1wCount: 8, + ParameterName.tabInactive2wCount: 15, + ParameterName.tabInactive3wCount: 22 + ] + + for bucket in expectedBuckets { + let count = bucket.key.lowerBound + + for parameter in parameterDaysOffsetMapping { + let daysOffset = parameter.value + // Create tabs based on expected count for bucket, using proper days offset + let tabs = Array(repeating: Tab.mock(lastViewedDate: now.daysAgo(daysOffset)), count: count) + + let parameters = TabSwitcherOpenDailyPixel().parameters(with: tabs, referenceDate: now) + + XCTAssertEqual(parameters[parameter.key], bucket.value, "Failed for bucket: \(bucket.key) with parameter: \(parameter.key)") + } + } + } + + // MARK: - Test helper methods + + private func testBucketParameters(_ parameters: [String: String], expectedCount: Int) throws { + let parameterNames = [ + ParameterName.tabActive7dCount, + ParameterName.tabInactive1wCount, + ParameterName.tabInactive2wCount, + ParameterName.tabInactive3wCount + ] + + let expectedBucket = try XCTUnwrap(Buckets.inactiveTabs.first { $0.key.contains(expectedCount) }).value + for parameterName in parameterNames { + let bucketValue = parameters[parameterName] + + XCTAssertEqual(bucketValue, expectedBucket, "Failed for parameter: \(parameterName)") + } + } +} + +private extension Tab { + static func mock(lastViewedDate: Date? = nil) -> Tab { + Tab(link: Link(title: nil, url: URL("https://example.com")!), lastViewedDate: lastViewedDate) + } + + static func stubCollectionForSecondInterval(baseDate: Date) -> [Tab] { + [ + // MARK: First week + .mock(lastViewedDate: baseDate), + .mock(lastViewedDate: baseDate.daysAgo(3)), + .mock(lastViewedDate: baseDate.daysAgo(4)), + .mock(lastViewedDate: baseDate.daysAgo(5)), + .mock(lastViewedDate: baseDate.daysAgo(7)), + + // MARK: >1 week + .mock(lastViewedDate: baseDate.daysAgo(8)), + .mock(lastViewedDate: baseDate.daysAgo(10)), + .mock(lastViewedDate: baseDate.daysAgo(11)), + .mock(lastViewedDate: baseDate.daysAgo(12)), + .mock(lastViewedDate: baseDate.daysAgo(14)), + + // MARK: >2 weeks + .mock(lastViewedDate: baseDate.daysAgo(15)), + .mock(lastViewedDate: baseDate.daysAgo(16)), + .mock(lastViewedDate: baseDate.daysAgo(17)), + .mock(lastViewedDate: baseDate.daysAgo(18)), + .mock(lastViewedDate: baseDate.daysAgo(21)), + + // MARK: >3 weeks + .mock(lastViewedDate: baseDate.daysAgo(22)), + .mock(lastViewedDate: baseDate.daysAgo(23)), + .mock(lastViewedDate: baseDate.daysAgo(24)), + .mock(lastViewedDate: baseDate.daysAgo(100)), + .mock(lastViewedDate: Date.distantPast), + ] + } + static func stubCollectionForThirdInterval(baseDate: Date) -> [Tab] { + stubCollectionForSecondInterval(baseDate: baseDate) + + [ + // MARK: First week + .mock(lastViewedDate: baseDate.daysAgo(4)), + + // MARK: >1 week + .mock(lastViewedDate: baseDate.daysAgo(14)), + + // MARK: >2 weeks + .mock(lastViewedDate: baseDate.daysAgo(15)), + + // MARK: >3 weeks + .mock(lastViewedDate: baseDate.daysAgo(22)) + ] + } +} + +private enum ParameterName { + static let newTabCount = "new_tab_count" + static let tabCount = "tab_count" + + static let tabActive7dCount = "tab_active_7d" + static let tabInactive1wCount = "tab_inactive_1w" + static let tabInactive2wCount = "tab_inactive_2w" + static let tabInactive3wCount = "tab_inactive_3w" +} + +private enum Buckets { + static let inactiveTabs = [ + 0...0: "0", + 1...5: "1-5", + 6...10: "6-10", + 11...20: "11-20", + 21...40: "21+" + ] +} + +private extension Date { + func daysAgo(_ days: Int) -> Date { + addingTimeInterval(TimeInterval(-days * 86400)) + } +}