diff --git a/Configuration/BuildNumber.xcconfig b/Configuration/BuildNumber.xcconfig index b6f88103f6..ba0dd6986e 100644 --- a/Configuration/BuildNumber.xcconfig +++ b/Configuration/BuildNumber.xcconfig @@ -1 +1 @@ -CURRENT_PROJECT_VERSION = 291 +CURRENT_PROJECT_VERSION = 296 diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5cf4fc734c..731efbd4e7 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -54,8 +54,6 @@ 1D01A3D92B88DF8B00FE8150 /* PreferencesSyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D01A3D72B88DF8B00FE8150 /* PreferencesSyncView.swift */; }; 1D02633628D8A9A9005CBB41 /* BWEncryption.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D02633528D8A9A9005CBB41 /* BWEncryption.m */; settings = {COMPILER_FLAGS = "-Wno-deprecated -Wno-strict-prototypes"; }; }; 1D074B272909A433006E4AC3 /* PasswordManagerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D074B262909A433006E4AC3 /* PasswordManagerCoordinator.swift */; }; - 1D0DE93E2C3BA9840037ABC2 /* AppRestarter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0DE93D2C3BA9840037ABC2 /* AppRestarter.swift */; }; - 1D0DE93F2C3BA9840037ABC2 /* AppRestarter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0DE93D2C3BA9840037ABC2 /* AppRestarter.swift */; }; 1D0DE9412C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0DE9402C3BB9CC0037ABC2 /* ReleaseNotesParser.swift */; }; 1D0DE9422C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0DE9402C3BB9CC0037ABC2 /* ReleaseNotesParser.swift */; }; 1D12F2E2298BC660009A65FD /* InternalUserDeciderStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D12F2E1298BC660009A65FD /* InternalUserDeciderStoreMock.swift */; }; @@ -69,8 +67,6 @@ 1D220BF92B86192200F8BBC6 /* PreferencesEmailProtectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D220BF72B86192200F8BBC6 /* PreferencesEmailProtectionView.swift */; }; 1D220BFC2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D220BFB2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift */; }; 1D220BFD2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D220BFB2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift */; }; - 1D232E942C7860DA0043840D /* BinaryOwnershipChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D232E932C7860DA0043840D /* BinaryOwnershipChecker.swift */; }; - 1D232E992C7870D90043840D /* BinaryOwnershipCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D232E962C786E7D0043840D /* BinaryOwnershipCheckerTests.swift */; }; 1D26EBAC2B74BECB0002A93F /* NSImageSendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D26EBAB2B74BECB0002A93F /* NSImageSendable.swift */; }; 1D26EBAD2B74BECB0002A93F /* NSImageSendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D26EBAB2B74BECB0002A93F /* NSImageSendable.swift */; }; 1D26EBB02B74DB600002A93F /* TabSnapshotCleanupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D26EBAF2B74DB600002A93F /* TabSnapshotCleanupService.swift */; }; @@ -1870,6 +1866,8 @@ 843965132C6F2FFE004C8899 /* NSDragOperationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843965112C6F2FFE004C8899 /* NSDragOperationExtension.swift */; }; 843965152C737022004C8899 /* NSPasteboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843965142C737022004C8899 /* NSPasteboardExtension.swift */; }; 843965162C737022004C8899 /* NSPasteboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843965142C737022004C8899 /* NSPasteboardExtension.swift */; }; + 843AD3DC2CD389CC00163067 /* XMLNodeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843AD3DB2CD389C500163067 /* XMLNodeExtension.swift */; }; + 843AD3DD2CD389CC00163067 /* XMLNodeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843AD3DB2CD389C500163067 /* XMLNodeExtension.swift */; }; 843D73BB2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8DA2C774CA900716446 /* BookmarkListPopover.swift */; }; 843D73BC2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8DA2C774CA900716446 /* BookmarkListPopover.swift */; }; 844D7DA42C9443EA00BE61D4 /* NSPrintInfoExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844D7DA32C9443E500BE61D4 /* NSPrintInfoExtension.swift */; }; @@ -1960,6 +1958,8 @@ 85AC3B4925DAC9BD00C7D2AA /* ConfigurationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC3B4825DAC9BD00C7D2AA /* ConfigurationStorageTests.swift */; }; 85AC7ADB27BD628400FFB69B /* HomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC7ADA27BD628400FFB69B /* HomePage.swift */; }; 85AC7ADD27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC7ADC27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift */; }; + 85B49AFA2CD1B7C5007FAA2A /* SystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B49AF92CD1B7C5007FAA2A /* SystemInfo.swift */; }; + 85B49AFB2CD1B7C5007FAA2A /* SystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B49AF92CD1B7C5007FAA2A /* SystemInfo.swift */; }; 85B7184A27677C2D00B4277F /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85B7184927677C2D00B4277F /* Onboarding.storyboard */; }; 85B7184C27677C6500B4277F /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B7184B27677C6500B4277F /* OnboardingViewController.swift */; }; 85B7184E27677CBB00B4277F /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85B7184D27677CBB00B4277F /* RootView.swift */; }; @@ -2788,6 +2788,7 @@ BD384ACA2BBC821A00EF3735 /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; BD384ACB2BBC821B00EF3735 /* vpn-dark-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */; }; BD384ACC2BBC821B00EF3735 /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; + BD6367252C877BE1009DE7A8 /* UpdateUserDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD6367242C877BE1009DE7A8 /* UpdateUserDriver.swift */; }; BD7090CF2C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090CE2C5182FB009EED82 /* UnifiedFeedbackFormView.swift */; }; BD7090D02C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090CE2C5182FB009EED82 /* UnifiedFeedbackFormView.swift */; }; BD7090D22C52ECFE009EED82 /* UnifiedMetadataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7090D12C52ECFE009EED82 /* UnifiedMetadataCollector.swift */; }; @@ -2830,6 +2831,14 @@ C10529442C9CC18B0041E502 /* AutofillCredentialsDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10529422C9CC18B0041E502 /* AutofillCredentialsDebugView.swift */; }; C10529462C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10529452C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift */; }; C10529472C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10529452C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift */; }; + C11198312C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11198302C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift */; }; + C11198322C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11198302C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift */; }; + C11198342C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11198332C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift */; }; + C11198352C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11198332C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift */; }; + C126B35A2C820924005DC2A3 /* FreemiumDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C126B3592C820924005DC2A3 /* FreemiumDebugMenu.swift */; }; + C126B35B2C820924005DC2A3 /* FreemiumDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C126B3592C820924005DC2A3 /* FreemiumDebugMenu.swift */; }; + C1351DD12C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1351DD02C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift */; }; + C1351DD22C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1351DD02C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift */; }; C1372EF42BBC5BAD003F8793 /* SecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */; }; C1372EF52BBC5BAD003F8793 /* SecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */; }; C13909EF2B85FD4E001626ED /* AutofillActionExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13909EE2B85FD4E001626ED /* AutofillActionExecutor.swift */; }; @@ -2838,6 +2847,10 @@ C13909F52B85FD79001626ED /* AutofillDeleteAllPasswordsExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13909F32B85FD79001626ED /* AutofillDeleteAllPasswordsExecutorTests.swift */; }; C13909FB2B861039001626ED /* AutofillActionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13909FA2B861039001626ED /* AutofillActionPresenter.swift */; }; C13909FC2B861039001626ED /* AutofillActionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13909FA2B861039001626ED /* AutofillActionPresenter.swift */; }; + C153E7C22C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153E7C12C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift */; }; + C153E7C32C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153E7C12C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift */; }; + C153E7C52C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153E7C42C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift */; }; + C153E7C62C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C153E7C42C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift */; }; C16127EE2BDFB46400966BB9 /* DataImportShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16127ED2BDFB46400966BB9 /* DataImportShortcutsView.swift */; }; C16127EF2BDFB46400966BB9 /* DataImportShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16127ED2BDFB46400966BB9 /* DataImportShortcutsView.swift */; }; C168B9AC2B31DC7E001AFAD9 /* AutofillNeverPromptWebsitesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C168B9AB2B31DC7E001AFAD9 /* AutofillNeverPromptWebsitesManager.swift */; }; @@ -2852,6 +2865,20 @@ C17CA7AE2B9B52E6008EC3C1 /* NavigationBarPopoversTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA7AC2B9B52E6008EC3C1 /* NavigationBarPopoversTests.swift */; }; C17CA7B22B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA7B12B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift */; }; C17CA7B32B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA7B12B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift */; }; + C17E105D2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17E105C2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift */; }; + C17E105E2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17E105C2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift */; }; + C18194592C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18194582C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift */; }; + C181945A2C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18194582C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift */; }; + C181945C2C7CDCC700381092 /* PromotionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C181945B2C7CDCC700381092 /* PromotionView.swift */; }; + C181945D2C7CDCC700381092 /* PromotionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C181945B2C7CDCC700381092 /* PromotionView.swift */; }; + C181945F2C7CDD0E00381092 /* PromotionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C181945E2C7CDD0E00381092 /* PromotionViewModel.swift */; }; + C18194602C7CDD0E00381092 /* PromotionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C181945E2C7CDD0E00381092 /* PromotionViewModel.swift */; }; + C18194642C7DF7D600381092 /* FreemiumDBPPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18194632C7DF7D600381092 /* FreemiumDBPPresenter.swift */; }; + C18194652C7DF7D600381092 /* FreemiumDBPPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18194632C7DF7D600381092 /* FreemiumDBPPresenter.swift */; }; + C18194672C7F60E500381092 /* FreemiumDBPPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18194662C7F60E500381092 /* FreemiumDBPPresenterTests.swift */; }; + C18194682C7F60E500381092 /* FreemiumDBPPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18194662C7F60E500381092 /* FreemiumDBPPresenterTests.swift */; }; + C1858CD22C7C971D00C9BEAB /* FreemiumDBPFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1858CD12C7C971D00C9BEAB /* FreemiumDBPFeature.swift */; }; + C1858CD32C7C971D00C9BEAB /* FreemiumDBPFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1858CD12C7C971D00C9BEAB /* FreemiumDBPFeature.swift */; }; C18BF9CC2C73678500ED6B8A /* Freemium in Frameworks */ = {isa = PBXBuildFile; productRef = C18BF9CB2C73678500ED6B8A /* Freemium */; }; C18BF9CE2C73678C00ED6B8A /* Freemium in Frameworks */ = {isa = PBXBuildFile; productRef = C18BF9CD2C73678C00ED6B8A /* Freemium */; }; C18BF9D02C736C9100ED6B8A /* Freemium in Frameworks */ = {isa = PBXBuildFile; productRef = C18BF9CF2C736C9100ED6B8A /* Freemium */; }; @@ -2864,6 +2891,14 @@ C1935A1C2C88F9ED001AD72D /* SyncPromoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1935A1A2C88F9ED001AD72D /* SyncPromoViewModel.swift */; }; C1B1CBE12BE1915100B6049C /* DataImportShortcutsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1CBE02BE1915100B6049C /* DataImportShortcutsViewModel.swift */; }; C1B1CBE22BE1915100B6049C /* DataImportShortcutsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1CBE02BE1915100B6049C /* DataImportShortcutsViewModel.swift */; }; + C1B51EB42C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B51EB32C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift */; }; + C1B51EB52C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B51EB32C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift */; }; + C1C405872C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C405862C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift */; }; + C1C405882C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C405862C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift */; }; + C1CE84692C887CF60068913B /* FreemiumDBPScanResultPolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CE84682C887CF60068913B /* FreemiumDBPScanResultPolling.swift */; }; + C1CE846A2C887CF60068913B /* FreemiumDBPScanResultPolling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CE84682C887CF60068913B /* FreemiumDBPScanResultPolling.swift */; }; + C1CE846C2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CE846B2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift */; }; + C1CE846D2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CE846B2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift */; }; C1D8BE452C1739E70057E426 /* DataBrokerProtectionMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D8BE442C1739E70057E426 /* DataBrokerProtectionMocks.swift */; }; C1D8BE462C1739EC0057E426 /* DataBrokerProtectionMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D8BE442C1739E70057E426 /* DataBrokerProtectionMocks.swift */; }; C1DAF3B52B9A44860059244F /* AutofillPopoverPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DAF3B42B9A44860059244F /* AutofillPopoverPresenter.swift */; }; @@ -2874,6 +2909,8 @@ C1E961F02B87AA29001760E1 /* AutofillActionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E961EE2B87AA29001760E1 /* AutofillActionBuilder.swift */; }; C1E961F32B87B273001760E1 /* MockAutofillActionExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E961EC2B879ED9001760E1 /* MockAutofillActionExecutor.swift */; }; C1E961F42B87B276001760E1 /* MockAutofillActionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E961E72B879E4D001760E1 /* MockAutofillActionPresenter.swift */; }; + C1F142DB2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F142DA2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift */; }; + C1F142DC2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F142DA2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift */; }; CB24F70C29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB24F70B29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift */; }; CB24F70D29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB24F70B29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift */; }; CB6BCDF927C6BEFF00CC76DC /* PrivacyFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB6BCDF827C6BEFF00CC76DC /* PrivacyFeatures.swift */; }; @@ -2974,6 +3011,8 @@ EED9A6762C37FE6900E0FAB9 /* login_deduplication_test_data.csv in Resources */ = {isa = PBXBuildFile; fileRef = EED9A6732C37FE6800E0FAB9 /* login_deduplication_test_data.csv */; }; EED9A6772C37FE6900E0FAB9 /* login_deduplication_starting_data.csv in Resources */ = {isa = PBXBuildFile; fileRef = EED9A6742C37FE6900E0FAB9 /* login_deduplication_starting_data.csv */; }; EED9A6782C37FE6900E0FAB9 /* login_deduplication_starting_data.csv in Resources */ = {isa = PBXBuildFile; fileRef = EED9A6742C37FE6900E0FAB9 /* login_deduplication_starting_data.csv */; }; + EEDFA38A2CD148DE00D1C558 /* SyncDiagnosisHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEDFA3892CD148DE00D1C558 /* SyncDiagnosisHelper.swift */; }; + EEDFA38B2CD148DE00D1C558 /* SyncDiagnosisHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEDFA3892CD148DE00D1C558 /* SyncDiagnosisHelper.swift */; }; EEE0E1CD2C32F5690058E148 /* CSVImporterIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE0E1CC2C32F5690058E148 /* CSVImporterIntegrationTests.swift */; }; EEE0E1CF2C32F6530058E148 /* mock_login_data_large.csv in Resources */ = {isa = PBXBuildFile; fileRef = EEE0E1CE2C32F6530058E148 /* mock_login_data_large.csv */; }; EEE0E1D02C32F6530058E148 /* mock_login_data_large.csv in Resources */ = {isa = PBXBuildFile; fileRef = EEE0E1CE2C32F6530058E148 /* mock_login_data_large.csv */; }; @@ -3286,7 +3325,6 @@ 1D02633428D8A9A9005CBB41 /* BWEncryption.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BWEncryption.h; sourceTree = ""; }; 1D02633528D8A9A9005CBB41 /* BWEncryption.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BWEncryption.m; sourceTree = ""; }; 1D074B262909A433006E4AC3 /* PasswordManagerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordManagerCoordinator.swift; sourceTree = ""; }; - 1D0DE93D2C3BA9840037ABC2 /* AppRestarter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRestarter.swift; sourceTree = ""; }; 1D0DE9402C3BB9CC0037ABC2 /* ReleaseNotesParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotesParser.swift; sourceTree = ""; }; 1D12F2E1298BC660009A65FD /* InternalUserDeciderStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalUserDeciderStoreMock.swift; sourceTree = ""; }; 1D1A33482A6FEB170080ACED /* BurnerMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnerMode.swift; sourceTree = ""; }; @@ -3294,8 +3332,6 @@ 1D1C36E529FB019C001FA40C /* HistoryTabExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryTabExtensionTests.swift; sourceTree = ""; }; 1D220BF72B86192200F8BBC6 /* PreferencesEmailProtectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesEmailProtectionView.swift; sourceTree = ""; }; 1D220BFB2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyProtectionStatus.swift; sourceTree = ""; }; - 1D232E932C7860DA0043840D /* BinaryOwnershipChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BinaryOwnershipChecker.swift; sourceTree = ""; }; - 1D232E962C786E7D0043840D /* BinaryOwnershipCheckerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BinaryOwnershipCheckerTests.swift; sourceTree = ""; }; 1D26EBAB2B74BECB0002A93F /* NSImageSendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSImageSendable.swift; sourceTree = ""; }; 1D26EBAF2B74DB600002A93F /* TabSnapshotCleanupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSnapshotCleanupService.swift; sourceTree = ""; }; 1D36E657298AA3BA00AA485D /* InternalUserDeciderStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalUserDeciderStore.swift; sourceTree = ""; }; @@ -3998,6 +4034,7 @@ 8426108C2C9811EC0070D5F9 /* KeyEquivalentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyEquivalentView.swift; sourceTree = ""; }; 843965112C6F2FFE004C8899 /* NSDragOperationExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSDragOperationExtension.swift; sourceTree = ""; }; 843965142C737022004C8899 /* NSPasteboardExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPasteboardExtension.swift; sourceTree = ""; }; + 843AD3DB2CD389C500163067 /* XMLNodeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMLNodeExtension.swift; sourceTree = ""; }; 844D7DA32C9443E500BE61D4 /* NSPrintInfoExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPrintInfoExtension.swift; sourceTree = ""; }; 84537A022C998C24008723BC /* FireWindowSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireWindowSession.swift; sourceTree = ""; }; 848648A02C76F4B20082282D /* BookmarksBarMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarMenuViewController.swift; sourceTree = ""; }; @@ -4070,6 +4107,7 @@ 85AC7ADA27BD628400FFB69B /* HomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePage.swift; sourceTree = ""; }; 85AC7ADC27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePageDefaultBrowserModel.swift; sourceTree = ""; }; 85AE2FF124A33A2D002D507F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + 85B49AF92CD1B7C5007FAA2A /* SystemInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfo.swift; sourceTree = ""; }; 85B7184927677C2D00B4277F /* Onboarding.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Onboarding.storyboard; sourceTree = ""; }; 85B7184B27677C6500B4277F /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; 85B7184D27677CBB00B4277F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -4660,6 +4698,7 @@ BBFF355C2C4AF26200DA3289 /* BookmarksSortModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksSortModeTests.swift; sourceTree = ""; }; BD384AC72BBC821100EF3735 /* vpn-light-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-light-mode.json"; sourceTree = ""; }; BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-dark-mode.json"; sourceTree = ""; }; + BD6367242C877BE1009DE7A8 /* UpdateUserDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateUserDriver.swift; sourceTree = ""; }; BD7090CE2C5182FB009EED82 /* UnifiedFeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedFeedbackFormView.swift; sourceTree = ""; }; BD7090D12C52ECFE009EED82 /* UnifiedMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedMetadataCollector.swift; sourceTree = ""; }; BD7090D52C540D5D009EED82 /* EmptyMetadataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyMetadataCollector.swift; sourceTree = ""; }; @@ -4676,10 +4715,16 @@ BDCB66D72C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNFeedbackFormViewModelTests.swift; sourceTree = ""; }; C10529422C9CC18B0041E502 /* AutofillCredentialsDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillCredentialsDebugView.swift; sourceTree = ""; }; C10529452C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillCredentialsDebugViewModel.swift; sourceTree = ""; }; + C11198302C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPFirstProfileSavedNotifier.swift; sourceTree = ""; }; + C11198332C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPFirstProfileSavedNotifierTests.swift; sourceTree = ""; }; + C126B3592C820924005DC2A3 /* FreemiumDebugMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDebugMenu.swift; sourceTree = ""; }; + C1351DD02C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+FreemiumDBP.swift"; sourceTree = ""; }; C1372EF32BBC5BAD003F8793 /* SecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureTextField.swift; sourceTree = ""; }; C13909EE2B85FD4E001626ED /* AutofillActionExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillActionExecutor.swift; sourceTree = ""; }; C13909F32B85FD79001626ED /* AutofillDeleteAllPasswordsExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillDeleteAllPasswordsExecutorTests.swift; sourceTree = ""; }; C13909FA2B861039001626ED /* AutofillActionPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillActionPresenter.swift; sourceTree = ""; }; + C153E7C12C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPPromotionViewCoordinator.swift; sourceTree = ""; }; + C153E7C42C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+FreemiumDBP.swift"; sourceTree = ""; }; C16127ED2BDFB46400966BB9 /* DataImportShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportShortcutsView.swift; sourceTree = ""; }; C168B9AB2B31DC7E001AFAD9 /* AutofillNeverPromptWebsitesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillNeverPromptWebsitesManager.swift; sourceTree = ""; }; C172E72E2C9329D300521D9A /* FlippedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlippedView.swift; sourceTree = ""; }; @@ -4687,15 +4732,27 @@ C172E7362C93968E00521D9A /* SyncPromoPixelKitEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoPixelKitEvent.swift; sourceTree = ""; }; C17CA7AC2B9B52E6008EC3C1 /* NavigationBarPopoversTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarPopoversTests.swift; sourceTree = ""; }; C17CA7B12B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAutofillPopoverPresenter.swift; sourceTree = ""; }; + C17E105C2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPPixelExperimentManagingTests.swift; sourceTree = ""; }; + C18194582C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPFeatureTests.swift; sourceTree = ""; }; + C181945B2C7CDCC700381092 /* PromotionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionView.swift; sourceTree = ""; }; + C181945E2C7CDD0E00381092 /* PromotionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionViewModel.swift; sourceTree = ""; }; + C18194632C7DF7D600381092 /* FreemiumDBPPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPPresenter.swift; sourceTree = ""; }; + C18194662C7F60E500381092 /* FreemiumDBPPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPPresenterTests.swift; sourceTree = ""; }; + C1858CD12C7C971D00C9BEAB /* FreemiumDBPFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPFeature.swift; sourceTree = ""; }; C1935A132C88F958001AD72D /* SyncPromoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoView.swift; sourceTree = ""; }; C1935A172C88F9D6001AD72D /* SyncPromoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoManager.swift; sourceTree = ""; }; C1935A1A2C88F9ED001AD72D /* SyncPromoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPromoViewModel.swift; sourceTree = ""; }; C1B1CBE02BE1915100B6049C /* DataImportShortcutsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportShortcutsViewModel.swift; sourceTree = ""; }; + C1B51EB32C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPPixelExperimentManaging.swift; sourceTree = ""; }; + C1C405862C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PromotionView+FreemiumDBP.swift"; sourceTree = ""; }; + C1CE84682C887CF60068913B /* FreemiumDBPScanResultPolling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPScanResultPolling.swift; sourceTree = ""; }; + C1CE846B2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPScanResultPollingTests.swift; sourceTree = ""; }; C1D8BE442C1739E70057E426 /* DataBrokerProtectionMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionMocks.swift; sourceTree = ""; }; C1DAF3B42B9A44860059244F /* AutofillPopoverPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillPopoverPresenter.swift; sourceTree = ""; }; C1E961E72B879E4D001760E1 /* MockAutofillActionPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAutofillActionPresenter.swift; sourceTree = ""; }; C1E961EC2B879ED9001760E1 /* MockAutofillActionExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAutofillActionExecutor.swift; sourceTree = ""; }; C1E961EE2B87AA29001760E1 /* AutofillActionBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillActionBuilder.swift; sourceTree = ""; }; + C1F142DA2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreemiumDBPPromotionViewCoordinatorTests.swift; sourceTree = ""; }; CB24F70B29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationURLProvider.swift; sourceTree = ""; }; CB6BCDF827C6BEFF00CC76DC /* PrivacyFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyFeatures.swift; sourceTree = ""; }; CBDD5DE229A67F2700832877 /* MockConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockConfigurationStore.swift; sourceTree = ""; }; @@ -4745,6 +4802,7 @@ EED735352BB46B6000F173D6 /* AutocompleteTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutocompleteTests.swift; sourceTree = ""; }; EED9A6732C37FE6800E0FAB9 /* login_deduplication_test_data.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = login_deduplication_test_data.csv; sourceTree = ""; }; EED9A6742C37FE6900E0FAB9 /* login_deduplication_starting_data.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = login_deduplication_starting_data.csv; sourceTree = ""; }; + EEDFA3892CD148DE00D1C558 /* SyncDiagnosisHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosisHelper.swift; sourceTree = ""; }; EEE0E1CC2C32F5690058E148 /* CSVImporterIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVImporterIntegrationTests.swift; sourceTree = ""; }; EEE0E1CE2C32F6530058E148 /* mock_login_data_large.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = mock_login_data_large.csv; sourceTree = ""; }; EEE11C5D2C7F54AD000ABD7E /* AutofillLoginImportState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginImportState.swift; sourceTree = ""; }; @@ -5214,12 +5272,11 @@ 1DA84D312C119AE70011C80F /* UpdateMenuItemFactory.swift */, 1D72D59B2BFF61B200AEDE36 /* UpdateNotificationPresenter.swift */, 1D9297BE2C1B062900A38521 /* ApplicationUpdateDetector.swift */, - 1D0DE93D2C3BA9840037ABC2 /* AppRestarter.swift */, - 1D232E932C7860DA0043840D /* BinaryOwnershipChecker.swift */, 1D39E5762C2BFD5700757339 /* ReleaseNotesTabExtension.swift */, 1D39E5792C2C0F3700757339 /* ReleaseNotesUserScript.swift */, 1D0DE9402C3BB9CC0037ABC2 /* ReleaseNotesParser.swift */, 1D710F4A2C48F1F200C3975F /* UpdateDialogHelper.swift */, + BD6367242C877BE1009DE7A8 /* UpdateUserDriver.swift */, ); path = Updates; sourceTree = ""; @@ -5256,7 +5313,6 @@ children = ( 1D838A312C44F0180078373F /* ReleaseNotesParserTests.swift */, 1D638D602C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift */, - 1D232E962C786E7D0043840D /* BinaryOwnershipCheckerTests.swift */, ); path = Updates; sourceTree = ""; @@ -5583,6 +5639,7 @@ 37CEFCA82A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift */, 37A6A8F02AFCC988008580A3 /* FaviconsFetcherOnboarding.swift */, 37A6A8F52AFCCA59008580A3 /* FaviconsFetcherOnboardingViewController.swift */, + EEDFA3892CD148DE00D1C558 /* SyncDiagnosisHelper.swift */, ); path = Sync; sourceTree = ""; @@ -6993,6 +7050,7 @@ 569277C029DDCBB500B633EF /* HomePageContinueSetUpModel.swift */, 56D145EA29E6C99B00E3488A /* DataImportStatusProviding.swift */, 3768D83A2C24C0A8004120AE /* RemoteMessageViewModel.swift */, + C181945E2C7CDD0E00381092 /* PromotionViewModel.swift */, ); path = Model; sourceTree = ""; @@ -7615,6 +7673,7 @@ 85A0115D25AF1C4700FA6A0C /* FindInPage */, AA6820E825503A21005ED0D5 /* Fire */, 4B02197B25E05FAC00ED7DEA /* Fireproofing */, + C1858CCF2C7C95DB00C9BEAB /* Freemium */, B65536902684409300085A79 /* Geolocation */, AAE75275263B036300B973F8 /* History */, AAE71DB225F66A0900D74437 /* HomePage */, @@ -7690,6 +7749,7 @@ 8553FF50257523630029327F /* FileDownload */, AA9C361D25518AAB004B1BA3 /* Fire */, 4B02199725E063DE00ED7DEA /* Fireproofing */, + C18194562C7CA98500381092 /* Freemium */, B68172AC269EB415006D1092 /* Geolocation */, AAEC74AE2642C47300C2EFBC /* History */, 4BF6961B28BE90E800D402D4 /* HomePage */, @@ -8093,6 +8153,7 @@ B634DBE2293C8FFF00C3C99E /* UserDialogRequest.swift */, 1D1A33482A6FEB170080ACED /* BurnerMode.swift */, 56406D4A2C636A8900BF8FA2 /* SpecialPagesUserScriptExtension.swift */, + 85B49AF92CD1B7C5007FAA2A /* SystemInfo.swift */, ); path = Model; sourceTree = ""; @@ -8510,6 +8571,7 @@ AA6FFB4324DC33320028F4D0 /* NSViewExtension.swift */, AA9E9A5525A3AE8400D1959D /* NSWindowExtension.swift */, B643BF1327ABF772000BACEC /* NSWorkspaceExtension.swift */, + 843AD3DB2CD389C500163067 /* XMLNodeExtension.swift */, B6A9E46A2614618A0067D1B9 /* OperatingSystemVersionExtension.swift */, EEE50C282C38249C003DD7FF /* OptionalExtension.swift */, B637273C26CCF0C200C8CB02 /* OptionalExtension.swift */, @@ -8574,6 +8636,7 @@ 85F0FF1227CFAB04001C7C6E /* RecentlyVisitedView.swift */, 3768D8372C24BFF5004120AE /* RemoteMessageView.swift */, 3707EC492C47E36A00B67CBE /* CloseButton.swift */, + C181945B2C7CDCC700381092 /* PromotionView.swift */, ); path = View; sourceTree = ""; @@ -9244,6 +9307,84 @@ path = Mocks; sourceTree = ""; }; + C17E10582C94887A00317F2B /* Debug */ = { + isa = PBXGroup; + children = ( + C126B3592C820924005DC2A3 /* FreemiumDebugMenu.swift */, + ); + path = Debug; + sourceTree = ""; + }; + C17E10592C94888E00317F2B /* Extensions */ = { + isa = PBXGroup; + children = ( + C1C405862C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift */, + C153E7C42C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift */, + C1351DD02C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + C17E105A2C94889B00317F2B /* Experiment */ = { + isa = PBXGroup; + children = ( + C1B51EB32C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift */, + ); + path = Experiment; + sourceTree = ""; + }; + C17E105B2C94A06700317F2B /* Experiment */ = { + isa = PBXGroup; + children = ( + C17E105C2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift */, + ); + path = Experiment; + sourceTree = ""; + }; + C18194562C7CA98500381092 /* Freemium */ = { + isa = PBXGroup; + children = ( + C18194572C7CA99400381092 /* DBP */, + ); + path = Freemium; + sourceTree = ""; + }; + C18194572C7CA99400381092 /* DBP */ = { + isa = PBXGroup; + children = ( + C17E105B2C94A06700317F2B /* Experiment */, + C18194582C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift */, + C18194662C7F60E500381092 /* FreemiumDBPPresenterTests.swift */, + C1CE846B2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift */, + C11198332C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift */, + C1F142DA2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift */, + ); + path = DBP; + sourceTree = ""; + }; + C1858CCF2C7C95DB00C9BEAB /* Freemium */ = { + isa = PBXGroup; + children = ( + C17E10582C94887A00317F2B /* Debug */, + C1858CD02C7C95E500C9BEAB /* DBP */, + ); + path = Freemium; + sourceTree = ""; + }; + C1858CD02C7C95E500C9BEAB /* DBP */ = { + isa = PBXGroup; + children = ( + C17E105A2C94889B00317F2B /* Experiment */, + C17E10592C94888E00317F2B /* Extensions */, + C1858CD12C7C971D00C9BEAB /* FreemiumDBPFeature.swift */, + C18194632C7DF7D600381092 /* FreemiumDBPPresenter.swift */, + C1CE84682C887CF60068913B /* FreemiumDBPScanResultPolling.swift */, + C11198302C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift */, + C153E7C12C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift */, + ); + path = DBP; + sourceTree = ""; + }; C1935A162C88F9AA001AD72D /* Promotion */ = { isa = PBXGroup; children = ( @@ -10853,6 +10994,7 @@ 1DDC84FC2B8356CE00670238 /* PreferencesDefaultBrowserView.swift in Sources */, 3706FAC8293F65D500E42796 /* AppTrackerDataSetProvider.swift in Sources */, 3706FAC9293F65D500E42796 /* EncryptionKeyGeneration.swift in Sources */, + C1CE846A2C887CF60068913B /* FreemiumDBPScanResultPolling.swift in Sources */, 3706FACA293F65D500E42796 /* TabLazyLoader.swift in Sources */, 3706FACC293F65D500E42796 /* SaveCredentialsViewController.swift in Sources */, 3706FACD293F65D500E42796 /* PopUpButton.swift in Sources */, @@ -10866,6 +11008,7 @@ 3706FAD4293F65D500E42796 /* DataExtension.swift in Sources */, 3706FAD6293F65D500E42796 /* ConfigurationStore.swift in Sources */, FD22255E2C64B68500199373 /* AutoconsentExperiment.swift in Sources */, + EEDFA38B2CD148DE00D1C558 /* SyncDiagnosisHelper.swift in Sources */, 3706FAD7293F65D500E42796 /* Feedback.swift in Sources */, 1D0DE9422C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */, EED4D3D92C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */, @@ -10956,6 +11099,7 @@ 3706FB0B293F65D500E42796 /* DefaultBrowserPromptView.swift in Sources */, 3706FB0E293F65D500E42796 /* FaviconManager.swift in Sources */, 3706FB0F293F65D500E42796 /* ChromiumFaviconsReader.swift in Sources */, + C18194652C7DF7D600381092 /* FreemiumDBPPresenter.swift in Sources */, 4B0BD7B82A9FE6E600EF609D /* NetworkProtectionOnboardingMenu.swift in Sources */, 3706FB10293F65D500E42796 /* SuggestionTableRowView.swift in Sources */, 3706FB11293F65D500E42796 /* DownloadsPreferences.swift in Sources */, @@ -11002,6 +11146,7 @@ 3706FB2D293F65D500E42796 /* PasswordManagementPopover.swift in Sources */, 3706FB2F293F65D500E42796 /* HomePageRecentlyVisitedModel.swift in Sources */, C1935A152C88F958001AD72D /* SyncPromoView.swift in Sources */, + C126B35B2C820924005DC2A3 /* FreemiumDebugMenu.swift in Sources */, 3707C718294B5D0F00682A9F /* AdClickAttributionTabExtension.swift in Sources */, 31EF1E812B63FFB800E6DB17 /* DataBrokerProtectionManager.swift in Sources */, 3706FEBA293F6EFF00E42796 /* BWStatus.swift in Sources */, @@ -11104,6 +11249,7 @@ 9FEE986A2B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */, 3706FB6A293F65D500E42796 /* AddressBarButton.swift in Sources */, 1D4071AF2BD64267002D4537 /* DockCustomizer.swift in Sources */, + C153E7C32C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift in Sources */, 4B41EDA42B1543B9001EEDF4 /* VPNPreferencesModel.swift in Sources */, 1E559BB22BBCA9F1002B4AF6 /* RedirectNavigationResponder.swift in Sources */, 3706FB6C293F65D500E42796 /* FaviconStore.swift in Sources */, @@ -11148,6 +11294,7 @@ B6BCC5502AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */, 7BAF9E4B2A8A3CC9002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */, B6685E3D29A602D90043D2EE /* ExternalAppSchemeHandler.swift in Sources */, + C1C405882C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift in Sources */, B6E1491029A5C30500AAFBE8 /* ContentBlockingTabExtension.swift in Sources */, 3706FB82293F65D500E42796 /* PasswordManagementNoteItemView.swift in Sources */, 4BF97AD82B43C5B300EB4240 /* NetworkProtectionAppEvents.swift in Sources */, @@ -11213,10 +11360,12 @@ 98779A0129999B64005D8EB6 /* Bookmark.xcdatamodeld in Sources */, 3706FB9E293F65D500E42796 /* AboutPreferences.swift in Sources */, 3706FB9F293F65D500E42796 /* PasswordManagementCreditCardItemView.swift in Sources */, + C153E7C62C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift in Sources */, 9F9C4A022BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift in Sources */, 3706FBA0293F65D500E42796 /* NSTextFieldExtension.swift in Sources */, 9FA173E82B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */, 3706FBA1293F65D500E42796 /* FireproofDomainsContainer.swift in Sources */, + C1858CD32C7C971D00C9BEAB /* FreemiumDBPFeature.swift in Sources */, 3706FBA2293F65D500E42796 /* GeolocationService.swift in Sources */, 3706FBA3293F65D500E42796 /* FireproofingURLExtensions.swift in Sources */, 3169132A2BD2C7570051B46D /* DataBrokerProtectionErrorViewController.swift in Sources */, @@ -11248,6 +11397,7 @@ 3706FBB5293F65D500E42796 /* UserContentUpdating.swift in Sources */, 4B4D60B72A0C847D00BCD287 /* NetworkProtectionNavBarButtonModel.swift in Sources */, 3706FBB6293F65D500E42796 /* ChromiumPreferences.swift in Sources */, + 85B49AFB2CD1B7C5007FAA2A /* SystemInfo.swift in Sources */, 3706FBB7293F65D500E42796 /* FirePopoverViewController.swift in Sources */, 3706FBB8293F65D500E42796 /* SavePaymentMethodPopover.swift in Sources */, B6CC266D2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */, @@ -11388,6 +11538,7 @@ 3706FC06293F65D500E42796 /* OnboardingViewModel.swift in Sources */, 3706FC07293F65D500E42796 /* ScriptSourceProviding.swift in Sources */, 31EF1E832B63FFCA00E6DB17 /* LoginItem+DataBrokerProtection.swift in Sources */, + C1B51EB52C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift in Sources */, 021EA0812BD2A9D500772C9A /* TabsPreferences.swift in Sources */, B6619EFC2B111CC600CD9186 /* InstructionsFormatParser.swift in Sources */, 3706FC08293F65D500E42796 /* CoreDataBookmarkImporter.swift in Sources */, @@ -11459,9 +11610,11 @@ 3706FC30293F65D500E42796 /* FireInfoViewController.swift in Sources */, B6F1B02F2BCE6B47005E863C /* TunnelControllerProvider.swift in Sources */, 31A83FB62BE28D7D00F74E67 /* UserText+DBP.swift in Sources */, + C18194602C7CDD0E00381092 /* PromotionViewModel.swift in Sources */, 3706FC31293F65D500E42796 /* PermissionButton.swift in Sources */, 317307262CD248DB00C492AB /* AutofillToolbarOnboardingPopover.swift in Sources */, 9F6434622BEC82B700D2D8A0 /* AttributionPixelHandler.swift in Sources */, + 843AD3DC2CD389CC00163067 /* XMLNodeExtension.swift in Sources */, 3706FC32293F65D500E42796 /* MoreOptionsMenu.swift in Sources */, 3706FC34293F65D500E42796 /* PermissionAuthorizationViewController.swift in Sources */, 3706FC35293F65D500E42796 /* BookmarkNode.swift in Sources */, @@ -11482,6 +11635,7 @@ 4B9DB03C2A983B24000927DB /* InvitedToWaitlistView.swift in Sources */, 3706FC40293F65D500E42796 /* SaveIdentityViewController.swift in Sources */, 3706FC41293F65D500E42796 /* FileStore.swift in Sources */, + C11198322C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift in Sources */, 1DB67F2E2B6FEFDB003DF243 /* ViewSnapshotRenderer.swift in Sources */, 3706FC43293F65D500E42796 /* PinnedTabsViewModel.swift in Sources */, 85D0327C2B8E3D090041D1FB /* HistoryCoordinatorExtension.swift in Sources */, @@ -11625,6 +11779,7 @@ 3706FC98293F65D500E42796 /* CountryList.swift in Sources */, 1DEF3BAE2BD145A9004A2FBA /* AutoClearHandler.swift in Sources */, 4B37EE732B4CFF0800A89A61 /* HomePageRemoteMessagingStorage.swift in Sources */, + C1351DD22C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift in Sources */, 3706FC99293F65D500E42796 /* PreferencesSection.swift in Sources */, B6C8CAA82AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, B655124929A79465009BFE1C /* NavigationActionExtension.swift in Sources */, @@ -11646,11 +11801,11 @@ 3707C71E294B5D2900682A9F /* URLRequestExtension.swift in Sources */, 3706FCA3293F65D500E42796 /* WKProcessPool+GeolocationProvider.swift in Sources */, 372A0FED2B2379310033BF7F /* SyncMetricsEventsHandler.swift in Sources */, - 1D0DE93F2C3BA9840037ABC2 /* AppRestarter.swift in Sources */, 3706FCA4293F65D500E42796 /* RecentlyClosedMenu.swift in Sources */, 8400DC4C2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */, 4B9DB02D2A983B24000927DB /* WaitlistKeychainStorage.swift in Sources */, EECE10E629DD77E60044D027 /* FeatureFlag.swift in Sources */, + C181945D2C7CDCC700381092 /* PromotionView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -11775,6 +11930,7 @@ C13909F52B85FD79001626ED /* AutofillDeleteAllPasswordsExecutorTests.swift in Sources */, 37D046A52C7DAA8900AEAA50 /* ImageProcessorMock.swift in Sources */, 857E44642A9F70F200ED77A7 /* CampaignVariantTests.swift in Sources */, + C181945A2C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift in Sources */, 561D29C72BDA74F4007B91D0 /* MockDDGSyncing.swift in Sources */, 3706FE1E293F661700E42796 /* GeolocationProviderTests.swift in Sources */, 567A23CE2C80CF3D0010F66C /* SpecialErrorPageUserScriptTests.swift in Sources */, @@ -11796,6 +11952,7 @@ 56A054172C1C37B0007D8FAB /* CapturingOnboardingNavigation.swift in Sources */, 37DB56F32C3B36B80093D4DC /* RemoteMessagingClientTests.swift in Sources */, C17CA7AE2B9B52E6008EC3C1 /* NavigationBarPopoversTests.swift in Sources */, + C17E105E2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift in Sources */, 3706FE23293F661700E42796 /* SuggestionLoadingMock.swift in Sources */, 3706FE24293F661700E42796 /* PasteboardFolderTests.swift in Sources */, B603971229B9D67E00902A34 /* PublishersExtensions.swift in Sources */, @@ -11853,6 +12010,7 @@ 3706FE3C293F661700E42796 /* FireproofDomainsStoreMock.swift in Sources */, 56A054482C22536A007D8FAB /* CapturingOnboardingActionsManager.swift in Sources */, 3706FE3D293F661700E42796 /* DataEncryptionTests.swift in Sources */, + C11198352C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift in Sources */, C17CA7B32B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift in Sources */, 3706FE3F293F661700E42796 /* FileStoreMock.swift in Sources */, B6619F042B17123200CD9186 /* DataImportViewModelTests.swift in Sources */, @@ -11922,6 +12080,7 @@ 84DC715A2C1C1E9000033B8C /* UserDefaultsWrapperTests.swift in Sources */, B60C6F7F29B1B41D007BFAA8 /* TestRunHelperInitializer.m in Sources */, 9F0FFFBF2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift in Sources */, + C1CE846D2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift in Sources */, 3706FE61293F661700E42796 /* PinnedTabsViewModelTests.swift in Sources */, 560C6ECA2CCA381A00D411E2 /* OnboardingPixelReporterTests.swift in Sources */, 3706FE62293F661700E42796 /* PasswordManagementListSectionTests.swift in Sources */, @@ -11954,6 +12113,7 @@ 3706FE74293F661700E42796 /* WebsiteDataStoreMock.swift in Sources */, 1D4B03DA2CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift in Sources */, 3706FE75293F661700E42796 /* WebsiteBreakageReportTests.swift in Sources */, + C18194682C7F60E500381092 /* FreemiumDBPPresenterTests.swift in Sources */, 56D145EF29E6DAD900E3488A /* DataImportProviderTests.swift in Sources */, 569277C529DEE09D00B633EF /* ContinueSetUpModelTests.swift in Sources */, CD89DD622C89E08D0080F9AF /* PhishingDetectionTests.swift in Sources */, @@ -11983,6 +12143,7 @@ 3706FE7F293F661700E42796 /* FileDownloadManagerMock.swift in Sources */, 3706FE81293F661700E42796 /* PermissionModelTests.swift in Sources */, 5681ED442BDBA5F900F59729 /* SyncBookmarksAdapterTests.swift in Sources */, + C1F142DB2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift in Sources */, B60C6F8529B1BAD3007BFAA8 /* FileManagerTempDirReplacement.swift in Sources */, 567DA94629E95C3F008AC5EE /* YoutubeOverlayUserScriptTests.swift in Sources */, CD33012A2C887B1C009AA127 /* URLTokenValidatorTests.swift in Sources */, @@ -12396,6 +12557,7 @@ 379E877629E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift in Sources */, B6DA06E42913ECEE00225DE2 /* ContextMenuManager.swift in Sources */, B693955126F04BEB0015B914 /* GradientView.swift in Sources */, + C1858CD22C7C971D00C9BEAB /* FreemiumDBPFeature.swift in Sources */, 37AFCE8527DA2D3900471A10 /* PreferencesSidebar.swift in Sources */, 3797C7A32C62C38800DA77FB /* HomePageSettingsModel.swift in Sources */, B6C00ED5292FB21E009C73A6 /* HoveredLinkTabExtension.swift in Sources */, @@ -12538,6 +12700,7 @@ AAAB9114288EB1D600A057A9 /* CleanThisHistoryMenuItem.swift in Sources */, 1D0DE9412C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */, B6C0B23626E732000031CB7F /* DownloadListItem.swift in Sources */, + C1B51EB42C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift in Sources */, 4B9DB0232A983B24000927DB /* WaitlistRequest.swift in Sources */, 4BE3A6C12C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift in Sources */, 3148727F2CC68F6900EEF89B /* AIChatOnboardingTabExtension.swift in Sources */, @@ -12564,7 +12727,6 @@ B6BF5D8929470BC4006742B1 /* HTTPSUpgradeTabExtension.swift in Sources */, 1D36E65B298ACD2900AA485D /* AppIconChanger.swift in Sources */, 4B4D60E22A0C883A00BCD287 /* AppMain.swift in Sources */, - 1D0DE93E2C3BA9840037ABC2 /* AppRestarter.swift in Sources */, 7BCB90C22C18626E008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift in Sources */, 4B9DB0202A983B24000927DB /* ProductWaitlistRequest.swift in Sources */, 7B60B0022C5145EC008E32A3 /* VPNUIActionHandler.swift in Sources */, @@ -12616,6 +12778,7 @@ 37054FCE2876472D00033B6F /* WebViewSnapshotView.swift in Sources */, 560EB9392C789A450080DBC8 /* OnboardingSuggestedSearchesProvider.swift in Sources */, 4BBC16A027C4859400E00A38 /* DeviceAuthenticationService.swift in Sources */, + C181945C2C7CDCC700381092 /* PromotionView.swift in Sources */, CB24F70C29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift in Sources */, 1DEF3BAD2BD145A9004A2FBA /* AutoClearHandler.swift in Sources */, 37CBCA9A2A8966E60050218F /* SyncSettingsAdapter.swift in Sources */, @@ -12685,6 +12848,7 @@ 567A23D42C81E2180010F66C /* ContextualDaxDialogsFactory.swift in Sources */, B64C852A26942AC90048FEBE /* PermissionContextMenu.swift in Sources */, 85D438B6256E7C9E00F3BAF8 /* ContextMenuUserScript.swift in Sources */, + C153E7C52C8B21B500B9BAD7 /* Logger+FreemiumDBP.swift in Sources */, B693955526F04BEC0015B914 /* NSSavePanelExtension.swift in Sources */, 9826B0A22747DFEB0092F683 /* AppPrivacyConfigurationDataProvider.swift in Sources */, B6B1E88B26D774090062C350 /* LinkButton.swift in Sources */, @@ -12705,6 +12869,7 @@ 3797C7A02C61806500DA77FB /* HomePageSettingsView.swift in Sources */, B6F41031264D2B23003DA42C /* ProgressExtension.swift in Sources */, 4B723E0F26B0006500E14D75 /* CSVParser.swift in Sources */, + 843AD3DD2CD389CC00163067 /* XMLNodeExtension.swift in Sources */, B63BDF7E27FDAA640072D75B /* PrivacyDashboardWebView.swift in Sources */, 37CD54CF27F2FDD100F1F7B9 /* AppearancePreferences.swift in Sources */, 3199AF7B2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift in Sources */, @@ -12729,6 +12894,7 @@ 4B723E0D26B0006100E14D75 /* SecureVaultLoginImporter.swift in Sources */, B645D8F629FA95440024461F /* WKProcessPoolExtension.swift in Sources */, 9D9AE86B2AA76CF90026E7DC /* LoginItemsManager.swift in Sources */, + C18194642C7DF7D600381092 /* FreemiumDBPPresenter.swift in Sources */, 857E5AF52A79045800FC0FB4 /* PixelExperiment.swift in Sources */, B6C416A7294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift in Sources */, BDBA859F2C5D25B700BC54F5 /* VPNMetadataCollector.swift in Sources */, @@ -12813,6 +12979,7 @@ 3706FEC8293F6F7500E42796 /* BWManagement.swift in Sources */, 316C48F02CC2B232000B08C1 /* AIChatPreferencesStorage.swift in Sources */, B6830961274CDE99004B46BB /* FireproofDomainsContainer.swift in Sources */, + 85B49AFA2CD1B7C5007FAA2A /* SystemInfo.swift in Sources */, B687B7CC2947A1E9001DEA6F /* ExternalAppSchemeHandler.swift in Sources */, B65536AE2685E17200085A79 /* GeolocationService.swift in Sources */, 4B02198925E05FAC00ED7DEA /* FireproofingURLExtensions.swift in Sources */, @@ -12833,6 +13000,7 @@ 856CADF0271710F400E79BB0 /* HoverUserScript.swift in Sources */, B6DE57F62B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, AA6EF9B525081B4C004754E6 /* MainMenuActions.swift in Sources */, + EEDFA38A2CD148DE00D1C558 /* SyncDiagnosisHelper.swift in Sources */, EED4D3D82C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */, 56A0541F2C1CA1F5007D8FAB /* OnboardingTabExtension.swift in Sources */, B63D466925BEB6C200874977 /* WKWebView+SessionState.swift in Sources */, @@ -12874,6 +13042,7 @@ F188267C2BBEB3AA00D9AC4F /* GeneralPixel.swift in Sources */, 4B723E1026B0006700E14D75 /* CSVImporter.swift in Sources */, 37A4CEBA282E992F00D75B89 /* StartupPreferences.swift in Sources */, + C1CE84692C887CF60068913B /* FreemiumDBPScanResultPolling.swift in Sources */, 1D72D59C2BFF61B200AEDE36 /* UpdateNotificationPresenter.swift in Sources */, AA4BBA3B25C58FA200C4FB0F /* MainMenu.swift in Sources */, AA585D84248FD31100E9A3E2 /* BrowserTabViewController.swift in Sources */, @@ -13022,6 +13191,7 @@ 373D9B4829EEAC1B00381FDD /* SyncMetadataDatabase.swift in Sources */, B68458C025C7E9E000DC17B6 /* TabCollectionViewModel+NSSecureCoding.swift in Sources */, AA8EDF2724923EC70071C2E8 /* StringExtension.swift in Sources */, + C1351DD12C8B31CE0086B058 /* NotificationName+FreemiumDBP.swift in Sources */, 85378DA2274E7F25007C5CBF /* EmailManagerRequestDelegate.swift in Sources */, 1D43EB36292ACE690065E5D6 /* ApplicationVersionReader.swift in Sources */, 566B736C2BECC3C600FF1959 /* SyncPausedStateManaging.swift in Sources */, @@ -13062,6 +13232,7 @@ BDBA85992C5D258100BC54F5 /* VPNFeedbackFormViewController.swift in Sources */, B693955726F04BEC0015B914 /* MouseOverButton.swift in Sources */, AA61C0D02722159B00E6B681 /* FireInfoViewController.swift in Sources */, + C1C405872C7F80E50089DE8A /* PromotionView+FreemiumDBP.swift in Sources */, 9D9AE8692AA76CDC0026E7DC /* LoginItem+NetworkProtection.swift in Sources */, 9F9C49F92BC7BC970099738D /* BookmarkAllTabsDialogView.swift in Sources */, B64C85422694590B0048FEBE /* PermissionButton.swift in Sources */, @@ -13085,7 +13256,6 @@ 37BF3F21286F0A7A00BD9014 /* PinnedTabsViewModel.swift in Sources */, EEC4A6692B2C87D300F7C0AA /* VPNLocationView.swift in Sources */, AAC5E4D225D6A709007F5990 /* BookmarkList.swift in Sources */, - 1D232E942C7860DA0043840D /* BinaryOwnershipChecker.swift in Sources */, B602E81D2A1E25B1006D261F /* NEOnDemandRuleExtension.swift in Sources */, 56A0543E2C215FB3007D8FAB /* OnboardingUserScript.swift in Sources */, C1372EF42BBC5BAD003F8793 /* SecureTextField.swift in Sources */, @@ -13114,6 +13284,7 @@ 37219B3A2CBFD4F300C9D7A8 /* NewTabPageSearchBoxExperiment+Logger.swift in Sources */, 37AAA41C2C9CB9C0002A5377 /* AddressBarTextFieldView.swift in Sources */, AA6820EB25503D6A005ED0D5 /* Fire.swift in Sources */, + BD6367252C877BE1009DE7A8 /* UpdateUserDriver.swift in Sources */, 3158B1492B0BF73000AF130C /* DBPHomeViewController.swift in Sources */, 9F56CFA92B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift in Sources */, 37445F9C2A1569F00029F789 /* SyncBookmarksAdapter.swift in Sources */, @@ -13130,6 +13301,7 @@ 85B7184C27677C6500B4277F /* OnboardingViewController.swift in Sources */, 4B379C1E27BDB7FF008A968E /* DeviceAuthenticator.swift in Sources */, 1456D6E124EFCBC300775049 /* TabBarCollectionView.swift in Sources */, + C181945F2C7CDD0E00381092 /* PromotionViewModel.swift in Sources */, 4B4D60BF2A0C848A00BCD287 /* NetworkProtection+ConvenienceInitializers.swift in Sources */, 7B5A23682C468233007213AC /* ExcludedDomainsViewController.swift in Sources */, BDA7647C2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift in Sources */, @@ -13150,6 +13322,7 @@ 85589E8327BBB8630038AD11 /* HomePageViewController.swift in Sources */, F17114852C7C9D28009836C1 /* Logger+Fire.swift in Sources */, 5614B3A12BBD639D009B5031 /* ZoomPopover.swift in Sources */, + C126B35A2C820924005DC2A3 /* FreemiumDebugMenu.swift in Sources */, B6A9E46B2614618A0067D1B9 /* OperatingSystemVersionExtension.swift in Sources */, 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */, 1D36F4242A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */, @@ -13214,6 +13387,7 @@ 37BF3F22286F0A7A00BD9014 /* PinnedTabsView.swift in Sources */, 37FD78112A29EBD100B36DB1 /* SyncErrorHandler.swift in Sources */, 3767318B2C7F32C500EB097B /* GradientBackground.swift in Sources */, + C11198312C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift in Sources */, AA8EDF2424923E980071C2E8 /* URLExtension.swift in Sources */, B634DBDF293C8F7F00C3C99E /* Tab+UIDelegate.swift in Sources */, B6F1B02E2BCE6B47005E863C /* TunnelControllerProvider.swift in Sources */, @@ -13246,6 +13420,7 @@ 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, BDBA85962C5D256C00BC54F5 /* VPNFeedbackFormView.swift in Sources */, 987799F62999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, + C153E7C22C8AD68D00B9BAD7 /* FreemiumDBPPromotionViewCoordinator.swift in Sources */, 7BB4BC6A2C5CD96200E06FC8 /* ActiveDomainPublisher.swift in Sources */, 4BE53374286E39F10019DBFD /* ChromiumKeychainPrompt.swift in Sources */, B6553692268440D700085A79 /* WKProcessPool+GeolocationProvider.swift in Sources */, @@ -13303,6 +13478,7 @@ 4B0219A825E0646500ED7DEA /* WebsiteDataStoreTests.swift in Sources */, AAC9C01E24CB6BEB00AD1325 /* TabCollectionViewModelTests.swift in Sources */, 56CE77612C7DFCF800AC1ED2 /* OnboardingSuggestedSearchesProviderTests.swift in Sources */, + C18194592C7CA9AB00381092 /* FreemiumDBPFeatureTests.swift in Sources */, B662D3DE275613BB0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */, 1D3B1ABF29369FC8006F4388 /* BWEncryptionTests.swift in Sources */, B6F56567299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, @@ -13317,6 +13493,7 @@ 1D8C2FEA2B70F5A7005E4BBD /* MockWebViewSnapshotRenderer.swift in Sources */, AAC9C01724CAFBDC00AD1325 /* TabCollectionTests.swift in Sources */, 378205F8283BC6A600D1D4AA /* StartupPreferencesTests.swift in Sources */, + C1F142DC2CB93AED003DA518 /* FreemiumDBPPromotionViewCoordinatorTests.swift in Sources */, 3714B1E928EDBAAB0056C57A /* DuckPlayerTests.swift in Sources */, 5603D90629B7B746007F9F01 /* MockTabViewItemDelegate.swift in Sources */, B67C6C3D2654B897006C872E /* WebViewExtensionTests.swift in Sources */, @@ -13429,7 +13606,6 @@ 567A23E12C89B1EE0010F66C /* BrowserTabViewControllerOnboardingTests.swift in Sources */, 986189E62A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift in Sources */, AA652CD325DDA6E9009059CC /* LocalBookmarkManagerTests.swift in Sources */, - 1D232E992C7870D90043840D /* BinaryOwnershipCheckerTests.swift in Sources */, CBDD5DE329A67F2700832877 /* MockConfigurationStore.swift in Sources */, 9F3910692B68D87B00CB5112 /* ProgressExtensionTests.swift in Sources */, 560C6ED02CCA5C6000D411E2 /* CapturingOnboardingNavigationDelegate.swift in Sources */, @@ -13448,6 +13624,7 @@ 4BF4951826C08395000547B8 /* ThirdPartyBrowserTests.swift in Sources */, 4B98D27C28D960DD003C2B6F /* FirefoxFaviconsReaderTests.swift in Sources */, 9F0FFFBE2BCCAF1F007C87DD /* BookmarkAllTabsDialogViewModelMock.swift in Sources */, + C1CE846C2C88868D0068913B /* FreemiumDBPScanResultPollingTests.swift in Sources */, B60C6F7E29B1B41D007BFAA8 /* TestRunHelperInitializer.m in Sources */, 37479F152891BC8300302FE2 /* TabCollectionViewModelTests+WithoutPinnedTabsManager.swift in Sources */, AA63745424C9BF9A00AB2AC4 /* SuggestionContainerTests.swift in Sources */, @@ -13501,6 +13678,7 @@ 1DA860722BE3AE950027B813 /* DockPositionProviderTests.swift in Sources */, 4BBF0925283083EC00EE1418 /* FileSystemDSLTests.swift in Sources */, 4B11060A25903EAC0039B979 /* CoreDataEncryptionTests.swift in Sources */, + C17E105D2C94A10700317F2B /* FreemiumDBPPixelExperimentManagingTests.swift in Sources */, 3767319E2C7F416000EB097B /* CustomBackgroundTests.swift in Sources */, B603971029B9D67E00902A34 /* PublishersExtensions.swift in Sources */, 9F26060E2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift in Sources */, @@ -13549,6 +13727,7 @@ 4B2975992828285900187C4E /* FirefoxKeyReaderTests.swift in Sources */, B698E5042908011E00A746A8 /* AppKitPrivateMethodsAvailabilityTests.swift in Sources */, 56D145EE29E6DAD900E3488A /* DataImportProviderTests.swift in Sources */, + C11198342C89AEFA00F0272C /* FreemiumDBPFirstProfileSavedNotifierTests.swift in Sources */, 4BB99D0F26FE1A84001E4761 /* ChromiumBookmarksReaderTests.swift in Sources */, 1D9FDEB72B9B5D150040B78C /* SearchPreferencesTests.swift in Sources */, 021EA0842BD6E01A00772C9A /* TabsPreferencesTests.swift in Sources */, @@ -13580,6 +13759,7 @@ EEF53E182950CED5002D78F4 /* JSAlertViewModelTests.swift in Sources */, 376C4DB928A1A48A00CC0F5B /* FirePopoverViewModelTests.swift in Sources */, AAEC74B62642CC6A00C2EFBC /* HistoryStoringMock.swift in Sources */, + C18194672C7F60E500381092 /* FreemiumDBPPresenterTests.swift in Sources */, AA652CB125DD825B009059CC /* LocalBookmarkStoreTests.swift in Sources */, B630794226731F5400DCEE41 /* WKDownloadMock.swift in Sources */, B6C0B24626E9CB190031CB7F /* RunLoopExtensionTests.swift in Sources */, @@ -14598,7 +14778,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 201.0.0; + version = 203.1.0; }; }; 9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 51a522e596..6c7c3d5617 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "e5946eee6af859690cc1cc5e51daef3c8368981b", - "version" : "201.0.0" + "revision" : "19f1e5c945aa92562ad2d087e8d6c99801edf656", + "version" : "203.1.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "b74549bd869fdecc16fad851f2f608b1724764df", - "version" : "6.25.0" + "revision" : "48fee2508995d4ac02d18b3d55424adedcb4ce4f", + "version" : "6.28.0" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "945ac09a0189dc6736db617867fde193ea984b20", - "version" : "15.0.0" + "revision" : "c992041d16ec10d790e6204dce9abf9966d1363c", + "version" : "15.1.0" } }, { @@ -75,7 +75,7 @@ { "identity" : "lottie-spm", "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-spm", + "location" : "https://github.com/airbnb/lottie-spm.git", "state" : { "revision" : "1d29eccc24cc8b75bff9f6804155112c0ffc9605", "version" : "4.4.3" @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "9de2b2aa317a48d3ee31116dc15b0feeb2cc9414", - "version" : "5.3.0" + "revision" : "53fd1a0f8d91fcf475d9220f810141007300dffd", + "version" : "7.1.1" } }, { @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/sync_crypto", "state" : { - "revision" : "2ab6ab6f0f96b259c14c2de3fc948935fc16ac78", - "version" : "0.2.0" + "revision" : "0c8bf3c0e75591bc366407b9d7a73a9fcfc7736f", + "version" : "0.3.0" } }, { diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index c8c5f29855..8bce0390f3 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -40,6 +40,7 @@ import NetworkProtectionIPC import DataBrokerProtection import RemoteMessaging import os.log +import Freemium final class AppDelegate: NSObject, NSApplicationDelegate { @@ -96,15 +97,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { public let subscriptionManager: SubscriptionManager public let subscriptionUIHandler: SubscriptionUIHandling - public let subscriptionCookieManager: SubscriptionCookieManaging + private let subscriptionCookieManager: SubscriptionCookieManaging + private var subscriptionCookieManagerFeatureFlagCancellable: AnyCancellable? - public let vpnSettings = VPNSettings(defaults: .netP) + // MARK: - Freemium DBP + public let freemiumDBPFeature: FreemiumDBPFeature + public let freemiumDBPPromotionViewCoordinator: FreemiumDBPPromotionViewCoordinator + private var freemiumDBPScanResultPolling: FreemiumDBPScanResultPolling? var configurationStore = ConfigurationStore() var configurationManager: ConfigurationManager // MARK: - VPN + public let vpnSettings = VPNSettings(defaults: .netP) + private var networkProtectionSubscriptionEventHandler: NetworkProtectionSubscriptionEventHandler? private var vpnXPCClient: VPNControllerXPCClient { @@ -276,6 +283,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Update DBP environment and match the Subscription environment DataBrokerProtectionSettings().alignTo(subscriptionEnvironment: subscriptionManager.currentEnvironment) + + // Freemium DBP + let freemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp) + + let experimentManager = FreemiumDBPPixelExperimentManager(subscriptionManager: subscriptionManager) + experimentManager.assignUserToCohort() + + freemiumDBPFeature = DefaultFreemiumDBPFeature(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + experimentManager: experimentManager, + subscriptionManager: subscriptionManager, + accountManager: subscriptionManager.accountManager, + freemiumDBPUserStateManager: freemiumDBPUserStateManager) + freemiumDBPPromotionViewCoordinator = FreemiumDBPPromotionViewCoordinator(freemiumDBPUserStateManager: freemiumDBPUserStateManager, + freemiumDBPFeature: freemiumDBPFeature) } func applicationWillFinishLaunching(_ notification: Notification) { @@ -300,8 +321,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { networkProtectionSubscriptionEventHandler = NetworkProtectionSubscriptionEventHandler(subscriptionManager: subscriptionManager, tunnelController: tunnelController, vpnUninstaller: vpnUninstaller) + + // Freemium DBP + freemiumDBPFeature.subscribeToDependencyUpdates() } + // swiftlint:disable:next cyclomatic_complexity func applicationDidFinishLaunching(_ notification: Notification) { guard NSApp.runType.requiresEnvironment else { return } defer { @@ -344,6 +369,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { subscriptionManager.loadInitialData() + let privacyConfigurationManager = ContentBlocking.shared.privacyConfigurationManager + + // Enable subscriptionCookieManager if feature flag is present + if privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) { + subscriptionCookieManager.enableSettingSubscriptionCookie() + } + + // Keep track of feature flag changes + subscriptionCookieManagerFeatureFlagCancellable = privacyConfigurationManager.updatesPublisher + .sink { [weak self, weak privacyConfigurationManager] in + guard let self, let privacyConfigurationManager else { return } + + let isEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.setAccessTokenCookieForSubscriptionDomains) + + Task { [weak self] in + if isEnabled { + self?.subscriptionCookieManager.enableSettingSubscriptionCookie() + await self?.subscriptionCookieManager.refreshSubscriptionCookie() + } else { + await self?.subscriptionCookieManager.disableSettingSubscriptionCookie() + } + } + } + if [.normal, .uiTests].contains(NSApp.runType) { stateRestorationManager.applicationDidFinishLaunching() } @@ -391,7 +440,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { UNUserNotificationCenter.current().delegate = self dataBrokerProtectionSubscriptionEventHandler.registerForSubscriptionAccountManagerEvents() - DataBrokerProtectionAppEvents(featureGatekeeper: DefaultDataBrokerProtectionFeatureGatekeeper(accountManager: subscriptionManager.accountManager)).applicationDidFinishLaunching() + + let freemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp) + let pirGatekeeper = DefaultDataBrokerProtectionFeatureGatekeeper(accountManager: + subscriptionManager.accountManager, + freemiumDBPUserStateManager: freemiumDBPUserStateManager) + + DataBrokerProtectionAppEvents(featureGatekeeper: pirGatekeeper).applicationDidFinishLaunching() setUpAutoClearHandler() @@ -413,6 +468,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { PixelKit.fire(GeneralPixel.crashOnCrashHandlersSetUp) didCrashDuringCrashHandlersSetUp = false } + + freemiumDBPScanResultPolling = DefaultFreemiumDBPScanResultPolling(dataManager: DataBrokerProtectionManager.shared.dataManager, freemiumDBPUserStateManager: freemiumDBPUserStateManager) + freemiumDBPScanResultPolling?.startPollingOrObserving() } private func fireFailedCompilationsPixelIfNeeded() { @@ -433,14 +491,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard didFinishLaunching else { return } PixelExperiment.fireOnboardingTestPixels() - syncService?.initializeIfNeeded() - syncService?.scheduler.notifyAppLifecycleEvent() + initializeSync() NetworkProtectionAppEvents(featureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager)).applicationDidBecomeActive() - DataBrokerProtectionAppEvents(featureGatekeeper: - DefaultDataBrokerProtectionFeatureGatekeeper(accountManager: - subscriptionManager.accountManager)).applicationDidBecomeActive() + let freemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp) + let pirGatekeeper = DefaultDataBrokerProtectionFeatureGatekeeper(accountManager: + subscriptionManager.accountManager, + freemiumDBPUserStateManager: freemiumDBPUserStateManager) + + DataBrokerProtectionAppEvents(featureGatekeeper: pirGatekeeper).applicationDidBecomeActive() subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in if isSubscriptionActive { @@ -457,6 +517,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + private func initializeSync() { + guard let syncService else { return } + syncService.initializeIfNeeded() + syncService.scheduler.notifyAppLifecycleEvent() + SyncDiagnosisHelper(syncService: syncService).diagnoseAccountStatus() + } + func applicationDidResignActive(_ notification: Notification) { Task { @MainActor in await vpnRedditSessionWorkaround.removeRedditSessionWorkaround() diff --git a/DuckDuckGo/Assets.xcassets/Images/Check.imageset/Check-Color-16.svg b/DuckDuckGo/Assets.xcassets/Images/Check.imageset/Check-Color-16.svg new file mode 100644 index 0000000000..644b9a9c61 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Check.imageset/Check-Color-16.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/Check.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Check.imageset/Contents.json new file mode 100644 index 0000000000..14f73a2877 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Check.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Check-Color-16.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Contents.json index c4a79c7d54..f1d879d17e 100644 --- a/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Icon 22.pdf", + "filename" : "Exclamation-High-Color-16.svg", "idiom" : "universal" } ], diff --git a/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Exclamation-High-Color-16.svg b/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Exclamation-High-Color-16.svg new file mode 100644 index 0000000000..67505545b4 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Exclamation-High-Color-16.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Icon 22.pdf b/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Icon 22.pdf deleted file mode 100644 index 97d2e7db89..0000000000 Binary files a/DuckDuckGo/Assets.xcassets/Images/CriticalUpdateNotificationInfo.imageset/Icon 22.pdf and /dev/null differ diff --git a/DuckDuckGo/Assets.xcassets/Images/RadarCheck.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/RadarCheck.imageset/Contents.json new file mode 100644 index 0000000000..efee6037db --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/RadarCheck.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Radar-Check-96x96.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/RadarCheck.imageset/Radar-Check-96x96.pdf b/DuckDuckGo/Assets.xcassets/Images/RadarCheck.imageset/Radar-Check-96x96.pdf new file mode 100644 index 0000000000..fbed7efe35 Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/RadarCheck.imageset/Radar-Check-96x96.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Contents.json index 552be25080..e224dcfc1c 100644 --- a/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Document-Color-16.pdf", + "filename" : "Release-Notes-Color-16.svg", "idiom" : "universal" } ], diff --git a/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Document-Color-16.pdf b/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Document-Color-16.pdf deleted file mode 100644 index 5db4a4627f..0000000000 Binary files a/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Document-Color-16.pdf and /dev/null differ diff --git a/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Release-Notes-Color-16.svg b/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Release-Notes-Color-16.svg new file mode 100644 index 0000000000..440fa7505c --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/ReleaseNotesIndicator.imageset/Release-Notes-Color-16.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Contents.json index 8976fe4303..a68acc82ac 100644 --- a/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Icon 19.pdf", + "filename" : "Exclamation-Color-16-2.svg", "idiom" : "universal" } ], diff --git a/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Exclamation-Color-16-2.svg b/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Exclamation-Color-16-2.svg new file mode 100644 index 0000000000..39ad1e8ec7 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Exclamation-Color-16-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Icon 19.pdf b/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Icon 19.pdf deleted file mode 100644 index 64eb71ba5f..0000000000 Binary files a/DuckDuckGo/Assets.xcassets/Images/UpdateNotificationInfo.imageset/Icon 19.pdf and /dev/null differ diff --git a/DuckDuckGo/Common/Extensions/NSViewExtension.swift b/DuckDuckGo/Common/Extensions/NSViewExtension.swift index 2e81660afc..c17edf489f 100644 --- a/DuckDuckGo/Common/Extensions/NSViewExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSViewExtension.swift @@ -85,6 +85,12 @@ extension NSView { set { isHidden = !newValue } } + var isVisible: Bool { + guard !isHiddenOrHasHiddenAncestor, + let window, window.isVisible else { return false } + return true + } + func makeMeFirstResponder() { guard let window = window else { Logger.general.error("\(self.className): Window not available") diff --git a/DuckDuckGo/Common/Extensions/WKWebViewConfigurationExtensions.swift b/DuckDuckGo/Common/Extensions/WKWebViewConfigurationExtensions.swift index 090e9c3121..9a89e51e4f 100644 --- a/DuckDuckGo/Common/Extensions/WKWebViewConfigurationExtensions.swift +++ b/DuckDuckGo/Common/Extensions/WKWebViewConfigurationExtensions.swift @@ -119,7 +119,7 @@ extension NSPopover { // https://app.asana.com/0/1201037661562251/1206407295280737/f @objc(swizzled_showRelativeToRect:ofView:preferredEdge:) private dynamic func swizzled_show(relativeTo positioningRect: NSRect, of positioningView: NSView, preferredEdge: NSRectEdge) { - if positioningView.superview == nil { + if positioningView.window == nil { var observer: Cancellable? observer = positioningView.observe(\.window) { positioningView, _ in if positioningView.window != nil { diff --git a/DuckDuckGo/Common/Extensions/XMLNodeExtension.swift b/DuckDuckGo/Common/Extensions/XMLNodeExtension.swift new file mode 100644 index 0000000000..86e1829aad --- /dev/null +++ b/DuckDuckGo/Common/Extensions/XMLNodeExtension.swift @@ -0,0 +1,27 @@ +// +// XMLNodeExtension.swift +// +// 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. +// + +extension XMLNode { + + func childIfExists(at index: Int) -> XMLNode? { + assert(index >= 0) + guard childCount > index else { return nil } + return child(at: index) + } + +} diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 5b4b20b0c2..e71f1e97ec 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -709,7 +709,7 @@ struct UserText { static let autofill = NSLocalizedString("preferences.autofill", value: "Passwords", comment: "Show Autofill preferences") static let aboutDuckDuckGo = NSLocalizedString("preferences.about.about-duckduckgo", value: "About DuckDuckGo", comment: "About screen") - static let privacySimplified = NSLocalizedString("preferences.about.privacy-simplified", value: "Privacy, simplified.", comment: "About screen") + static let duckduckgoTagline = NSLocalizedString("preferences.about.duckduckgo-tagline", value: "Your protection, our priority.", comment: "About screen") static let aboutUnsupportedDeviceInfo1 = NSLocalizedString("preferences.about.unsupported-device-info1", value: "DuckDuckGo is no longer providing browser updates for your version of macOS.", comment: "This string represents a message informing the user that DuckDuckGo is no longer providing browser updates for their version of macOS") static func aboutUnsupportedDeviceInfo2(version: String) -> String { let localized = NSLocalizedString("preferences.about.unsupported-device-info2", value: "Please update to macOS %@ or later to use the most recent version of DuckDuckGo. You can also keep using your current version of the browser, but it will not receive further updates.", comment: "Copy in section that tells the user to update their macOS version since their current version is unsupported") @@ -965,8 +965,8 @@ struct UserText { static let bitwardenError = NSLocalizedString("bitwarden.error", value: "Unable to find or connect to Bitwarden", comment: "This message appears when the application is unable to find or connect to Bitwarden, indicating a connection issue.") static let bitwardenNotInstalled = NSLocalizedString("bitwarden.not.installed", value: "Bitwarden app is not installed", comment: "") static let bitwardenOldVersion = NSLocalizedString("bitwarden.old.version", value: "Please update Bitwarden to the latest version", comment: "Message that warns user they need to update their password manager Bitwarden app vesion") - static let bitwardenIncompatible = NSLocalizedString("bitwarden.incompatible", value: "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:", comment: "Message that warns user that specific Bitwarden app vesions are not compatible with this app") - static let bitwardenIncompatibleStep1 = NSLocalizedString("bitwarden.incompatible.step.1", value: "Download v2024.4.3", comment: "First step to downgrade Bitwarden") + static let bitwardenIncompatible = NSLocalizedString("bitwarden.incompatible", value: "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.10.0, v2024.10.1, v2024.10.2. Please downgrade to an older version by following these steps:", comment: "Message that warns user that specific Bitwarden app vesions are not compatible with this app") + static let bitwardenIncompatibleStep1 = NSLocalizedString("bitwarden.incompatible.step.1", value: "Download v2024.9.0", comment: "First step to downgrade Bitwarden") static let bitwardenIncompatibleStep2 = NSLocalizedString("bitwarden.incompatible.step.2", value: "2. Open the downloaded DMG file and drag the Bitwarden application to\nthe /Applications folder.", comment: "Second step to downgrade Bitwarden") static let bitwardenIntegrationNotApproved = NSLocalizedString("bitwarden.integration.not.approved", value: "Integration with DuckDuckGo is not approved in Bitwarden app", comment: "While the user tries to connect the DuckDuckGo Browser to password manager Bitwarden This message indicates that the integration with DuckDuckGo has not been approved in the Bitwarden app.") static let bitwardenMissingHandshake = NSLocalizedString("bitwarden.missing.handshake", value: "Missing handshake", comment: "While the user tries to connect the DuckDuckGo Browser to password manager Bitwarden This message indicates a missing handshake (a way for two devices or systems to say hello to each other and agree to communicate or exchange information).") @@ -1255,21 +1255,29 @@ struct UserText { static let downloadsOpenDownloadsFolder = NSLocalizedString("downloads.open-downloads-folder", value: "Open Downloads Folder", comment: "Button in the downloads manager that allows the user to open the downloads folder") // MARK: Updates - static let updateAvailableMenuItem = NSLocalizedString("update.available.menu.item", value: "Update Available - Restart Now", comment: "Title of the menu item that informs user that a new update is available. Clicking on the menu item restarts the app and installs the update") + static let updateAvailableMenuItem = NSLocalizedString("update.available.menu.item", value: "Update Available - Install Now", comment: "Title of the menu item that informs user that a new update is available. Clicking on the menu item installs the update") static let releaseNotesMenuItem = NSLocalizedString("release.notes.menu.item", value: "Release Notes", comment: "Title of the dialog menu item that opens release notes") static let whatsNewMenuItem = NSLocalizedString("whats.new.menu.item", value: "What's New", comment: "Title of the dialog menu item that opens the 'What's New' page") static let browserUpdatesTitle = NSLocalizedString("settings.browser.updates.title", value: "Browser Updates", comment: "Title of the section in Settings where people set up automatic vs manual updates") static let automaticUpdates = NSLocalizedString("settings.automatic.updates", value: "Automatically install updates (recommended)", comment: "Title of the checkbox item to set up automatic updates of the browser") static let manualUpdates = NSLocalizedString("settings.manual.updates", value: "Check for updates but let you choose to install them", comment: "Title of the checkbox item to set up manual updates of the browser") static let checkingForUpdate = NSLocalizedString("settings.checking.for.update", value: "Checking for update", comment: "Label informing users the app is currently checking for new update") + static let downloadingUpdate = NSLocalizedString("settings.downloading.update", value: "Downloading update %@", comment: "Label informing users the app is currently downloading the update. This will contain a percentage") + static let preparingUpdate = NSLocalizedString("settings.preparing.update", value: "Preparing update", comment: "Label informing users the app is preparing to update.") + static let updateFailed = NSLocalizedString("settings.update.failed", value: "Update failed", comment: "Label informing users the app is unable to update.") static let upToDate = NSLocalizedString("settings.up.to.date", value: "DuckDuckGo is up to date", comment: "Label informing users the app is currently up to date and no update is required.") static let newerVersionAvailable = NSLocalizedString("settings.newer.version.available", value: "Newer version available", comment: "Label informing users the newer version of the app is available to install.") + static let newerCriticalUpdateAvailable = NSLocalizedString("settings.newer.critical.update.available", value: "Critical update needed", comment: "Label informing users the critical update of the app is available to install.") static let lastChecked = NSLocalizedString("settings.last.checked", value: "Last checked", comment: "Label informing users what is the last time the app checked for the update.") - static let restartToUpdate = NSLocalizedString("settings.restart.to.update", value: "Restart to Update", comment: "Button label trigering restart and update of the application.") + static let restartToUpdate = NSLocalizedString("settings.restart.to.update", value: "Restart To Update", comment: "Button label triggering restart and update of the application.") + static let runUpdate = NSLocalizedString("settings.run.update", value: "Update DuckDuckGo", comment: "Button label triggering update of the application.") + static let retryUpdate = NSLocalizedString("settings.retry.update", value: "Retry Update", comment: "Button label triggering a retry of the update.") static let browserUpdatedNotification = NSLocalizedString("notification.browser.updated", value: "Browser Updated", comment: "Notification informing user the app has been updated") static let browserDowngradedNotification = NSLocalizedString("notification.browser.downgraded", value: "Browser Downgraded", comment: "Notification informing user the app has been downgraded") - static let criticalUpdateNotification = NSLocalizedString("notification.critical.update", value: "Critical update required. Restart to update.", comment: "Notification informing user a critical update is required.") - static let updateAvailableNotification = NSLocalizedString("notification.update.available", value: "New version available. Restart to update.", comment: "Notification informing user the a version of app is available") + static let criticalUpdateNotification = NSLocalizedString("notification.critical.update", value: "Critical update needed.", comment: "Notification informing user a critical update is available.") + static let updateAvailableNotification = NSLocalizedString("notification.update.available", value: "New version available.", comment: "Notification informing user the a version of app is available.") + static let autoUpdateAction = NSLocalizedString("notification.auto.update.action", value: "Restart to update.", comment: "Action to take when an automatic update is available.") + static let manualUpdateAction = NSLocalizedString("notification.manual.update.action", value: "Click here to update.", comment: "Action to take when a manual update is available.") static let viewDetails = NSLocalizedString("view.details.button", value: "View Details", comment: "Button title to open more details about the update") enum Bookmarks { @@ -1370,5 +1378,49 @@ struct UserText { static let syncPromoSidePanelTitle = NSLocalizedString("sync.promo.passwords.side.panel.title", value:"Setup", comment: "Title for the Sync Promotion in passwords side panel") static let syncPromoSidePanelSubtitle = NSLocalizedString("sync.promo.passwords.side.panel.subtitle", value:"Sync & Backup", comment: "Subtitle for the Sync Promotion in passwords side panel") + // Key: "freemium.pir.menu.item" + // Comment: "Title for Freemium Personal Information Removal (Scan-Only) item in the options menu" + static let freemiumDBPOptionsMenuItem = "Free Personal Information Scan" + + // Key: "home.page.promotion.freemium.dbp.text" + // Comment: "Text for the Freemium DBP Home Page Promotion" + static let homePagePromotionFreemiumDBPText = "Find your personal info on sites that sell it." + + // Key: "home.page.promotion.freemium.dbp.button.title" + // Comment: "Title for the Freemium DBP Home Page Promotion Button" + static let homePagePromotionFreemiumDBPButtonTitle = "Free Scan" + + // Key: "home.page.promotion.freemium.dbp.post.scan.engagement.result.single.match.text" + // Comment: "Text for the Freemium DBP Home Page Post Scan Engagement Promotion When Only One Record is Found" + static let homePagePromotionFreemiumDBPPostScanEngagementResultSingleMatchText = "Your free personal info scan found 1 record about you on 1 site." + + /// Generates Text for the Freemium DBP Home Page Post Scan Engagement Promotion when records are found on a single broker site. + /// Key: "home.page.promotion.freemium.dbp.post.scan.engagement.result.single.broker.text" + /// + /// - Parameter resultCount: The number of records found. + /// - Returns: A formatted string indicating the number of records found on 1 site. + static func homePagePromotionFreemiumDBPPostScanEngagementResultSingleBrokerText(resultCount: Int) -> String { + String(format: "Your free personal info scan found %d records about you on 1 site.", resultCount) + } + + /// Generates Text for the Freemium DBP Home Page Post Scan Engagement Promotion when records are found on multiple broker sites. + /// Key: "home.page.promotion.freemium.dbp.post.scan.engagement.result.plural.text" + /// + /// - Parameters: + /// - resultCount: The number of records found. + /// - brokerCount: The number of broker sites where records were found. + /// - Returns: A formatted string indicating the number of records found on multiple sites. + static func homePagePromotionFreemiumDBPPostScanEngagementResultPluralText(resultCount: Int, brokerCount: Int) -> String { + String(format: "Your free personal info scan found %d records about you on %d different sites.", resultCount, brokerCount) + } + + // Key: "home.page.promotion.freemium.dbp.post.scan.engagement.no.results.text" + // Comment: "Text for the Freemium DBP Home Page Post Scan Engagement Promotion When There Are No Results" + static let homePagePromotionFreemiumDBPPostScanEngagementNoResultsText = "Good news, your free personal info scan didn't find any records about you. We'll keep checking periodically." + + // Key: "home.page.promotion.freemium.dbp.post.scan.engagement.button.title" + // Comment: "Title for the Freemium DBP Home Page Post Scan Engagement Promotion Button" + static let homePagePromotionFreemiumDBPPostScanEngagementButtonTitle = "View Results" + static let removeSuggestionTooltip = NSLocalizedString("remove.suggestion.tooltip", value: "Remove from browsing history", comment: "Tooltip for the button which removes the history entry from the history") } diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index 04c38f1941..a18850b028 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -140,6 +140,9 @@ public struct UserDefaultsWrapper { case homePageCustomBackground = "home.page.custom.background" case homePageLastPickedCustomColor = "home.page.last.picked.custom.color" + case homePagePromotionVisible = "home.page.promotion.visible" + case homePagePromotionDidDismiss = "home.page.promotion.did.dismiss" + case appIsRelaunchingAutomatically = "app-relaunching-automatically" case historyV5toV6Migration = "history.v5.to.v6.migration.2" @@ -181,6 +184,7 @@ public struct UserDefaultsWrapper { // Updates case automaticUpdates = "updates.automatic" + case pendingUpdateShown = "pending.update.shown" // Experiments case pixelExperimentInstalled = "pixel.experiment.installed" diff --git a/DuckDuckGo/DBP/DBPHomeViewController.swift b/DuckDuckGo/DBP/DBPHomeViewController.swift index 9638418afd..d9d2e8e18b 100644 --- a/DuckDuckGo/DBP/DBPHomeViewController.swift +++ b/DuckDuckGo/DBP/DBPHomeViewController.swift @@ -34,6 +34,7 @@ final class DBPHomeViewController: NSViewController { private let pixelHandler: EventMapping = DataBrokerProtectionPixelsHandler() private var currentChildViewController: NSViewController? private var observer: NSObjectProtocol? + private var freemiumDBPFeature: FreemiumDBPFeature private let prerequisiteVerifier: DataBrokerPrerequisitesStatusVerifier private lazy var errorViewController: DataBrokerProtectionErrorViewController = { @@ -70,9 +71,12 @@ final class DBPHomeViewController: NSViewController { }) }() - init(dataBrokerProtectionManager: DataBrokerProtectionManager, prerequisiteVerifier: DataBrokerPrerequisitesStatusVerifier = DefaultDataBrokerPrerequisitesStatusVerifier()) { + init(dataBrokerProtectionManager: DataBrokerProtectionManager, + prerequisiteVerifier: DataBrokerPrerequisitesStatusVerifier = DefaultDataBrokerPrerequisitesStatusVerifier(), + freemiumDBPFeature: FreemiumDBPFeature) { self.dataBrokerProtectionManager = dataBrokerProtectionManager self.prerequisiteVerifier = prerequisiteVerifier + self.freemiumDBPFeature = freemiumDBPFeature super.init(nibName: nil, bundle: nil) } @@ -94,7 +98,7 @@ final class DBPHomeViewController: NSViewController { override func viewDidAppear() { super.viewDidAppear() - if !dataBrokerProtectionManager.isUserAuthenticated() { + if !dataBrokerProtectionManager.isUserAuthenticated() && !freemiumDBPFeature.isAvailable { assertionFailure("This UI should never be presented if the user is not authenticated") closeUI() } diff --git a/DuckDuckGo/DBP/DataBrokerProtectionFeatureGatekeeper.swift b/DuckDuckGo/DBP/DataBrokerProtectionFeatureGatekeeper.swift index 0affd1fb34..9134b30497 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionFeatureGatekeeper.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionFeatureGatekeeper.swift @@ -22,11 +22,10 @@ import Common import DataBrokerProtection import Subscription import os.log +import Freemium protocol DataBrokerProtectionFeatureGatekeeper { - func isFeatureVisible() -> Bool func disableAndDeleteForAllUsers() - func isPrivacyProEnabled() -> Bool func arePrerequisitesSatisfied() async -> Bool } @@ -37,19 +36,22 @@ struct DefaultDataBrokerProtectionFeatureGatekeeper: DataBrokerProtectionFeature private let userDefaults: UserDefaults private let subscriptionAvailability: SubscriptionFeatureAvailability private let accountManager: AccountManager + private let freemiumDBPUserStateManager: FreemiumDBPUserStateManager init(privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager, featureDisabler: DataBrokerProtectionFeatureDisabling = DataBrokerProtectionFeatureDisabler(), pixelHandler: EventMapping = DataBrokerProtectionPixelsHandler(), userDefaults: UserDefaults = .standard, subscriptionAvailability: SubscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(), - accountManager: AccountManager) { + accountManager: AccountManager, + freemiumDBPUserStateManager: FreemiumDBPUserStateManager) { self.privacyConfigurationManager = privacyConfigurationManager self.featureDisabler = featureDisabler self.pixelHandler = pixelHandler self.userDefaults = userDefaults self.subscriptionAvailability = subscriptionAvailability self.accountManager = accountManager + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager } var isUserLocaleAllowed: Bool { @@ -70,28 +72,24 @@ struct DefaultDataBrokerProtectionFeatureGatekeeper: DataBrokerProtectionFeature return (regionCode ?? "US") == "US" } - func isPrivacyProEnabled() -> Bool { - return subscriptionAvailability.isFeatureAvailable - } - func disableAndDeleteForAllUsers() { featureDisabler.disableAndDelete() Logger.dataBrokerProtection.debug("Disabling and removing DBP for all users") } - /// If we want to prevent new users from joining the waitlist while still allowing waitlist users to continue using it, - /// we should set isWaitlistEnabled to false and isWaitlistBetaActive to true. - /// To remove it from everyone, isWaitlistBetaActive should be set to false - func isFeatureVisible() -> Bool { - // only US locale should be available - guard isUserLocaleAllowed else { return false } + /// Checks DBP prerequisites + /// + /// Prerequisites are satisified if either: + /// 1. The user is an active freemium user (e.g has activated freemium and is not authenticated) + /// 2. The user has a subscription with valid entitlements + /// + /// - Returns: Bool indicating prerequisites are satisfied + func arePrerequisitesSatisfied() async -> Bool { - // US internal users should have it available by default - return isInternalUser - } + let isAuthenticated = accountManager.isUserAuthenticated + if !isAuthenticated && freemiumDBPUserStateManager.didActivate { return true } - func arePrerequisitesSatisfied() async -> Bool { let entitlements = await accountManager.hasEntitlement(forProductName: .dataBrokerProtection, cachePolicy: .reloadIgnoringLocalCacheData) var hasEntitlements: Bool @@ -102,8 +100,6 @@ struct DefaultDataBrokerProtectionFeatureGatekeeper: DataBrokerProtectionFeature hasEntitlements = false } - let isAuthenticated = accountManager.accessToken != nil - firePrerequisitePixelsAndLogIfNecessary(hasEntitlements: hasEntitlements, isAuthenticatedResult: isAuthenticated) return hasEntitlements && isAuthenticated @@ -118,12 +114,10 @@ private extension DefaultDataBrokerProtectionFeatureGatekeeper { func firePrerequisitePixelsAndLogIfNecessary(hasEntitlements: Bool, isAuthenticatedResult: Bool) { if !hasEntitlements { - pixelHandler.fire(.gatekeeperEntitlementsInvalid) Logger.dataBrokerProtection.error("DBP feature Gatekeeper: Entitlement check failed") } if !isAuthenticatedResult { - pixelHandler.fire(.gatekeeperNotAuthenticated) Logger.dataBrokerProtection.error("DBP feature Gatekeeper: Authentication check failed") } } diff --git a/DuckDuckGo/DBP/DataBrokerProtectionManager.swift b/DuckDuckGo/DBP/DataBrokerProtectionManager.swift index 3725995b3f..8bb55f7893 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionManager.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionManager.swift @@ -21,6 +21,7 @@ import BrowserServicesKit import DataBrokerProtection import LoginItems import Common +import Freemium public final class DataBrokerProtectionManager { @@ -30,8 +31,16 @@ public final class DataBrokerProtectionManager { private let authenticationManager: DataBrokerProtectionAuthenticationManaging private let fakeBrokerFlag: DataBrokerDebugFlag = DataBrokerDebugFlagFakeBroker() + private lazy var freemiumDBPFirstProfileSavedNotifier: FreemiumDBPFirstProfileSavedNotifier = { + let freemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp) + let accountManager = Application.appDelegate.subscriptionManager.accountManager + let freemiumDBPFirstProfileSavedNotifier = FreemiumDBPFirstProfileSavedNotifier(freemiumDBPUserStateManager: freemiumDBPUserStateManager, + accountManager: accountManager) + return freemiumDBPFirstProfileSavedNotifier + }() + lazy var dataManager: DataBrokerProtectionDataManager = { - let dataManager = DataBrokerProtectionDataManager(pixelHandler: pixelHandler, fakeBrokerFlag: fakeBrokerFlag) + let dataManager = DataBrokerProtectionDataManager(profileSavedNotifier: freemiumDBPFirstProfileSavedNotifier, pixelHandler: pixelHandler, fakeBrokerFlag: fakeBrokerFlag) dataManager.delegate = self return dataManager }() @@ -63,6 +72,7 @@ public final class DataBrokerProtectionManager { } extension DataBrokerProtectionManager: DataBrokerProtectionDataManagerDelegate { + public func dataBrokerProtectionDataManagerDidUpdateData() { loginItemInterface.profileSaved() } @@ -74,4 +84,8 @@ extension DataBrokerProtectionManager: DataBrokerProtectionDataManagerDelegate { public func dataBrokerProtectionDataManagerWillOpenSendFeedbackForm() { NotificationCenter.default.post(name: .OpenUnifiedFeedbackForm, object: nil, userInfo: UnifiedFeedbackSource.userInfo(source: .pir)) } + + public func isAuthenticatedUser() -> Bool { + isUserAuthenticated() + } } diff --git a/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift b/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift index 7051c22287..cc4c928d89 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift @@ -105,8 +105,6 @@ public class DataBrokerProtectionPixelsHandler: EventMapping XMLNode? { let root = document.rootElement() - guard let body = root?.child(at: 1) else { throw ImportError(type: .validationBody, underlyingError: nil) } + guard let body = root?.childIfExists(at: 1) else { throw ImportError(type: .validationBody, underlyingError: nil) } // get /html/body/*[0] - let cursor = body.child(at: 0) + let cursor = body.childIfExists(at: 0) return cursor } @@ -155,12 +155,12 @@ final class BookmarkHTMLReader { switch cursor?.htmlTag { case .dl: let originalCursorValue = cursor - cursor = cursor?.child(at: 0) + cursor = cursor?.childIfExists(at: 0) dlLoop: while cursor != nil { switch cursor?.htmlTag { case .dd: - if cursor?.child(at: 0)?.htmlTag == .h3 { - cursor = cursor?.child(at: 0) + if cursor?.childIfExists(at: 0)?.htmlTag == .h3 { + cursor = cursor?.childIfExists(at: 0) break dlLoop } cursor = cursor?.nextSibling @@ -195,7 +195,7 @@ final class BookmarkHTMLReader { itemType = cursor?.itemType(inSafariFormat: false) switch itemType { case .some: - cursor = cursor?.child(at: 0) + cursor = cursor?.childIfExists(at: 0) case .none: cursor = cursor?.nextSibling } @@ -243,12 +243,12 @@ final class BookmarkHTMLReader { private func readFolderContents(_ node: XMLNode?) throws -> [ImportedBookmarks.BookmarkOrFolder] { var cursor = node - cursor = cursor?.child(at: 0) + cursor = cursor?.childIfExists(at: 0) var children = [ImportedBookmarks.BookmarkOrFolder]() while cursor != nil { - let firstChild = cursor?.child(at: 0) + let firstChild = cursor?.childIfExists(at: 0) switch (cursor?.htmlTag, firstChild?.htmlTag) { case (.dd, .h3): children.append(try readFolder(firstChild)) @@ -354,13 +354,13 @@ private extension XMLNode { return .folder case .dt: return .bookmark - case .dl where child(at: 0)?.child(at: 0)?.htmlTag == .a: + case .dl where childIfExists(at: 0)?.childIfExists(at: 0)?.htmlTag == .a: return .safariTopLevelBookmarks default: return nil } } else { - switch (htmlTag, child(at: 0)?.htmlTag) { + switch (htmlTag, childIfExists(at: 0)?.htmlTag) { case (.dd, .h3): return .folder case (.dt, .a): diff --git a/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift b/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift index a14221a965..6f6bc2c95e 100644 --- a/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift +++ b/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift @@ -30,7 +30,7 @@ public enum FeatureFlag: String { case appendAtbToSerpQueries // https://app.asana.com/0/1206488453854252/1207136666798700/f - case freemiumPIR + case freemiumDBP case contextualOnboarding @@ -54,8 +54,8 @@ extension FeatureFlag: FeatureFlagSourceProviding { return .remoteReleasable(.subfeature(SslCertificatesSubfeature.allowBypass)) case .unknownUsernameCategorization: return .remoteReleasable(.subfeature(AutofillSubfeature.unknownUsernameCategorization)) - case .freemiumPIR: - return .remoteDevelopment(.subfeature(DBPSubfeature.freemium)) + case .freemiumDBP: + return .remoteReleasable(.subfeature(DBPSubfeature.freemium)) case .phishingDetectionErrorPage: return .remoteReleasable(.subfeature(PhishingDetectionSubfeature.allowErrorPage)) case .phishingDetectionPreferences: diff --git a/DuckDuckGo/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManaging.swift b/DuckDuckGo/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManaging.swift new file mode 100644 index 0000000000..c14894d0fe --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManaging.swift @@ -0,0 +1,205 @@ +// +// FreemiumDBPPixelExperimentManaging.swift +// +// 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 Subscription +import OSLog +import PixelKit + +/// Protocol defining the interface for managing Freemium DBP pixel experiments. +protocol FreemiumDBPPixelExperimentManaging { + + /// Property indicating if the user is in the treatment cohort or not + var isTreatment: Bool { get } + + /// Property which provides parameters for experiment pixels + var pixelParameters: [String: String]? { get } + + /// Assigns the user to an experimental cohort if eligible. + func assignUserToCohort() +} + +/// Manager responsible for handling user assignments to experimental cohorts and providing pixel parameters for analytics. +final class FreemiumDBPPixelExperimentManager: FreemiumDBPPixelExperimentManaging { + + /// Represents the different experimental cohorts a user can be assigned to. + enum Cohort: String { + case control + case treatment + } + + private enum PixelKeys { + static let daysEnrolled = "daysEnrolled" + } + + // MARK: - Dependencies + + private let subscriptionManager: SubscriptionManager + private let userDefaults: UserDefaults + private let locale: Locale + + // MARK: - Initialization + + /// Initializes the experiment manager with necessary dependencies. + /// + /// - Parameters: + /// - subscriptionManager: Manages user subscriptions. + /// - userDefaults: Storage for experiment data. Defaults to `.dbp`. + /// - locale: Determines user eligibility based on region. Defaults to `Locale.current`. + init(subscriptionManager: SubscriptionManager, + userDefaults: UserDefaults = .dbp, + locale: Locale = Locale.current) { + self.subscriptionManager = subscriptionManager + self.userDefaults = userDefaults + self.locale = locale + } + + // MARK: - FreemiumDBPPixelExperimentManaging + + var isTreatment: Bool { + experimentCohort == .treatment + } + + /// Constructs a dictionary of pixel parameters used for pixel events related to the experiment. + /// + /// This property creates a dictionary of key-value pairs for parameters to be passed with a pixel event. + /// The key for the number of days enrolled is added if `daysEnrolled` has a valid value. If there are no valid parameters, + /// it returns `nil` to indicate that no parameters are needed for the event. + var pixelParameters: [String: String]? { + var parameters: [String: String] = [:] + + if let daysEnrolled = daysEnrolled { + parameters[PixelKeys.daysEnrolled] = daysEnrolled + } + + return parameters.isEmpty ? nil : parameters + } + + /// Assigns the user to a cohort (`control` or `treatment`) if eligible and not already enrolled. + func assignUserToCohort() { + guard shouldEnroll else { return } + + let cohort: Cohort = Bool.random() ? .control : .treatment + userDefaults.experimentCohort = cohort + userDefaults.enrollmentDate = Date() + + Logger.freemiumDBP.debug("[Freemium DBP] User enrolled to cohort: \(cohort.rawValue)") + } +} + +// MARK: - FreemiumDBPPixelExperimentManager Private Extension + +private extension FreemiumDBPPixelExperimentManager { + + /// Determines if the user is eligible for the experiment based on subscription status and locale. + var userIsEligible: Bool { + subscriptionManager.isPotentialPrivacyProSubscriber + && locale.isUSRegion + } + + /// Checks if the user is not already enrolled in the experiment. + var userIsNotEnrolled: Bool { + userDefaults.enrollmentDate == nil + } + + /// Determines whether the user should be enrolled in the experiment. + var shouldEnroll: Bool { + guard userIsNotEnrolled else { + Logger.freemiumDBP.debug("[Freemium DBP] User is already enrolled in experiment") + return false + } + + guard userIsEligible else { + Logger.freemiumDBP.debug("[Freemium DBP] User is ineligible for experiment") + return false + } + + return true + } + + /// Retrieves the user's assigned experiment cohort from `UserDefaults`. + var experimentCohort: Cohort? { + userDefaults.experimentCohort + } + + /// Calculates the number of days the user has been enrolled in the experiment. + /// + /// This property retrieves the enrollment date from `UserDefaults.dbp.enrollmentDate`, and if available, computes the number + /// of days from that date to the current date using the `Calendar.days(from:to:)` method. Returns `nil` if there is no + /// enrollment date. + var daysEnrolled: String? { + guard let enrollmentDate = userDefaults.enrollmentDate else { return nil } + return Calendar.days(from: enrollmentDate, to: Date()) + } +} + +// MARK: - Other Extensions + +private extension Calendar { + + /// Calculates the number of days between two dates. + /// + /// Uses the current `Calendar` to compute the difference in days between the `from` date and the `to` date. + /// If the difference in days cannot be determined, returns `nil`. + /// + /// - Parameters: + /// - from: The start date. + /// - to: The end date. + /// - Returns: The number of days between the two dates as a `String`, or `nil` if the calculation fails. + static func days(from: Date, to: Date) -> String? { + let calendar = Calendar.current + let components = calendar.dateComponents([.day], from: from, to: to) + guard let days = components.day else { return nil } + return String(days) + } +} + +private extension Locale { + + /// Determines if the locale's region is the United States. + var isUSRegion: Bool { + var regionCode = self.regionCode + + if #available(macOS 13, *) { + regionCode = self.region?.identifier ?? self.regionCode + } + + return regionCode == "US" + } +} + +private extension UserDefaults { + + /// Keys used for storing experiment-related data. + enum Keys { + static let enrollmentDate = "freemium.dbp.experiment.enrollment-date" + static let experimentCohort = "freemium.dbp.experiment.cohort" + } + + /// Stores or retrieves the user's enrollment date for the experiment. + var enrollmentDate: Date? { + get { return object(forKey: Keys.enrollmentDate) as? Date } + set { set(newValue, forKey: Keys.enrollmentDate) } + } + + /// Stores or retrieves the user's assigned experiment cohort. + var experimentCohort: FreemiumDBPPixelExperimentManager.Cohort? { + get { FreemiumDBPPixelExperimentManager.Cohort(rawValue: string(forKey: Keys.experimentCohort) ?? "") } + set { set(newValue?.rawValue, forKey: Keys.experimentCohort) } + } +} diff --git a/DuckDuckGo/Freemium/DBP/Extensions/Logger+FreemiumDBP.swift b/DuckDuckGo/Freemium/DBP/Extensions/Logger+FreemiumDBP.swift new file mode 100644 index 0000000000..ff60b306c1 --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/Extensions/Logger+FreemiumDBP.swift @@ -0,0 +1,30 @@ +// +// Logger+FreemiumDBP.swift +// +// 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 OSLog + +/// Extension to the `Logger` type providing a static logger instance for the "Freemium DBP" subsystem. +/// +/// Usage: +/// ```swift +/// Logger.freemiumDBP.log("This is a log message.") +/// ``` +public extension Logger { + static var freemiumDBP: Logger = { Logger(subsystem: "Freemium DBP", category: "") }() +} diff --git a/DuckDuckGo/Freemium/DBP/Extensions/NotificationName+FreemiumDBP.swift b/DuckDuckGo/Freemium/DBP/Extensions/NotificationName+FreemiumDBP.swift new file mode 100644 index 0000000000..d122acd17f --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/Extensions/NotificationName+FreemiumDBP.swift @@ -0,0 +1,26 @@ +// +// NotificationName+FreemiumDBP.swift +// +// 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 + +extension Notification.Name { + /// Notification posted when `FreemiumDBPScanResultPolling` has finished polling for scan results + static let freemiumDBPResultPollingComplete = Notification.Name("freemiumDBPResultPollingComplete") + /// Notification posted when the user has entered Freemium DBP via a non-new tab entry point (e.g. via the meatball menu item) + static let freemiumDBPEntryPointActivated = Notification.Name("freemiumDBPEntryPointActivated") +} diff --git a/DuckDuckGo/Freemium/DBP/Extensions/PromotionView+FreemiumDBP.swift b/DuckDuckGo/Freemium/DBP/Extensions/PromotionView+FreemiumDBP.swift new file mode 100644 index 0000000000..d744e99b4f --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/Extensions/PromotionView+FreemiumDBP.swift @@ -0,0 +1,73 @@ +// +// PromotionView+FreemiumDBP.swift +// +// 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 + +extension PromotionViewModel { + static func freemiumDBPPromotion(proceedAction: @escaping () -> Void, + closeAction: @escaping () -> Void) -> PromotionViewModel { + + let text = UserText.homePagePromotionFreemiumDBPText + let actionButtonText = UserText.homePagePromotionFreemiumDBPButtonTitle + + return PromotionViewModel(image: .radarCheck, + text: text, + proceedButtonText: actionButtonText, + proceedAction: proceedAction, + closeAction: closeAction) + } + + static func freemiumDBPPromotionScanEngagementResults(resultCount: Int, + brokerCount: Int, + proceedAction: @escaping () -> Void, + closeAction: @escaping () -> Void) -> PromotionViewModel { + + var text = "" + + switch (resultCount, brokerCount) { + case (1, _): + text = UserText.homePagePromotionFreemiumDBPPostScanEngagementResultSingleMatchText + case (let resultCount, 1): + text = UserText.homePagePromotionFreemiumDBPPostScanEngagementResultSingleBrokerText(resultCount: resultCount) + default: + text = UserText.homePagePromotionFreemiumDBPPostScanEngagementResultPluralText(resultCount: resultCount, + brokerCount: brokerCount) + } + + let actionButtonText = UserText.homePagePromotionFreemiumDBPPostScanEngagementButtonTitle + + return PromotionViewModel(image: .radarCheck, + text: text, + proceedButtonText: actionButtonText, + proceedAction: proceedAction, + closeAction: closeAction) + } + + static func freemiumDBPPromotionScanEngagementNoResults(proceedAction: @escaping () -> Void, + closeAction: @escaping () -> Void) -> PromotionViewModel { + + let text = UserText.homePagePromotionFreemiumDBPPostScanEngagementNoResultsText + let actionButtonText = UserText.homePagePromotionFreemiumDBPPostScanEngagementButtonTitle + + return PromotionViewModel(image: .radarCheck, + text: text, + proceedButtonText: actionButtonText, + proceedAction: proceedAction, + closeAction: closeAction) + } +} diff --git a/DuckDuckGo/Freemium/DBP/FreemiumDBPFeature.swift b/DuckDuckGo/Freemium/DBP/FreemiumDBPFeature.swift new file mode 100644 index 0000000000..60141b4b7b --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/FreemiumDBPFeature.swift @@ -0,0 +1,198 @@ +// +// FreemiumDBPFeature.swift +// +// 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 BrowserServicesKit +import Subscription +import Freemium +import Combine +import OSLog + +/// A protocol that defines the behavior for the Freemium DBP feature. +/// This protocol provides the ability to check the availability of the feature and subscribe to updates +/// from various dependencies, such as privacy configurations and user subscriptions. +protocol FreemiumDBPFeature { + + /// A boolean value indicating whether the Freemium DBP feature is currently available. + var isAvailable: Bool { get } + + /// A publisher that emits updates when the availability of the Freemium DBP feature changes. + /// The publisher emits a `Bool` value indicating whether the feature is available. + var isAvailablePublisher: AnyPublisher { get } + + /// Subscribes to updates from dependencies, including privacy configurations and notifications + /// such as subscription changes, and triggers updates for feature availability accordingly. + func subscribeToDependencyUpdates() +} + +/// The default implementation of the `FreemiumDBPFeature` protocol. +/// This class manages the Freemium Personal Information Removal (DBP) feature, including +/// determining its availability based on privacy configurations and user subscription status. +/// It listens for updates from multiple dependencies, including privacy configurations +/// and subscription changes, and notifies subscribers accordingly. +final class DefaultFreemiumDBPFeature: FreemiumDBPFeature { + + /// A boolean value indicating whether the Freemium DBP feature is currently available. + /// + /// The feature is considered available if: + /// 1. It is enabled in the privacy configuration (`DBPSubfeature.freemium`), and + /// 2. User is in the experiement treatment cohort + /// 3. The user is a potential privacy pro subscriber. + var isAvailable: Bool { + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(DBPSubfeature.freemium) + && experimentManager.isTreatment + && subscriptionManager.isPotentialPrivacyProSubscriber + } + + /// A publisher that emits updates when the availability of the Freemium DBP feature changes. + /// + /// Subscribers receive updates when changes occur in the privacy configuration or user subscription status. + var isAvailablePublisher: AnyPublisher { + isAvailableSubject.eraseToAnyPublisher() + } + + // MARK: - Private Properties + private let privacyConfigurationManager: PrivacyConfigurationManaging + private let experimentManager: FreemiumDBPPixelExperimentManaging + private let subscriptionManager: SubscriptionManager + private let accountManager: AccountManager + private var freemiumDBPUserStateManager: FreemiumDBPUserStateManager + private let notificationCenter: NotificationCenter + private lazy var featureDisabler: DataBrokerProtectionFeatureDisabling = DataBrokerProtectionFeatureDisabler() + + private let isAvailableSubject = PassthroughSubject() + private var cancellables = Set() + + // MARK: - Initialization + + /// Initializes a new instance of the `DefaultFreemiumDBPFeature`. + /// + /// - Parameters: + /// - privacyConfigurationManager: Manages privacy configurations for the app. + /// - subscriptionManager: Manages subscriptions for the user. + /// - accountManager: Manages user account details. + /// - freemiumDBPUserStateManager: Manages the user state for Freemium DBP. + /// - notificationCenter: Observes notifications, defaulting to `.default`. + /// - featureDisabler: Optional feature disabler. If not provided, the default `DataBrokerProtectionFeatureDisabler` is used. + init(privacyConfigurationManager: PrivacyConfigurationManaging, + experimentManager: FreemiumDBPPixelExperimentManaging, + subscriptionManager: SubscriptionManager, + accountManager: AccountManager, + freemiumDBPUserStateManager: FreemiumDBPUserStateManager, + notificationCenter: NotificationCenter = .default, + featureDisabler: DataBrokerProtectionFeatureDisabling? = nil) { + + self.privacyConfigurationManager = privacyConfigurationManager + self.experimentManager = experimentManager + self.subscriptionManager = subscriptionManager + self.accountManager = accountManager + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager + self.notificationCenter = notificationCenter + + // Use the provided feature disabler if available, otherwise initialize lazily. + if let featureDisabler = featureDisabler { + self.featureDisabler = featureDisabler + } + } + + // MARK: - Public Methods + + /// Subscribes to updates from dependencies such as privacy configuration changes and + /// subscription-related notifications. + /// + /// - When the privacy configuration is updated, it checks whether the Freemium DBP feature + /// is still available based on the user's subscription status and the current privacy settings. + /// - When the user's subscription changes, it also triggers a re-evaluation of the feature's availability. + func subscribeToDependencyUpdates() { + // Subscribe to privacy configuration updates + privacyConfigurationManager.updatesPublisher + .sink { [weak self] in + guard let self = self else { return } + + let featureAvailable = self.isAvailable + Logger.freemiumDBP.debug("[Freemium DBP] Privacy Config Updated. Feature Availability = \(featureAvailable)") + + self.isAvailableSubject.send(featureAvailable) + + self.offBoardIfNecessary() + } + .store(in: &cancellables) + + // Subscribe to notifications about subscription changes + notificationCenter.publisher(for: .subscriptionDidChange) + .sink { [weak self] _ in + guard let self = self else { return } + + let featureAvailable = self.isAvailable + Logger.freemiumDBP.debug("[Freemium DBP] Subscription Updated. Feature Availability = \(featureAvailable)") + + self.isAvailableSubject.send(featureAvailable) + } + .store(in: &cancellables) + } +} + +private extension DefaultFreemiumDBPFeature { + + /// Returns true IFF: + /// + /// 1. The user did activate Freemium DBP + /// 2. The feature flag is disabled + /// 3. The user `isPotentialPrivacyProSubscriber` (see definition) + var shouldDisableAndDelete: Bool { + guard freemiumDBPUserStateManager.didActivate else { return false } + + return !privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(DBPSubfeature.freemium) + && subscriptionManager.isPotentialPrivacyProSubscriber + } + + /// This method offboards a Freemium user if the feature flag was disabled + /// + /// Offboarding involves: + /// - Resettting `FreemiumDBPUserStateManager`state + /// - Disabling and deleting DBP data + func offBoardIfNecessary() { + if shouldDisableAndDelete { + Logger.freemiumDBP.debug("[Freemium DBP] Feature Disabled: Offboarding") + freemiumDBPUserStateManager.resetAllState() + featureDisabler.disableAndDelete() + } + } +} + +extension SubscriptionManager { + + /// Returns true if a user is a "potential" Privacy Pro subscriber. This means: + /// + /// 1. Is eligible to purchase + /// 2. Is not a current subscriber + var isPotentialPrivacyProSubscriber: Bool { + isPrivacyProPurchaseAvailable + && !accountManager.isUserAuthenticated + } + + private var isPrivacyProPurchaseAvailable: Bool { + let platform = currentEnvironment.purchasePlatform + switch platform { + case .appStore: + return canPurchase + case .stripe: + return true + } + } +} diff --git a/DuckDuckGo/Freemium/DBP/FreemiumDBPFirstProfileSavedNotifier.swift b/DuckDuckGo/Freemium/DBP/FreemiumDBPFirstProfileSavedNotifier.swift new file mode 100644 index 0000000000..d7493816e0 --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/FreemiumDBPFirstProfileSavedNotifier.swift @@ -0,0 +1,61 @@ +// +// FreemiumDBPFirstProfileSavedNotifier.swift +// +// 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 Freemium +import DataBrokerProtection +import Subscription +import OSLog + +/// A concrete implementation of the `DBPProfileSavedNotifier` protocol that handles posting the "Profile Saved" notification +/// for Freemium users based on their Freemium activation state, authentication state, and if this is their first saved profile. This class ensures the notification is posted only once. +final class FreemiumDBPFirstProfileSavedNotifier: DBPProfileSavedNotifier { + + private var freemiumDBPUserStateManager: FreemiumDBPUserStateManager + private var accountManager: AccountManager + private let notificationCenter: NotificationCenter + + /// Initializes the notifier with the necessary dependencies to check user state and post notifications. + /// + /// - Parameters: + /// - freemiumDBPUserStateManager: Manages the user state related to Freemium DBP. + /// - accountManager: Manages account-related information, such as whether the user is authenticated. + /// - notificationCenter: The notification center for posting notifications. Defaults to the system's default notification center. + init(freemiumDBPUserStateManager: FreemiumDBPUserStateManager, accountManager: AccountManager, notificationCenter: NotificationCenter = .default) { + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager + self.accountManager = accountManager + self.notificationCenter = notificationCenter + } + + /// Posts the "Profile Saved" notification if the following conditions are met: + /// - The user is not authenticated + /// - The user has activated Freemium (i.e accessed the feature). + /// - The "Profile Saved" notification has not already been posted. + /// + /// If all conditions are met, the method posts a `pirProfileSaved` notification via the `NotificationCenter` and records that the notification has been posted. + func postProfileSavedNotificationIfPermitted() { + guard !accountManager.isUserAuthenticated + && freemiumDBPUserStateManager.didActivate + && !freemiumDBPUserStateManager.didPostFirstProfileSavedNotification else { return } + + Logger.freemiumDBP.debug("[Freemium DBP] Posting Profile Saved Notification") + notificationCenter.post(name: .pirProfileSaved, object: nil) + + freemiumDBPUserStateManager.didPostFirstProfileSavedNotification = true + } +} diff --git a/DuckDuckGo/Freemium/DBP/FreemiumDBPPresenter.swift b/DuckDuckGo/Freemium/DBP/FreemiumDBPPresenter.swift new file mode 100644 index 0000000000..06654880bf --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/FreemiumDBPPresenter.swift @@ -0,0 +1,44 @@ +// +// FreemiumDBPPresenter.swift +// +// 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 Freemium + +/// Conforming types provide functionality to show Freemium DBP +protocol FreemiumDBPPresenter { + func showFreemiumDBPAndSetActivated(windowControllerManager: WindowControllersManagerProtocol?) +} + +/// Default implementation of `FreemiumDBPPresenter` +final class DefaultFreemiumDBPPresenter: FreemiumDBPPresenter { + + private var freemiumDBPStateManager: FreemiumDBPUserStateManager + + init(freemiumDBPStateManager: FreemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp)) { + self.freemiumDBPStateManager = freemiumDBPStateManager + } + + @MainActor + /// Displays Freemium DBP + func showFreemiumDBPAndSetActivated(windowControllerManager: WindowControllersManagerProtocol? = nil) { + + let windowControllerManager = windowControllerManager ?? WindowControllersManager.shared + freemiumDBPStateManager.didActivate = true + windowControllerManager.showTab(with: .dataBrokerProtection) + } +} diff --git a/DuckDuckGo/Freemium/DBP/FreemiumDBPPromotionViewCoordinator.swift b/DuckDuckGo/Freemium/DBP/FreemiumDBPPromotionViewCoordinator.swift new file mode 100644 index 0000000000..d764c8f154 --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/FreemiumDBPPromotionViewCoordinator.swift @@ -0,0 +1,231 @@ +// +// FreemiumDBPPromotionViewCoordinator.swift +// +// 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 Combine +import Foundation +import Freemium +import OSLog +import DataBrokerProtection +import Common + +/// Default implementation of `FreemiumDBPPromotionViewCoordinating`, responsible for managing +/// the visibility of the promotion and responding to user interactions with the promotion view. +@MainActor +final class FreemiumDBPPromotionViewCoordinator: ObservableObject { + + /// Published property that determines whether the promotion is visible on the home page. + @Published var isHomePagePromotionVisible: Bool = false + + /// The view model representing the promotion, which updates based on the user's state. + var viewModel: PromotionViewModel { + createViewModel() + } + + /// Stores whether the user has dismissed the home page promotion. + private var didDismissHomePagePromotion: Bool { + get { + return freemiumDBPUserStateManager.didDismissHomePagePromotion + } + set { + Logger.freemiumDBP.debug("[Freemium DBP] Promotion dismiss state set to \(newValue)") + freemiumDBPUserStateManager.didDismissHomePagePromotion = newValue + isHomePagePromotionVisible = !newValue + } + } + + /// The user state manager, which tracks the user's activation status and scan results. + private var freemiumDBPUserStateManager: FreemiumDBPUserStateManager + + /// Responsible for determining the availability of Freemium DBP. + private let freemiumDBPFeature: FreemiumDBPFeature + + /// The presenter used to show the Freemium DBP UI. + private let freemiumDBPPresenter: FreemiumDBPPresenter + + /// A set of cancellables for managing Combine subscriptions. + private var cancellables = Set() + + /// The `NotificationCenter` instance used when subscribing to notifications + private let notificationCenter: NotificationCenter + + /// The `FreemiumDBPExperimentPixelHandler` instance used to fire pixels + private let freemiumDBPExperimentPixelHandler: EventMapping + + /// Initializes the coordinator with the necessary dependencies. + /// + /// - Parameters: + /// - freemiumDBPUserStateManager: Manages the user's state in the Freemium DBP system. + /// - freemiumDBPFeature: The feature that determines the availability of DBP. + /// - freemiumDBPPresenter: The presenter used to show the Freemium DBP UI. Defaults to `DefaultFreemiumDBPPresenter`. + /// - notificationCenter: The `NotificationCenter` instance used when subscribing to notifications + init(freemiumDBPUserStateManager: FreemiumDBPUserStateManager, + freemiumDBPFeature: FreemiumDBPFeature, + freemiumDBPPresenter: FreemiumDBPPresenter = DefaultFreemiumDBPPresenter(), + notificationCenter: NotificationCenter = .default, + freemiumDBPExperimentPixelHandler: EventMapping = FreemiumDBPExperimentPixelHandler()) { + + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager + self.freemiumDBPFeature = freemiumDBPFeature + self.freemiumDBPPresenter = freemiumDBPPresenter + self.notificationCenter = notificationCenter + self.freemiumDBPExperimentPixelHandler = freemiumDBPExperimentPixelHandler + + setInitialPromotionVisibilityState() + subscribeToFeatureAvailabilityUpdates() + observeFreemiumDBPNotifications() + } +} + +private extension FreemiumDBPPromotionViewCoordinator { + + /// Action to be executed when the user proceeds with the promotion (e.g opens DBP) + var proceedAction: () -> Void { + { [weak self] in + guard let self else { return } + + execute(resultsAction: { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabResultsClick) + }, orNoResultsAction: { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabNoResultsClick) + }, orPromotionAction: { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabScanClick) + }) + + showFreemiumDBP() + dismissHomePagePromotion() + } + } + + /// Action to be executed when the user closes the promotion. + var closeAction: () -> Void { + { [weak self] in + guard let self else { return } + + execute(resultsAction: { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabResultsDismiss) + }, orNoResultsAction: { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabNoResultsDismiss) + }, orPromotionAction: { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabScanDismiss) + }) + + dismissHomePagePromotion() + } + } + + /// Shows the Freemium DBP user interface via the presenter. + func showFreemiumDBP() { + freemiumDBPPresenter.showFreemiumDBPAndSetActivated(windowControllerManager: WindowControllersManager.shared) + } + + /// Dismisses the home page promotion and updates the user state to reflect this. + func dismissHomePagePromotion() { + didDismissHomePagePromotion = true + } + + /// Sets the initial visibility state of the promotion based on whether the promotion was + /// previously dismissed and whether the Freemium DBP feature is available. + func setInitialPromotionVisibilityState() { + isHomePagePromotionVisible = (!didDismissHomePagePromotion && freemiumDBPFeature.isAvailable) + } + + /// Creates the view model for the promotion, updating based on the user's scan results. + /// + /// - Returns: The `PromotionViewModel` that represents the current state of the promotion. + func createViewModel() -> PromotionViewModel { + + if let results = freemiumDBPUserStateManager.firstScanResults { + if results.matchesCount > 0 { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabResultsImpression) + return .freemiumDBPPromotionScanEngagementResults( + resultCount: results.matchesCount, + brokerCount: results.brokerCount, + proceedAction: proceedAction, + closeAction: closeAction + ) + } else { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabNoResultsImpression) + return .freemiumDBPPromotionScanEngagementNoResults( + proceedAction: proceedAction, + closeAction: closeAction + ) + } + } else { + self.freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.newTabScanImpression) + return .freemiumDBPPromotion(proceedAction: proceedAction, closeAction: closeAction) + } + } + + /// Subscribes to feature availability updates from the `freemiumDBPFeature`'s availability publisher. + /// + /// This method listens to the `isAvailablePublisher` of the `freemiumDBPFeature`, which publishes + /// changes to the feature's availability. It performs the following actions when an update is received: + func subscribeToFeatureAvailabilityUpdates() { + freemiumDBPFeature.isAvailablePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isAvailable in + guard let self else { return } + isHomePagePromotionVisible = (!didDismissHomePagePromotion && isAvailable) + } + .store(in: &cancellables) + } + + /// Observes notifications related to Freemium DBP (e.g., result polling complete or entry point activated), + /// and updates the promotion visibility state accordingly. + func observeFreemiumDBPNotifications() { + notificationCenter.publisher(for: .freemiumDBPResultPollingComplete) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Logger.freemiumDBP.debug("[Freemium DBP] Received Scan Results Notification") + self?.didDismissHomePagePromotion = false + } + .store(in: &cancellables) + + notificationCenter.publisher(for: .freemiumDBPEntryPointActivated) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Logger.freemiumDBP.debug("[Freemium DBP] Received Entry Point Activation Notification") + self?.didDismissHomePagePromotion = true + } + .store(in: &cancellables) + } + + /// Executes one of three possible actions based on the state of the user's first scan results. + /// + /// This function checks the results of the user's first scan, stored in `freemiumDBPUserStateManager`. + /// Depending on the state of the scan results, it executes one of the provided actions: + /// - If there are scan results with a `matchesCount` greater than 0, it calls `resultsAction`. + /// - If there are scan results, but `matchesCount` is 0, it calls `noResultsAction`. + /// - If no scan results are available, it calls `promotionAction`. + /// + /// - Parameters: + /// - resultsAction: The action to execute when there are scan results with one or more matches. + /// - noResultsAction: The action to execute when there are scan results but no matches. + /// - promotionAction: The action to execute when there are no scan results available. + func execute(resultsAction: () -> Void, orNoResultsAction noResultsAction: () -> Void, orPromotionAction promotionAction: () -> Void) { + if let results = freemiumDBPUserStateManager.firstScanResults { + if results.matchesCount > 0 { + resultsAction() + } else { + noResultsAction() + } + } else { + promotionAction() + } + } +} diff --git a/DuckDuckGo/Freemium/DBP/FreemiumDBPScanResultPolling.swift b/DuckDuckGo/Freemium/DBP/FreemiumDBPScanResultPolling.swift new file mode 100644 index 0000000000..a86c03c1b6 --- /dev/null +++ b/DuckDuckGo/Freemium/DBP/FreemiumDBPScanResultPolling.swift @@ -0,0 +1,202 @@ +// +// FreemiumDBPScanResultPolling.swift +// +// 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 DataBrokerProtection +import Freemium +import OSLog + +/// Protocol defining the interface for DBP scan result polling. +protocol FreemiumDBPScanResultPolling { + /// Starts the polling process for scan results or begins observing for profile saved events. + func startPollingOrObserving() +} + +/// A class that manages the polling for DBP scan results and handles posting notifications for results. +/// It either starts polling if a profile has been saved or begins observing for the event of saving a profile. +/// The polling checks for results periodically and posts notifications when results are found or no results are found after a set duration. +final class DefaultFreemiumDBPScanResultPolling: FreemiumDBPScanResultPolling { + + /// Internal for testing purposes to allow access in test cases. + var timer: Timer? + + private var observer: Any? + + private let dataManager: DataBrokerProtectionDataManaging + private var freemiumDBPUserStateManager: FreemiumDBPUserStateManager + private let notificationCenter: NotificationCenter + private let timerInterval: TimeInterval + private let maxCheckDuration: TimeInterval + + /// Initializes the `DefaultFreemiumDBPScanResultPolling` instance with the necessary dependencies. + /// + /// - Parameters: + /// - dataManager: The data manager responsible for managing broker protection data. + /// - freemiumDBPUserStateManager: Manages the state of the user's profile in Freemium DBP. + /// - notificationCenter: The notification center used for posting and observing notifications. Defaults to `.default`. + /// - timerInterval: The interval in seconds between polling checks. Defaults to 30 mins. + /// - maxCheckDuration: The maximum time allowed before stopping polling without results. Defaults to 24 hours. + init( + dataManager: DataBrokerProtectionDataManaging, + freemiumDBPUserStateManager: FreemiumDBPUserStateManager, + notificationCenter: NotificationCenter = .default, + timerInterval: TimeInterval = 1800, // 30 mins in seconds + maxCheckDuration: TimeInterval = 86400 // 24 hours in seconds + ) { + self.dataManager = dataManager + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager + self.notificationCenter = notificationCenter + self.timerInterval = timerInterval + self.maxCheckDuration = maxCheckDuration + } + + // MARK: - Public Methods + + /// Starts polling for DBP scan results or observes for a profile saved notification if no profile has been saved yet. + func startPollingOrObserving() { + guard !freemiumDBPUserStateManager.didPostResultsNotification else { return } + + if firstProfileSaved { + startPolling() + } else { + startObserving() + } + } + + deinit { + stopObserving() + stopTimer() + } +} + +private extension DefaultFreemiumDBPScanResultPolling { + + /// A Boolean value indicating whether the first profile has been saved. + var firstProfileSaved: Bool { + freemiumDBPUserStateManager.firstProfileSavedTimestamp != nil + } + + /// The saved timestamp of the first profile as a `Date`, or `nil` if no profile has been saved yet. + var firstProfileSavedTimestamp: Date? { + get { + freemiumDBPUserStateManager.firstProfileSavedTimestamp + } + set { + freemiumDBPUserStateManager.firstProfileSavedTimestamp = newValue + } + } + + /// Starts the polling process for DBP scan results. + /// + /// It first checks if any results are available. + /// If no results are found, it starts a repeating timer to poll for results at regular intervals. + func startPolling() { + Logger.freemiumDBP.debug("[Freemium DBP] Starting to Poll for Scan Results") + checkResultsAndNotifyIfApplicable() + startPollingTimer() + } + + /// Starts observing for the profile saved notification. + func startObserving() { + Logger.freemiumDBP.debug("[Freemium DBP] Starting to Observe for Profile Saved Notifications") + observeProfileSavedNotification() + } + + /// Observes the notification for when the first profile is saved and triggers the polling process. + func observeProfileSavedNotification() { + observer = notificationCenter.addObserver( + forName: .pirProfileSaved, + object: nil, + queue: .main + ) { [weak self] _ in + Logger.freemiumDBP.debug("[Freemium DBP] Profile Saved Notification Received") + self?.profileSavedNotificationReceived() + } + } + + /// Called when the profile saved notification is received. Saves the current timestamp and starts polling for results. + func profileSavedNotificationReceived() { + if !firstProfileSaved { + saveCurrentTimestamp() + } + startPollingTimer() + } + + /// Saves the current timestamp as the time when the first profile was saved. + func saveCurrentTimestamp() { + firstProfileSavedTimestamp = Date() + } + + /// Starts a timer that polls for results at regular intervals, ensuring the timer is not already running. + func startPollingTimer() { + guard timer == nil, !freemiumDBPUserStateManager.didPostResultsNotification else { return } + + timer = Timer.scheduledTimer(withTimeInterval: timerInterval, repeats: true) { [weak self] _ in + self?.checkResultsAndNotifyIfApplicable() + } + } + + /// Checks if any matches have been found or if the maximum polling duration has been exceeded. + /// Posts a notification if results are found or if no results are found after the maximum duration. + func checkResultsAndNotifyIfApplicable() { + guard let firstProfileSavedTimestamp = firstProfileSavedTimestamp else { return } + + let currentDate = Date() + let elapsedTime = currentDate.timeIntervalSince(firstProfileSavedTimestamp) + + let (matchesCount, brokerCount) = (try? dataManager.matchesFoundAndBrokersCount()) ?? (0, 0) + + if matchesCount > 0 || elapsedTime >= maxCheckDuration{ + notifyOfResultsAndStopTimer(matchesCount, brokerCount) + } + } + + /// Notifies the system of scan results and stops the polling timer. + /// + /// This method posts a notification with the results, either with or without matches, and updates the user's + /// state to reflect that the results have been posted. Finally, it stops the polling timer. + /// + /// - Parameters: + /// - matchesCount: The number of matches found during the scan. + /// - brokerCount: The number of brokers associated with the matches found. + func notifyOfResultsAndStopTimer(_ matchesCount: Int, _ brokerCount: Int) { + + freemiumDBPUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: matchesCount, brokerCount: brokerCount) + let withOrWith = matchesCount > 0 ? "WITH" : "WITHOUT" + Logger.freemiumDBP.debug("[Freemium DBP] Posting Scan Results Notification \(withOrWith) matches") + + notificationCenter.post(name: .freemiumDBPResultPollingComplete, object: nil) + + freemiumDBPUserStateManager.didPostResultsNotification = true + stopTimer() + } + + /// Stops observing `pirProfileSaved` notifications. + func stopObserving() { + if let observer = observer { + notificationCenter.removeObserver(observer) + } + } + + /// Stops the polling timer and clears the timer reference. + func stopTimer() { + Logger.freemiumDBP.debug("[Freemium DBP] Stopping Polling Timer") + timer?.invalidate() + timer = nil + } +} diff --git a/DuckDuckGo/Freemium/Debug/FreemiumDebugMenu.swift b/DuckDuckGo/Freemium/Debug/FreemiumDebugMenu.swift new file mode 100644 index 0000000000..c3741f8fb6 --- /dev/null +++ b/DuckDuckGo/Freemium/Debug/FreemiumDebugMenu.swift @@ -0,0 +1,178 @@ +// +// FreemiumDebugMenu.swift +// +// 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 Freemium +import OSLog + +final class FreemiumDebugMenu: NSMenuItem { + + private enum Keys { + static let enrollmentDate = "freemium.dbp.experiment.enrollment-date" + static let experimentCohort = "freemium.dbp.experiment.cohort" + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public init() { + super.init(title: "Freemium", action: nil, keyEquivalent: "") + self.submenu = makeSubmenu() + } + + private func makeSubmenu() -> NSMenu { + let menu = NSMenu(title: "") + + menu.addItem(NSMenuItem(title: "Set Freemium DBP Activated State TRUE", action: #selector(setFreemiumDBPActivateStateTrue), target: self)) + menu.addItem(NSMenuItem(title: "Set Freemium DBP Activated State FALSE", action: #selector(setFreemiumDBPActivateStateFalse), target: self)) + menu.addItem(NSMenuItem(title: "Set Freemium DBP First Profile Saved Timestamp NIL", action: #selector(setFirstProfileSavedTimestampNil), target: self)) + menu.addItem(NSMenuItem(title: "Set Freemium DBP Did Post First Profile Saved FALSE", action: #selector(setDidPostFirstProfileSavedNotificationFalse), target: self)) + menu.addItem(NSMenuItem(title: "Set Freemium DBP Did Post Results FALSE", action: #selector(setDidPostResultsNotificationFalse), target: self)) + menu.addItem(NSMenuItem(title: "Set Results and Trigger Post-Scan Banner", action: #selector(setResultsAndTriggerPostScanBanner), target: self)) + menu.addItem(NSMenuItem(title: "Set No Results and Trigger Post-Scan Banner", action: #selector(setNoResultsAndTriggerPostScanBanner), target: self)) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Set New Tab Promotion Did Dismiss FALSE", action: #selector(setNewTabPromotionDidDismissFalse), target: self)) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Log all state", action: #selector(logAllState), target: self)) + menu.addItem(NSMenuItem(title: "Display all state", action: #selector(displayAllState), target: self)) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Enroll into Experiment", action: #selector(enrollIntoExperiment), target: self)) + menu.addItem(NSMenuItem(title: "Reset Freemium DBP Experiment State", action: #selector(resetFreemiumDBPExperimentState), target: self)) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Reset all Freemium Feature State", action: #selector(resetAllState), target: self)) + + return menu + } + + @objc + func setFreemiumDBPActivateStateTrue() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didActivate = true + } + + @objc + func setFreemiumDBPActivateStateFalse() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didActivate = false + } + + @objc + func setFirstProfileSavedTimestampNil() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstProfileSavedTimestamp = nil + } + + @objc + func setDidPostFirstProfileSavedNotificationFalse() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostFirstProfileSavedNotification = false + } + + @objc + func setDidPostResultsNotificationFalse() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostResultsNotification = false + } + + @objc + func setResultsAndTriggerPostScanBanner() { + let results = FreemiumDBPMatchResults(matchesCount: 19, brokerCount: 3) + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstScanResults = results + NotificationCenter.default.post(name: .freemiumDBPResultPollingComplete, object: nil) + } + + @objc + func setNoResultsAndTriggerPostScanBanner() { + let noResults = FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0) + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstScanResults = noResults + NotificationCenter.default.post(name: .freemiumDBPResultPollingComplete, object: nil) + } + + @objc + func setFirstScanResultsNil() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstScanResults = nil + } + + @objc + func setNewTabPromotionDidDismissFalse() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didDismissHomePagePromotion = false + } + + @objc + func logAllState() { + + Logger.freemiumDBP.debug("FREEMIUM DBP: DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didActivate \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didActivate)") + Logger.freemiumDBP.debug("FREEMIUM DBP: DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstProfileSavedTimestamp \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstProfileSavedTimestamp?.description ?? "Nil")") + Logger.freemiumDBP.debug("FREEMIUM DBP: DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostFirstProfileSavedNotification \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostFirstProfileSavedNotification)") + Logger.freemiumDBP.debug("FREEMIUM DBP: DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostResultsNotification \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostResultsNotification)") + if let results = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstScanResults { + Logger.freemiumDBP.debug("FREEMIUM DBP: DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstScanResults \(results.matchesCount) - \(results.brokerCount)") + } else { + Logger.freemiumDBP.debug("FREEMIUM DBP: DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstScanResults Nil") + } + Logger.freemiumDBP.debug("FREEMIUM DBP: DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didDismissHomePagePromotion \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didDismissHomePagePromotion)") + + if let enrollmentDate = UserDefaults.dbp.object(forKey: Keys.enrollmentDate) as? Date { + Logger.freemiumDBP.debug("FREEMIUM DBP: freemium.dbp.experiment.enrollment-date \(enrollmentDate)") + } + if let cohortValue = UserDefaults.dbp.string(forKey: Keys.experimentCohort) { + Logger.freemiumDBP.debug("FREEMIUM DBP: freemium.dbp.experiment.cohort \(cohortValue)") + } + } + + @objc + func displayAllState() { + let didActivate = "Activated: \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didActivate)" + let firstProfileSavedTimestamp = "First Profile Saved Timestamp: \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstProfileSavedTimestamp?.description ?? "Nil")" + let didPostFirstProfileSavedNotification = "Posted First Profile Saved Notification: \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostFirstProfileSavedNotification)" + let didPostResultsNotification = "Posted Results Notification: \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didPostResultsNotification)" + let firstScanResults = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).firstScanResults.map { "First Scan Results: \($0.matchesCount) matches, \($0.brokerCount) brokers" } ?? "First Scan Results: Nil" + let didDismissHomePagePromotion = "Dismissed Home Page Promotion: \(DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).didDismissHomePagePromotion)" + let enrollmentDateLog = UserDefaults.dbp.object(forKey: Keys.enrollmentDate).flatMap { $0 as? Date }.map { "Enrollment Date: \($0)" } ?? "Enrollment Date: Not set" + let cohortValueLog = UserDefaults.dbp.string(forKey: Keys.experimentCohort).map { "Cohort: \($0)" } ?? "Cohort: Not set" + + let alert = NSAlert() + alert.messageText = "State Information" + alert.informativeText = """ + • \(didActivate) + • \(firstProfileSavedTimestamp) + • \(didPostFirstProfileSavedNotification) + • \(didPostResultsNotification) + • \(firstScanResults) + • \(didDismissHomePagePromotion) + • \(enrollmentDateLog) + • \(cohortValueLog) + """ + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + + @objc + func enrollIntoExperiment() { + UserDefaults.dbp.set("treatment", forKey: Keys.experimentCohort) + UserDefaults.dbp.set(Date(), forKey: Keys.enrollmentDate) + } + + @objc + func resetFreemiumDBPExperimentState() { + UserDefaults.dbp.removeObject(forKey: Keys.enrollmentDate) + UserDefaults.dbp.removeObject(forKey: Keys.experimentCohort) + } + + @objc + func resetAllState() { + DefaultFreemiumDBPUserStateManager(userDefaults: .dbp).resetAllState() + } +} diff --git a/DuckDuckGo/HomePage/Model/PromotionViewModel.swift b/DuckDuckGo/HomePage/Model/PromotionViewModel.swift new file mode 100644 index 0000000000..069ec55a92 --- /dev/null +++ b/DuckDuckGo/HomePage/Model/PromotionViewModel.swift @@ -0,0 +1,40 @@ +// +// PromotionViewModel.swift +// +// 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 + +extension HomePage.Models { + + /// Model for a `PromotionView` type + final class PromotionViewModel: ObservableObject { + + let image: ImageResource + let text: String + let proceedButtonText: String + let proceedAction: () -> Void + let closeAction: () -> Void + + init(image: ImageResource, text: String, proceedButtonText: String, proceedAction: @escaping () -> Void, closeAction: @escaping () -> Void) { + self.image = image + self.text = text + self.proceedButtonText = proceedButtonText + self.proceedAction = proceedAction + self.closeAction = closeAction + } + } +} diff --git a/DuckDuckGo/HomePage/View/HomePageView.swift b/DuckDuckGo/HomePage/View/HomePageView.swift index 7d88a8b828..428c7b85db 100644 --- a/DuckDuckGo/HomePage/View/HomePageView.swift +++ b/DuckDuckGo/HomePage/View/HomePageView.swift @@ -39,6 +39,8 @@ extension HomePage.Views { @EnvironmentObject var addressBarModel: HomePage.Models.AddressBarModel @EnvironmentObject var recentlyVisitedModel: HomePage.Models.RecentlyVisitedModel + @ObservedObject var freemiumDBPPromotionViewCoordinator: FreemiumDBPPromotionViewCoordinator + var body: some View { if isBurner { BurnerHomePageView() @@ -149,6 +151,8 @@ extension HomePage.Views { view.padding(.top, Const.remoteMessageTopPaddingWithSearchBar) } + freemiumPromotionView() + if addressBarModel.shouldShowAddressBar { BigSearchBox(isCompact: isCompactLogo(with: geometry)) .id(Const.searchBarIdentifier) @@ -220,6 +224,12 @@ extension HomePage.Views { } } + func freemiumPromotionView() -> some View { + PromotionView(viewModel: freemiumDBPPromotionViewCoordinator.viewModel) + .padding(.bottom, 16) + .visibility(freemiumDBPPromotionViewCoordinator.isHomePagePromotionVisible ? .visible : .gone) + } + @ViewBuilder func sectionsVisibilityContextMenuItems() -> some View { if addressBarModel.shouldShowAddressBar { diff --git a/DuckDuckGo/HomePage/View/HomePageViewController.swift b/DuckDuckGo/HomePage/View/HomePageViewController.swift index 9a0b903e6b..fef4a90e1b 100644 --- a/DuckDuckGo/HomePage/View/HomePageViewController.swift +++ b/DuckDuckGo/HomePage/View/HomePageViewController.swift @@ -23,6 +23,7 @@ import SwiftUI import History import PixelKit import RemoteMessaging +import Freemium @MainActor final class HomePageViewController: NSViewController { @@ -32,6 +33,7 @@ final class HomePageViewController: NSViewController { private let historyCoordinating: HistoryCoordinating private let fireViewModel: FireViewModel private let onboardingViewModel: OnboardingViewModel + private let freemiumDBPPromotionViewCoordinator: FreemiumDBPPromotionViewCoordinator private(set) lazy var faviconsFetcherOnboarding: FaviconsFetcherOnboarding? = { guard let syncService = NSApp.delegateTyped.syncService, let syncBookmarksAdapter = NSApp.delegateTyped.syncDataProviders?.bookmarksAdapter else { @@ -70,6 +72,7 @@ final class HomePageViewController: NSViewController { accessibilityPreferences: AccessibilityPreferences = AccessibilityPreferences.shared, appearancePreferences: AppearancePreferences = AppearancePreferences.shared, defaultBrowserPreferences: DefaultBrowserPreferences = DefaultBrowserPreferences.shared, + freemiumDBPPromotionViewCoordinator: FreemiumDBPPromotionViewCoordinator, privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager) { self.tabCollectionViewModel = tabCollectionViewModel @@ -80,6 +83,7 @@ final class HomePageViewController: NSViewController { self.accessibilityPreferences = accessibilityPreferences self.appearancePreferences = appearancePreferences self.defaultBrowserPreferences = defaultBrowserPreferences + self.freemiumDBPPromotionViewCoordinator = freemiumDBPPromotionViewCoordinator self.privacyConfigurationManager = privacyConfigurationManager super.init(nibName: nil, bundle: nil) @@ -94,7 +98,7 @@ final class HomePageViewController: NSViewController { refreshModels() - let rootView = HomePage.Views.RootView(isBurner: tabCollectionViewModel.isBurner) + let rootView = HomePage.Views.RootView(isBurner: tabCollectionViewModel.isBurner, freemiumDBPPromotionViewCoordinator: freemiumDBPPromotionViewCoordinator) .environmentObject(favoritesModel) .environmentObject(defaultBrowserModel) .environmentObject(recentlyVisitedModel) @@ -348,5 +352,4 @@ final class HomePageViewController: NSViewController { self?.refreshModels() } } - } diff --git a/DuckDuckGo/HomePage/View/PromotionView.swift b/DuckDuckGo/HomePage/View/PromotionView.swift new file mode 100644 index 0000000000..1a6bec0437 --- /dev/null +++ b/DuckDuckGo/HomePage/View/PromotionView.swift @@ -0,0 +1,105 @@ +// +// PromotionView.swift +// +// 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 SwiftUI +import SwiftUIExtensions + +typealias PromotionViewModel = HomePage.Models.PromotionViewModel + +extension HomePage.Views { + + /// A `PromotionView` is intended to be displayed on the new tab home page, and used to promote a feature, product etc + struct PromotionView: View { + + var viewModel: PromotionViewModel + + @State var isHovering = false + @EnvironmentObject var settingsModel: HomePage.Models.SettingsModel + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.homeFavoritesGhost, style: StrokeStyle(lineWidth: 1.0)) + .homePageViewBackground(settingsModel.customBackground) + .cornerRadius(12) + VStack(spacing: 12) { + HStack(spacing: 8) { + image + + text + .padding(.leading, 0) + + Spacer(minLength: 4) + + button + + } + .padding(.trailing, 16) + } + .padding(.leading, 8) + .padding(.trailing, 16) + .padding(.vertical, 16) + + HStack { + Spacer() + VStack { + closeButton + Spacer() + } + } + } + .padding(.horizontal, 2) + .onHover { isHovering in + self.isHovering = isHovering + } + } + + private var closeButton: some View { + HomePage.Views.CloseButton(icon: .close, size: 16) { + viewModel.closeAction() + } + .visibility(isHovering ? .visible : .invisible) + .padding(6) + } + + private var image: some View { + Group { + Image(viewModel.image) + .resizable() + .frame(width: 48, height: 48) + } + } + + private var text: some View { + Text(viewModel.text) + } + + private var button: some View { + Group { + Button(action: viewModel.proceedAction) { + Text(viewModel.proceedButtonText) + } + .controlSize(.large) + } + } + } +} + +#Preview { + return HomePage.Views.PromotionView(viewModel: PromotionViewModel.freemiumDBPPromotion(proceedAction: {}, closeAction: {})) +} diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index d03f499f27..ad91e2e3be 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -53,22 +53,6 @@ } } }, - " " : { - "localizations" : { - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : " " - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : " " - } - } - } - }, " %@" : { "localizations" : { "de" : { @@ -1538,366 +1522,6 @@ } } }, - "ai-chat.onboarding.popover.accept" : { - "comment" : "AI Chat onboarding CTA for approval", - "extractionState" : "extracted_with_value", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shortcut hinzufügen" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Add Shortcut" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Añadir acceso directo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajouter un raccourci" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aggiungi scorciatoia" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sneltoets toevoegen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodaj skrót" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adicionar atalho" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Добавить ярлык" - } - } - } - }, - "ai-chat.onboarding.popover.confirmation" : { - "comment" : "Confirmation for accepting the AI Chat onboarding popover", - "extractionState" : "extracted_with_value", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI-Chat-Shortcut hinzugefügt!" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "AI Chat Shortcut Added!" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¡Se ha añadido un acceso directo a AI Chat!" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Raccourci AI Chat ajouté !" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scorciatoia per AI Chat aggiunta!" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Snelkoppeling naar AI Chat toegevoegd!" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dodano skrót do AI Chat!" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Atalho do AI Chat adicionado!" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ярлык AI Chat добавлен!" - } - } - } - }, - "ai-chat.onboarding.popover.message1" : { - "comment" : "AI Chat onboarding popover message", - "extractionState" : "extracted_with_value", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Du kannst diese und andere AI-Chat-Funktionen anpassen" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "You can adjust this and other AI Chat features in" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Puedes ajustar esta y otras funciones de AI Chat en" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vous pouvez ajuster cela et d'autres fonctionnalités de AI Chat dans" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Puoi regolare questa e altre funzionalità di AI Chat in" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je kunt deze en andere AI Chat-functies aanpassen in" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Możesz dostosować tę i inne funkcje AI Chat wybierając kolejno" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podes ajustar esta e outras funcionalidades do AI Chat em" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Эту и другие возможности AI Chat можно проконтролировать в разделе" - } - } - } - }, - "ai-chat.onboarding.popover.message2" : { - "comment" : "AI Chat onboarding popover message continuation", - "extractionState" : "extracted_with_value", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einstellungen > AI Chat." - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Settings > AI Chat." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajustes > AI Chat." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paramètres > AI Chat." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impostazioni > AI Chat." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "'Instellingen' > 'AI Chat'." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ustawienia > AI Chat." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Definições > AI Chat." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "«Настройки > AI Chat»." - } - } - } - }, - "ai-chat.onboarding.popover.reject" : { - "comment" : "AI Chat onboarding CTA for rejection", - "extractionState" : "extracted_with_value", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nein, danke" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No Thanks" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "No, gracias" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non merci" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "No, grazie" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nee, bedankt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie, dziękuję" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não, obrigado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нет, спасибо" - } - } - } - }, - "ai-chat.onboarding.popover.title" : { - "comment" : "AI Chat onboarding popover title", - "extractionState" : "extracted_with_value", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Starte AI Chat direkt von deiner Symbolleiste" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Launch AI Chat directly from your toolbar" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inicia AI Chat directamente desde la barra de herramientas" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lancez AI Chat directement depuis votre barre d'outils" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avvia AI Chat direttamente dalla barra degli strumenti" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start AI Chat rechtstreeks vanuit je werkbalk" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uruchom AI Chat bezpośrednio z paska narzędzi" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inicia o AI Chat diretamente a partir da tua barra de ferramentas" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Запуск AI Chat прямо с панели инструментов" - } - } - } - }, "ai-chat.preferences.caption" : { "comment" : "Ai Chat preferences explanation", "extractionState" : "extracted_with_value", @@ -7988,55 +7612,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" + "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.10.0, v2024.10.1, v2024.10.2. Please downgrade to an older version by following these steps:" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "The following Bitwarden versions are incompatible with DuckDuckGo: v2024.3.0, v2024.3.2, v2024.4.0, v2024.4.1. Please update to a newer version by following these steps:" } } @@ -8048,55 +7672,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Download v2024.4.3" + "value" : "Download v2024.9.0" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Download v2024.4.3" } } @@ -38257,6 +37881,66 @@ } } }, + "notification.auto.update.action" : { + "comment" : "Action to take when an automatic update is available.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zum Aktualisieren neu starten." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart to update." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reinicia para actualizar." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrer pour mettre à jour." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riavvia per aggiornare." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start opnieuw om bij te werken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uruchom ponownie, aby zaktualizować." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar para atualizar." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапустите приложение." + } + } + } + }, "notification.badge.cookiesmanaged" : { "comment" : "Notification that appears when browser automatically handle cookies", "extractionState" : "extracted_with_value", @@ -38498,121 +38182,181 @@ } }, "notification.critical.update" : { - "comment" : "Notification informing user a critical update is required.", + "comment" : "Notification informing user a critical update is available.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisches Update erforderlich." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Critical update needed." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se necesita una actualización crítica." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une mise à jour critique est nécessaire." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento critico necessario." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritieke update nodig." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wymagana krytyczna aktualizacja." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualização crítica necessária." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется критическое обновление." + } + } + } + }, + "notification.manual.update.action" : { + "comment" : "Action to take when a manual update is available.", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Kritisches Update erforderlich. Zum Aktualisieren neu starten." + "value" : "Klicke hier, um zu aktualisieren." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Critical update required. Restart to update." + "value" : "Click here to update." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Se requiere una actualización crítica. Reinicia para actualizar." + "value" : "Haz clic aquí para actualizar." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Une mise à jour critique est requise. Redémarrer pour mettre à jour." + "value" : "Cliquez ici pour mettre à jour." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Aggiornamento critico obbligatorio. Riavvia per aggiornare." + "value" : "Fai clic qui per aggiornare." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Kritieke update vereist. Start opnieuw om bij te werken." + "value" : "Klik hier om te updaten." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Wymagana krytyczna aktualizacja. Uruchom ponownie, aby zaktualizować." + "value" : "Kliknij tutaj, aby zaktualizować." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Atualização crítica obrigatória. Reiniciar para atualizar." + "value" : "Clica aqui para atualizar." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Требуется критическое обновление. Перезапустите приложение." + "value" : "Нажмите здесь, чтобы обновить." } } } }, "notification.update.available" : { - "comment" : "Notification informing user the a version of app is available", + "comment" : "Notification informing user the a version of app is available.", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Neue Version verfügbar. Zum Aktualisieren neu starten." + "value" : "Neue Version verfügbar." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "New version available. Restart to update." + "value" : "New version available." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Nueva versión disponible. Reinicia para actualizar." + "value" : "Nueva versión disponible." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Une nouvelle version est disponible. Redémarrer pour mettre à jour." + "value" : "Une nouvelle version est disponible." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Nuova versione disponibile. Riavvia per aggiornare." + "value" : "Nuova versione disponibile." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Nieuwe versie beschikbaar. Start opnieuw om bij te werken." + "value" : "Nieuwe versie beschikbaar." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Dostępna nowa wersja. Uruchom ponownie, aby zaktualizować." + "value" : "Dostępna nowa wersja." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Nova versão disponível. Reiniciar para atualizar." + "value" : "Nova versão disponível." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Доступна новая версия. Перезапустите приложение." + "value" : "Доступна новая версия." } } } @@ -39163,55 +38907,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Du bist bereit! Du kannst mich jederzeit im Dock antreffen.\nMöchtest du sehen, wie ich dich beschütze? Versuche, eine deiner Lieblingsseiten zu besuchen 👆\n\nBehalte die Adressleiste im Auge. Ich werde Tracker blockieren und die Sicherheit deiner Verbindung verbessern, wenn möglichu{00A0}🔒" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "You’re all set! You can find me hanging out in the Dock anytime.\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possibleu{00A0}🔒" + "value" : "You’re all set! You can find me hanging out in the Dock anytime.\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possible 🔒" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "¡Ya está todo listo! Puedes encontrarme en el Dock en cualquier momento.\n¿Quieres ver cómo te protejo? Prueba a visitar uno de tus sitios favoritos 👆\n\nNo pierdas de vista la barra de direcciones al navegar. Bloquearé los rastreadores y mejoraré la seguridad de tu conexión cuando sea posible{00A0}🔒" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Tout est prêt ! Vous pouvez me trouver sur le Dock à tout moment.\nVous voulez voir comment je vous protège ? Essayez de visiter l'un de vos sites préférés 👆\n\nContinuez à regarder la barre d'adresse au fur et à mesure. Je bloquerai les traqueurs et mettrai à niveau la sécurité de votre connexion si possible 🔒" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Tutto pronto! Puoi trovarmi nel dock in qualsiasi momento.\nVuoi vedere come ti proteggo? Prova a visitare uno dei tuoi siti preferiti 👆\n\nContinua a controllare la barra degli indirizzi mentre esplori. Bloccherò i sistemi di tracciamento e aggiornerò la sicurezza della tua connessione quando possibile{00A0} 🔒" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Je bent helemaal klaar! Je kunt me altijd vinden in het Dock.\nWil je zien hoe ik je bescherm? Ga eens naar een van je favoriete websites 👆\n\nKijk tijdens het surfen goed naar de adresbalk. Ik blokkeer trackers en werk de beveiliging van je verbinding bij wanneer mogelijk 🔒" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Wszystko gotowe! W każdej chwili możesz mnie znaleźć w Docku.\nChcesz zobaczyć, jak Cię chronię? Spróbuj odwiedzić jedną z ulubionych stron 👆\n\nW międzyczasie obserwuj pasek adresu. Będę blokować mechanizmy śledzące i w miarę możliwości poprawiać bezpieczeństwo połączenia 🔒" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Está tudo pronto! Podes encontrar-me na Dock em qualquer altura.\nQueres ver como te protejo? Experimenta visitar um dos teus sites favoritos 👆\n\nContinua a observar a barra de endereço à medida que vais avançando. Vou bloquear os rastreadores e melhorar a segurança da tua ligação sempre que possível 🔒" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Готово! Теперь меня всегда можно найти на док-панели.\nВам интересно, как я защищаю вашу конфиденциальность? Зайдите на свой любимый сайт...👆\n\nИ следите за адресной строкой. По возможности я заблокирую все трекеры и сделаю соединение более безопасным {00A0}🔒" } } @@ -39223,55 +38967,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Du bist bereit!\n\nMöchtest du sehen, wie ich dich beschütze? Versuche, eine deiner Lieblingsseiten zu besuchen 👆\n\nBehalte die Adressleiste im Auge. Ich werde Tracker blockieren und die Sicherheit deiner Verbindung verbessern, wenn möglich 🔒" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "You’re all set!\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possibleu{00A0}🔒" + "value" : "You’re all set!\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possible 🔒" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "¡Ya está todo listo!\n\n¿Quieres ver cómo te protejo? Prueba a visitar uno de tus sitios favoritos 👆\n\nSigue viendo la barra de direcciones sobre la marcha. Bloquearé los rastreadores y mejoraré la seguridad de tu conexión cuando sea posible 🔒" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Tout est prêt !\n\nVous voulez voir comment je vous protège ? Essayez de visiter l'un de vos sites préférés 👆\n\n Continuez à regarder la barre d'adresse au fur et à mesure. Je bloquerai les traqueurs et mettrai à niveau la sécurité de votre connexion si possible 🔒" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Tutto pronto.\n\nVuoi vedere come ti proteggo? Prova a visitare uno dei tuoi siti preferiti 👆Continua a controllare la barra degli indirizzi mentre esplori. Bloccherò i sistemi di tracciamento e aggiornerò la sicurezza della tua connessione quando possibile 🔒" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Je bent helemaal klaar!\n\nWil je zien hoe ik je bescherm? Ga eens naar een van je favoriete websites 👆 \n\n Kijk tijdens het surfen goed naar de adresbalk. Ik blokkeer trackers en werk de beveiliging van je verbinding bij wanneer mogelijk 🔒" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Wszystko gotowe!\n\nChcesz zobaczyć, jak Cię chronię? Spróbuj odwiedzić jedną z ulubionych stron 👆\n\nW międzyczasie obserwuj pasek adresu. Będę blokować mechanizmy śledzące i w miarę możliwości poprawiać bezpieczeństwo połączenia 🔒" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Estás tudo pronto!\n\nQueres ver como te protejo? Experimenta visitar um dos teus sites favoritos 👆\n\nContinua a observar a barra de endereço à medida que vais avançando. Vou bloquear os rastreadores e melhorar a segurança da tua ligação sempre que possível 🔒" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Все готово!\n\nХотите увидеть, как я вас защищаю? Зайдите на один из любимых сайтов 👆\n\nСледите за адресной строкой. Я по возможности буду блокировать трекеры и обеспечивать вам более безопасное соединение 🔒" } } @@ -44586,59 +44330,11 @@ "comment" : "Menu item for hiding the AI Chat shortcut", "extractionState" : "extracted_with_value", "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI-Chat-Verknüpfung ausblenden" - } - }, "en" : { "stringUnit" : { "state" : "new", "value" : "Hide AI Chat Shortcut" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ocultar acceso directo a AI Chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Masquer le raccourci de chat IA" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nascondi scorciatoia AI Chat" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Snelkoppeling voor AI-chat verbergen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ukryj skrót do AI Chat" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ocultar atalho do AI Chat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скрыть ярлык AI Chat" - } } } }, @@ -44886,59 +44582,11 @@ "comment" : "Menu item for showing the AI Chat shortcut", "extractionState" : "extracted_with_value", "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "AI-Chat-Verknüpfung anzeigen" - } - }, "en" : { "stringUnit" : { "state" : "new", "value" : "Show AI Chat Shortcut" } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostrar acceso directo a AI Chat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afficher le raccourci AI Chat" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostra scorciatoia AI Chat" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Snelkoppeling voor AI Chat weergeven" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pokaż skrót do AI Chat" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostrar atalho do AI Chat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Показывать ярлык AI Chat" - } } } }, @@ -51354,6 +51002,66 @@ } } }, + "preferences.about.duckduckgo-tagline" : { + "comment" : "About screen", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Schutz, unsere Priorität." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your protection, our priority." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu protección, nuestra prioridad." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre protection, notre priorité." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua protezione, la nostra priorità." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jouw bescherming, onze prioriteit." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoje bezpieczeństwo jest naszym priorytetem." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "A tua proteção, a nossa prioridade." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша защита — наш приоритет." + } + } + } + }, "preferences.about.more-at" : { "comment" : "Link to the about page", "extractionState" : "extracted_with_value", @@ -51476,7 +51184,7 @@ }, "preferences.about.privacy-simplified" : { "comment" : "About screen", - "extractionState" : "extracted_with_value", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -57237,6 +56945,66 @@ } } }, + "settings.downloading.update" : { + "comment" : "Label informing users the app is currently downloading the update. This will contain a percentage", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update wird heruntergeladen %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloading update %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargando actualización %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Téléchargement de la mise à jour %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download dell'aggiornamento %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update %@ downloaden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pobieranie aktualizacji %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "A transferir a atualização %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка обновления %@" + } + } + } + }, "settings.last.checked" : { "comment" : "Label informing users what is the last time the app checked for the update.", "extractionState" : "extracted_with_value", @@ -57357,6 +57125,66 @@ } } }, + "settings.newer.critical.update.available" : { + "comment" : "Label informing users the critical update of the app is available to install.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisches Update erforderlich" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Critical update needed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se necesita una actualización crítica" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une mise à jour critique est nécessaire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento critico necessario" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritieke update nodig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wymagana krytyczna aktualizacja" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualização crítica necessária" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется критическое обновление" + } + } + } + }, "settings.newer.version.available" : { "comment" : "Label informing users the newer version of the app is available to install.", "extractionState" : "extracted_with_value", @@ -57417,8 +57245,68 @@ } } }, + "settings.preparing.update" : { + "comment" : "Label informing users the app is preparing to update.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update vorbereiten" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Preparing update" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparando la actualización" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préparation de la mise à jour" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparazione dell'aggiornamento" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update voorbereiden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przygotowanie aktualizacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "A preparar a atualização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подготовка обновления" + } + } + } + }, "settings.restart.to.update" : { - "comment" : "Button label trigering restart and update of the application.", + "comment" : "Button label triggering restart and update of the application.", "extractionState" : "extracted_with_value", "localizations" : { "de" : { @@ -57430,7 +57318,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Restart to Update" + "value" : "Restart To Update" } }, "es" : { @@ -57477,6 +57365,126 @@ } } }, + "settings.retry.update" : { + "comment" : "Button label triggering a retry of the update.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update nochmal versuchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Retry Update" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reintentar actualización" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réessayer la mise à jour" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riprova l'aggiornamento" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update opnieuw proberen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ponów próbę aktualizacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repetir atualização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить попытку обновления" + } + } + } + }, + "settings.run.update" : { + "comment" : "Button label triggering update of the application.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DuckDuckGo aktualisieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Update DuckDuckGo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizar DuckDuckGo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre à jour DuckDuckGo" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiorna DuckDuckGo" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "DuckDuckGo bijwerken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaktualizuj DuckDuckGo" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizar o DuckDuckGo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить DuckDuckGo" + } + } + } + }, "settings.up.to.date" : { "comment" : "Label informing users the app is currently up to date and no update is required.", "extractionState" : "extracted_with_value", @@ -57537,6 +57545,66 @@ } } }, + "settings.update.failed" : { + "comment" : "Label informing users the app is unable to update.", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update fehlgeschlagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Update failed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización fallida" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de la mise à jour" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento non riuscito" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niepowodzenie aktualizacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "A atualização falhou" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось обновить" + } + } + } + }, "share.menu.item" : { "comment" : "Menu item title", "extractionState" : "extracted_with_value", @@ -62131,61 +62199,61 @@ } }, "update.available.menu.item" : { - "comment" : "Title of the menu item that informs user that a new update is available. Clicking on the menu item restarts the app and installs the update", + "comment" : "Title of the menu item that informs user that a new update is available. Clicking on the menu item installs the update", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Update verfügbar – Jetzt neu starten" + "value" : "Update verfügbar – jetzt installieren" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Update Available - Restart Now" + "value" : "Update Available - Install Now" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Actualización disponible - Reiniciar ahora" + "value" : "Actualización disponible - Instalar ahora" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mise à jour disponible : redémarrer" + "value" : "Mise à jour disponible : installer" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Aggiornamento disponibile - Riavvia ora" + "value" : "Aggiornamento disponibile - Installa ora" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Update beschikbaar - nu opnieuw opstarten" + "value" : "Update beschikbaar - nu installeren" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Dostępna aktualizacja — uruchom ponownie teraz" + "value" : "Dostępna aktualizacja — zainstaluj teraz" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Atualização disponível – Reiniciar agora" + "value" : "Atualização disponível – Instalar agora" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Доступно обновление: перезапустить" + "value" : "Доступно обновление – установить сейчас" } } } diff --git a/DuckDuckGo/MainWindow/MainWindowController.swift b/DuckDuckGo/MainWindow/MainWindowController.swift index 5911d9c289..a21f293ae6 100644 --- a/DuckDuckGo/MainWindow/MainWindowController.swift +++ b/DuckDuckGo/MainWindow/MainWindowController.swift @@ -74,8 +74,12 @@ final class MainWindowController: NSWindowController { return false #elseif REVIEW if Application.runType == .uiTests { + Application.appDelegate.onboardingStateMachine.state = .onboardingCompleted return false } else { + if Application.runType == .uiTestsOnboarding { + Application.appDelegate.onboardingStateMachine.state = .onboardingCompleted + } let onboardingIsComplete = OnboardingViewModel.isOnboardingFinished || LocalStatisticsStore().waitlistUnlocked return !onboardingIsComplete } diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 0adfe345ee..4b89758f67 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -618,6 +618,7 @@ final class MainMenu: NSMenu { NSMenuItem(title: "150 Tabs", action: #selector(MainViewController.addDebugTabs(_:)), representedObject: 150) } } + NSMenuItem(title: "Skip Onboarding", action: #selector(MainViewController.skipOnboarding)) NSMenuItem(title: "Reset Data") { NSMenuItem(title: "Reset Default Browser Prompt", action: #selector(MainViewController.resetDefaultBrowserPrompt)) NSMenuItem(title: "Reset Default Grammar Checks", action: #selector(MainViewController.resetDefaultGrammarChecks)) @@ -670,6 +671,8 @@ final class MainMenu: NSMenu { NSMenuItem(title: "Personal Information Removal") .submenu(DataBrokerProtectionDebugMenu()) + FreemiumDebugMenu() + if case .normal = NSApp.runType { NSMenuItem(title: "VPN") .submenu(NetworkProtectionDebugMenu()) diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 807390ca52..41b56eccbe 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -850,6 +850,13 @@ extension MainViewController { UserDefaults.standard.set(true, forKey: UserDefaultsWrapper.Key.homePageShowEmailProtection.rawValue) } + @objc func skipOnboarding(_ sender: Any?) { + UserDefaults.standard.set(true, forKey: UserDefaultsWrapper.Key.onboardingFinished.rawValue) + Application.appDelegate.onboardingStateMachine.state = .onboardingCompleted + WindowControllersManager.shared.updatePreventUserInteraction(prevent: false) + WindowControllersManager.shared.replaceTabWith(Tab(content: .newtab)) + } + @objc func resetOnboarding(_ sender: Any?) { UserDefaults.standard.set(false, forKey: UserDefaultsWrapper.Key.onboardingFinished.rawValue) } diff --git a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift index 44e6cf951e..63766e5838 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift @@ -61,7 +61,7 @@ final class AddressBarButtonsViewController: NSViewController { @IBOutlet weak var imageButtonWrapper: NSView! @IBOutlet weak var imageButton: NSButton! @IBOutlet weak var clearButton: NSButton! - @IBOutlet weak var buttonsContainer: NSStackView! + @IBOutlet private weak var buttonsContainer: NSStackView! @IBOutlet weak var animationWrapperView: NSView! var trackerAnimationView1: LottieAnimationView! @@ -72,7 +72,7 @@ final class AddressBarButtonsViewController: NSViewController { @IBOutlet weak var notificationAnimationView: NavigationBarBadgeAnimationView! - @IBOutlet weak var permissionButtons: NSView! + @IBOutlet private weak var permissionButtons: NSView! @IBOutlet weak var cameraButton: PermissionButton! { didSet { cameraButton.isHidden = true @@ -368,7 +368,7 @@ final class AddressBarButtonsViewController: NSViewController { return } } - guard button.isShown, permissionButtons.isShown else { return } + guard button.isVisible else { return } (popover.contentViewController as? PermissionAuthorizationViewController)?.query = query popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY) diff --git a/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift b/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift index 1be1ba6e06..66610ce9b3 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift @@ -1234,11 +1234,6 @@ extension AddressBarTextField: SuggestionViewControllerDelegate { navigate(suggestion: suggestion) } - func shouldCloseSuggestionWindow(forMouseEvent event: NSEvent) -> Bool { - // don't hide suggestions if clicking somewhere inside the Address Bar view - return superview?.isMouseLocationInsideBounds(event.locationInWindow) != true - } - } extension Notification.Name { diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 737d922c3b..c0625778b8 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -24,6 +24,8 @@ import PixelKit import NetworkProtection import Subscription import os.log +import Freemium +import DataBrokerProtection protocol OptionsButtonMenuDelegate: AnyObject { @@ -47,7 +49,7 @@ protocol OptionsButtonMenuDelegate: AnyObject { func optionsButtonMenuRequestedIdentityTheftRestoration(_ menu: NSMenu) } -final class MoreOptionsMenu: NSMenu { +final class MoreOptionsMenu: NSMenu, NSMenuDelegate { weak var actionDelegate: OptionsButtonMenuDelegate? @@ -59,11 +61,20 @@ final class MoreOptionsMenu: NSMenu { private lazy var sharingMenu: NSMenu = SharingMenu(title: UserText.shareMenuItem) private var accountManager: AccountManager { subscriptionManager.accountManager } private let subscriptionManager: SubscriptionManager + private let freemiumDBPUserStateManager: FreemiumDBPUserStateManager + private let freemiumDBPFeature: FreemiumDBPFeature + private let freemiumDBPPresenter: FreemiumDBPPresenter + private let appearancePreferences: AppearancePreferences + + private let notificationCenter: NotificationCenter private let vpnFeatureGatekeeper: VPNFeatureGatekeeper private let subscriptionFeatureAvailability: SubscriptionFeatureAvailability private let aiChatMenuConfiguration: AIChatMenuVisibilityConfigurable + /// The `FreemiumDBPExperimentPixelHandler` instance used to fire pixels + private let freemiumDBPExperimentPixelHandler: EventMapping + required init(coder: NSCoder) { fatalError("MoreOptionsMenu: Bad initializer") } @@ -77,6 +88,12 @@ final class MoreOptionsMenu: NSMenu { sharingMenu: NSMenu? = nil, internalUserDecider: InternalUserDecider, subscriptionManager: SubscriptionManager, + freemiumDBPUserStateManager: FreemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp), + freemiumDBPFeature: FreemiumDBPFeature, + freemiumDBPPresenter: FreemiumDBPPresenter = DefaultFreemiumDBPPresenter(), + appearancePreferences: AppearancePreferences = .shared, + notificationCenter: NotificationCenter = .default, + freemiumDBPExperimentPixelHandler: EventMapping = FreemiumDBPExperimentPixelHandler(), aiChatMenuConfiguration: AIChatMenuVisibilityConfigurable = AIChatMenuConfiguration()) { self.tabCollectionViewModel = tabCollectionViewModel @@ -86,6 +103,12 @@ final class MoreOptionsMenu: NSMenu { self.subscriptionFeatureAvailability = subscriptionFeatureAvailability self.internalUserDecider = internalUserDecider self.subscriptionManager = subscriptionManager + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager + self.freemiumDBPFeature = freemiumDBPFeature + self.freemiumDBPPresenter = freemiumDBPPresenter + self.appearancePreferences = appearancePreferences + self.notificationCenter = notificationCenter + self.freemiumDBPExperimentPixelHandler = freemiumDBPExperimentPixelHandler self.aiChatMenuConfiguration = aiChatMenuConfiguration super.init(title: "") @@ -95,6 +118,8 @@ final class MoreOptionsMenu: NSMenu { } self.emailManager.requestDelegate = self + delegate = self + setupMenuItems() } @@ -139,7 +164,7 @@ final class MoreOptionsMenu: NSMenu { addItem(NSMenuItem.separator()) - addSubscriptionItems() + addSubscriptionAndFreemiumDBPItems() addPageItems() @@ -264,6 +289,19 @@ final class MoreOptionsMenu: NSMenu { actionDelegate?.optionsButtonMenuRequestedIdentityTheftRestoration(self) } + @MainActor + @objc func openFreemiumDBP(_ sender: NSMenuItem) { + + if freemiumDBPUserStateManager.didPostFirstProfileSavedNotification { + freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.overFlowResults) + } else { + freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.overFlowScan) + } + + freemiumDBPPresenter.showFreemiumDBPAndSetActivated(windowControllerManager: WindowControllersManager.shared) + notificationCenter.post(name: .freemiumDBPEntryPointActivated, object: nil) + } + @MainActor @objc func findInPage(_ sender: NSMenuItem) { tabCollectionViewModel.selectedTabViewModel?.showFindInPage() @@ -276,8 +314,10 @@ final class MoreOptionsMenu: NSMenu { private func addUpdateItem() { #if SPARKLE guard NSApp.runType != .uiTests, - let update = Application.appDelegate.updateController.latestUpdate, - !update.isInstalled + let updateController = Application.appDelegate.updateController, + let update = updateController.latestUpdate, + !update.isInstalled, + updateController.updateProgress.isDone else { return } @@ -345,6 +385,14 @@ final class MoreOptionsMenu: NSMenu { addItem(NSMenuItem.separator()) } + @MainActor + private func addSubscriptionAndFreemiumDBPItems() { + addSubscriptionItems() + addFreemiumDBPItem() + + addItem(NSMenuItem.separator()) + } + @MainActor private func addSubscriptionItems() { guard subscriptionFeatureAvailability.isFeatureAvailable else { return } @@ -363,17 +411,27 @@ final class MoreOptionsMenu: NSMenu { // Do not add for App Store when purchase not available in the region if !shouldHideDueToNoProduct() { addItem(privacyProItem) - addItem(NSMenuItem.separator()) } } else { privacyProItem.submenu = SubscriptionSubMenu(targeting: self, subscriptionFeatureAvailability: DefaultSubscriptionFeatureAvailability(), accountManager: accountManager) addItem(privacyProItem) - addItem(NSMenuItem.separator()) } } + @MainActor + private func addFreemiumDBPItem() { + guard freemiumDBPFeature.isAvailable else { return } + + let freemiumDBPItem = NSMenuItem(title: UserText.freemiumDBPOptionsMenuItem).withImage(.dbpIcon) + + freemiumDBPItem.target = self + freemiumDBPItem.action = #selector(openFreemiumDBP(_:)) + + addItem(freemiumDBPItem) + } + @MainActor private func addPageItems() { guard let tabViewModel = tabCollectionViewModel.selectedTabViewModel, @@ -425,6 +483,14 @@ final class MoreOptionsMenu: NSMenu { return networkProtectionItem } + func menuWillOpen(_ menu: NSMenu) { +#if SPARKLE + guard let updateController = Application.appDelegate.updateController else { return } + if updateController.hasPendingUpdate && updateController.needsNotificationDot { + updateController.needsNotificationDot = false + } +#endif + } } final class EmailOptionsButtonSubMenu: NSMenu { diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift index 4e2934e4be..a81893b20e 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift @@ -40,6 +40,9 @@ final class MoreOptionsMenuButton: MouseOverButton { var isNotificationVisible: Bool = false { didSet { updateNotificationVisibility() +#if SPARKLE + needsDisplay = isNotificationVisible != oldValue +#endif } } @@ -50,8 +53,8 @@ final class MoreOptionsMenuButton: MouseOverButton { if NSApp.runType != .uiTests { updateController = Application.appDelegate.updateController } -#endif subscribeToUpdateInfo() +#endif } override func updateLayer() { @@ -61,10 +64,11 @@ final class MoreOptionsMenuButton: MouseOverButton { private func subscribeToUpdateInfo() { #if SPARKLE - cancellable = updateController?.isUpdateAvailableToInstallPublisher + guard let updateController else { return } + cancellable = Publishers.CombineLatest(updateController.hasPendingUpdatePublisher, updateController.notificationDotPublisher) .receive(on: DispatchQueue.main) - .sink { [weak self] isAvailable in - self?.isNotificationVisible = isAvailable + .sink { [weak self] hasPendingUpdate, needsNotificationDot in + self?.isNotificationVisible = hasPendingUpdate && needsNotificationDot } #endif } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard index 3e5fa0e070..2fb09b93d1 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard +++ b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard @@ -621,7 +621,7 @@ + - + + + + + + + + @@ -176,7 +207,9 @@ + + @@ -224,7 +257,7 @@ - + @@ -285,6 +318,7 @@ + diff --git a/DuckDuckGo/Suggestions/View/SuggestionTableCellView.swift b/DuckDuckGo/Suggestions/View/SuggestionTableCellView.swift index 2f7a2823f0..a66ac1a3af 100644 --- a/DuckDuckGo/Suggestions/View/SuggestionTableCellView.swift +++ b/DuckDuckGo/Suggestions/View/SuggestionTableCellView.swift @@ -19,6 +19,7 @@ import Cocoa import Common import os.log +import Suggestions final class SuggestionTableCellView: NSTableCellView { @@ -31,25 +32,39 @@ final class SuggestionTableCellView: NSTableCellView { static let selectedTintColor: NSColor = .selectedSuggestionTint @IBOutlet weak var iconImageView: NSImageView! + @IBOutlet weak var removeButton: NSButton! @IBOutlet weak var suffixTextField: NSTextField! + @IBOutlet weak var suffixTrailingConstraint: NSLayoutConstraint! + + var suggestion: Suggestion? override func awakeFromNib() { suffixTextField.textColor = Self.suffixColor + removeButton.toolTip = UserText.removeSuggestionTooltip + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + updateDeleteImageViewVisibility() } var isSelected: Bool = false { didSet { - updateIconImageView() + updateImageViews() updateTextField() + updateDeleteImageViewVisibility() } } var isBurner: Bool = false func display(_ suggestionViewModel: SuggestionViewModel) { + self.suggestion = suggestionViewModel.suggestion attributedString = suggestionViewModel.tableCellViewAttributedString iconImageView.image = suggestionViewModel.icon suffixTextField.stringValue = suggestionViewModel.suffix + setRemoveButtonHidden(true) updateTextField() } @@ -76,8 +91,28 @@ final class SuggestionTableCellView: NSTableCellView { } } - private func updateIconImageView() { + private func updateImageViews() { iconImageView.contentTintColor = isSelected ? Self.selectedTintColor : Self.iconColor + removeButton.contentTintColor = isSelected ? Self.selectedTintColor : Self.iconColor + } + + func updateDeleteImageViewVisibility() { + guard let window = window else { return } + let mouseLocation = NSEvent.mouseLocation + let windowFrameInScreen = window.frame + + // If the suggestion is based on history, if the mouse is inside the window's frame and + // the suggestion is selected, show the delete button + if let suggestion, suggestion.isHistoryEntry, windowFrameInScreen.contains(mouseLocation) { + setRemoveButtonHidden(!isSelected) + } else { + setRemoveButtonHidden(true) + } + } + + private func setRemoveButtonHidden(_ hidden: Bool) { + removeButton.isHidden = hidden + suffixTrailingConstraint.priority = hidden ? .required : .defaultLow } } diff --git a/DuckDuckGo/Suggestions/View/SuggestionViewController.swift b/DuckDuckGo/Suggestions/View/SuggestionViewController.swift index 7d49e13c0d..f7a6765e40 100644 --- a/DuckDuckGo/Suggestions/View/SuggestionViewController.swift +++ b/DuckDuckGo/Suggestions/View/SuggestionViewController.swift @@ -18,10 +18,11 @@ import Cocoa import Combine +import History +import Suggestions protocol SuggestionViewControllerDelegate: AnyObject { - func shouldCloseSuggestionWindow(forMouseEvent event: NSEvent) -> Bool func suggestionViewControllerDidConfirmSelection(_ suggestionViewController: SuggestionViewController) } @@ -90,6 +91,15 @@ final class SuggestionViewController: NSViewController { clearSelection() } + override func viewDidLayout() { + super.viewDidLayout() + + // Make sure the table view width equals the encapsulating scroll view + tableView.sizeToFit() + let column = tableView.tableColumns.first + column?.width = tableView.frame.width + } + private func setupTableView() { tableView.style = .plain tableView.setAccessibilityIdentifier("SuggestionViewController.tableView") @@ -105,18 +115,23 @@ final class SuggestionViewController: NSViewController { tableView.addTrackingArea(trackingArea) } - private func addEventMonitors() { - eventMonitorCancellables.removeAll() + @IBAction func confirmButtonAction(_ sender: NSButton) { + delegate?.suggestionViewControllerDidConfirmSelection(self) + closeWindow() + } - NSEvent.addLocalCancellableMonitor(forEventsMatching: [.leftMouseUp, .rightMouseUp]) { [weak self] event in - guard let self else { return event } - return self.mouseUp(with: event) - }.store(in: &eventMonitorCancellables) + @IBAction func removeButtonAction(_ sender: NSButton) { + guard let cell = sender.superview as? SuggestionTableCellView, + let suggestion = cell.suggestion else { + assertionFailure("Correct cell or url are not available") + return + } - NSEvent.addLocalCancellableMonitor(forEventsMatching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in - guard let self else { return event } - return self.mouseDown(with: event) - }.store(in: &eventMonitorCancellables) + removeHistory(for: suggestion) + } + + private func addEventMonitors() { + eventMonitorCancellables.removeAll() NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification).sink { [weak self] _ in self?.closeWindow() @@ -140,6 +155,10 @@ final class SuggestionViewController: NSViewController { } private func displayNewSuggestions() { + defer { + selectedRowCache = nil + } + guard suggestionContainerViewModel.numberOfSuggestions > 0 else { closeWindow() tableView.reloadData() @@ -150,12 +169,24 @@ final class SuggestionViewController: NSViewController { if suggestionContainerViewModel.suggestionContainer.result != nil { updateHeight() tableView.reloadData() + + // Select at the same position where the suggestion was removed + if let selectedRowCache = selectedRowCache { + suggestionContainerViewModel.select(at: selectedRowCache) + } + self.selectRow(at: self.suggestionContainerViewModel.selectionIndex) } } private func selectRow(at index: Int?) { - if tableView.selectedRow == index { return } + if tableView.selectedRow == index { + if let index, let cell = tableView.view(atColumn: 0, row: index, makeIfNecessary: false) as? SuggestionTableCellView { + // Show the delete button if necessary + cell.updateDeleteImageViewVisibility() + } + return + } guard let index = index, index >= 0, @@ -186,28 +217,6 @@ final class SuggestionViewController: NSViewController { clearSelection() } - func mouseDown(with event: NSEvent) -> NSEvent? { - if event.window === view.window { - return nil - } - if delegate?.shouldCloseSuggestionWindow(forMouseEvent: event) ?? true { - closeWindow() - } - - return event - } - - func mouseUp(with event: NSEvent) -> NSEvent? { - if event.window === view.window, - tableView.isMouseLocationInsideBounds(event.locationInWindow) { - - delegate?.suggestionViewControllerDidConfirmSelection(self) - closeWindow() - return nil - } - return event - } - private func updateHeight() { guard suggestionContainerViewModel.numberOfSuggestions > 0 else { tableViewHeightConstraint.constant = 0 @@ -230,6 +239,32 @@ final class SuggestionViewController: NSViewController { window.orderOut(nil) } + var selectedRowCache: Int? + + private func removeHistory(for suggestion: Suggestion) { + assert(suggestion.isHistoryEntry) + + guard let url = suggestion.url else { + assertionFailure("URL not available") + return + } + + selectedRowCache = tableView.selectedRow + + HistoryCoordinator.shared.removeUrlEntry(url) { [weak self] error in + guard let self = self, error == nil else { + return + } + + if let userStringValue = suggestionContainerViewModel.userStringValue { + suggestionContainerViewModel.isTopSuggestionSelectionExpected = false + self.suggestionContainerViewModel.suggestionContainer.getSuggestions(for: userStringValue, useCachedData: true) + } else { + self.suggestionContainerViewModel.removeSuggestionFromResult(suggestion: suggestion) + } + } + } + } extension SuggestionViewController: NSTableViewDataSource { diff --git a/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift b/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift index 37dc1bd40d..2324502656 100644 --- a/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift +++ b/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift @@ -49,7 +49,7 @@ final class SuggestionContainerViewModel { private(set) var userStringValue: String? - private var isTopSuggestionSelectionExpected = false + var isTopSuggestionSelectionExpected = false private enum IgnoreTopSuggestionError: Error { case emptyResult @@ -181,4 +181,19 @@ final class SuggestionContainerViewModel { select(at: newIndex) } + func removeSuggestionFromResult(suggestion: Suggestion) { + let topHits = suggestionContainer.result?.topHits.filter({ + !($0 == suggestion && $0.isHistoryEntry) + }) ?? [] + let duckduckgoSuggestions = suggestionContainer.result?.duckduckgoSuggestions ?? [] + let localSuggestions = suggestionContainer.result?.localSuggestions.filter({ + !($0 == suggestion && $0.isHistoryEntry) + }) ?? [] + let result = SuggestionResult(topHits: topHits, + duckduckgoSuggestions: duckduckgoSuggestions, + localSuggestions: localSuggestions) + + suggestionContainer.result = result + } + } diff --git a/DuckDuckGo/Sync/SyncDiagnosisHelper.swift b/DuckDuckGo/Sync/SyncDiagnosisHelper.swift new file mode 100644 index 0000000000..dcce535fe3 --- /dev/null +++ b/DuckDuckGo/Sync/SyncDiagnosisHelper.swift @@ -0,0 +1,63 @@ +// +// SyncDiagnosisHelper.swift +// +// 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 DDGSync +import PixelKit + +struct SyncDiagnosisHelper { + private let userDefaults = UserDefaults.standard + private let syncService: DDGSyncing + + @UserDefaultsWrapper(key: .syncManuallyDisabledKey) + private var syncManuallyDisabled: Bool? + + @UserDefaultsWrapper(key: .syncWasDisabledUnexpectedlyPixelFiredKey, defaultValue: false) + private var syncWasDisabledUnexpectedlyPixelFired: Bool + + init(syncService: DDGSyncing) { + self.syncService = syncService + } + +// Non-user-initiated deactivation +// For events to help understand the impact of https://app.asana.com/0/1201493110486074/1208538487332133/f + + func didManuallyDisableSync() { + syncManuallyDisabled = true + } + + func diagnoseAccountStatus() { + if syncService.account == nil { + // Nil value means sync was never on in the first place. So don't fire in this case. + if syncManuallyDisabled == false, + !syncWasDisabledUnexpectedlyPixelFired { + PixelKit.fire(DebugEvent(GeneralPixel.syncDebugWasDisabledUnexpectedly), frequency: .dailyAndCount) + syncWasDisabledUnexpectedlyPixelFired = true + } + } else { + syncManuallyDisabled = false + syncWasDisabledUnexpectedlyPixelFired = false + } + } + +} + +extension UserDefaultsWrapper.DefaultsKey { + static let syncManuallyDisabledKey = Self(rawValue: "com.duckduckgo.app.key.debug.SyncManuallyDisabled") + static let syncWasDisabledUnexpectedlyPixelFiredKey = Self(rawValue: "com.duckduckgo.app.key.debug.SyncWasDisabledUnexpectedlyPixelFired") +} diff --git a/DuckDuckGo/Tab/Model/SystemInfo.swift b/DuckDuckGo/Tab/Model/SystemInfo.swift new file mode 100644 index 0000000000..58240a11e5 --- /dev/null +++ b/DuckDuckGo/Tab/Model/SystemInfo.swift @@ -0,0 +1,76 @@ +// +// SystemInfo.swift +// +// 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 Common + +#if !APPSTORE + +final class SystemInfo { + + static func pixelParameters(appVersion: AppVersion = AppVersion.shared) async -> [String: String] { + let availableMemoryPercent = Self.getAvailableMemoryPercent() + let availableDiskSpacePercent = Self.getAvailableDiskSpacePercent() + return [ + "available_memory": String(availableMemoryPercent), + "available_diskspace": String(format: "%.2f", availableDiskSpacePercent), + "os_version": appVersion.osVersion, + ] + } + + static func getAvailableMemoryPercent() -> Int { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/memory_pressure") + + let pipe = Pipe() + process.standardOutput = pipe + + do { + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let output = String(data: data, encoding: .utf8) { + // Should be the last line, but just in case search for it explicitly + let lines = output.split(separator: "\n") + if let memoryLine = lines.first(where: { $0.contains("System-wide memory free percentage") }), + let range = memoryLine.range(of: "\\d+", options: .regularExpression), + let percentage = Int(memoryLine[range]) { + return percentage + } + } + } catch { + assertionFailure("Unable to run memory_pressure") + } + + return -1 + } + + static func getAvailableDiskSpacePercent() -> Double { + guard let attributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), + let totalSpace = attributes[.systemSize] as? UInt64, + let freeSpace = attributes[.systemFreeSize] as? UInt64 else { + return -1.0 + } + + return Double(freeSpace) / Double(totalSpace) * 100 + } + +} + +#endif diff --git a/DuckDuckGo/Tab/Model/Tab.swift b/DuckDuckGo/Tab/Model/Tab.swift index af5691c907..e4449ac5bb 100644 --- a/DuckDuckGo/Tab/Model/Tab.swift +++ b/DuckDuckGo/Tab/Model/Tab.swift @@ -1274,7 +1274,15 @@ extension Tab/*: NavigationResponder*/ { // to be moved to Tab+Navigation.swift loadErrorHTML(error, header: UserText.webProcessCrashPageHeader, forUnreachableURL: url, alternate: true) } - PixelKit.fire(DebugEvent(GeneralPixel.webKitDidTerminate, error: error)) + Task { +#if APPSTORE + let additionalParameters = [String: String]() +#else + let additionalParameters = await SystemInfo.pixelParameters() +#endif + + PixelKit.fire(DebugEvent(GeneralPixel.webKitDidTerminate, error: error), withAdditionalParameters: additionalParameters) + } } @MainActor diff --git a/DuckDuckGo/Tab/Services/WebsiteDataStore.swift b/DuckDuckGo/Tab/Services/WebsiteDataStore.swift index 69504c873e..e4c167a284 100644 --- a/DuckDuckGo/Tab/Services/WebsiteDataStore.swift +++ b/DuckDuckGo/Tab/Services/WebsiteDataStore.swift @@ -19,6 +19,7 @@ import Common import WebKit import GRDB +import Subscription import os.log public protocol HTTPCookieStore { @@ -154,7 +155,7 @@ internal class WebCacheManager { // Don't clear fireproof domains let cookiesToRemove = cookies.filter { cookie in - !self.fireproofDomains.isFireproof(cookieDomain: cookie.domain) && cookie.domain != URL.cookieDomain + !self.fireproofDomains.isFireproof(cookieDomain: cookie.domain) && ![URL.cookieDomain, SubscriptionCookieManager.cookieDomain].contains(cookie.domain) } for cookie in cookiesToRemove { diff --git a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift index a6b9f5d3df..a2d90a4a19 100644 --- a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift @@ -24,6 +24,8 @@ import UserScript import Subscription import PixelKit import os.log +import Freemium +import DataBrokerProtection /// Use Subscription sub-feature final class SubscriptionPagesUseSubscriptionFeature: Subfeature { @@ -44,16 +46,31 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { let subscriptionFeatureAvailability: SubscriptionFeatureAvailability + private var freemiumDBPUserStateManager: FreemiumDBPUserStateManager + private let freemiumDBPPixelExperimentManager: FreemiumDBPPixelExperimentManaging + private let notificationCenter: NotificationCenter + + /// The `FreemiumDBPExperimentPixelHandler` instance used to fire pixels + private let freemiumDBPExperimentPixelHandler: EventMapping + public init(subscriptionManager: SubscriptionManager, subscriptionSuccessPixelHandler: SubscriptionAttributionPixelHandler = PrivacyProSubscriptionAttributionPixelHandler(), stripePurchaseFlow: StripePurchaseFlow, uiHandler: SubscriptionUIHandling, - subscriptionFeatureAvailability: SubscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability()) { + subscriptionFeatureAvailability: SubscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(), + freemiumDBPUserStateManager: FreemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp), + freemiumDBPPixelExperimentManager: FreemiumDBPPixelExperimentManaging, + notificationCenter: NotificationCenter = .default, + freemiumDBPExperimentPixelHandler: EventMapping = FreemiumDBPExperimentPixelHandler()) { self.subscriptionManager = subscriptionManager self.stripePurchaseFlow = stripePurchaseFlow self.subscriptionSuccessPixelHandler = subscriptionSuccessPixelHandler self.uiHandler = uiHandler self.subscriptionFeatureAvailability = subscriptionFeatureAvailability + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager + self.freemiumDBPPixelExperimentManager = freemiumDBPPixelExperimentManager + self.notificationCenter = notificationCenter + self.freemiumDBPExperimentPixelHandler = freemiumDBPExperimentPixelHandler } func with(broker: UserScriptMessageBroker) { @@ -142,8 +159,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID) } - DispatchQueue.main.async { - NotificationCenter.default.post(name: .subscriptionPageCloseAndOpenPreferences, object: self) + DispatchQueue.main.async { [weak self] in + self?.notificationCenter.post(name: .subscriptionPageCloseAndOpenPreferences, object: self) } return nil @@ -179,8 +196,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { let message = original - // Extract the origin from the webview URL to use for attribution pixel. - subscriptionSuccessPixelHandler.origin = await originFrom(originalMessage: message) + await setPixelOrigin(from: message) + if subscriptionManager.currentEnvironment.purchasePlatform == .appStore { if #available(macOS 12.0, *) { guard let subscriptionSelection: SubscriptionSelection = DecodableHelper.decode(from: params) else { @@ -257,8 +274,11 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { case .success(let purchaseUpdate): Logger.subscription.info("[Purchase] Purchase complete") PixelKit.fire(PrivacyProPixel.privacyProPurchaseSuccess, frequency: .dailyAndCount) + sendFreemiumSubscriptionPixelIfFreemiumActivated() + saveSubscriptionUpgradeTimestampIfFreemiumActivated() PixelKit.fire(PrivacyProPixel.privacyProSubscriptionActivated, frequency: .unique) subscriptionSuccessPixelHandler.fireSuccessfulSubscriptionAttributionPixel() + sendSubscriptionUpgradeFromFreemiumNotificationIfFreemiumActivated() await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: purchaseUpdate) case .failure(let error): switch error { @@ -276,8 +296,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { subscriptionErrorReporter.report(subscriptionActivationError: .cancelledByUser) case .missingEntitlements: subscriptionErrorReporter.report(subscriptionActivationError: .missingEntitlements) - DispatchQueue.main.async { - NotificationCenter.default.post(name: .subscriptionPageCloseAndOpenPreferences, object: self) + DispatchQueue.main.async { [weak self] in + self?.notificationCenter.post(name: .subscriptionPageCloseAndOpenPreferences, object: self) } await uiHandler.dismissProgressViewController() return nil @@ -337,19 +357,19 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { switch subscriptionFeatureName { case .privateBrowsing: - NotificationCenter.default.post(name: .openPrivateBrowsing, object: self, userInfo: nil) + notificationCenter.post(name: .openPrivateBrowsing, object: self, userInfo: nil) case .privateSearch: - NotificationCenter.default.post(name: .openPrivateSearch, object: self, userInfo: nil) + notificationCenter.post(name: .openPrivateSearch, object: self, userInfo: nil) case .emailProtection: - NotificationCenter.default.post(name: .openEmailProtection, object: self, userInfo: nil) + notificationCenter.post(name: .openEmailProtection, object: self, userInfo: nil) case .appTrackingProtection: - NotificationCenter.default.post(name: .openAppTrackingProtection, object: self, userInfo: nil) + notificationCenter.post(name: .openAppTrackingProtection, object: self, userInfo: nil) case .vpn: PixelKit.fire(PrivacyProPixel.privacyProWelcomeVPN, frequency: .unique) - NotificationCenter.default.post(name: .ToggleNetworkProtectionInMainWindow, object: self, userInfo: nil) + notificationCenter.post(name: .ToggleNetworkProtectionInMainWindow, object: self, userInfo: nil) case .personalInformationRemoval: PixelKit.fire(PrivacyProPixel.privacyProWelcomePersonalInformationRemoval, frequency: .unique) - NotificationCenter.default.post(name: .openPersonalInformationRemoval, object: self, userInfo: nil) + notificationCenter.post(name: .openPersonalInformationRemoval, object: self, userInfo: nil) await uiHandler.showTab(with: .dataBrokerProtection) case .identityTheftRestoration: PixelKit.fire(PrivacyProPixel.privacyProWelcomeIdentityRestoration, frequency: .unique) @@ -366,7 +386,10 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { await uiHandler.dismissProgressViewController() PixelKit.fire(PrivacyProPixel.privacyProPurchaseStripeSuccess, frequency: .dailyAndCount) + sendFreemiumSubscriptionPixelIfFreemiumActivated() + saveSubscriptionUpgradeTimestampIfFreemiumActivated() subscriptionSuccessPixelHandler.fireSuccessfulSubscriptionAttributionPixel() + sendSubscriptionUpgradeFromFreemiumNotificationIfFreemiumActivated() return [String: String]() // cannot be nil, the web app expect something back before redirecting the user to the final page } @@ -465,6 +488,18 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { default: return } } + + // MARK: - Attribution + /// Sets the appropriate origin for the subscription success tracking pixel. + /// + /// - Note: This method is asynchronous when extracting the origin from the webview URL. + private func setPixelOrigin(from message: WKScriptMessage) async { + // If the user has performed a Freemium scan, set a Freemium origin and return + guard !setFreemiumOriginIfScanPerformed() else { return } + + // Else, Extract the origin from the webview URL to use for attribution pixel. + subscriptionSuccessPixelHandler.origin = await originFrom(originalMessage: message) + } } extension SubscriptionPagesUseSubscriptionFeature: SubscriptionAccessActionHandling { @@ -499,3 +534,60 @@ extension SubscriptionPagesUseSubscriptionFeature: SubscriptionAccessActionHandl } } } + +private extension SubscriptionPagesUseSubscriptionFeature { + + /** + Sends a subscription upgrade notification if the freemium state is activated. + + This function checks if the freemium state has been activated by verifying the + `didActivate` property in `freemiumDBPUserStateManager`. If the freemium activation + is detected, it posts a `subscriptionUpgradeFromFreemium` notification via + `notificationCenter`. + + - Important: The notification will only be posted if `didActivate` is `true`. + */ + func sendSubscriptionUpgradeFromFreemiumNotificationIfFreemiumActivated() { + if freemiumDBPUserStateManager.didActivate { + notificationCenter.post(name: .subscriptionUpgradeFromFreemium, object: nil) + } + } + + /// Sends a freemium subscription pixel event if the freemium feature has been activated. + /// + /// This function checks whether the user has activated the freemium feature by querying the `freemiumDBPUserStateManager`. + /// If the feature is activated (`didActivate` returns `true`), it fires a unique subscription-related pixel event using `PixelKit`. + func sendFreemiumSubscriptionPixelIfFreemiumActivated() { + if freemiumDBPUserStateManager.didActivate { + freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.subscription, parameters: freemiumDBPPixelExperimentManager.pixelParameters) + } + } + + /// Saves the current timestamp for a subscription upgrade if the freemium feature has been activated. + /// + /// This function checks whether the user has activated the freemium feature and if the subscription upgrade timestamp + /// has not already been set. If the user has activated the freemium feature and no upgrade timestamp exists, it assigns + /// the current date and time to `freemiumDBPUserStateManager.upgradeToSubscriptionTimestamp`. + func saveSubscriptionUpgradeTimestampIfFreemiumActivated() { + if freemiumDBPUserStateManager.didActivate && freemiumDBPUserStateManager.upgradeToSubscriptionTimestamp == nil { + freemiumDBPUserStateManager.upgradeToSubscriptionTimestamp = Date() + } + } + + /// Sets the origin for attribution if the user has started their first Freemium PIR scan + /// + /// This method checks whether the user has started their first Freemium PIR scan. + /// If they have, the method sets the subscription success tracking origin to `"funnel_pro_mac_freemium"` and returns `true`. + /// + /// - Returns: + /// - `true` if the origin is set because the user has started their first Freemim PIR scan. + /// - `false` if a first scan has not been started and the origin is not set. + func setFreemiumOriginIfScanPerformed() -> Bool { + let origin = PrivacyProSubscriptionAttributionPixelHandler.Consts.freemiumOrigin + if freemiumDBPUserStateManager.didPostFirstProfileSavedNotification { + subscriptionSuccessPixelHandler.origin = origin + return true + } + return false + } +} diff --git a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUserScript.swift b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUserScript.swift index 3e48b083b8..c5ffbb38bd 100644 --- a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUserScript.swift +++ b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUserScript.swift @@ -28,6 +28,7 @@ import PixelKit public extension Notification.Name { static let subscriptionPageCloseAndOpenPreferences = Notification.Name("com.duckduckgo.subscriptionPage.CloseAndOpenPreferences") + static let subscriptionUpgradeFromFreemium = Notification.Name("com.duckduckgo.subscriptionPage.UpgradeFromFreemium") } /// The user script that will be the broker for all subscription features diff --git a/DuckDuckGo/Tab/UserScripts/UserScripts.swift b/DuckDuckGo/Tab/UserScripts/UserScripts.swift index 0804b63aad..970344e775 100644 --- a/DuckDuckGo/Tab/UserScripts/UserScripts.swift +++ b/DuckDuckGo/Tab/UserScripts/UserScripts.swift @@ -120,9 +120,11 @@ final class UserScripts: UserScriptsProvider { let stripePurchaseFlow = DefaultStripePurchaseFlow(subscriptionEndpointService: subscriptionManager.subscriptionEndpointService, authEndpointService: subscriptionManager.authEndpointService, accountManager: subscriptionManager.accountManager) + let freemiumDBPPixelExperimentManager = FreemiumDBPPixelExperimentManager(subscriptionManager: subscriptionManager) let delegate = SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, stripePurchaseFlow: stripePurchaseFlow, - uiHandler: Application.appDelegate.subscriptionUIHandler) + uiHandler: Application.appDelegate.subscriptionUIHandler, + freemiumDBPPixelExperimentManager: freemiumDBPPixelExperimentManager) subscriptionPagesUserScript.registerSubfeature(delegate: delegate) userScripts.append(subscriptionPagesUserScript) diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index a400a683ce..46667bc167 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -26,6 +26,7 @@ import Subscription import PixelKit import os.log import Onboarding +import Freemium protocol BrowserTabViewControllerDelegate: AnyObject { func highlightFireButton() @@ -163,43 +164,7 @@ final class BrowserTabViewController: NSViewController { override func viewDidAppear() { super.viewDidAppear() - NotificationCenter.default.addObserver(self, - selector: #selector(windowWillClose(_:)), - name: NSWindow.willCloseNotification, - object: self.view.window) - - NotificationCenter.default.addObserver(self, - selector: #selector(onDuckDuckGoEmailIncontextSignup), - name: .emailDidIncontextSignup, - object: nil) - - NotificationCenter.default.addObserver(self, - selector: #selector(onCloseDuckDuckGoEmailProtection), - name: .emailDidCloseEmailProtection, - object: nil) - - NotificationCenter.default.addObserver(self, - selector: #selector(onPasswordImportFlowFinish), - name: .passwordImportDidCloseImportDialog, - object: nil) - - NotificationCenter.default.addObserver(self, - selector: #selector(onDBPFeatureDisabled), - name: .dbpWasDisabled, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(onCloseDataBrokerProtection), - name: .dbpDidClose, - object: nil) - - NotificationCenter.default.addObserver(self, - selector: #selector(onCloseSubscriptionPage), - name: .subscriptionPageCloseAndOpenPreferences, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(onSubscriptionAccountDidSignOut), - name: .accountDidSignOut, - object: nil) + subscribeToNotifications() } @objc @@ -301,6 +266,13 @@ final class BrowserTabViewController: NSViewController { } } + @objc + private func onSubscriptionUpgradeFromFreemium(_ notification: Notification) { + Task { @MainActor in + tabCollectionViewModel.removeAll(with: .dataBrokerProtection) + } + } + private func subscribeToSelectedTabViewModel() { tabCollectionViewModel.$selectedTabViewModel .sink { [weak self] selectedTabViewModel in @@ -336,6 +308,50 @@ final class BrowserTabViewController: NSViewController { .sink(receiveValue: setDelegate()) } + private func subscribeToNotifications() { + NotificationCenter.default.addObserver(self, + selector: #selector(windowWillClose(_:)), + name: NSWindow.willCloseNotification, + object: self.view.window) + + NotificationCenter.default.addObserver(self, + selector: #selector(onDuckDuckGoEmailIncontextSignup), + name: .emailDidIncontextSignup, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(onCloseDuckDuckGoEmailProtection), + name: .emailDidCloseEmailProtection, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(onPasswordImportFlowFinish), + name: .passwordImportDidCloseImportDialog, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(onDBPFeatureDisabled), + name: .dbpWasDisabled, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(onCloseDataBrokerProtection), + name: .dbpDidClose, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(onCloseSubscriptionPage), + name: .subscriptionPageCloseAndOpenPreferences, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(onSubscriptionAccountDidSignOut), + name: .accountDidSignOut, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(onSubscriptionUpgradeFromFreemium), + name: .subscriptionUpgradeFromFreemium, + object: nil) + } + private func removeDataBrokerViewIfNecessary() -> ([Tab]) -> Void { { [weak self] (tabs: [Tab]) in guard let self else { return } @@ -840,7 +856,9 @@ final class BrowserTabViewController: NSViewController { var homePageViewController: HomePageViewController? private func homePageViewControllerCreatingIfNeeded() -> HomePageViewController { return homePageViewController ?? { - let homePageViewController = HomePageViewController(tabCollectionViewModel: tabCollectionViewModel, bookmarkManager: bookmarkManager) + let freemiumDBPPromotionViewCoordinator = Application.appDelegate.freemiumDBPPromotionViewCoordinator + let homePageViewController = HomePageViewController(tabCollectionViewModel: tabCollectionViewModel, bookmarkManager: bookmarkManager, + freemiumDBPPromotionViewCoordinator: freemiumDBPPromotionViewCoordinator) self.homePageViewController = homePageViewController return homePageViewController }() @@ -851,7 +869,8 @@ final class BrowserTabViewController: NSViewController { var dataBrokerProtectionHomeViewController: DBPHomeViewController? private func dataBrokerProtectionHomeViewControllerCreatingIfNeeded() -> DBPHomeViewController { return dataBrokerProtectionHomeViewController ?? { - let dataBrokerProtectionHomeViewController = DBPHomeViewController(dataBrokerProtectionManager: DataBrokerProtectionManager.shared) + let freemiumDBPFeature = Application.appDelegate.freemiumDBPFeature + let dataBrokerProtectionHomeViewController = DBPHomeViewController(dataBrokerProtectionManager: DataBrokerProtectionManager.shared, freemiumDBPFeature: freemiumDBPFeature) self.dataBrokerProtectionHomeViewController = dataBrokerProtectionHomeViewController return dataBrokerProtectionHomeViewController }() diff --git a/DuckDuckGo/Updates/AppRestarter.swift b/DuckDuckGo/Updates/AppRestarter.swift deleted file mode 100644 index fa498016f7..0000000000 --- a/DuckDuckGo/Updates/AppRestarter.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// AppRestarter.swift -// -// 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 - -protocol AppRestarting { - - func restart() - -} - -final class AppRestarter: AppRestarting { - - func restart() { - let pid = ProcessInfo.processInfo.processIdentifier - let destinationPath = Bundle.main.bundlePath - - guard isValidApplicationBundle(at: destinationPath) else { - print("Invalid destination path") - return - } - - let preOpenCmd = "/usr/bin/xattr -d -r com.apple.quarantine \(shellQuotedString(destinationPath))" - let openCmd = "/usr/bin/open \(shellQuotedString(destinationPath))" - - let script = """ - (while /bin/kill -0 \(pid) >&/dev/null; do /bin/sleep 0.1; done; \(preOpenCmd); \(openCmd)) & - """ - - let task = Process() - task.launchPath = "/bin/sh" - task.arguments = ["-c", script] - - do { - try task.run() - } catch { - print("Unable to launch the task: \(error)") - return - } - - // Terminate the current app instance - exit(0) - } - - private func isValidApplicationBundle(at path: String) -> Bool { - let fileManager = FileManager.default - var isDirectory: ObjCBool = false - let exists = fileManager.fileExists(atPath: path, isDirectory: &isDirectory) - let isAppBundle = path.hasSuffix(".app") && isDirectory.boolValue - return exists && isAppBundle - } - - private func shellQuotedString(_ string: String) -> String { - // Validate that the string is a valid file path - guard isValidFilePath(string) else { - fatalError("Invalid file path") - } - let escapedString = string.replacingOccurrences(of: "'", with: "'\\''") - return "'\(escapedString)'" - } - - private func isValidFilePath(_ path: String) -> Bool { - // Perform validation to ensure the path is a valid and safe file path - let fileManager = FileManager.default - return fileManager.fileExists(atPath: path) - } - -} diff --git a/DuckDuckGo/Updates/BinaryOwnershipChecker.swift b/DuckDuckGo/Updates/BinaryOwnershipChecker.swift deleted file mode 100644 index e3825f92ae..0000000000 --- a/DuckDuckGo/Updates/BinaryOwnershipChecker.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// BinaryOwnershipChecker.swift -// -// 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 Common -import os.log - -protocol BinaryOwnershipChecking { - func isCurrentUserOwner() -> Bool -} - -/// A class responsible for checking whether the current user owns the binary of the app. -/// The result is cached after the first check to avoid repeated file system access. -final class BinaryOwnershipChecker: BinaryOwnershipChecking { - - private let fileManager: FileManager - private var ownershipCache: Bool? - - init(fileManager: FileManager = .default) { - self.fileManager = fileManager - } - - /// Checks if the current user owns the binary of the currently running app. - /// The method caches the result after the first check to improve performance on subsequent calls. - /// - Returns: `true` if the current user is the owner, `false` otherwise. - func isCurrentUserOwner() -> Bool { - if let cachedResult = ownershipCache { - return cachedResult - } - - guard let binaryPath = Bundle.main.executablePath else { - Logger.updates.debug("Failed to get the binary path") - ownershipCache = false - return false - } - - do { - let attributes = try fileManager.attributesOfItem(atPath: binaryPath) - if let ownerID = attributes[FileAttributeKey.ownerAccountID] as? NSNumber { - let isOwner = ownerID.intValue == getuid() - ownershipCache = isOwner - return isOwner - } - } catch { - Logger.updates.error("Failed to get binary file attributes: \(error.localizedDescription, privacy: .public)") - } - - ownershipCache = false - return false - } -} diff --git a/DuckDuckGo/Updates/ReleaseNotesTabExtension.swift b/DuckDuckGo/Updates/ReleaseNotesTabExtension.swift index 9ad7e59e48..76e06dc537 100644 --- a/DuckDuckGo/Updates/ReleaseNotesTabExtension.swift +++ b/DuckDuckGo/Updates/ReleaseNotesTabExtension.swift @@ -32,6 +32,15 @@ protocol ReleaseNotesUserScriptProvider { extension UserScripts: ReleaseNotesUserScriptProvider {} public struct ReleaseNotesValues: Codable { + enum Status: String { + case loaded + case loading + case updateReady + case updateDownloading + case updatePreparing + case updateError + case criticalUpdateReady + } let status: String let currentVersion: String @@ -40,7 +49,8 @@ public struct ReleaseNotesValues: Codable { let releaseTitle: String? let releaseNotes: [String]? let releaseNotesPrivacyPro: [String]? - + let downloadProgress: Double? + let automaticUpdate: Bool? } final class ReleaseNotesTabExtension: NavigationResponder { @@ -84,7 +94,7 @@ final class ReleaseNotesTabExtension: NavigationResponder { return } let updateController = Application.appDelegate.updateController! - Publishers.CombineLatest(updateController.isUpdateBeingLoadedPublisher, updateController.latestUpdatePublisher) + Publishers.CombineLatest(updateController.updateProgressPublisher, updateController.latestUpdatePublisher) .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self else { return } @@ -93,6 +103,14 @@ final class ReleaseNotesTabExtension: NavigationResponder { .store(in: &cancellables) } + @MainActor + func navigationDidFinish(_ navigation: Navigation) { +#if !DEBUG + guard NSApp.runType != .uiTests, navigation.url.isReleaseNotesScheme else { return } + let updateController = Application.appDelegate.updateController! + updateController.checkForUpdateIfNeeded() +#endif + } } protocol ReleaseNotesTabExtensionProtocol: AnyObject, NavigationResponder {} @@ -107,49 +125,87 @@ extension TabExtensions { extension ReleaseNotesValues { - init(status: String, + init(status: Status, currentVersion: String, - lastUpdate: UInt) { - self.init(status: status, - currentVersion: currentVersion, - latestVersion: nil, - lastUpdate: lastUpdate, - releaseTitle: nil, - releaseNotes: nil, - releaseNotesPrivacyPro: nil) + latestVersion: String? = nil, + lastUpdate: UInt, + releaseTitle: String? = nil, + releaseNotes: [String]? = nil, + releaseNotesPrivacyPro: [String]? = nil, + downloadProgress: Double? = nil, + automaticUpdate: Bool? = nil) { + self.status = status.rawValue + self.currentVersion = currentVersion + self.latestVersion = latestVersion + self.lastUpdate = lastUpdate + self.releaseTitle = releaseTitle + self.releaseNotes = releaseNotes + self.releaseNotesPrivacyPro = releaseNotesPrivacyPro + self.downloadProgress = downloadProgress + self.automaticUpdate = automaticUpdate } init(from updateController: UpdateController?) { let currentVersion = "\(AppVersion().versionNumber) (\(AppVersion().buildNumber))" let lastUpdate = UInt((updateController?.lastUpdateCheckDate ?? Date()).timeIntervalSince1970) - let status: String - let latestVersion: String - guard let updateController, !updateController.isUpdateBeingLoaded else { - self.init(status: "loading", + guard let updateController, let latestUpdate = updateController.latestUpdate else { + self.init(status: updateController?.updateProgress.toStatus ?? .loaded, currentVersion: currentVersion, lastUpdate: lastUpdate) return } - if let latestUpdate = updateController.latestUpdate { - status = latestUpdate.isInstalled ? "loaded" : "updateReady" - latestVersion = "\(latestUpdate.version) (\(latestUpdate.build))" - self.init(status: status, - currentVersion: currentVersion, - latestVersion: latestVersion, - lastUpdate: lastUpdate, - releaseTitle: latestUpdate.title, - releaseNotes: latestUpdate.releaseNotes, - releaseNotesPrivacyPro: latestUpdate.releaseNotesPrivacyPro) - return - } else { - self.init(status: "loaded", - currentVersion: currentVersion, - lastUpdate: lastUpdate) + let updateState = UpdateState(from: updateController.latestUpdate, progress: updateController.updateProgress) + + let status: Status + let downloadProgress: Double? + switch updateState { + case .upToDate: + status = .loaded + downloadProgress = nil + case .updateCycle(let progress): + if updateController.hasPendingUpdate { + status = updateController.latestUpdate?.type == .critical ? .criticalUpdateReady : .updateReady + } else { + status = progress.toStatus + } + downloadProgress = progress.toDownloadProgress } + + self.init(status: status, + currentVersion: currentVersion, + latestVersion: latestUpdate.versionString, + lastUpdate: lastUpdate, + releaseTitle: latestUpdate.title, + releaseNotes: latestUpdate.releaseNotes, + releaseNotesPrivacyPro: latestUpdate.releaseNotesPrivacyPro, + downloadProgress: downloadProgress, + automaticUpdate: updateController.areAutomaticUpdatesEnabled) } +} +private extension Update { + var versionString: String? { + "\(version) \(build)" + } +} + +private extension UpdateCycleProgress { + var toStatus: ReleaseNotesValues.Status { + switch self { + case .updateCycleDidStart: return .loading + case .downloadDidStart, .downloading: return .updateDownloading + case .extractionDidStart, .extracting, .readyToInstallAndRelaunch, .installationDidStart, .installing: return .updatePreparing + case .updaterError: return .updateError + case .updateCycleNotStarted, .updateCycleDone: return .updateReady + } + } + + var toDownloadProgress: Double? { + guard case .downloading(let percentage) = self else { return nil } + return percentage + } } #else diff --git a/DuckDuckGo/Updates/ReleaseNotesUserScript.swift b/DuckDuckGo/Updates/ReleaseNotesUserScript.swift index 199d0da2d0..620c2ff3b9 100644 --- a/DuckDuckGo/Updates/ReleaseNotesUserScript.swift +++ b/DuckDuckGo/Updates/ReleaseNotesUserScript.swift @@ -43,6 +43,7 @@ final class ReleaseNotesUserScript: NSObject, Subfeature { case reportPageException case reportInitException case browserRestart + case retryUpdate } override init() { @@ -57,7 +58,8 @@ final class ReleaseNotesUserScript: NSObject, Subfeature { .initialSetup: initialSetup, .reportPageException: reportPageException, .reportInitException: reportInitException, - .browserRestart: browserRestart + .browserRestart: browserRestart, + .retryUpdate: retryUpdate, ] @MainActor @@ -108,6 +110,14 @@ extension ReleaseNotesUserScript { return InitialSetupResult(env: env, locale: Locale.current.identifier) } + @MainActor + private func retryUpdate(params: Any, original: WKScriptMessage) async throws -> Encodable? { + DispatchQueue.main.async { [weak self] in + self?.updateController.checkForUpdateIfNeeded() + } + return nil + } + struct InitialSetupResult: Encodable { let env: String let locale: String diff --git a/DuckDuckGo/Updates/UpdateController.swift b/DuckDuckGo/Updates/UpdateController.swift index 2d7b4ffec8..f044522f63 100644 --- a/DuckDuckGo/Updates/UpdateController.swift +++ b/DuckDuckGo/Updates/UpdateController.swift @@ -31,17 +31,18 @@ protocol UpdateControllerProtocol: AnyObject { var latestUpdate: Update? { get } var latestUpdatePublisher: Published.Publisher { get } - var isUpdateAvailableToInstall: Bool { get } - var isUpdateAvailableToInstallPublisher: Published.Publisher { get } + var hasPendingUpdate: Bool { get } + var hasPendingUpdatePublisher: Published.Publisher { get } - var isUpdateBeingLoaded: Bool { get } - var isUpdateBeingLoadedPublisher: Published.Publisher { get } + var needsNotificationDot: Bool { get set } + var notificationDotPublisher: AnyPublisher { get } - var lastUpdateCheckDate: Date? { get } + var updateProgress: UpdateCycleProgress { get } + var updateProgressPublisher: Published.Publisher { get } - func checkForUpdate() - func checkForUpdateInBackground() + var lastUpdateCheckDate: Date? { get } + func checkForUpdateIfNeeded() func runUpdate() var areAutomaticUpdatesEnabled: Bool { get set } @@ -59,85 +60,75 @@ final class UpdateController: NSObject, UpdateControllerProtocol { lazy var notificationPresenter = UpdateNotificationPresenter() let willRelaunchAppPublisher: AnyPublisher - @Published private(set) var isUpdateBeingLoaded = false - var isUpdateBeingLoadedPublisher: Published.Publisher { $isUpdateBeingLoaded } - // Struct used to cache data until the updater finishes checking for updates struct UpdateCheckResult { let item: SUAppcastItem let isInstalled: Bool } - private var updateCheckResult: UpdateCheckResult? + private var cachedUpdateResult: UpdateCheckResult? - @Published private(set) var latestUpdate: Update? { + @Published private(set) var updateProgress = UpdateCycleProgress.default { didSet { - if let latestUpdate, !latestUpdate.isInstalled { - if !shouldShowManualUpdateDialog { - switch latestUpdate.type { - case .critical: - notificationPresenter.showUpdateNotification(icon: NSImage.criticalUpdateNotificationInfo, text: UserText.criticalUpdateNotification, presentMultiline: true) - case .regular: - notificationPresenter.showUpdateNotification(icon: NSImage.updateNotificationInfo, text: UserText.updateAvailableNotification, presentMultiline: true) - } - } - isUpdateAvailableToInstall = !latestUpdate.isInstalled - } else { - isUpdateAvailableToInstall = false + if let cachedUpdateResult { + latestUpdate = Update(appcastItem: cachedUpdateResult.item, isInstalled: cachedUpdateResult.isInstalled) + hasPendingUpdate = latestUpdate?.isInstalled == false && updateProgress.isIdle + needsNotificationDot = hasPendingUpdate } + showUpdateNotificationIfNeeded() } } + var updateProgressPublisher: Published.Publisher { $updateProgress } + + @Published private(set) var latestUpdate: Update? + var latestUpdatePublisher: Published.Publisher { $latestUpdate } - @Published private(set) var isUpdateAvailableToInstall = false - var isUpdateAvailableToInstallPublisher: Published.Publisher { $isUpdateAvailableToInstall } + @Published private(set) var hasPendingUpdate = false + var hasPendingUpdatePublisher: Published.Publisher { $hasPendingUpdate } + + var lastUpdateCheckDate: Date? { updater?.lastUpdateCheckDate } + var lastUpdateNotificationShownDate: Date = .distantPast - var lastUpdateCheckDate: Date? { - updater.updater.lastUpdateCheckDate + private var shouldShowUpdateNotification: Bool { + Date().timeIntervalSince(lastUpdateNotificationShownDate) > .days(7) } @UserDefaultsWrapper(key: .automaticUpdates, defaultValue: true) var areAutomaticUpdatesEnabled: Bool { didSet { - Logger.updates.debug("areAutomaticUpdatesEnabled: \(self.areAutomaticUpdatesEnabled)") - if updater.updater.automaticallyDownloadsUpdates != areAutomaticUpdatesEnabled { - updater.updater.automaticallyDownloadsUpdates = areAutomaticUpdatesEnabled - - // Reinitialize in order to reset the current loaded state - if !areAutomaticUpdatesEnabled { - configureUpdater() - latestUpdate = nil - } + Logger.updates.log("areAutomaticUpdatesEnabled: \(self.areAutomaticUpdatesEnabled)") + if oldValue != areAutomaticUpdatesEnabled { + userDriver?.cancelAndDismissCurrentUpdate() + try? configureUpdater() } } } - var automaticUpdateFlow: Bool { - // In case the current user is not the owner of the binary, we have to switch - // to manual update flow because the authentication is required. - return areAutomaticUpdatesEnabled && binaryOwnershipChecker.isCurrentUserOwner() + @UserDefaultsWrapper(key: .pendingUpdateShown, defaultValue: false) + var needsNotificationDot: Bool { + didSet { + notificationDotSubject.send(needsNotificationDot) + } } - var shouldShowManualUpdateDialog = false + private let notificationDotSubject = CurrentValueSubject(false) + lazy var notificationDotPublisher = notificationDotSubject.eraseToAnyPublisher() - private(set) var updater: SPUStandardUpdaterController! - private var appRestarter: AppRestarting + private(set) var updater: SPUUpdater? + private(set) var userDriver: UpdateUserDriver? private let willRelaunchAppSubject = PassthroughSubject() private var internalUserDecider: InternalUserDecider - private let binaryOwnershipChecker: BinaryOwnershipChecking + private var updateProcessCancellable: AnyCancellable! // MARK: - Public - init(internalUserDecider: InternalUserDecider, - appRestarter: AppRestarting = AppRestarter(), - binaryOwnershipChecker: BinaryOwnershipChecking = BinaryOwnershipChecker()) { + init(internalUserDecider: InternalUserDecider) { willRelaunchAppPublisher = willRelaunchAppSubject.eraseToAnyPublisher() self.internalUserDecider = internalUserDecider - self.appRestarter = appRestarter - self.binaryOwnershipChecker = binaryOwnershipChecker super.init() - configureUpdater() + try? configureUpdater() } func checkNewApplicationVersion() { @@ -151,80 +142,80 @@ final class UpdateController: NSObject, UpdateControllerProtocol { } } - func checkForUpdate() { - Logger.updates.debug("Checking for updates") - - updater.updater.checkForUpdates() - } - - func checkForUpdateInBackground() { - Logger.updates.debug("Checking for updates in background") - - updater.updater.checkForUpdatesInBackground() - } + func checkForUpdateIfNeeded() { + guard let updater, !updater.sessionInProgress else { return } - @objc func runUpdate() { - PixelKit.fire(DebugEvent(GeneralPixel.updaterDidRunUpdate)) + Logger.updates.log("Checking for updates") - if automaticUpdateFlow { - appRestarter.restart() - } else { - updater.userDriver.activeUpdateAlert?.hideUnnecessaryUpdateButtons() - shouldShowManualUpdateDialog = true - checkForUpdate() - } + updater.checkForUpdates() } // MARK: - Private - private func configureUpdater() { + private func configureUpdater() throws { + // Workaround to reset the updater state + cachedUpdateResult = nil + latestUpdate = nil + // The default configuration of Sparkle updates is in Info.plist - updater = SPUStandardUpdaterController(updaterDelegate: self, userDriverDelegate: self) - shouldShowManualUpdateDialog = false + userDriver = UpdateUserDriver(internalUserDecider: internalUserDecider, + areAutomaticUpdatesEnabled: areAutomaticUpdatesEnabled) + guard let userDriver else { return } - if updater.updater.automaticallyDownloadsUpdates != automaticUpdateFlow { - updater.updater.automaticallyDownloadsUpdates = automaticUpdateFlow - } + updater = SPUUpdater(hostBundle: Bundle.main, applicationBundle: Bundle.main, userDriver: userDriver, delegate: self) + + updateProcessCancellable = userDriver.updateProgressPublisher + .assign(to: \.updateProgress, onWeaklyHeld: self) + + try updater?.start() #if DEBUG - updater.updater.automaticallyChecksForUpdates = false - updater.updater.automaticallyDownloadsUpdates = false - updater.updater.updateCheckInterval = 0 + updater?.automaticallyChecksForUpdates = false + updater?.automaticallyDownloadsUpdates = false + updater?.updateCheckInterval = 0 #else - // Load the appcast to retrieve information about the latest update (required for displaying Release Notes) - checkForUpdateInBackground() + checkForUpdateIfNeeded() #endif } - @objc private func openUpdatesPage() { - notificationPresenter.openUpdatesPage() - } - -} + private func showUpdateNotificationIfNeeded() { + guard let latestUpdate, hasPendingUpdate, shouldShowUpdateNotification else { return } + + let action = areAutomaticUpdatesEnabled ? UserText.autoUpdateAction : UserText.manualUpdateAction + + switch latestUpdate.type { + case .critical: + notificationPresenter.showUpdateNotification( + icon: NSImage.criticalUpdateNotificationInfo, + text: "\(UserText.criticalUpdateNotification) \(action)", + presentMultiline: true + ) + case .regular: + notificationPresenter.showUpdateNotification( + icon: NSImage.updateNotificationInfo, + text: "\(UserText.updateAvailableNotification) \(action)", + presentMultiline: true + ) + } -extension UpdateController: SPUStandardUserDriverDelegate { + lastUpdateNotificationShownDate = Date() + } - func standardUserDriverShouldHandleShowingScheduledUpdate(_ update: SUAppcastItem, andInImmediateFocus immediateFocus: Bool) -> Bool { - return shouldShowManualUpdateDialog + @objc func openUpdatesPage() { + notificationPresenter.openUpdatesPage() } - func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState) {} + @objc func runUpdate() { + if let userDriver { + PixelKit.fire(DebugEvent(GeneralPixel.updaterDidRunUpdate)) + userDriver.resume() + } + } } extension UpdateController: SPUUpdaterDelegate { - func updater(_ updater: SPUUpdater, mayPerform updateCheck: SPUUpdateCheck) throws { - Logger.updates.debug("Updater started performing the update check. (isInternalUser: \(self.internalUserDecider.isInternalUser)") - - onUpdateCheckStart() - } - - private func onUpdateCheckStart() { - updateCheckResult = nil - isUpdateBeingLoaded = true - } - func allowedChannels(for updater: SPUUpdater) -> Set { if internalUserDecider.isInternalUser { return Set([Constants.internalChannelName]) @@ -251,63 +242,41 @@ extension UpdateController: SPUUpdaterDelegate { } func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { - Logger.updates.debug("Updater did find valid update: \(item.displayVersionString)(\(item.versionString))") - + Logger.updates.log("Updater did find valid update: \(item.displayVersionString)(\(item.versionString))") PixelKit.fire(DebugEvent(GeneralPixel.updaterDidFindUpdate)) - - if !automaticUpdateFlow { - // For manual updates, we can present the available update without waiting for the update cycle to finish. The Sparkle flow downloads the update later - updateCheckResult = UpdateCheckResult(item: item, isInstalled: false) - onUpdateCheckEnd() - } + cachedUpdateResult = UpdateCheckResult(item: item, isInstalled: false) } func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: any Error) { - let item = (error as NSError).userInfo["SULatestAppcastItemFound"] as? SUAppcastItem - Logger.updates.debug("Updater did not find update: \(String(describing: item?.displayVersionString))(\(String(describing: item?.versionString)))") - if let item { - // User is running the latest version - updateCheckResult = UpdateCheckResult(item: item, isInstalled: true) - } + let nsError = error as NSError + guard let item = nsError.userInfo["SULatestAppcastItemFound"] as? SUAppcastItem else { return } + Logger.updates.log("Updater did not find update: \(String(describing: item.displayVersionString))(\(String(describing: item.versionString)))") PixelKit.fire(DebugEvent(GeneralPixel.updaterDidNotFindUpdate, error: error)) + + cachedUpdateResult = UpdateCheckResult(item: item, isInstalled: true) } func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - Logger.updates.debug("Updater did download update: \(item.displayVersionString)(\(item.versionString))") - - if automaticUpdateFlow { - // For automatic updates, the available item has to be downloaded - updateCheckResult = UpdateCheckResult(item: item, isInstalled: false) - return - } - + Logger.updates.log("Updater did download update: \(item.displayVersionString)(\(item.versionString))") PixelKit.fire(DebugEvent(GeneralPixel.updaterDidDownloadUpdate)) } - func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: (any Error)?) { - Logger.updates.debug("Updater did finish update cycle") - - onUpdateCheckEnd() + func updater(_ updater: SPUUpdater, didExtractUpdate item: SUAppcastItem) { + Logger.updates.log("Updater did extract update: \(item.displayVersionString)(\(item.versionString))") } - private func onUpdateCheckEnd() { - guard isUpdateBeingLoaded else { - // The update check end is already handled - return - } + func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { + Logger.updates.log("Updater will install update: \(item.displayVersionString)(\(item.versionString))") + } - // If the update is available, present it - if let updateCheckResult = updateCheckResult { - latestUpdate = Update(appcastItem: updateCheckResult.item, - isInstalled: updateCheckResult.isInstalled) + func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: (any Error)?) { + if error == nil { + Logger.updates.log("Updater did finish update cycle") + updateProgress = .updateCycleDone } else { - latestUpdate = nil + Logger.updates.log("Updater did finish update cycle with error") } - - // Clear cache - isUpdateBeingLoaded = false - updateCheckResult = nil } } diff --git a/DuckDuckGo/Updates/UpdateNotificationPresenter.swift b/DuckDuckGo/Updates/UpdateNotificationPresenter.swift index 5c68f0c64b..bea8259c16 100644 --- a/DuckDuckGo/Updates/UpdateNotificationPresenter.swift +++ b/DuckDuckGo/Updates/UpdateNotificationPresenter.swift @@ -26,7 +26,7 @@ final class UpdateNotificationPresenter { static let presentationTimeInterval: TimeInterval = 10 func showUpdateNotification(icon: NSImage, text: String, buttonText: String? = nil, presentMultiline: Bool = false) { - Logger.updates.debug("Notification presented: \(text)") + Logger.updates.log("Notification presented: \(text)") DispatchQueue.main.async { guard let windowController = WindowControllersManager.shared.lastKeyMainWindowController ?? WindowControllersManager.shared.mainWindowControllers.last, @@ -34,6 +34,12 @@ final class UpdateNotificationPresenter { return } + let parentViewController = windowController.mainViewController + + guard parentViewController.view.window?.isKeyWindow == true, (parentViewController.presentedViewControllers ?? []).isEmpty else { + return + } + let buttonAction: (() -> Void)? = { [weak self] in self?.openUpdatesPage() } @@ -49,8 +55,7 @@ final class UpdateNotificationPresenter { self?.openUpdatesPage() }) - viewController.show(onParent: windowController.mainViewController, - relativeTo: button) + viewController.show(onParent: parentViewController, relativeTo: button) } } diff --git a/DuckDuckGo/Updates/UpdateUserDriver.swift b/DuckDuckGo/Updates/UpdateUserDriver.swift new file mode 100644 index 0000000000..960825f46e --- /dev/null +++ b/DuckDuckGo/Updates/UpdateUserDriver.swift @@ -0,0 +1,214 @@ +// +// UpdateUserDriver.swift +// +// 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 Sparkle +import PixelKit +import BrowserServicesKit +import Combine +import os.log + +#if SPARKLE + +enum UpdateState { + case upToDate + case updateCycle(UpdateCycleProgress) + + init(from update: Update?, progress: UpdateCycleProgress) { + if let update, !update.isInstalled { + self = .updateCycle(progress) + } else if progress.isFailed { + self = .updateCycle(progress) + } else { + self = .upToDate + } + } +} + +enum UpdateCycleProgress { + case updateCycleNotStarted + case updateCycleDidStart + case updateCycleDone + case downloadDidStart + case downloading(Double) + case extractionDidStart + case extracting(Double) + case readyToInstallAndRelaunch + case installationDidStart + case installing + case updaterError(Error) + + static var `default` = UpdateCycleProgress.updateCycleNotStarted + + var isDone: Bool { + switch self { + case .updateCycleDone: return true + default: return false + } + } + + var isIdle: Bool { + switch self { + case .updateCycleDone, .updateCycleNotStarted, .updaterError: return true + default: return false + } + } + + var isFailed: Bool { + switch self { + case .updaterError: return true + default: return false + } + } +} + +final class UpdateUserDriver: NSObject, SPUUserDriver { + enum Checkpoint: Equatable { + case download + case restart + } + + private var internalUserDecider: InternalUserDecider + + private var checkpoint: Checkpoint + private var onResuming: () -> Void = {} + + private var onSkipping: () -> Void = {} + + private var bytesToDownload: UInt64 = 0 + private var bytesDownloaded: UInt64 = 0 + + @Published var updateProgress = UpdateCycleProgress.default + public var updateProgressPublisher: Published.Publisher { $updateProgress } + + init(internalUserDecider: InternalUserDecider, + areAutomaticUpdatesEnabled: Bool) { + self.internalUserDecider = internalUserDecider + self.checkpoint = areAutomaticUpdatesEnabled ? .restart : .download + } + + func resume() { + onResuming() + } + + func cancelAndDismissCurrentUpdate() { + onSkipping() + } + + func show(_ request: SPUUpdatePermissionRequest) async -> SUUpdatePermissionResponse { +#if DEBUG + .init(automaticUpdateChecks: false, sendSystemProfile: false) +#else + .init(automaticUpdateChecks: true, sendSystemProfile: false) +#endif + } + + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { + Logger.updates.log("Updater started performing the update check. (isInternalUser: \(self.internalUserDecider.isInternalUser)") + updateProgress = .updateCycleDidStart + } + + func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) { + if appcastItem.isInformationOnlyUpdate { + reply(.dismiss) + } + + onSkipping = { reply(.skip) } + + if checkpoint == .download { + onResuming = { reply(.install) } + updateProgress = .updateCycleDone + } else { + reply(.install) + } + } + + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + // no-op + } + + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { + // no-op + } + + func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { + acknowledgement() + } + + func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { + updateProgress = .updaterError(error) + acknowledgement() + } + + func showDownloadInitiated(cancellation: @escaping () -> Void) { + updateProgress = .downloadDidStart + } + + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + bytesDownloaded = 0 + bytesToDownload = expectedContentLength + } + + func showDownloadDidReceiveData(ofLength length: UInt64) { + bytesDownloaded += length + if bytesDownloaded > bytesToDownload { + bytesToDownload = bytesDownloaded + } + updateProgress = .downloading(Double(bytesDownloaded) / Double(bytesToDownload)) + } + + func showDownloadDidStartExtractingUpdate() { + updateProgress = .extractionDidStart + } + + func showExtractionReceivedProgress(_ progress: Double) { + updateProgress = .extracting(progress) + } + + func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) { + onSkipping = { reply(.skip) } + + if checkpoint == .restart { + onResuming = { reply(.install) } + } else { + reply(.install) + } + + updateProgress = .updateCycleDone + } + + func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { + updateProgress = .installationDidStart + } + + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + updateProgress = .installing + acknowledgement() + } + + func showUpdateInFocus() { + // no-op + } + + func dismissUpdateInstallation() { + guard !updateProgress.isFailed else { return } + updateProgress = .updateCycleDone + } +} + +#endif diff --git a/DuckDuckGo/Windows/View/WindowControllersManager.swift b/DuckDuckGo/Windows/View/WindowControllersManager.swift index 90e8f59ec7..e1d106586c 100644 --- a/DuckDuckGo/Windows/View/WindowControllersManager.swift +++ b/DuckDuckGo/Windows/View/WindowControllersManager.swift @@ -46,6 +46,7 @@ protocol WindowControllersManagerProtocol { popUp: Bool, lazyLoadTabs: Bool, isMiniaturized: Bool) -> MainWindow? + func showTab(with content: Tab.TabContent) } extension WindowControllersManagerProtocol { @discardableResult diff --git a/DuckDuckGoDBPBackgroundAgent/DuckDuckGoDBPBackgroundAgentAppDelegate.swift b/DuckDuckGoDBPBackgroundAgent/DuckDuckGoDBPBackgroundAgentAppDelegate.swift index df118fecf3..6e45dcc1ac 100644 --- a/DuckDuckGoDBPBackgroundAgent/DuckDuckGoDBPBackgroundAgentAppDelegate.swift +++ b/DuckDuckGoDBPBackgroundAgent/DuckDuckGoDBPBackgroundAgentAppDelegate.swift @@ -103,7 +103,8 @@ final class DuckDuckGoDBPBackgroundAgentAppDelegate: NSObject, NSApplicationDele authenticationRepository: KeychainAuthenticationData()) let authenticationManager = DataBrokerAuthenticationManagerBuilder.buildAuthenticationManager(redeemUseCase: redeemUseCase, subscriptionManager: subscriptionManager) - manager = DataBrokerProtectionAgentManagerProvider.agentManager(authenticationManager: authenticationManager) + manager = DataBrokerProtectionAgentManagerProvider.agentManager(authenticationManager: authenticationManager, + accountManager: subscriptionManager.accountManager) manager?.agentFinishedLaunching() setupStatusBarMenu() diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index f9ef9def11..8f91c6078c 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -21,11 +21,11 @@ import PackagePlugin import XcodeProjectPlugin let nonSandboxedExtraInputFiles: Set = [ - .init("BinaryOwnershipChecker.swift", .source), .init("BWEncryption.m", .source), .init("BWEncryptionOutput.m", .source), .init("BWManager.swift", .source), .init("UpdateController.swift", .source), + .init("UpdateUserDriver.swift", .source), .init("PFMoveApplication.m", .source), .init("DuckDuckGo VPN.app", .unknown), .init("DuckDuckGo Notifications.app", .unknown), @@ -50,7 +50,6 @@ let extraInputFiles: [TargetName: Set] = [ "DuckDuckGo Privacy Pro": nonSandboxedExtraInputFiles, "Unit Tests": [ - .init("BinaryOwnershipCheckerTests.swift", .source), .init("BWEncryptionTests.swift", .source), .init("WKWebViewPrivateMethodsAvailabilityTests.swift", .source) ], diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index 7a924e71d0..33cc4173f6 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,9 +29,10 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "201.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "203.1.0"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), + .package(path: "../Freemium"), ], targets: [ .target( @@ -43,6 +44,7 @@ let package = Package( .product(name: "PixelKit", package: "BrowserServicesKit"), .product(name: "Configuration", package: "BrowserServicesKit"), .product(name: "Persistence", package: "BrowserServicesKit"), + .product(name: "Freemium", package: "Freemium"), ], resources: [.copy("Resources")], swiftSettings: [ @@ -54,6 +56,7 @@ let package = Package( dependencies: [ "DataBrokerProtection", "BrowserServicesKit", + "Freemium", ], resources: [ .copy("Resources") diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift index 9c1a7a4496..f6ad3673cb 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDataManager.swift @@ -20,17 +20,35 @@ import Foundation import Common import os.log +public extension Notification.Name { + /// Notification posted when a profile is saved. + static let pirProfileSaved = Notification.Name("pirProfileSaved") +} + +/// A protocol that defines the behavior for posting a notification when a profile is saved, if permitted by certain conditions. +public protocol DBPProfileSavedNotifier { + + /// Posts the "Profile Saved" notification if certain conditions allow it. + /// This method checks whether the notification can be posted, and if so, it triggers the notification. + func postProfileSavedNotificationIfPermitted() +} + public protocol DataBrokerProtectionDataManaging { var cache: InMemoryDataCache { get } var delegate: DataBrokerProtectionDataManagerDelegate? { get set } - init(pixelHandler: EventMapping, fakeBrokerFlag: DataBrokerDebugFlag) + init(database: DataBrokerProtectionRepository?, + profileSavedNotifier: DBPProfileSavedNotifier?, + pixelHandler: EventMapping, + fakeBrokerFlag: DataBrokerDebugFlag) func saveProfile(_ profile: DataBrokerProtectionProfile) async throws func fetchProfile() throws -> DataBrokerProtectionProfile? func prepareProfileCache() throws func fetchBrokerProfileQueryData(ignoresCache: Bool) throws -> [BrokerProfileQueryData] func prepareBrokerProfileQueryDataCache() throws func hasMatches() throws -> Bool + /// Returns the total number of matches and brokers with matches. + func matchesFoundAndBrokersCount() throws -> (matchCount: Int, brokerCount: Int) func profileQueriesCount() throws -> Int } @@ -38,24 +56,33 @@ public protocol DataBrokerProtectionDataManagerDelegate: AnyObject { func dataBrokerProtectionDataManagerDidUpdateData() func dataBrokerProtectionDataManagerDidDeleteData() func dataBrokerProtectionDataManagerWillOpenSendFeedbackForm() + func isAuthenticatedUser() -> Bool } public class DataBrokerProtectionDataManager: DataBrokerProtectionDataManaging { + + private let profileSavedNotifier: DBPProfileSavedNotifier? + public let cache = InMemoryDataCache() public weak var delegate: DataBrokerProtectionDataManagerDelegate? internal let database: DataBrokerProtectionRepository - required public init(pixelHandler: EventMapping, fakeBrokerFlag: DataBrokerDebugFlag = DataBrokerDebugFlagFakeBroker()) { - self.database = DataBrokerProtectionDatabase(fakeBrokerFlag: fakeBrokerFlag, pixelHandler: pixelHandler) + required public init(database: DataBrokerProtectionRepository? = nil, + profileSavedNotifier: DBPProfileSavedNotifier? = nil, + pixelHandler: EventMapping, + fakeBrokerFlag: DataBrokerDebugFlag = DataBrokerDebugFlagFakeBroker()) { + self.database = database ?? DataBrokerProtectionDatabase(fakeBrokerFlag: fakeBrokerFlag, pixelHandler: pixelHandler) + self.profileSavedNotifier = profileSavedNotifier cache.delegate = self } public func saveProfile(_ profile: DataBrokerProtectionProfile) async throws { do { try await database.save(profile) + profileSavedNotifier?.postProfileSavedNotificationIfPermitted() } catch { // We should still invalidate the cache if the save fails cache.invalidate() @@ -118,9 +145,54 @@ public class DataBrokerProtectionDataManager: DataBrokerProtectionDataManaging { public func hasMatches() throws -> Bool { return try database.hasMatches() } + + /// Fetches all broker profile query data from the database and calculates the total number of matches and brokers with matches. + /// + /// A match is defined as: An extracted profile associated with the broker profile query data. + /// + /// Additionally, a broker is counted if it has at least one match (either an extracted profile). + /// + /// - Returns: A tuple containing: + /// - `matchCount`: The total number of matches found (extracted profiles). + /// - `brokerCount`: The number of brokers that have at least one match. + /// - Throws: An error if fetching broker profile query data from the database fails. + public func matchesFoundAndBrokersCount() throws -> (matchCount: Int, brokerCount: Int) { + let queryData = try database.fetchAllBrokerProfileQueryData() + return matchesAndBrokersCount(forQueryData: queryData) + } +} + +private extension DataBrokerProtectionDataManager { + + /// Calculates the number of profile matches and the unique broker count based on the provided query data. + /// + /// This method filters out deprecated profile queries from the input `queryData`, generates the profile matches, + /// and then calculates two counts: + /// - The total number of profile matches (`matchCount`). + /// - The number of unique data brokers involved in those matches (`brokerCount`). + /// + /// The method groups the profile matches by data broker to ensure that each broker is counted only once. + /// + /// - Parameter queryData: An array of `BrokerProfileQueryData` that contains data brokers and profile queries. + /// - Returns: A tuple containing: + /// - `matchCount`: The total number of profile matches after filtering out deprecated queries. + /// - `brokerCount`: The number of unique data brokers involved in the profile matches. + func matchesAndBrokersCount(forQueryData queryData: [BrokerProfileQueryData]) -> (matchCount: Int, brokerCount: Int) { + let withoutDeprecated = queryData.filter { !$0.profileQuery.deprecated } + let profileMatches = DBPUIDataBrokerProfileMatch.profileMatches(from: withoutDeprecated) + + // Calculate the total number of profile matches. + let matchCount = profileMatches.count + + // Calculate the number of unique brokers by grouping the matches by data broker. + let brokerCount = Dictionary(grouping: profileMatches, by: { $0.dataBroker }).values.count + + return (matchCount, brokerCount) + } } extension DataBrokerProtectionDataManager: InMemoryDataCacheDelegate { + public func saveCachedProfileToDatabase(_ profile: DataBrokerProtectionProfile) async throws { try await saveProfile(profile) @@ -137,12 +209,17 @@ extension DataBrokerProtectionDataManager: InMemoryDataCacheDelegate { public func willOpenSendFeedbackForm() { delegate?.dataBrokerProtectionDataManagerWillOpenSendFeedbackForm() } + + public func isAuthenticatedUser() -> Bool { + delegate?.isAuthenticatedUser() ?? true + } } public protocol InMemoryDataCacheDelegate: AnyObject { func saveCachedProfileToDatabase(_ profile: DataBrokerProtectionProfile) async throws func removeAllData() throws func willOpenSendFeedbackForm() + func isAuthenticatedUser() -> Bool } public final class InMemoryDataCache { @@ -164,6 +241,12 @@ public final class InMemoryDataCache { } extension InMemoryDataCache: DBPUICommunicationDelegate { + + func getHandshakeUserData() -> DBPUIHandshakeUserData? { + let isAuthenticatedUser = delegate?.isAuthenticatedUser() ?? true + return DBPUIHandshakeUserData(isAuthenticatedUser: isAuthenticatedUser) + } + func saveProfile() async throws { guard let profile = profile else { return } try await delegate?.saveCachedProfileToDatabase(profile) @@ -326,10 +409,10 @@ extension InMemoryDataCache: DBPUICommunicationDelegate { // 2. We map the brokers to the UI model .flatMap { dataBroker -> [DBPUIDataBroker] in var result: [DBPUIDataBroker] = [] - result.append(DBPUIDataBroker(name: dataBroker.name, url: dataBroker.url, parentURL: dataBroker.parent)) + result.append(DBPUIDataBroker(name: dataBroker.name, url: dataBroker.url, parentURL: dataBroker.parent, optOutUrl: dataBroker.optOutUrl)) for mirrorSite in dataBroker.mirrorSites { - result.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, parentURL: dataBroker.parent)) + result.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, parentURL: dataBroker.parent, optOutUrl: dataBroker.optOutUrl)) } return result } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDatabase.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDatabase.swift index fa24220206..272033fd2b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDatabase.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Database/DataBrokerProtectionDatabase.swift @@ -21,7 +21,7 @@ import Common import SecureStorage import os.log -protocol DataBrokerProtectionRepository { +public protocol DataBrokerProtectionRepository { func save(_ profile: DataBrokerProtectionProfile) async throws func fetchProfile() throws -> DataBrokerProtectionProfile? func deleteProfileData() throws diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCClient.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCClient.swift index 25d033a053..76e9d63242 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCClient.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/IPC/DataBrokerProtectionIPCClient.swift @@ -133,7 +133,8 @@ extension DataBrokerProtectionIPCClient: IPCServerInterface { Logger.dataBrokerProtection.error("Error \(error.localizedDescription)") // Intentional no-op as there's no completion block // If you add a completion block, please remember to call it here too! - }) } + }) + } public func runAllOptOuts(showWebView: Bool) { xpc.execute(call: { server in diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Actions/Expectaction.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Actions/Expectaction.swift index d3669a3290..13b03761de 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Actions/Expectaction.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Actions/Expectaction.swift @@ -29,11 +29,50 @@ struct Item: Codable, Sendable { let expect: String? let selector: String? let parent: String? + let failSilently: Bool? } -internal struct ExpectationAction: Action { +internal final class ExpectationAction: Action { let id: String let actionType: ActionType let expectations: [Item] let dataSource: DataSource? + let actions: [Action]? + + enum CodingKeys: String, CodingKey { + case id, actionType, expectations, dataSource, actions + } + + init(id: String, actionType: ActionType, expectations: [Item], dataSource: DataSource?, actions: [Action]?) { + self.id = id + self.actionType = actionType + self.expectations = expectations + self.dataSource = dataSource + self.actions = actions + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.actionType = try container.decode(ActionType.self, forKey: .actionType) + self.expectations = try container.decode([Item].self, forKey: .expectations) + self.dataSource = try container.decodeIfPresent(DataSource.self, forKey: .dataSource) + let actionsList = try container.decodeIfPresent([[String: Any]].self, forKey: .actions) + if let actionsList = actionsList { + self.actions = try Step.parse(actionsList) + } else { + self.actions = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(actionType, forKey: .actionType) + try container.encode(expectations, forKey: .expectations) + try container.encode(dataSource, forKey: .dataSource) + + var actionsContainer = container.nestedUnkeyedContainer(forKey: .actions) + try actions?.encode(to: &actionsContainer) + } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/AttemptInformation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/AttemptInformation.swift index ac61c65d0d..ef5acc9867 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/AttemptInformation.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/AttemptInformation.swift @@ -18,7 +18,7 @@ import Foundation -struct AttemptInformation { +public struct AttemptInformation { let extractedProfileId: Int64 let dataBroker: String let attemptId: String diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/BrokerJobData.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/BrokerJobData.swift index d581826e62..8cda7bd0c2 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/BrokerJobData.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/BrokerJobData.swift @@ -67,7 +67,7 @@ struct ScanJobData: BrokerJobData, Sendable { } } -struct OptOutJobData: BrokerJobData, Sendable { +public struct OptOutJobData: BrokerJobData, Sendable { let brokerId: Int64 let profileQueryId: Int64 let createdDate: Date diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift index 13379680a9..68e5f19d0e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DBPUICommunicationModel.swift @@ -43,6 +43,18 @@ struct DBPUIHandshake: Codable { let version: Int } +/// User-related data intended to be returned as part of a hardshake response +struct DBPUIHandshakeUserData: Codable, Equatable { + let isAuthenticatedUser: Bool +} + +/// Data type returned in response to a handshake request +struct DBPUIHandshakeResponse: Codable { + let version: Int + let success: Bool + let userdata: DBPUIHandshakeUserData +} + /// Standard response from the host to the UI. The response contains the /// current version of the host's communication protocol and a bool value /// indicating if the requested operation was successful. @@ -115,12 +127,14 @@ struct DBPUIDataBroker: Codable, Hashable { let url: String let date: Double? let parentURL: String? + let optOutUrl: String - init(name: String, url: String, date: Double? = nil, parentURL: String?) { + init(name: String, url: String, date: Double? = nil, parentURL: String?, optOutUrl: String) { self.name = name self.url = url self.date = date self.parentURL = parentURL + self.optOutUrl = optOutUrl } func hash(into hasher: inout Hasher) { @@ -158,7 +172,8 @@ extension DBPUIDataBrokerProfileMatch { dataBrokerName: String, dataBrokerURL: String, dataBrokerParentURL: String?, - parentBrokerOptOutJobData: [OptOutJobData]?) { + parentBrokerOptOutJobData: [OptOutJobData]?, + optOutUrl: String) { let extractedProfile = optOutJobData.extractedProfile /* @@ -193,7 +208,7 @@ extension DBPUIDataBrokerProfileMatch { extractedProfile.doesMatchExtractedProfile(parentOptOut.extractedProfile) } ?? false - self.init(dataBroker: DBPUIDataBroker(name: dataBrokerName, url: dataBrokerURL, parentURL: dataBrokerParentURL), + self.init(dataBroker: DBPUIDataBroker(name: dataBrokerName, url: dataBrokerURL, parentURL: dataBrokerParentURL, optOutUrl: optOutUrl), name: extractedProfile.fullName ?? "No name", addresses: extractedProfile.addresses?.map {DBPUIUserProfileAddress(addressCityState: $0) } ?? [], alternativeNames: extractedProfile.alternativeNames ?? [String](), @@ -205,12 +220,66 @@ extension DBPUIDataBrokerProfileMatch { hasMatchingRecordOnParentBroker: hasFoundParentMatch) } - init(optOutJobData: OptOutJobData, dataBroker: DataBroker, parentBrokerOptOutJobData: [OptOutJobData]?) { + init(optOutJobData: OptOutJobData, dataBroker: DataBroker, parentBrokerOptOutJobData: [OptOutJobData]?, optOutUrl: String) { self.init(optOutJobData: optOutJobData, dataBrokerName: dataBroker.name, dataBrokerURL: dataBroker.url, dataBrokerParentURL: dataBroker.parent, - parentBrokerOptOutJobData: parentBrokerOptOutJobData) + parentBrokerOptOutJobData: parentBrokerOptOutJobData, + optOutUrl: optOutUrl) + } + + /// Generates an array of `DBPUIDataBrokerProfileMatch` objects from the provided query data. + /// + /// This method processes an array of `BrokerProfileQueryData` to create a list of profile matches for data brokers. + /// It takes into account the opt-out data associated with each data broker, as well as any parent data brokers and their opt-out data. + /// Additionally, it includes mirror sites for each data broker, if applicable, based on the conditions defined in `shouldWeIncludeMirrorSite()`. + /// + /// - Parameter queryData: An array of `BrokerProfileQueryData` objects, which contains data brokers and their respective opt-out data. + /// - Returns: An array of `DBPUIDataBrokerProfileMatch` objects representing matches for each data broker, including parent brokers and mirror sites. + static func profileMatches(from queryData: [BrokerProfileQueryData]) -> [DBPUIDataBrokerProfileMatch] { + // Group the query data by data broker URL to easily find parent data broker opt-outs later. + let brokerURLsToQueryData = Dictionary(grouping: queryData, by: { $0.dataBroker.url }) + + return queryData.flatMap { + var profiles = [DBPUIDataBrokerProfileMatch]() + + for optOutJobData in $0.optOutJobData { + let dataBroker = $0.dataBroker + + // Find opt-out job data for the parent broker, if applicable. + var parentBrokerOptOutJobData: [OptOutJobData]? + if let parent = dataBroker.parent, + let parentsQueryData = brokerURLsToQueryData[parent] { + parentBrokerOptOutJobData = parentsQueryData.flatMap { $0.optOutJobData } + } + + // Create a profile match for the current data broker and append it to the list of profiles. + profiles.append(DBPUIDataBrokerProfileMatch(optOutJobData: optOutJobData, + dataBroker: dataBroker, + parentBrokerOptOutJobData: parentBrokerOptOutJobData, + optOutUrl: dataBroker.optOutUrl)) + + // Handle mirror sites associated with the data broker. + if !dataBroker.mirrorSites.isEmpty { + // Create profile matches for each mirror site if it meets the inclusion criteria. + let mirrorSitesMatches = dataBroker.mirrorSites.compactMap { mirrorSite in + if mirrorSite.shouldWeIncludeMirrorSite() { + return DBPUIDataBrokerProfileMatch(optOutJobData: optOutJobData, + dataBrokerName: mirrorSite.name, + dataBrokerURL: mirrorSite.url, + dataBrokerParentURL: dataBroker.parent, + parentBrokerOptOutJobData: parentBrokerOptOutJobData, + optOutUrl: dataBroker.optOutUrl) + } + return nil + } + profiles.append(contentsOf: mirrorSitesMatches) + } + } + + return profiles + } } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift index afb6d99efc..5328a699f6 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift @@ -85,6 +85,20 @@ extension MirrorSite { func scannedBroker(withStatus status: ScannedBroker.Status) -> ScannedBroker { ScannedBroker(name: name, url: url, status: status) } + + /// Determines whether a mirror site should be included in scan result calculations based on the provided date. + /// + /// - Parameter date: The date for which to check if the mirror site should be included. Defaults to the current date. + /// - Returns: A Boolean value indicating whether the mirror site should be included. + /// - `true`: If the profile was added before the given date and has not been removed, or if it was removed but the provided date is between the `addedAt` and `removedAt` timestamps. + /// - `false`: If the profile was either added after the given date or has been removed before the given date. + func shouldWeIncludeMirrorSite(for date: Date = Date()) -> Bool { + if let removedAt = self.removedAt { + return self.addedAt < date && date < removedAt + } else { + return self.addedAt < date + } + } } public enum DataBrokerHierarchy: Int { @@ -92,7 +106,7 @@ public enum DataBrokerHierarchy: Int { case child = 0 } -struct DataBroker: Codable, Sendable { +public struct DataBroker: Codable, Sendable { let id: Int64? let name: String let url: String @@ -101,6 +115,7 @@ struct DataBroker: Codable, Sendable { let schedulingConfig: DataBrokerScheduleConfig let parent: String? let mirrorSites: [MirrorSite] + let optOutUrl: String var isFakeBroker: Bool { name.contains("fake") // A future improvement will be to add a property in the JSON file. @@ -114,6 +129,7 @@ struct DataBroker: Codable, Sendable { case schedulingConfig case parent case mirrorSites + case optOutUrl } init(id: Int64? = nil, @@ -123,7 +139,8 @@ struct DataBroker: Codable, Sendable { version: String, schedulingConfig: DataBrokerScheduleConfig, parent: String? = nil, - mirrorSites: [MirrorSite] = [MirrorSite]() + mirrorSites: [MirrorSite] = [MirrorSite](), + optOutUrl: String ) { self.id = id self.name = name @@ -139,9 +156,10 @@ struct DataBroker: Codable, Sendable { self.schedulingConfig = schedulingConfig self.parent = parent self.mirrorSites = mirrorSites + self.optOutUrl = optOutUrl } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) @@ -165,6 +183,8 @@ struct DataBroker: Codable, Sendable { mirrorSites = [MirrorSite]() } + optOutUrl = (try? container.decode(String.self, forKey: .optOutUrl)) ?? "" + id = nil } @@ -207,11 +227,11 @@ struct DataBroker: Codable, Sendable { extension DataBroker: Hashable { - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(name) } - static func == (lhs: DataBroker, rhs: DataBroker) -> Bool { + public static func == (lhs: DataBroker, rhs: DataBroker) -> Bool { return lhs.name == rhs.name } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift index 3412e94def..85b2e9e417 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/ExtractedProfile.swift @@ -65,7 +65,7 @@ struct AddressCityState: Codable, Hashable { } } -struct ExtractedProfile: Codable, Sendable { +public struct ExtractedProfile: Codable, Sendable { let id: Int64? let name: String? let alternativeNames: [String]? @@ -127,7 +127,7 @@ struct ExtractedProfile: Codable, Sendable { self.identifier = identifier } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeIfPresent(Int64.self, forKey: .id) name = try container.decodeIfPresent(String.self, forKey: .name) @@ -202,7 +202,7 @@ struct ExtractedProfile: Codable, Sendable { } extension ExtractedProfile: Equatable { - static func == (lhs: ExtractedProfile, rhs: ExtractedProfile) -> Bool { + public static func == (lhs: ExtractedProfile, rhs: ExtractedProfile) -> Bool { lhs.name == rhs.name } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Step.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Step.swift index d1ffc96754..97b13dd9a9 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Step.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/Step.swift @@ -58,25 +58,7 @@ struct Step: Codable, Sendable { try container.encode(optOutType, forKey: .optOutType) var actionsContainer = container.nestedUnkeyedContainer(forKey: .actions) - for action in actions { - if let navigateAction = action as? NavigateAction { - try actionsContainer.encode(navigateAction) - } else if let extractAction = action as? ExtractAction { - try actionsContainer.encode(extractAction) - } else if let fillFormAction = action as? FillFormAction { - try actionsContainer.encode(fillFormAction) - } else if let getCaptchaInfoAction = action as? GetCaptchaInfoAction { - try actionsContainer.encode(getCaptchaInfoAction) - } else if let solveCaptchaInfoAction = action as? SolveCaptchaAction { - try actionsContainer.encode(solveCaptchaInfoAction) - } else if let emailConfirmationAction = action as? EmailConfirmationAction { - try actionsContainer.encode(emailConfirmationAction) - } else if let clickAction = action as? ClickAction { - try actionsContainer.encode(clickAction) - } else if let expectactionAction = action as? ExpectationAction { - try actionsContainer.encode(expectactionAction) - } - } + try actions.encode(to: &actionsContainer) } static func parse(_ actions: [[String: Any]]) throws -> [Action] { @@ -120,3 +102,27 @@ struct Step: Codable, Sendable { return actionList } } + +extension Array where Element == Action { + func encode(to container: inout any UnkeyedEncodingContainer) throws { + for action in self { + if let navigateAction = action as? NavigateAction { + try container.encode(navigateAction) + } else if let extractAction = action as? ExtractAction { + try container.encode(extractAction) + } else if let fillFormAction = action as? FillFormAction { + try container.encode(fillFormAction) + } else if let getCaptchaInfoAction = action as? GetCaptchaInfoAction { + try container.encode(getCaptchaInfoAction) + } else if let solveCaptchaInfoAction = action as? SolveCaptchaAction { + try container.encode(solveCaptchaInfoAction) + } else if let emailConfirmationAction = action as? EmailConfirmationAction { + try container.encode(emailConfirmationAction) + } else if let clickAction = action as? ClickAction { + try container.encode(clickAction) + } else if let expectactionAction = action as? ExpectationAction { + try container.encode(expectactionAction) + } + } + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift index bb9b72f09a..a04ab7d0b4 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Operations/DataBrokerOperation.swift @@ -39,7 +39,8 @@ struct DefaultDataBrokerOperationDependencies: DataBrokerOperationDependencies { } enum OperationType { - case scan + case manualScan + case scheduledScan case optOut case all } @@ -120,7 +121,7 @@ class DataBrokerOperation: Operation, @unchecked Sendable { switch operationType { case .optOut: operationsData = brokerProfileQueriesData.flatMap { $0.optOutJobData } - case .scan: + case .manualScan, .scheduledScan: operationsData = brokerProfileQueriesData.filter { $0.profileQuery.deprecated == false }.compactMap { $0.scanJobData } case .all: operationsData = brokerProfileQueriesData.flatMap { $0.operationsData } @@ -180,7 +181,7 @@ class DataBrokerOperation: Operation, @unchecked Sendable { runner: operationDependencies.runnerProvider.getJobRunner(), pixelHandler: operationDependencies.pixelHandler, showWebView: showWebView, - isImmediateOperation: operationType == .scan, + isImmediateOperation: operationType == .manualScan, userNotificationService: operationDependencies.userNotificationService, shouldRunNextStep: { [weak self] in guard let self = self else { return false } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift index b9fec0cadc..f5fdcd9ce4 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift @@ -206,10 +206,6 @@ public enum DataBrokerProtectionPixels { case dataBrokerMetricsWeeklyStats(dataBrokerURL: String, profilesFound: Int, optOutsInProgress: Int, successfulOptOuts: Int, failedOptOuts: Int, durationOfFirstOptOut: Int, numberOfNewRecordsFound: Int, numberOfReappereances: Int) case dataBrokerMetricsMonthlyStats(dataBrokerURL: String, profilesFound: Int, optOutsInProgress: Int, successfulOptOuts: Int, failedOptOuts: Int, durationOfFirstOptOut: Int, numberOfNewRecordsFound: Int, numberOfReappereances: Int) - // Feature Gatekeeper - case gatekeeperNotAuthenticated - case gatekeeperEntitlementsInvalid - // Custom stats case customDataBrokerStatsOptoutSubmit(dataBrokerName: String, optOutSubmitSuccessRate: Double) case customGlobalStatsOptoutSubmit(optOutSubmitSuccessRate: Double) @@ -338,10 +334,6 @@ extension DataBrokerProtectionPixels: PixelKitEvent { case .dataBrokerMetricsWeeklyStats: return "m_mac_dbp_databroker_weekly_stats" case .dataBrokerMetricsMonthlyStats: return "m_mac_dbp_databroker_monthly_stats" - // Feature Gatekeeper - case .gatekeeperNotAuthenticated: return "m_mac_dbp_gatekeeper_not_authenticated" - case .gatekeeperEntitlementsInvalid: return "m_mac_dbp_gatekeeper_entitlements_invalid" - // Configuration case .invalidPayload(let configuration): return "m_mac_dbp_\(configuration.rawValue)_invalid_payload".lowercased() case .errorLoadingCachedConfig: return "m_mac_dbp_configuration_error_loading_cached_config" @@ -460,8 +452,6 @@ extension DataBrokerProtectionPixels: PixelKitEvent { .secureVaultKeyStoreReadError, .secureVaultKeyStoreUpdateError, .secureVaultError, - .gatekeeperNotAuthenticated, - .gatekeeperEntitlementsInvalid, .invalidPayload, .pixelTest, .failedToParsePrivacyConfig: @@ -623,8 +613,6 @@ public class DataBrokerProtectionPixelsHandler: EventMapping { + + public init() { + super.init { event, _, params, _ in + switch event { + case .subscription: + PixelKit.fire(event, frequency: .unique, withAdditionalParameters: params) + default: + PixelKit.fire(event, frequency: .unique) + } + + } + } + + override init(mapping: @escaping EventMapping.Mapping) { + fatalError("Use init()") + } +} + +public enum FreemiumDBPExperimentPixel: PixelKitEventV2 { + + // Before the first scan + case newTabScanImpression + case newTabScanClick + case newTabScanDismiss + // When receiving results + case newTabResultsImpression + case newTabResultsClick + case newTabResultsDismiss + // When receiving no results + case newTabNoResultsImpression + case newTabNoResultsClick + case newTabNoResultsDismiss + // Overflow menu + case overFlowScan + case overFlowResults + // System notification + case firstScanCompleteNotificationSent + case firstScanCompleteNotificationClicked + // Subscription + case subscription + + public var name: String { + switch self { + case .newTabScanImpression: + return "dbp-free_newtab_scan_impression_u" + case .newTabScanClick: + return "dbp-free_newtab_scan_click_u" + case .newTabScanDismiss: + return "dbp-free_newtab_scan_dismiss_u" + case .newTabResultsImpression: + return "dbp-free_newtab_results_impression_u" + case .newTabResultsClick: + return "dbp-free_newtab_results_click_u" + case .newTabResultsDismiss: + return "dbp-free_newtab_results_dismiss_u" + case .newTabNoResultsImpression: + return "dbp-free_newtab_no-results_impression_u" + case .newTabNoResultsClick: + return "dbp-free_newtab_no-results_click_u" + case .newTabNoResultsDismiss: + return "dbp-free_newtab_no-results_dismiss_u" + case .overFlowScan: + return "dbp-free_overflow_scan_u" + case .overFlowResults: + return "dbp-free_overflow_results_u" + case .firstScanCompleteNotificationSent: + return "dbp-free_notification_sent_first_scan_complete_u" + case .firstScanCompleteNotificationClicked: + return "dbp-free_notification_opened_first_scan_complete_u" + case .subscription: + return "dbp-free_subscription_u" + } + } + + public var parameters: [String: String]? { + return nil + } + + public var error: (any Error)? { + nil + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/advancedbackgroundchecks.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/advancedbackgroundchecks.com.json index 21e440b017..34b78c33ac 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/advancedbackgroundchecks.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/advancedbackgroundchecks.com.json @@ -1,9 +1,10 @@ { "name": "AdvancedBackgroundChecks", "url": "advancedbackgroundchecks.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1678060800000, + "optOutUrl": "https://www.advancedbackgroundchecks.com/removal", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/backgroundcheck.run.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/backgroundcheck.run.json index 4b9838adb6..baf3bc57a9 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/backgroundcheck.run.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/backgroundcheck.run.json @@ -1,9 +1,10 @@ { "name": "backgroundcheck.run", "url": "backgroundcheck.run", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1677736800000, + "optOutUrl": "https://backgroundcheck.run/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/centeda.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/centeda.com.json index 4b94440a09..930c917e7e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/centeda.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/centeda.com.json @@ -1,9 +1,10 @@ { "name": "Centeda", "url": "centeda.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1677736800000, + "optOutUrl": "https://centeda.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clubset.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clubset.com.json index 04ff5f40a8..8276c67d46 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clubset.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clubset.com.json @@ -1,9 +1,10 @@ { "name": "Clubset", "url": "clubset.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1702965600000, + "optOutUrl": "https://clubset.com/private/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clustrmaps.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clustrmaps.com.json index 9f61d0322b..e040c2ce0b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clustrmaps.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/clustrmaps.com.json @@ -1,9 +1,10 @@ { "name": "ClustrMaps", "url": "clustrmaps.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "neighbor.report", "addedDatetime": 1692594000000, + "optOutUrl": "https://clustrmaps.com/bl/opt-out", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/councilon.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/councilon.com.json index ea60ad88eb..ef7150468c 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/councilon.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/councilon.com.json @@ -1,9 +1,10 @@ { "name": "Councilon", "url": "councilon.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1702965600000, + "optOutUrl": "https://councilon.com/ex/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/curadvisor.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/curadvisor.com.json index 17df2de1a4..d86d01ad0a 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/curadvisor.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/curadvisor.com.json @@ -1,9 +1,10 @@ { "name": "CurAdvisor", "url": "curadvisor.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1703052000000, + "optOutUrl": "https://curadvisor.com/nada/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/cyberbackgroundchecks.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/cyberbackgroundchecks.com.json index e9fc7a8a72..a96ec6fe2b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/cyberbackgroundchecks.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/cyberbackgroundchecks.com.json @@ -1,9 +1,10 @@ { "name": "Cyber Background Checks", "url": "cyberbackgroundchecks.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1705644000000, + "optOutUrl": "https://cyberbackgroundchecks.com/donotsellmyinfo", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/dataveria.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/dataveria.com.json index 13f93af79b..c1af6f4b9b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/dataveria.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/dataveria.com.json @@ -1,9 +1,10 @@ { "name": "Dataveria", "url": "dataveria.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1677736800000, + "optOutUrl": "https://dataveria.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastbackgroundcheck.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastbackgroundcheck.com.json index c27fa7e2b8..b8553b4234 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastbackgroundcheck.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastbackgroundcheck.com.json @@ -1,9 +1,10 @@ { "name": "FastBackgroundCheck.com", "url": "fastbackgroundcheck.com", - "version": "0.1.5", + "version": "0.2.0", "parent": "peoplefinders.com", "addedDatetime": 1706248800000, + "optOutUrl": "https://www.fastbackgroundcheck.com/opt-out", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastpeoplesearch.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastpeoplesearch.com.json index a93344ee2b..e72f5c4ee1 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastpeoplesearch.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/fastpeoplesearch.com.json @@ -1,9 +1,10 @@ { "name": "FastPeopleSearch", "url": "fastpeoplesearch.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1675317600000, + "optOutUrl": "https://www.fastpeoplesearch.com/removal", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/freepeopledirectory.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/freepeopledirectory.com.json index e1c73055b3..9c6bef5acb 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/freepeopledirectory.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/freepeopledirectory.com.json @@ -1,9 +1,10 @@ { "name": "FreePeopleDirectory", "url": "freepeopledirectory.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "spokeo.com", "addedDatetime": 1674540000000, + "optOutUrl": "https://freepeopledirectory.com/contact", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/inforver.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/inforver.com.json index dd5e640a84..8eb8abac49 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/inforver.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/inforver.com.json @@ -1,9 +1,10 @@ { "name": "Inforver", "url": "inforver.com", - "version": "0.1.5", + "version": "0.2.0", "parent": "verecor.com", "addedDatetime": 1677736800000, + "optOutUrl": "https://persontrust.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/kwold.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/kwold.com.json index 27934c7dd6..6aa687a239 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/kwold.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/kwold.com.json @@ -1,9 +1,10 @@ { "name": "Kwold", "url": "kwold.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1702965600000, + "optOutUrl": "https://kwold.com/ns/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/mylife.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/mylife.com.json index cf41273da7..ce751c4b01 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/mylife.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/mylife.com.json @@ -1,8 +1,9 @@ { "name": "mylife", "url": "mylife.com", - "version": "0.1.0", + "version": "0.2.0", "addedDatetime": 1715797497496, + "optOutUrl": "https://www.mylife.com/ccpa/index.pubview", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/neighbor.report.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/neighbor.report.json index 44866ad60b..05162de644 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/neighbor.report.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/neighbor.report.json @@ -1,8 +1,9 @@ { "name": "Neighbor Report", "url": "neighbor.report", - "version": "0.2.0", + "version": "0.4.0", "addedDatetime": 1703570400000, + "optOutUrl": "https://neighbor.report/remove", "steps": [ { "stepType": "scan", @@ -62,17 +63,9 @@ "id": "7fbaf65f-c130-494b-8f61-a77cebb422f4", "selector": ".form-horizontal", "elements": [ - { - "type": "fullName", - "selector": ".//input[@name='inputName']" - }, { "type": "email", "selector": ".//input[@name='inputEmail']" - }, - { - "type": "profileUrl", - "selector": ".//input[@name='inputURL']" } ] }, @@ -103,17 +96,110 @@ { "type": "text", "selector": "body", - "expect": "Remove persons" + "expect": "How to remove" } ] }, + { + "actionType": "click", + "elements": [ + { + "type": "button", + "selector": "//input[@name='locality']" + } + ], + "id": "0f1a545b-7dcf-4c66-b811-1d2c49d211ca" + }, + { + "actionType": "fillForm", + "selector": "//form[@data-class='city-search']", + "elements": [ + { + "type": "fullName", + "selector": ".//input[@name='name']" + } + ], + "id": "e9f9e323-3234-4e90-adec-cb503c7f0b43" + }, + { + "actionType": "fillForm", + "selector": "//form[@data-class='city']", + "dataSource": "userProfile", + "elements": [ + { + "type": "cityState", + "selector": ".//input[@name='q']" + } + ], + "id": "8184c48e-80c0-4259-8798-dc9721eaba52" + }, + { + "actionType": "click", + "elements": [ + { + "type": "button", + "selector": "//div[@class='deep-suggestion-block'][1]" + } + ], + "id": "0a28b6d1-4865-4183-97eb-561efce0ca0b" + }, + { + "actionType": "click", + "elements": [ + { + "type": "button", + "selector": "//button[@type='submit']" + } + ], + "id": "daad4b56-01e6-410d-9ee9-9c8490507ce1" + }, + { + "actionType": "expectation", + "expectations": [ + { + "type": "element", + "selector": ".//div[@class='results']//div" + } + ], + "id": "276f754b-9289-46f6-ae06-877b98af1cac" + }, + { + "actionType": "click", + "elements": [ + { + "selector": "a[class~='btn-success']", + "type": "button", + "parent": { + "profileMatch": { + "selector": "//div[@class='results']/div", + "profile": { + "name": { + "selector": ".//div[@class='h4']//strong", + "beforeText": "," + }, + "age": { + "selector": ".//div[@class='h4']//strong//span", + "afterText": ", ", + "beforeText": "years" + }, + "addressFull": { + "selector": "./div/div[2]" + } + } + } + } + } + ], + "id": "b7a4e671-c78c-4047-b047-6d4b30479022" + }, { "actionType": "click", "id": "1f411b2e-9f93-4519-ae35-7c0411b535e1", "elements": [ { "type": "button", - "selector": ".btn-danger" + "selector": ".btn-danger", + "multiple": true }, { "type": "button", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/newenglandfacts.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/newenglandfacts.com.json index 04a3864c77..eda48cd136 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/newenglandfacts.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/newenglandfacts.com.json @@ -1,9 +1,10 @@ { "name": "New England Facts", "url": "newenglandfacts.com", - "version": "0.1.5", + "version": "0.2.0", "parent": "verecor.com", "addedDatetime": 1703052000000, + "optOutUrl": "https://newenglandfacts.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/officialusa.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/officialusa.com.json index 2cb897e497..8e3b26ea82 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/officialusa.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/officialusa.com.json @@ -1,9 +1,10 @@ { "name": "OfficialUSA", "url": "officialusa.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "neighbor.report", "addedDatetime": 1692594000000, + "optOutUrl": "https://www.officialusa.com/opt-out/", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-background-check.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-background-check.com.json index 44969a2613..90c7a76451 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-background-check.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-background-check.com.json @@ -1,9 +1,10 @@ { "name": "People Background Check", "url": "people-background-check.com", - "version": "0.3.0", + "version": "0.4.0", "parent": "verecor.com", "addedDatetime": 1702965600000, + "optOutUrl": "https://people-background-check.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-wizard.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-wizard.com.json index 394e39b40f..6869ad81c3 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-wizard.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/people-wizard.com.json @@ -1,9 +1,10 @@ { "name": "People-Wizard.com", "url": "people-wizard.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://people-wizard.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplefinders.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplefinders.com.json index 50b327ae49..e5235c490b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplefinders.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplefinders.com.json @@ -1,8 +1,9 @@ { "name": "PeopleFinders", "url": "peoplefinders.com", - "version": "0.3.0", + "version": "0.4.0", "addedDatetime": 1677132000000, + "optOutUrl": "https://www.peoplefinders.com/opt-out", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplesearchnow.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplesearchnow.com.json index dc8a8c711a..80726eba2e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplesearchnow.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplesearchnow.com.json @@ -1,9 +1,10 @@ { "name": "People Search Now", "url": "peoplesearchnow.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1705989600000, + "optOutUrl": "https://www.peoplesearchnow.com/opt-out", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswhizr.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswhizr.com.json index 2bea79b774..404ea1880a 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswhizr.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswhizr.com.json @@ -1,9 +1,10 @@ { "name": "PeoplesWhizr", "url": "peopleswhizr.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peopleswhizr.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswiz.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswiz.com.json index 3d88a0b760..fce9ab7779 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswiz.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswiz.com.json @@ -1,9 +1,10 @@ { "name": "PeoplesWiz", "url": "peopleswiz.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peopleswiz.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswizard.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswizard.com.json index 96a8049550..c643df4e9b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswizard.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peopleswizard.com.json @@ -1,9 +1,10 @@ { "name": "PeoplesWizard", "url": "peopleswizard.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peopleswizard.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.com.json index f51b86a541..e17433a1b6 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.com.json @@ -1,8 +1,9 @@ { "name": "PeopleWhiz.com", "url": "peoplewhiz.com", - "version": "0.2.0", + "version": "0.3.0", "addedDatetime": 1676160000000, + "optOutUrl": "https://www.peoplewhiz.com/remove-my-info", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.net.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.net.json index cd7f9b1b23..d2208183a4 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.net.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhiz.net.json @@ -1,9 +1,10 @@ { "name": "PeopleWhiz.net", "url": "peoplewhiz.net", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709424000000, + "optOutUrl": "https://peoplewhiz.net/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.com.json index 18f4023df7..f25b73566b 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.com.json @@ -1,9 +1,10 @@ { "name": "PeopleWhized.com", "url": "peoplewhized.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709424000000, + "optOutUrl": "https://peoplewhized.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.net.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.net.json index 37b4f1d0ea..da48cfd3cd 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.net.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhized.net.json @@ -1,9 +1,10 @@ { "name": "PeopleWhized.net", "url": "peoplewhized.net", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peoplewhized.net/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.com.json index c2bb799576..35d74a9e43 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.com.json @@ -1,9 +1,10 @@ { "name": "PeopleWhizr.com", "url": "peoplewhizr.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peoplewhizr.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.net.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.net.json index 7afddb9410..7f21bcb86d 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.net.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewhizr.net.json @@ -1,9 +1,10 @@ { "name": "PeopleWhizr.net", "url": "peoplewhizr.net", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peoplewhizr.net/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewiz.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewiz.com.json index 25e6637ad9..1fca4818ff 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewiz.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewiz.com.json @@ -1,9 +1,10 @@ { "name": "PeopleWiz", "url": "peoplewiz.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peoplewiz.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizard.net.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizard.net.json index b318e39d57..d248ab9ba4 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizard.net.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizard.net.json @@ -1,9 +1,10 @@ { "name": "PeopleWizard.net", "url": "peoplewizard.net", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peoplewizard.net/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizr.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizr.com.json index bda74021eb..93c1cb9c2a 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizr.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/peoplewizr.com.json @@ -1,9 +1,10 @@ { "name": "PeopleWizr", "url": "peoplewizr.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplewhiz.com", "addedDatetime": 1709445600000, + "optOutUrl": "https://peoplewizr.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/pub360.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/pub360.com.json index f43c1ba8ad..8cb36f320f 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/pub360.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/pub360.com.json @@ -1,9 +1,10 @@ { "name": "Pub360", "url": "pub360.com", - "version": "0.1.5", + "version": "0.2.0", "parent": "verecor.com", "addedDatetime": 1677736800000, + "optOutUrl": "https://plcom.net/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/publicreports.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/publicreports.com.json index 6e8dc7e14b..972f8b7cf0 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/publicreports.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/publicreports.com.json @@ -1,9 +1,10 @@ { "name": "PublicReports", "url": "publicreports.com", - "version": "0.1.5", + "version": "0.2.0", "parent": "verecor.com", "addedDatetime": 1703052000000, + "optOutUrl": "https://persontrust.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/quickpeopletrace.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/quickpeopletrace.com.json index 95632dc76b..1fe00b938e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/quickpeopletrace.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/quickpeopletrace.com.json @@ -1,9 +1,10 @@ { "name": "Quick People Trace", "url": "quickpeopletrace.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1674540000000, + "optOutUrl": "https://www.quickpeopletrace.com/contact-us/", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/searchpeoplefree.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/searchpeoplefree.com.json index b61ac35153..04ccf151a3 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/searchpeoplefree.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/searchpeoplefree.com.json @@ -1,9 +1,10 @@ { "name": "Search People FREE", "url": "searchpeoplefree.com", - "version": "0.3.0", + "version": "0.4.0", "parent": "peoplefinders.com", "addedDatetime": 1703052000000, + "optOutUrl": "https://www.searchpeoplefree.com/opt-out", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/smartbackgroundchecks.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/smartbackgroundchecks.com.json index 6c4cbe397b..bb91a77e1c 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/smartbackgroundchecks.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/smartbackgroundchecks.com.json @@ -1,9 +1,10 @@ { "name": "SmartBackgroundChecks", "url": "smartbackgroundchecks.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1678082400000, + "optOutUrl": "https://www.smartbackgroundchecks.com/optout", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/spokeo.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/spokeo.com.json index 3586fc4aa1..7f81b9ebb0 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/spokeo.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/spokeo.com.json @@ -1,32 +1,37 @@ { "name": "Spokeo", "url": "spokeo.com", - "version": "0.3.0", + "version": "0.4.0", "addedDatetime": 1692572400000, + "optOutUrl": "https://spokeo.com/optout", "mirrorSites": [ { "name": "CallerSmart", "url": "callersmart.com", "addedAt": 1705599286529, - "removedAt": null + "removedAt": null, + "optOutUrl": "https://www.callersmart.com/opt-out" }, { "name": "Selfie Network", "url": "selfie.network", "addedAt": 1705599286529, - "removedAt": null + "removedAt": null, + "optOutUrl": "https://spokeo.com/optout" }, { "name": "Selfie Systems", "url": "selfie.systems", "addedAt": 1705599286529, - "removedAt": null + "removedAt": null, + "optOutUrl": "https://spokeo.com/optout" }, { "name": "PeopleWin", "url": "peoplewin.com", "addedAt": 1705599286529, - "removedAt": null + "removedAt": null, + "optOutUrl": "https://www.spokeo.com/optout" } ], "steps": [ diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/truepeoplesearch.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/truepeoplesearch.com.json index 8bf070f6a7..abeae8096d 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/truepeoplesearch.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/truepeoplesearch.com.json @@ -1,9 +1,10 @@ { "name": "TruePeopleSearch", "url": "truepeoplesearch.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1703138400000, + "optOutUrl": "https://www.truepeoplesearch.com/removal", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usa-people-search.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usa-people-search.com.json index be524c576c..c53577b07f 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usa-people-search.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usa-people-search.com.json @@ -1,9 +1,10 @@ { "name": "USA People Search", "url": "usa-people-search.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1678082400000, + "optOutUrl": "https://www.usa-people-search.com/manage", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usatrace.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usatrace.com.json index b60b879512..7455e16563 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usatrace.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usatrace.com.json @@ -1,9 +1,10 @@ { "name": "USA Trace", "url": "usatrace.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1674540000000, + "optOutUrl": "https://www.usatrace.com/contact-us/", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usphonebook.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usphonebook.com.json index ca6e8ec4b9..c2813dfad5 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usphonebook.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/usphonebook.com.json @@ -1,9 +1,10 @@ { "name": "USPhoneBook", "url": "usphonebook.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "peoplefinders.com", "addedDatetime": 1678082400000, + "optOutUrl": "https://www.usphonebook.com/opt-out", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/verecor.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/verecor.com.json index 4764541a2a..d3e6e69c12 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/verecor.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/verecor.com.json @@ -1,8 +1,9 @@ { "name": "Verecor", "url": "verecor.com", - "version": "0.2.0", + "version": "0.3.0", "addedDatetime": 1677132000000, + "optOutUrl": "https://verecor.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/vericora.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/vericora.com.json index 5d12472d64..23b7a1e295 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/vericora.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/vericora.com.json @@ -1,9 +1,10 @@ { "name": "Vericora", "url": "vericora.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1677736800000, + "optOutUrl": "https://vericora.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veriforia.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veriforia.com.json index eaa42e6687..6dc5d21a52 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veriforia.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veriforia.com.json @@ -1,9 +1,10 @@ { "name": "Veriforia", "url": "veriforia.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1677736800000, + "optOutUrl": "https://veriforia.com/ng/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veripages.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veripages.com.json index f34c0b98ee..b642833a4c 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veripages.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/veripages.com.json @@ -1,9 +1,10 @@ { "name": "Veripages", "url": "veripages.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1691989200000, + "optOutUrl": "https://veripages.com/inner/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/virtory.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/virtory.com.json index 00c964556d..494a43d63e 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/virtory.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/virtory.com.json @@ -1,9 +1,10 @@ { "name": "Virtory", "url": "virtory.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1703052000000, + "optOutUrl": "https://virtory.com/prvt/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/wellnut.com.json b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/wellnut.com.json index 4ed98c00a4..cb0f4b47d8 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/wellnut.com.json +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Resources/JSON/wellnut.com.json @@ -1,9 +1,10 @@ { "name": "Wellnut", "url": "wellnut.com", - "version": "0.2.0", + "version": "0.3.0", "parent": "verecor.com", "addedDatetime": 1703052000000, + "optOutUrl": "https://wellnut.com/noi/control/privacy", "steps": [ { "stepType": "scan", diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerExecutionConfig.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerExecutionConfig.swift index 7720725d85..953b68e824 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerExecutionConfig.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerExecutionConfig.swift @@ -26,9 +26,9 @@ public struct DataBrokerExecutionConfig { private let concurrentOperationsOnManualScans: Int = 6 func concurrentOperationsFor(_ operation: OperationType) -> Int { switch operation { - case .all, .optOut: + case .all, .optOut, .scheduledScan: return concurrentOperationsDifferentBrokers - case .scan: + case .manualScan: return concurrentOperationsOnManualScans } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionAgentManager.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionAgentManager.swift index ca406149b8..e60248a16f 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionAgentManager.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionAgentManager.swift @@ -23,17 +23,21 @@ import BrowserServicesKit import Configuration import PixelKit import os.log +import Freemium +import Subscription +import UserNotifications // This is to avoid exposing all the dependancies outside of the DBP package public class DataBrokerProtectionAgentManagerProvider { - public static func agentManager(authenticationManager: DataBrokerProtectionAuthenticationManaging) -> DataBrokerProtectionAgentManager { + public static func agentManager(authenticationManager: DataBrokerProtectionAuthenticationManaging, + accountManager: AccountManager) -> DataBrokerProtectionAgentManager { let pixelHandler = DataBrokerProtectionPixelsHandler() let executionConfig = DataBrokerExecutionConfig() let activityScheduler = DefaultDataBrokerProtectionBackgroundActivityScheduler(config: executionConfig) - let notificationService = DefaultDataBrokerProtectionUserNotificationService(pixelHandler: pixelHandler) + let notificationService = DefaultDataBrokerProtectionUserNotificationService(pixelHandler: pixelHandler, userNotificationCenter: UNUserNotificationCenter.current(), authenticationManager: authenticationManager) Configuration.setURLProvider(DBPAgentConfigurationURLProvider()) let configStore = ConfigurationStore() let privacyConfigurationManager = DBPPrivacyConfigurationManager() @@ -82,10 +86,13 @@ public class DataBrokerProtectionAgentManagerProvider { emailService: emailService, captchaService: captchaService) + let freemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp) + let agentstopper = DefaultDataBrokerProtectionAgentStopper(dataManager: dataManager, entitlementMonitor: DataBrokerProtectionEntitlementMonitor(), authenticationManager: authenticationManager, - pixelHandler: pixelHandler) + pixelHandler: pixelHandler, + freemiumDBPUserStateManager: freemiumDBPUserStateManager) let operationDependencies = DefaultDataBrokerOperationDependencies( database: dataManager.database, @@ -105,7 +112,9 @@ public class DataBrokerProtectionAgentManagerProvider { pixelHandler: pixelHandler, agentStopper: agentstopper, configurationManager: configurationManager, - privacyConfigurationManager: privacyConfigurationManager) + privacyConfigurationManager: privacyConfigurationManager, + authenticationManager: authenticationManager, + freemiumDBPUserStateManager: freemiumDBPUserStateManager) } } @@ -121,6 +130,8 @@ public final class DataBrokerProtectionAgentManager { private let agentStopper: DataBrokerProtectionAgentStopper private let configurationManger: DefaultConfigurationManager private let privacyConfigurationManager: DBPPrivacyConfigurationManager + private let authenticationManager: DataBrokerProtectionAuthenticationManaging + private let freemiumDBPUserStateManager: FreemiumDBPUserStateManager // Used for debug functions only, so not injected private lazy var browserWindowManager = BrowserWindowManager() @@ -138,7 +149,9 @@ public final class DataBrokerProtectionAgentManager { pixelHandler: EventMapping, agentStopper: DataBrokerProtectionAgentStopper, configurationManager: DefaultConfigurationManager, - privacyConfigurationManager: DBPPrivacyConfigurationManager + privacyConfigurationManager: DBPPrivacyConfigurationManager, + authenticationManager: DataBrokerProtectionAuthenticationManaging, + freemiumDBPUserStateManager: FreemiumDBPUserStateManager ) { self.userNotificationService = userNotificationService self.activityScheduler = activityScheduler @@ -150,6 +163,8 @@ public final class DataBrokerProtectionAgentManager { self.agentStopper = agentStopper self.configurationManger = configurationManager self.privacyConfigurationManager = privacyConfigurationManager + self.authenticationManager = authenticationManager + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager self.activityScheduler.delegate = self self.ipcServer.serverDelegate = self @@ -167,11 +182,11 @@ public final class DataBrokerProtectionAgentManager { activityScheduler.startScheduler() didStartActivityScheduler = true fireMonitoringPixels() - queueManager.startScheduledOperationsIfPermitted(showWebView: false, operationDependencies: operationDependencies, errorHandler: nil, completion: nil) + startFreemiumOrSubscriptionScheduledOperations(showWebView: false, operationDependencies: operationDependencies, errorHandler: nil, completion: nil) /// Monitors entitlement changes every 60 minutes to optimize system performance and resource utilization by avoiding unnecessary operations when entitlement is invalid. /// While keeping the agent active with invalid entitlement has no significant risk, setting the monitoring interval at 60 minutes is a good balance to minimize backend checks. - agentStopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: .minutes(60)) + agentStopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: .minutes(60)) configurationSubscription = privacyConfigurationManager.updatesPublisher .sink { [weak self] _ in @@ -187,6 +202,9 @@ public final class DataBrokerProtectionAgentManager { extension DataBrokerProtectionAgentManager { func fireMonitoringPixels() { + // Only send pixels for authenticated users + guard authenticationManager.isUserAuthenticated else { return } + let database = operationDependencies.database let engagementPixels = DataBrokerProtectionEngagementPixels(database: database, handler: pixelHandler) @@ -199,11 +217,35 @@ extension DataBrokerProtectionAgentManager { eventPixels.tryToFireWeeklyPixels() // This will try to fire the stats pixels statsPixels.tryToFireStatsPixels() + + // If a user upgraded from Freemium, don't send 24-hour opt-out submit pixels + guard !freemiumDBPUserStateManager.didActivate else { return } + // Fire custom stats pixels if needed statsPixels.fireCustomStatsPixelsIfNeeded() } } +private extension DataBrokerProtectionAgentManager { + + /// Starts either Subscription (scan and opt-out) or Freemium (scan-only) scheduled operations + /// - Parameters: + /// - showWebView: Whether to show the web view or not + /// - operationDependencies: Operation dependencies + /// - errorHandler: Error handler + /// - completion: Completion handler + func startFreemiumOrSubscriptionScheduledOperations(showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) { + if authenticationManager.isUserAuthenticated { + queueManager.startScheduledAllOperationsIfPermitted(showWebView: showWebView, operationDependencies: operationDependencies, errorHandler: errorHandler, completion: completion) + } else { + queueManager.startScheduledScanOperationsIfPermitted(showWebView: showWebView, operationDependencies: operationDependencies, errorHandler: errorHandler, completion: completion) + } + } +} + extension DataBrokerProtectionAgentManager: DataBrokerProtectionBackgroundActivitySchedulerDelegate { public func dataBrokerProtectionBackgroundActivitySchedulerDidTrigger(_ activityScheduler: DataBrokerProtection.DataBrokerProtectionBackgroundActivityScheduler, completion: (() -> Void)?) { @@ -212,10 +254,9 @@ extension DataBrokerProtectionAgentManager: DataBrokerProtectionBackgroundActivi func startScheduledOperations(completion: (() -> Void)?) { fireMonitoringPixels() - queueManager.startScheduledOperationsIfPermitted(showWebView: false, - operationDependencies: operationDependencies, - errorHandler: nil, - completion: completion) + startFreemiumOrSubscriptionScheduledOperations(showWebView: false, operationDependencies: operationDependencies, errorHandler: nil) { + completion?() + } } } @@ -225,7 +266,7 @@ extension DataBrokerProtectionAgentManager: DataBrokerProtectionAgentAppEvents { userNotificationService.requestNotificationPermission() fireMonitoringPixels() - queueManager.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: operationDependencies) { [weak self] errors in + queueManager.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: operationDependencies) { [weak self] errors in guard let self = self else { return } if let errors = errors { @@ -265,9 +306,9 @@ extension DataBrokerProtectionAgentManager: DataBrokerProtectionAgentAppEvents { public func appLaunched() { fireMonitoringPixels() - queueManager.startScheduledOperationsIfPermitted(showWebView: false, - operationDependencies: operationDependencies, - errorHandler: { [weak self] errors in + startFreemiumOrSubscriptionScheduledOperations(showWebView: false, + operationDependencies: + operationDependencies, errorHandler: { [weak self] errors in guard let self = self else { return } if let errors = errors { @@ -316,14 +357,14 @@ extension DataBrokerProtectionAgentManager: DataBrokerProtectionAgentDebugComman } public func startImmediateOperations(showWebView: Bool) { - queueManager.startImmediateOperationsIfPermitted(showWebView: showWebView, + queueManager.startImmediateScanOperationsIfPermitted(showWebView: showWebView, operationDependencies: operationDependencies, errorHandler: nil, completion: nil) } public func startScheduledOperations(showWebView: Bool) { - queueManager.startScheduledOperationsIfPermitted(showWebView: showWebView, + startFreemiumOrSubscriptionScheduledOperations(showWebView: showWebView, operationDependencies: operationDependencies, errorHandler: nil, completion: nil) diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionQueueManager.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionQueueManager.swift index 8ed9e8dd3f..feedc98340 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionQueueManager.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Scheduler/DataBrokerProtectionQueueManager.swift @@ -75,14 +75,18 @@ protocol DataBrokerProtectionQueueManager { brokerUpdater: DataBrokerProtectionBrokerUpdater?, pixelHandler: EventMapping) - func startImmediateOperationsIfPermitted(showWebView: Bool, - operationDependencies: DataBrokerOperationDependencies, - errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, - completion: (() -> Void)?) - func startScheduledOperationsIfPermitted(showWebView: Bool, - operationDependencies: DataBrokerOperationDependencies, - errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, - completion: (() -> Void)?) + func startImmediateScanOperationsIfPermitted(showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) + func startScheduledAllOperationsIfPermitted(showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) + func startScheduledScanOperationsIfPermitted(showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) func execute(_ command: DataBrokerProtectionQueueManagerDebugCommand) var debugRunningStatusString: String { get } @@ -122,14 +126,14 @@ final class DefaultDataBrokerProtectionQueueManager: DataBrokerProtectionQueueMa self.pixelHandler = pixelHandler } - func startImmediateOperationsIfPermitted(showWebView: Bool, - operationDependencies: DataBrokerOperationDependencies, - errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, - completion: (() -> Void)?) { + func startImmediateScanOperationsIfPermitted(showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) { let newMode = DataBrokerProtectionQueueMode.immediate(errorHandler: errorHandler, completion: completion) startOperationsIfPermitted(forNewMode: newMode, - type: .scan, + type: .manualScan, showWebView: showWebView, operationDependencies: operationDependencies) { [weak self] errors in self?.mismatchCalculator.calculateMismatches() @@ -139,17 +143,26 @@ final class DefaultDataBrokerProtectionQueueManager: DataBrokerProtectionQueueMa } } - func startScheduledOperationsIfPermitted(showWebView: Bool, - operationDependencies: DataBrokerOperationDependencies, - errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, - completion: (() -> Void)?) { - let newMode = DataBrokerProtectionQueueMode.scheduled(errorHandler: errorHandler, completion: completion) - startOperationsIfPermitted(forNewMode: newMode, - type: .all, - showWebView: showWebView, - operationDependencies: operationDependencies, - errorHandler: errorHandler, - completion: completion) + func startScheduledAllOperationsIfPermitted(showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) { + startScheduleOperationsIfPermitted(withOperationType: .all, + showWebView: showWebView, + operationDependencies: operationDependencies, + errorHandler: errorHandler, + completion: completion) + } + + func startScheduledScanOperationsIfPermitted(showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) { + startScheduleOperationsIfPermitted(withOperationType: .scheduledScan, + showWebView: showWebView, + operationDependencies: operationDependencies, + errorHandler: errorHandler, + completion: completion) } func execute(_ command: DataBrokerProtectionQueueManagerDebugCommand) { @@ -168,6 +181,20 @@ final class DefaultDataBrokerProtectionQueueManager: DataBrokerProtectionQueueMa private extension DefaultDataBrokerProtectionQueueManager { + func startScheduleOperationsIfPermitted(withOperationType operationType: OperationType, + showWebView: Bool, + operationDependencies: DataBrokerOperationDependencies, + errorHandler: ((DataBrokerProtectionAgentErrorCollection?) -> Void)?, + completion: (() -> Void)?) { + let newMode = DataBrokerProtectionQueueMode.scheduled(errorHandler: errorHandler, completion: completion) + startOperationsIfPermitted(forNewMode: newMode, + type: operationType, + showWebView: showWebView, + operationDependencies: operationDependencies, + errorHandler: errorHandler, + completion: completion) + } + func startOperationsIfPermitted(forNewMode newMode: DataBrokerProtectionQueueMode, type: OperationType, showWebView: Bool, diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Storage/Mappers.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Storage/Mappers.swift index fc221f3872..ab3be3f110 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Storage/Mappers.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Storage/Mappers.swift @@ -176,7 +176,8 @@ struct MapperToModel { version: decodedBroker.version, schedulingConfig: decodedBroker.schedulingConfig, parent: decodedBroker.parent, - mirrorSites: decodedBroker.mirrorSites + mirrorSites: decodedBroker.mirrorSites, + optOutUrl: decodedBroker.optOutUrl ) } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift index 1438e0b0dc..5992962044 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift @@ -24,6 +24,7 @@ import Common import os.log protocol DBPUICommunicationDelegate: AnyObject { + func getHandshakeUserData() -> DBPUIHandshakeUserData? func saveProfile() async throws func getUserProfile() -> DBPUIUserProfile? func deleteProfileData() throws @@ -78,7 +79,7 @@ struct DBPUICommunicationLayer: Subfeature { weak var delegate: DBPUICommunicationDelegate? private enum Constants { - static let version = 6 + static let version = 8 } internal init(webURLSettings: DataBrokerProtectionWebUIURLSettingsRepresentable, @@ -126,13 +127,16 @@ struct DBPUICommunicationLayer: Subfeature { throw DBPUIError.malformedRequest } + // Attempt to get handshake user data, but fallback to a default + let userData = delegate?.getHandshakeUserData() ?? DBPUIHandshakeUserData(isAuthenticatedUser: true) + if result.version != Constants.version { Logger.dataBrokerProtection.debug("Incorrect protocol version presented by UI") - return DBPUIStandardResponse(version: Constants.version, success: false) + return DBPUIHandshakeResponse(version: Constants.version, success: false, userdata: userData) } Logger.dataBrokerProtection.debug("Successful handshake made by UI") - return DBPUIStandardResponse(version: Constants.version, success: true) + return DBPUIHandshakeResponse(version: Constants.version, success: true, userdata: userData) } func saveProfile(params: Any, original: WKScriptMessage) async throws -> Encodable? { diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift index d59fbd1f38..36efa17943 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/UIMapper.swift @@ -44,51 +44,11 @@ struct MapperToUI { totalScans: totalScans, scannedBrokers: partiallyScannedBrokers) - let matches = mapMatchesToUI(withoutDeprecated) + let matches = DBPUIDataBrokerProfileMatch.profileMatches(from: withoutDeprecated) return .init(resultsFound: matches, scanProgress: scanProgress) } - private func mapMatchesToUI(_ brokerProfileQueryData: [BrokerProfileQueryData]) -> [DBPUIDataBrokerProfileMatch] { - - // Used to find opt outs on the parent - let brokerURLsToQueryData = Dictionary(grouping: brokerProfileQueryData, by: { $0.dataBroker.url }) - - return brokerProfileQueryData.flatMap { - var profiles = [DBPUIDataBrokerProfileMatch]() - for optOutJobData in $0.optOutJobData { - let dataBroker = $0.dataBroker - - var parentBrokerOptOutJobData: [OptOutJobData]? - if let parent = dataBroker.parent, - let parentsQueryData = brokerURLsToQueryData[parent] { - parentBrokerOptOutJobData = parentsQueryData.flatMap { $0.optOutJobData } - } - - profiles.append(DBPUIDataBrokerProfileMatch(optOutJobData: optOutJobData, - dataBroker: dataBroker, - parentBrokerOptOutJobData: parentBrokerOptOutJobData)) - - if !dataBroker.mirrorSites.isEmpty { - let mirrorSitesMatches = dataBroker.mirrorSites.compactMap { mirrorSite in - if mirrorSite.shouldWeIncludeMirrorSite() { - return DBPUIDataBrokerProfileMatch(optOutJobData: optOutJobData, - dataBrokerName: mirrorSite.name, - dataBrokerURL: mirrorSite.url, - dataBrokerParentURL: dataBroker.parent, - parentBrokerOptOutJobData: parentBrokerOptOutJobData) - } - - return nil - } - profiles.append(contentsOf: mirrorSitesMatches) - } - } - - return profiles - } - } - func maintenanceScanState(_ brokerProfileQueryData: [BrokerProfileQueryData]) -> DBPUIScanAndOptOutMaintenanceState { var inProgressOptOuts = [DBPUIDataBrokerProfileMatch]() var removedProfiles = [DBPUIDataBrokerProfileMatch]() @@ -113,7 +73,8 @@ struct MapperToUI { let profileMatch = DBPUIDataBrokerProfileMatch(optOutJobData: optOutJob, dataBroker: dataBroker, - parentBrokerOptOutJobData: parentBrokerOptOutJobData) + parentBrokerOptOutJobData: parentBrokerOptOutJobData, + optOutUrl: dataBroker.optOutUrl) if extractedProfile.removedDate == nil { inProgressOptOuts.append(profileMatch) @@ -127,7 +88,8 @@ struct MapperToUI { dataBrokerName: mirrorSite.name, dataBrokerURL: mirrorSite.url, dataBrokerParentURL: dataBroker.parent, - parentBrokerOptOutJobData: parentBrokerOptOutJobData) + parentBrokerOptOutJobData: parentBrokerOptOutJobData, + optOutUrl: dataBroker.optOutUrl) if let extractedProfileRemovedDate = extractedProfile.removedDate, mirrorSite.shouldWeIncludeMirrorSite(for: extractedProfileRemovedDate) { @@ -175,13 +137,15 @@ struct MapperToUI { brokers.append(DBPUIDataBroker(name: $0.dataBroker.name, url: $0.dataBroker.url, date: $0.scanJobData.lastRunDate!.timeIntervalSince1970, - parentURL: $0.dataBroker.parent)) + parentURL: $0.dataBroker.parent, + optOutUrl: $0.dataBroker.optOutUrl)) for mirrorSite in $0.dataBroker.mirrorSites where mirrorSite.addedAt < $0.scanJobData.lastRunDate! { brokers.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, date: $0.scanJobData.lastRunDate!.timeIntervalSince1970, - parentURL: $0.dataBroker.parent)) + parentURL: $0.dataBroker.parent, + optOutUrl: $0.dataBroker.optOutUrl)) } return brokers @@ -211,7 +175,8 @@ struct MapperToUI { brokers.append(DBPUIDataBroker(name: $0.dataBroker.name, url: $0.dataBroker.url, date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970, - parentURL: $0.dataBroker.parent)) + parentURL: $0.dataBroker.parent, + optOutUrl: $0.dataBroker.optOutUrl)) for mirrorSite in $0.dataBroker.mirrorSites { if let removedDate = mirrorSite.removedAt { @@ -219,13 +184,15 @@ struct MapperToUI { brokers.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970, - parentURL: $0.dataBroker.parent)) + parentURL: $0.dataBroker.parent, + optOutUrl: $0.dataBroker.optOutUrl)) } } else { brokers.append(DBPUIDataBroker(name: mirrorSite.name, url: mirrorSite.url, date: $0.scanJobData.preferredRunDate!.timeIntervalSince1970, - parentURL: $0.dataBroker.parent)) + parentURL: $0.dataBroker.parent, + optOutUrl: $0.dataBroker.optOutUrl)) } } @@ -519,14 +486,3 @@ extension HistoryEvent { } } } - -fileprivate extension MirrorSite { - - func shouldWeIncludeMirrorSite(for date: Date = Date()) -> Bool { - if let removedAt = self.removedAt { - return self.addedAt < date && date < removedAt - } else { - return self.addedAt < date - } - } -} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UserNotifications/DataBrokerProtectionUserNotificationService.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UserNotifications/DataBrokerProtectionUserNotificationService.swift index ef8599d437..6e39a50336 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UserNotifications/DataBrokerProtectionUserNotificationService.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UserNotifications/DataBrokerProtectionUserNotificationService.swift @@ -21,6 +21,7 @@ import UserNotifications import Common import AppKit import os.log +import PixelKit public enum DataBrokerProtectionNotificationCommand: String { case showDashboard = "databrokerprotection://show_dashboard" @@ -38,18 +39,37 @@ public protocol DataBrokerProtectionUserNotificationService { func scheduleCheckInNotificationIfPossible() } +// Protocol to enable injection and testing of `DataBrokerProtectionUserNotificationService` +public protocol DBPUserNotificationCenter { + var delegate: (any UNUserNotificationCenterDelegate)? { get set } + func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: (((any Error)?) -> Void)?) + func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void) + func requestAuthorization(options: UNAuthorizationOptions, completionHandler: @escaping (Bool, (any Error)?) -> Void) +} + +// Conform system `UNUserNotificationCenter` to `DBPUserNotificationCenter` protocol +extension UNUserNotificationCenter: DBPUserNotificationCenter {} + public class DefaultDataBrokerProtectionUserNotificationService: NSObject, DataBrokerProtectionUserNotificationService { private let pixelHandler: EventMapping private let userDefaults: UserDefaults - private let userNotificationCenter: UNUserNotificationCenter + private var userNotificationCenter: DBPUserNotificationCenter + private let authenticationManager: DataBrokerProtectionAuthenticationManaging private let areNotificationsEnabled = true + /// The `FreemiumDBPExperimentPixelHandler` instance used to fire pixels + private let freemiumDBPExperimentPixelHandler: EventMapping + public init(pixelHandler: EventMapping, userDefaults: UserDefaults = .standard, - userNotificationCenter: UNUserNotificationCenter = .current()) { + userNotificationCenter: DBPUserNotificationCenter, + authenticationManager: DataBrokerProtectionAuthenticationManaging, + freemiumDBPExperimentPixelHandler: EventMapping = FreemiumDBPExperimentPixelHandler()) { self.pixelHandler = pixelHandler self.userDefaults = userDefaults self.userNotificationCenter = userNotificationCenter + self.authenticationManager = authenticationManager + self.freemiumDBPExperimentPixelHandler = freemiumDBPExperimentPixelHandler super.init() @@ -109,8 +129,14 @@ public class DefaultDataBrokerProtectionUserNotificationService: NSObject, DataB public func sendFirstScanCompletedNotification() { guard areNotificationsEnabled else { return } - sendNotification(.firstScanComplete) - pixelHandler.fire(.dataBrokerProtectionNotificationSentFirstScanComplete) + // If the user is not authenticated, this is a Freemium scan + if !authenticationManager.isUserAuthenticated { + sendNotification(.firstFreemiumScanComplete) + freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.firstScanCompleteNotificationSent) + } else { + sendNotification(.firstScanComplete) + pixelHandler.fire(.dataBrokerProtectionNotificationSentFirstScanComplete) + } } public func sendFirstRemovedNotificationIfPossible() { @@ -169,6 +195,10 @@ extension DefaultDataBrokerProtectionUserNotificationService: UNUserNotification if let pixel = pixelMapper[identifier] { pixelHandler.fire(pixel) } + case .firstFreemiumScanComplete: + NSWorkspace.shared.open(DataBrokerProtectionNotificationCommand.showDashboard.url) + + freemiumDBPExperimentPixelHandler.fire(FreemiumDBPExperimentPixel.firstScanCompleteNotificationClicked) } } } @@ -176,6 +206,7 @@ extension DefaultDataBrokerProtectionUserNotificationService: UNUserNotification extension UNNotificationRequest { enum Identifier: String { + case firstFreemiumScanComplete = "dbp.freemium.scan.complete" case firstScanComplete = "dbp.scan.complete" case firstProfileRemoved = "dbp.first.removed" case allInfoRemoved = "dbp.all.removed" @@ -184,6 +215,7 @@ extension UNNotificationRequest { } private enum UserNotification { + case firstFreemiumScanComplete case firstScanComplete case firstProfileRemoved case allInfoRemoved @@ -191,6 +223,8 @@ private enum UserNotification { var title: String { switch self { + case .firstFreemiumScanComplete: + return "Free Personal Information Scan" case .firstScanComplete: return "Scan complete!" case .firstProfileRemoved: @@ -204,6 +238,8 @@ private enum UserNotification { var message: String { switch self { + case .firstFreemiumScanComplete: + return "Your free personal info scan is now complete. Check out the results..." case .firstScanComplete: return "DuckDuckGo has started the process to remove records matching your personal info online. See what we found..." case .firstProfileRemoved: @@ -217,6 +253,8 @@ private enum UserNotification { var identifier: String { switch self { + case .firstFreemiumScanComplete: + return UNNotificationRequest.Identifier.firstFreemiumScanComplete.rawValue case .firstScanComplete: return UNNotificationRequest.Identifier.firstScanComplete.rawValue case .firstProfileRemoved: diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/CodableExtension.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/CodableExtension.swift index 46cc1e4a56..4106124961 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/CodableExtension.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/CodableExtension.swift @@ -59,6 +59,16 @@ extension KeyedDecodingContainer { return try decode(type, forKey: key) } + func decodeIfPresent(_ type: [[String: Any]].Type, forKey key: K) throws -> [[String: Any]]? { + guard contains(key) else { + return nil + } + guard try decodeNil(forKey: key) == false else { + return nil + } + return try decode(type, forKey: key) + } + func decode(_ type: [Any].Type, forKey key: K) throws -> [Any] { var container = try self.nestedUnkeyedContainer(forKey: key) return try container.decode(type) diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/DataBrokerProtectionAgentStopper.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/DataBrokerProtectionAgentStopper.swift index 747f2b18a6..e22df1fb56 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/DataBrokerProtectionAgentStopper.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/DataBrokerProtectionAgentStopper.swift @@ -19,14 +19,15 @@ import Foundation import os.log import Common +import Freemium protocol DataBrokerProtectionAgentStopper { - /// Validates if the user has profile data, is authenticated, and has valid entitlement. If any of these conditions are not met, the agent will be stopped. + /// Validates if the user is an active freemium user, OR if they have profile data, is authenticated, and has valid entitlement. If any of these conditions are not met, the agent will be stopped. func validateRunPrerequisitesAndStopAgentIfNecessary() async - /// Monitors the entitlement package. If the entitlement check returns false, the agent will be stopped. + /// Monitors the entitlement package. If the entitlement check returns false, and the user is NOT an active freemium user, the agent will be stopped. /// This function ensures that the agent is stopped if the user's subscription has expired, even if the browser is not active. Regularly checking for entitlement is required since notifications are not posted to agents. - func monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: TimeInterval) + func monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: TimeInterval) } struct DefaultDataBrokerProtectionAgentStopper: DataBrokerProtectionAgentStopper { @@ -35,48 +36,69 @@ struct DefaultDataBrokerProtectionAgentStopper: DataBrokerProtectionAgentStopper private let authenticationManager: DataBrokerProtectionAuthenticationManaging private let pixelHandler: EventMapping private let stopAction: DataProtectionStopAction + private let freemiumDBPUserStateManager: FreemiumDBPUserStateManager init(dataManager: DataBrokerProtectionDataManaging, entitlementMonitor: DataBrokerProtectionEntitlementMonitoring, authenticationManager: DataBrokerProtectionAuthenticationManaging, pixelHandler: EventMapping, - stopAction: DataProtectionStopAction = DefaultDataProtectionStopAction()) { + stopAction: DataProtectionStopAction = DefaultDataProtectionStopAction(), + freemiumDBPUserStateManager: FreemiumDBPUserStateManager) { self.dataManager = dataManager self.entitlementMonitor = entitlementMonitor self.authenticationManager = authenticationManager self.pixelHandler = pixelHandler self.stopAction = stopAction + self.freemiumDBPUserStateManager = freemiumDBPUserStateManager } + /// Checks PIR prerequisites and stops the agent if necessary + /// + /// Prerequisites are satisified if either: + /// 1. The user is an active freemium user + /// 2. The user has a subscription with valid entitlements public func validateRunPrerequisitesAndStopAgentIfNecessary() async { + do { - guard try dataManager.fetchProfile() != nil, - authenticationManager.isUserAuthenticated else { + let hasProfile = try dataManager.fetchProfile() != nil + let isAuthenticated = authenticationManager.isUserAuthenticated + let didActivateFreemium = freemiumDBPUserStateManager.didActivate + + if !hasProfile || (!isAuthenticated && !didActivateFreemium) { Logger.dataBrokerProtection.debug("Prerequisites are invalid") stopAgent() return } - Logger.dataBrokerProtection.debug("Prerequisites are valid") - } catch { - Logger.dataBrokerProtection.error("Error validating prerequisites, error: \(error.localizedDescription, privacy: .public)") - stopAgent() - } - do { - let result = try await authenticationManager.hasValidEntitlement() - stopAgentBasedOnEntitlementCheckResult(result ? .enabled : .disabled) + if satisfiesFreemiumPrerequisites() { + Logger.dataBrokerProtection.debug("User is Freemium") + return + } + + let hasValidEntitlement = try await authenticationManager.hasValidEntitlement() + stopAgentBasedOnEntitlementCheckResult(hasValidEntitlement ? .enabled : .disabled) + } catch { + Logger.dataBrokerProtection.error("Error validating prerequisites, error: \(error.localizedDescription, privacy: .public)") stopAgentBasedOnEntitlementCheckResult(.error) } } - public func monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: TimeInterval) { + public func monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: TimeInterval) { entitlementMonitor.start(checkEntitlementFunction: authenticationManager.hasValidEntitlement, interval: interval) { result in + + if satisfiesFreemiumPrerequisites() { return } stopAgentBasedOnEntitlementCheckResult(result) } } + private func satisfiesFreemiumPrerequisites() -> Bool { + let isAuthenticated = authenticationManager.isUserAuthenticated + let didActivateFreemium = freemiumDBPUserStateManager.didActivate + return !isAuthenticated && didActivateFreemium + } + private func stopAgent() { stopAction.stopAgent() } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationLayerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationLayerTests.swift new file mode 100644 index 0000000000..e077dfe851 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationLayerTests.swift @@ -0,0 +1,151 @@ +// +// DBPUICommunicationLayerTests.swift +// +// 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 WebKit +@testable import DataBrokerProtection + +final class DBPUICommunicationLayerTests: XCTestCase { + + func testWhenHandshakeCalled_andDelegateAuthenticatedUserTrue_thenHandshakeUserDataTrue() async throws { + // Given + let mockDelegate = MockDelegate() + let handshakeUserData = DBPUIHandshakeUserData(isAuthenticatedUser: true) + mockDelegate.handshakeUserDataToReturn = handshakeUserData + var sut = DBPUICommunicationLayer(webURLSettings: MockWebSettings(), privacyConfig: PrivacyConfigurationManagingMock()) + sut.delegate = mockDelegate + let handshakeParams: [String: Any] = ["version": 4] + let scriptMessage = await WKScriptMessage() + + // When + let handler = sut.handler(forMethodNamed: DBPUIReceivedMethodName.handshake.rawValue) + let result = try await handler?(handshakeParams, scriptMessage) + + // Then + XCTAssertTrue(mockDelegate.handshakeUserDataCalled) + + guard let resultUserData = result as? DBPUIHandshakeResponse else { + XCTFail("Expected DBPUIHandshakeResponse to be returned") + return + } + + XCTAssertEqual(resultUserData.userdata.isAuthenticatedUser, true) + } + + func testWhenHandshakeCalled_andDelegateAuthenticatedUserFalse_thenHandshakeUserDataFalse() async throws { + // Given + let mockDelegate = MockDelegate() + let handshakeUserData = DBPUIHandshakeUserData(isAuthenticatedUser: false) + mockDelegate.handshakeUserDataToReturn = handshakeUserData + var sut = DBPUICommunicationLayer(webURLSettings: MockWebSettings(), privacyConfig: PrivacyConfigurationManagingMock()) + sut.delegate = mockDelegate + let handshakeParams: [String: Any] = ["version": 4] + let scriptMessage = await WKScriptMessage() + + // When + let handler = sut.handler(forMethodNamed: DBPUIReceivedMethodName.handshake.rawValue) + let result = try await handler?(handshakeParams, scriptMessage) + + // Then + XCTAssertTrue(mockDelegate.handshakeUserDataCalled) + + guard let resultUserData = result as? DBPUIHandshakeResponse else { + XCTFail("Expected DBPUIHandshakeResponse to be returned") + return + } + + XCTAssertEqual(resultUserData.userdata.isAuthenticatedUser, false) + } + + func testWhenHandshakeCalled_andDelegateIsNil_thenHandshakeUserDataIsDefaultTrue() async throws { + // Given + let sut = DBPUICommunicationLayer(webURLSettings: MockWebSettings(), privacyConfig: PrivacyConfigurationManagingMock()) + let handshakeParams: [String: Any] = ["version": 4] + let scriptMessage = await WKScriptMessage() + + // When + let handler = sut.handler(forMethodNamed: DBPUIReceivedMethodName.handshake.rawValue) + let result = try await handler?(handshakeParams, scriptMessage) + + // Then + guard let resultUserData = result as? DBPUIHandshakeResponse else { + XCTFail("Expected DBPUIHandshakeResponse to be returned") + return + } + + XCTAssertEqual(resultUserData.userdata.isAuthenticatedUser, true) + } +} + +// MARK: - Mock Classes + +private final class MockDelegate: DBPUICommunicationDelegate { + var handshakeUserDataCalled = false + var handshakeUserDataToReturn: DBPUIHandshakeUserData? + + func getHandshakeUserData() -> DBPUIHandshakeUserData? { + handshakeUserDataCalled = true + return handshakeUserDataToReturn + } + + func saveProfile() async throws {} + func getUserProfile() -> DBPUIUserProfile? { nil } + func deleteProfileData() throws {} + func addNameToCurrentUserProfile(_ name: DBPUIUserProfileName) -> Bool { false } + func setNameAtIndexInCurrentUserProfile(_ payload: DataBrokerProtection.DBPUINameAtIndex) -> Bool { false } + func removeNameAtIndexFromUserProfile(_ index: DataBrokerProtection.DBPUIIndex) -> Bool { false } + func setBirthYearForCurrentUserProfile(_ year: DataBrokerProtection.DBPUIBirthYear) -> Bool { false } + func addAddressToCurrentUserProfile(_ address: DataBrokerProtection.DBPUIUserProfileAddress) -> Bool { false } + func setAddressAtIndexInCurrentUserProfile(_ payload: DataBrokerProtection.DBPUIAddressAtIndex) -> Bool { false } + func removeAddressAtIndexFromUserProfile(_ index: DataBrokerProtection.DBPUIIndex) -> Bool { false } + func startScanAndOptOut() -> Bool { false } + + func getInitialScanState() async -> DataBrokerProtection.DBPUIInitialScanState { + DBPUIInitialScanState(resultsFound: [], scanProgress: .init(currentScans: 0, totalScans: 0, scannedBrokers: [])) + } + + func getMaintananceScanState() async -> DataBrokerProtection.DBPUIScanAndOptOutMaintenanceState { + DBPUIScanAndOptOutMaintenanceState( + inProgressOptOuts: [], + completedOptOuts: [], + scanSchedule: .init(lastScan: .init(date: 2, dataBrokers: []), nextScan: .init(date: 2, dataBrokers: [])), + scanHistory: .init(sitesScanned: 2) + ) + } + + func getDataBrokers() async -> [DataBrokerProtection.DBPUIDataBroker] { + [] + } + + func getBackgroundAgentMetadata() async -> DataBrokerProtection.DBPUIDebugMetadata { + DBPUIDebugMetadata(lastRunAppVersion: "") + } + + func openSendFeedbackModal() async {} +} + +private final class MockWebSettings: DataBrokerProtectionWebUIURLSettingsRepresentable { + var customURL: String? + var productionURL: String = "" + var selectedURL: String = "" + var selectedURLType: DataBrokerProtection.DataBrokerProtectionWebUIURLType = .production + var selectedURLHostname: String = "" + + func setCustomURL(_ url: String) {} + func setURLType(_ type: DataBrokerProtection.DataBrokerProtectionWebUIURLType) {} +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift index 68b71f4fdd..aff311ce82 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DBPUICommunicationModelTests.swift @@ -46,7 +46,8 @@ final class DBPUICommunicationModelTests: XCTestCase { dataBrokerName: "doesn't matter for the test", dataBrokerURL: "see above", dataBrokerParentURL: "whatever", - parentBrokerOptOutJobData: nil) + parentBrokerOptOutJobData: nil, + optOutUrl: "broker.com") // Then XCTAssertEqual(profileMatch.foundDate, createdDate.timeIntervalSince1970) @@ -77,7 +78,8 @@ final class DBPUICommunicationModelTests: XCTestCase { dataBrokerName: "doesn't matter for the test", dataBrokerURL: "see above", dataBrokerParentURL: "whatever", - parentBrokerOptOutJobData: nil) + parentBrokerOptOutJobData: nil, + optOutUrl: "broker.com") // Then XCTAssertEqual(profileMatch.foundDate, foundEventDate.timeIntervalSince1970) @@ -116,7 +118,8 @@ final class DBPUICommunicationModelTests: XCTestCase { dataBrokerName: "doesn't matter for the test", dataBrokerURL: "see above", dataBrokerParentURL: "whatever", - parentBrokerOptOutJobData: nil) + parentBrokerOptOutJobData: nil, + optOutUrl: "broker.com") // Then XCTAssertEqual(profileMatch.foundDate, foundEventDate2.timeIntervalSince1970) @@ -147,7 +150,8 @@ final class DBPUICommunicationModelTests: XCTestCase { dataBrokerName: "doesn't matter for the test", dataBrokerURL: "see above", dataBrokerParentURL: "whatever", - parentBrokerOptOutJobData: [parentOptOut]) + parentBrokerOptOutJobData: [parentOptOut], + optOutUrl: "broker.com") // Then XCTAssertTrue(profileMatch.hasMatchingRecordOnParentBroker) @@ -177,7 +181,8 @@ final class DBPUICommunicationModelTests: XCTestCase { dataBrokerParentURL: "whatever", parentBrokerOptOutJobData: [parentOptOutNonmatching1, parentOptOutMatching, - parentOptOutNonmatching2]) + parentOptOutNonmatching2], + optOutUrl: "broker.com") // Then XCTAssertTrue(profileMatch.hasMatchingRecordOnParentBroker) @@ -203,7 +208,8 @@ final class DBPUICommunicationModelTests: XCTestCase { dataBrokerURL: "see above", dataBrokerParentURL: "whatever", parentBrokerOptOutJobData: [parentOptOutNonmatching1, - parentOptOutNonmatching2]) + parentOptOutNonmatching2], + optOutUrl: "broker.com") // Then XCTAssertFalse(profileMatch.hasMatchingRecordOnParentBroker) @@ -225,9 +231,70 @@ final class DBPUICommunicationModelTests: XCTestCase { dataBrokerName: "doesn't matter for the test", dataBrokerURL: "see above", dataBrokerParentURL: "whatever", - parentBrokerOptOutJobData: [parentOptOut]) + parentBrokerOptOutJobData: [parentOptOut], + optOutUrl: "broker.com") // Then XCTAssertTrue(profileMatch.hasMatchingRecordOnParentBroker) } + + // MARK: - `profileMatches` Broker OptOut URL & Name tests + + func testProfileMatches_optOutUrlAndBrokerNameForChildBroker() { + // Given + let extractedProfile = ExtractedProfile(id: 1, name: "Sample Name", profileUrl: "profile.com") + + let childBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ChildBroker", + url: "child.com", + parentURL: "parent.com", + optOutUrl: "child.com/optout", + extractedProfile: extractedProfile + ) + + let parentBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ParentBroker", + url: "parent.com", + optOutUrl: "parent.com/optout", + extractedProfile: extractedProfile + ) + + // When + let results = DBPUIDataBrokerProfileMatch.profileMatches(from: [childBroker, parentBroker]) + + // Then + XCTAssertEqual(results.count, 2) + + let childProfile = results.first { $0.dataBroker.name == "ChildBroker" } + XCTAssertEqual(childProfile?.dataBroker.optOutUrl, "child.com/optout") + } + + func testProfileMatches_optOutUrlAndBrokerNameForParentBroker() { + // Given + let extractedProfile = ExtractedProfile(id: 1, name: "Sample Name", profileUrl: "profile.com") + + let childBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ChildBroker", + url: "child.com", + parentURL: "parent.com", + optOutUrl: "parent.com/optout", + extractedProfile: extractedProfile + ) + + let parentBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ParentBroker", + url: "parent.com", + optOutUrl: "parent.com/optout", + extractedProfile: extractedProfile + ) + + // When + let results = DBPUIDataBrokerProfileMatch.profileMatches(from: [childBroker, parentBroker]) + + // Then + XCTAssertEqual(results.count, 2) + + let childProfile = results.first { $0.dataBroker.name == "ChildBroker" } + XCTAssertEqual(childProfile?.dataBroker.optOutUrl, "parent.com/optout") + } } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerExecutionConfigTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerExecutionConfigTests.swift index cb54af211b..1ed238590a 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerExecutionConfigTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerExecutionConfigTests.swift @@ -25,11 +25,17 @@ final class DataBrokerExecutionConfigTests: XCTestCase { private let sut = DataBrokerExecutionConfig() func testWhenOperationIsManualScans_thenConcurrentOperationsBetweenBrokersIsSix() { - let value = sut.concurrentOperationsFor(.scan) + let value = sut.concurrentOperationsFor(.manualScan) let expectedValue = 6 XCTAssertEqual(value, expectedValue) } + func testWhenOperationIsScheduledScans_thenConcurrentOperationsBetweenBrokersIsTwo() { + let value = sut.concurrentOperationsFor(.scheduledScan) + let expectedValue = 2 + XCTAssertEqual(value, expectedValue) + } + func testWhenOperationIsAll_thenConcurrentOperationsBetweenBrokersIsTwo() { let value = sut.concurrentOperationsFor(.all) let expectedValue = 2 diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift index 330619c12d..cc14f56b56 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationActionTests.swift @@ -331,7 +331,7 @@ final class DataBrokerOperationActionTests: XCTestCase { } func testWhenRunningActionWithoutExtractedProfile_thenExecuteIsCalledWithProfileData() async { - let expectationAction = ExpectationAction(id: "1", actionType: .expectation, expectations: [Item](), dataSource: nil) + let expectationAction = ExpectationAction(id: "1", actionType: .expectation, expectations: [Item](), dataSource: nil, actions: nil) let sut = OptOutJob( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, @@ -415,7 +415,7 @@ final class DataBrokerOperationActionTests: XCTestCase { func testWhenExpectationActionRuns_thenStageIsSetToSubmit() async { let mockStageCalculator = MockStageDurationCalculator() - let expectationAction = ExpectationAction(id: "1", actionType: .expectation, expectations: [Item](), dataSource: nil) + let expectationAction = ExpectationAction(id: "1", actionType: .expectation, expectations: [Item](), dataSource: nil, actions: nil) let sut = OptOutJob( privacyConfig: PrivacyConfigurationManagingMock(), prefs: ContentScopeProperties.mock, diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationsCreatorTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationsCreatorTests.swift index a19e16863a..8c839fc327 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationsCreatorTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerOperationsCreatorTests.swift @@ -70,7 +70,7 @@ final class DataBrokerOperationsCreatorTests: XCTestCase { mockDatabase.brokerProfileQueryDataToReturn = dataBrokerProfileQueries // When - let result = try! sut.operations(forOperationType: .scan, + let result = try! sut.operations(forOperationType: .manualScan, withPriorityDate: Date(), showWebView: false, errorDelegate: MockDataBrokerOperationErrorDelegate(), diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift index b41f802d47..f33b340479 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProfileQueryOperationManagerTests.swift @@ -44,7 +44,7 @@ final class DataBrokerProfileQueryOperationManagerTests: XCTestCase { let extractedProfileId: Int64 = 1 let currentPreferredRunDate = Date() - let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config) + let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config, optOutUrl: "") let mockProfileQuery = ProfileQuery(id: profileQueryId, firstName: "a", lastName: "b", city: "c", state: "d", birthYear: 1222) let historyEvents = [HistoryEvent(extractedProfileId: extractedProfileId, brokerId: brokerId, profileQueryId: profileQueryId, type: .optOutRequested)] @@ -92,7 +92,7 @@ final class DataBrokerProfileQueryOperationManagerTests: XCTestCase { let extractedProfileId: Int64 = 1 let currentPreferredRunDate = Date() - let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config) + let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config, optOutUrl: "") let mockProfileQuery = ProfileQuery(id: profileQueryId, firstName: "a", lastName: "b", city: "c", state: "d", birthYear: 1222) let historyEvents = [HistoryEvent(extractedProfileId: extractedProfileId, brokerId: brokerId, profileQueryId: profileQueryId, type: .optOutRequested)] @@ -143,7 +143,7 @@ final class DataBrokerProfileQueryOperationManagerTests: XCTestCase { let extractedProfileId: Int64 = 1 let currentPreferredRunDate = Date() - let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config) + let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config, optOutUrl: "") let mockProfileQuery = ProfileQuery(id: profileQueryId, firstName: "a", lastName: "b", city: "c", state: "d", birthYear: 1222) let historyEvents = [HistoryEvent(extractedProfileId: extractedProfileId, brokerId: brokerId, profileQueryId: profileQueryId, type: .optOutRequested)] @@ -888,7 +888,7 @@ final class DataBrokerProfileQueryOperationManagerTests: XCTestCase { let extractedProfileId: Int64 = 1 let currentPreferredRunDate = Date() - let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config) + let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config, optOutUrl: "") let mockProfileQuery = ProfileQuery(id: profileQueryId, firstName: "a", lastName: "b", city: "c", state: "d", birthYear: 1222) let historyEvents = [HistoryEvent(extractedProfileId: extractedProfileId, brokerId: brokerId, profileQueryId: profileQueryId, type: .optOutRequested)] @@ -913,7 +913,7 @@ final class DataBrokerProfileQueryOperationManagerTests: XCTestCase { let currentPreferredRunDate = Date() let expectedPreferredRunDate = Date().addingTimeInterval(config.confirmOptOutScan.hoursToSeconds) - let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config) + let mockDataBroker = DataBroker(name: "databroker", url: "databroker.com", steps: [Step](), version: "1.0", schedulingConfig: config, optOutUrl: "") let mockProfileQuery = ProfileQuery(id: profileQueryId, firstName: "a", lastName: "b", city: "c", state: "d", birthYear: 1222) let historyEvents = [HistoryEvent(extractedProfileId: extractedProfileId, brokerId: brokerId, profileQueryId: profileQueryId, type: .optOutRequested)] @@ -987,7 +987,8 @@ extension DataBroker { retryError: 0, confirmOptOutScan: 0, maintenanceScan: 0 - ) + ), + optOutUrl: "" ) } @@ -1006,7 +1007,8 @@ extension DataBroker { confirmOptOutScan: 0, maintenanceScan: 0 ), - parent: "some" + parent: "some", + optOutUrl: "" ) } @@ -1020,7 +1022,8 @@ extension DataBroker { retryError: 0, confirmOptOutScan: 0, maintenanceScan: 0 - ) + ), + optOutUrl: "" ) } @@ -1033,7 +1036,8 @@ extension DataBroker { retryError: 0, confirmOptOutScan: 0, maintenanceScan: 0 - ) + ), + optOutUrl: "" ) } @@ -1052,7 +1056,8 @@ extension DataBroker { confirmOptOutScan: 0, maintenanceScan: 0 ), - mirrorSites: mirroSites + mirrorSites: mirroSites, + optOutUrl: "" ) } } diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift index 0400895c38..08ebfdeb28 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift @@ -36,12 +36,15 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { private var mockAgentStopper: MockAgentStopper! private var mockConfigurationManager: MockConfigurationManager! private var mockPrivacyConfigurationManager: DBPPrivacyConfigurationManager! + private var mockAuthenticationManager: MockAuthenticationManager! + private var mockFreemiumDBPUserStateManager: MockFreemiumDBPUserStateManager! override func setUpWithError() throws { mockPixelHandler = MockPixelHandler() mockActivityScheduler = MockDataBrokerProtectionBackgroundActivityScheduler() mockNotificationService = MockUserNotificationService() + mockAuthenticationManager = MockAuthenticationManager() mockAgentStopper = MockAgentStopper() mockConfigurationManager = MockConfigurationManager() mockPrivacyConfigurationManager = DBPPrivacyConfigurationManager() @@ -72,9 +75,54 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { addresses: [], phones: [], birthYear: 1992) + + mockFreemiumDBPUserStateManager = MockFreemiumDBPUserStateManager() + } + + func testWhenAgentStart_andProfileExists_andUserIsNotFreemium_thenActivityIsScheduled_andScheduledAllOperationsRun() async throws { + // Given + sut = DataBrokerProtectionAgentManager( + userNotificationService: mockNotificationService, + activityScheduler: mockActivityScheduler, + ipcServer: mockIPCServer, + queueManager: mockQueueManager, + dataManager: mockDataManager, + operationDependencies: mockDependencies, + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper, + configurationManager: mockConfigurationManager, + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + mockDataManager.profileToReturn = mockProfile + mockAuthenticationManager.isUserAuthenticatedValue = true + mockFreemiumDBPUserStateManager.didActivate = false + + let schedulerStartedExpectation = XCTestExpectation(description: "Scheduler started") + var schedulerStarted = false + mockActivityScheduler.startSchedulerCompletion = { + schedulerStarted = true + schedulerStartedExpectation.fulfill() + } + + let scanCalledExpectation = XCTestExpectation(description: "Scan called") + var startScheduledScansCalled = false + mockQueueManager.startScheduledAllOperationsIfPermittedCalledCompletion = { + startScheduledScansCalled = true + scanCalledExpectation.fulfill() + } + + // When + sut.agentFinishedLaunching() + + // Then + await fulfillment(of: [scanCalledExpectation, schedulerStartedExpectation], timeout: 1.0) + XCTAssertTrue(schedulerStarted) + XCTAssertTrue(startScheduledScansCalled) } - func testWhenAgentStart_andProfileExists_thenActivityIsScheduled_andSheduledOpereationsRun() async throws { + func testWhenAgentStart_andProfileExists_andUserIsFreemium_thenActivityIsScheduled_andScheduledScanOperationsRun() async throws { // Given sut = DataBrokerProtectionAgentManager( userNotificationService: mockNotificationService, @@ -86,9 +134,12 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockDataManager.profileToReturn = mockProfile + mockFreemiumDBPUserStateManager.didActivate = true let schedulerStartedExpectation = XCTestExpectation(description: "Scheduler started") var schedulerStarted = false @@ -99,7 +150,7 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { let scanCalledExpectation = XCTestExpectation(description: "Scan called") var startScheduledScansCalled = false - mockQueueManager.startScheduledOperationsIfPermittedCalledCompletion = { + mockQueueManager.startScheduledScanOperationsIfPermittedCalledCompletion = { startScheduledScansCalled = true scanCalledExpectation.fulfill() } @@ -113,14 +164,14 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { XCTAssertTrue(startScheduledScansCalled) } - func testWhenAgentStart_andProfileDoesNotExist_thenActivityIsNotScheduled_andStopAgentIsCalled() async throws { + func testWhenAgentStart_andProfileDoesNotExist_andUserIsFreemium_thenActivityIsNotScheduled_andStopAgentIsCalled() async throws { // Given let mockStopAction = MockDataProtectionStopAction() let agentStopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, entitlementMonitor: DataBrokerProtectionEntitlementMonitor(), authenticationManager: MockAuthenticationManager(), pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, freemiumDBPUserStateManager: MockFreemiumDBPUserStateManager()) sut = DataBrokerProtectionAgentManager( userNotificationService: mockNotificationService, activityScheduler: mockActivityScheduler, @@ -131,9 +182,12 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: agentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockDataManager.profileToReturn = nil + mockFreemiumDBPUserStateManager.didActivate = true let stopAgentExpectation = XCTestExpectation(description: "Stop agent expectation") @@ -165,7 +219,9 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockDataManager.profileToReturn = nil @@ -192,7 +248,7 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { XCTAssertTrue(monitorEntitlementWasCalled) } - func testWhenActivitySchedulerTriggers_thenSheduledOpereationsRun() async throws { + func testWhenActivitySchedulerTriggers_andUserIsNotFreemium_thenScheduledAllOperationsRun() async throws { // Given sut = DataBrokerProtectionAgentManager( userNotificationService: mockNotificationService, @@ -204,12 +260,16 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockDataManager.profileToReturn = mockProfile + mockAuthenticationManager.isUserAuthenticatedValue = true + mockFreemiumDBPUserStateManager.didActivate = false var startScheduledScansCalled = false - mockQueueManager.startScheduledOperationsIfPermittedCalledCompletion = { + mockQueueManager.startScheduledAllOperationsIfPermittedCalledCompletion = { startScheduledScansCalled = true } @@ -220,7 +280,7 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { XCTAssertTrue(startScheduledScansCalled) } - func testWhenProfileSaved_thenImmediateOpereationsRun() async throws { + func testWhenActivitySchedulerTriggers_andUserIsFreemium_thenScheduledScanOperationsRun() async throws { // Given sut = DataBrokerProtectionAgentManager( userNotificationService: mockNotificationService, @@ -232,12 +292,77 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockDataManager.profileToReturn = mockProfile + mockFreemiumDBPUserStateManager.didActivate = true + + var startScheduledScansCalled = false + mockQueueManager.startScheduledScanOperationsIfPermittedCalledCompletion = { + startScheduledScansCalled = true + } + + // When + mockActivityScheduler.triggerDelegateCall() + + // Then + XCTAssertTrue(startScheduledScansCalled) + } + + func testWhenProfileSaved_andUserIsNotFreemium_thenImmediateOperationsRun() async throws { + // Given + sut = DataBrokerProtectionAgentManager( + userNotificationService: mockNotificationService, + activityScheduler: mockActivityScheduler, + ipcServer: mockIPCServer, + queueManager: mockQueueManager, + dataManager: mockDataManager, + operationDependencies: mockDependencies, + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper, + configurationManager: mockConfigurationManager, + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + mockDataManager.profileToReturn = mockProfile + mockFreemiumDBPUserStateManager.didActivate = false var startImmediateScansCalled = false - mockQueueManager.startImmediateOperationsIfPermittedCalledCompletion = { + mockQueueManager.startImmediateScanOperationsIfPermittedCalledCompletion = { + startImmediateScansCalled = true + } + + // When + sut.profileSaved() + + // Then + XCTAssertTrue(startImmediateScansCalled) + } + + func testWhenProfileSaved_andUserIsFreemium_thenImmediateOperationsRun() async throws { + // Given + sut = DataBrokerProtectionAgentManager( + userNotificationService: mockNotificationService, + activityScheduler: mockActivityScheduler, + ipcServer: mockIPCServer, + queueManager: mockQueueManager, + dataManager: mockDataManager, + operationDependencies: mockDependencies, + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper, + configurationManager: mockConfigurationManager, + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + mockDataManager.profileToReturn = mockProfile + mockFreemiumDBPUserStateManager.didActivate = true + + var startImmediateScansCalled = false + mockQueueManager.startImmediateScanOperationsIfPermittedCalledCompletion = { startImmediateScansCalled = true } @@ -260,7 +385,9 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockNotificationService.reset() @@ -283,7 +410,9 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockNotificationService.reset() @@ -306,10 +435,12 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockNotificationService.reset() - mockQueueManager.startImmediateOperationsIfPermittedCompletionError = DataBrokerProtectionAgentErrorCollection(oneTimeError: NSError(domain: "test", code: 10)) + mockQueueManager.startImmediateScanOperationsIfPermittedCompletionError = DataBrokerProtectionAgentErrorCollection(oneTimeError: NSError(domain: "test", code: 10)) // When sut.profileSaved() @@ -330,7 +461,9 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockNotificationService.reset() mockDataManager.shouldReturnHasMatches = true @@ -354,7 +487,9 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) mockNotificationService.reset() mockDataManager.shouldReturnHasMatches = false @@ -366,7 +501,7 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { XCTAssertFalse(mockNotificationService.checkInNotificationWasScheduled) } - func testWhenAppLaunched_thenSheduledOpereationsRun() async throws { + func testWhenAppLaunched_andUserIsNotFreemium_thenScheduledAllOperationsRun() async throws { // Given sut = DataBrokerProtectionAgentManager( userNotificationService: mockNotificationService, @@ -378,10 +513,45 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { pixelHandler: mockPixelHandler, agentStopper: mockAgentStopper, configurationManager: mockConfigurationManager, - privacyConfigurationManager: mockPrivacyConfigurationManager) + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + mockAuthenticationManager.isUserAuthenticatedValue = true + mockFreemiumDBPUserStateManager.didActivate = false + + var startScheduledScansCalled = false + mockQueueManager.startScheduledAllOperationsIfPermittedCalledCompletion = { + startScheduledScansCalled = true + } + + // When + sut.appLaunched() + + // Then + XCTAssertTrue(startScheduledScansCalled) + } + + func testWhenAppLaunched_andUserIsFreemium_thenScheduledScanOperationsRun() async throws { + // Given + sut = DataBrokerProtectionAgentManager( + userNotificationService: mockNotificationService, + activityScheduler: mockActivityScheduler, + ipcServer: mockIPCServer, + queueManager: mockQueueManager, + dataManager: mockDataManager, + operationDependencies: mockDependencies, + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper, + configurationManager: mockConfigurationManager, + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + mockFreemiumDBPUserStateManager.didActivate = true var startScheduledScansCalled = false - mockQueueManager.startScheduledOperationsIfPermittedCalledCompletion = { + mockQueueManager.startScheduledScanOperationsIfPermittedCalledCompletion = { startScheduledScansCalled = true } @@ -391,6 +561,59 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { // Then XCTAssertTrue(startScheduledScansCalled) } + + func testWhenFirePixelsCalled_andUserIsAuthenticated_thenPixelsAreFired() async throws { + // Given + sut = DataBrokerProtectionAgentManager( + userNotificationService: mockNotificationService, + activityScheduler: mockActivityScheduler, + ipcServer: mockIPCServer, + queueManager: mockQueueManager, + dataManager: mockDataManager, + operationDependencies: mockDependencies, + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper, + configurationManager: mockConfigurationManager, + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + mockAuthenticationManager.isUserAuthenticatedValue = true + mockFreemiumDBPUserStateManager.didActivate = false + + // When + sut.fireMonitoringPixels() + + // Then + XCTAssertNotNil(mockPixelHandler.lastFiredEvent) + } + + func testWhenFirePixelsCalled_andUserIsNotAuthenticated_thenPixelsAreNotFired() async throws { + // Given + sut = DataBrokerProtectionAgentManager( + userNotificationService: mockNotificationService, + activityScheduler: mockActivityScheduler, + ipcServer: mockIPCServer, + queueManager: mockQueueManager, + dataManager: mockDataManager, + operationDependencies: mockDependencies, + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper, + configurationManager: mockConfigurationManager, + privacyConfigurationManager: mockPrivacyConfigurationManager, + authenticationManager: mockAuthenticationManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + mockAuthenticationManager.isUserAuthenticatedValue = false + mockFreemiumDBPUserStateManager.didActivate = false + + // When + sut.fireMonitoringPixels() + + // Then + XCTAssertNil(mockPixelHandler.lastFiredEvent) + } + } struct MockConfigurationFetcher: ConfigurationFetching { diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentStopperTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentStopperTests.swift index 3271a31f69..d18285fc69 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentStopperTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentStopperTests.swift @@ -23,11 +23,13 @@ import Common @testable import DataBrokerProtection final class DataBrokerProtectionAgentStopperTests: XCTestCase { - private var mockPixelHandler: EventMapping! - private var mockAuthenticationManager: MockAuthenticationManager! - private var mockEntitlementMonitor: DataBrokerProtectionEntitlementMonitor! - private var mockDataManager: MockDataBrokerProtectionDataManager! - private var mockStopAction: MockDataProtectionStopAction! + + private var mockPixelHandler: EventMapping! + private var mockAuthenticationManager: MockAuthenticationManager! + private var mockEntitlementMonitor: DataBrokerProtectionEntitlementMonitor! + private var mockDataManager: MockDataBrokerProtectionDataManager! + private var mockStopAction: MockDataProtectionStopAction! + private var mockFreemiumDBPUserStateManager: MockFreemiumDBPUserStateManager! private var fakeProfile: DataBrokerProtectionProfile { let name = DataBrokerProtectionProfile.Name(firstName: "John", lastName: "Doe") @@ -44,6 +46,8 @@ final class DataBrokerProtectionAgentStopperTests: XCTestCase { mockDataManager = MockDataBrokerProtectionDataManager(pixelHandler: mockPixelHandler, fakeBrokerFlag: DataBrokerDebugFlagFakeBroker()) mockStopAction = MockDataProtectionStopAction() + mockFreemiumDBPUserStateManager = MockFreemiumDBPUserStateManager() + mockFreemiumDBPUserStateManager.didActivate = false } override func tearDown() { @@ -55,51 +59,176 @@ final class DataBrokerProtectionAgentStopperTests: XCTestCase { mockStopAction = nil } - func testNoProfile_thenStopAgentIsCalled() async { + func testNoProfile_andUserIsNotAuthenticated_andUserIsNotFreemium_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = false + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = nil + mockFreemiumDBPUserStateManager.didActivate = false + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testNoProfile_andUserIsNotAuthenticated_andUserIsFreemium_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = false + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = nil + mockFreemiumDBPUserStateManager.didActivate = true + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testNoProfile_andUserIsAuthenticated_andUserIsNotFreemium_thenStopAgentIsCalled() async { mockAuthenticationManager.isUserAuthenticatedValue = true mockAuthenticationManager.hasValidEntitlementValue = true mockDataManager.profileToReturn = nil + mockFreemiumDBPUserStateManager.didActivate = false let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() XCTAssertTrue(mockStopAction.wasStopCalled) } - func testInvalidEntitlement_thenStopAgentIsCalled() async { + func testNoProfile_andUserIsAuthenticated_andUserIsFreemium_thenStopAgentIsCalled() async { mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = nil + mockFreemiumDBPUserStateManager.didActivate = true + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testInvalidEntitlement_andUserIsNotAuthenticated_andUserIsNotFreemium_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = false mockAuthenticationManager.hasValidEntitlementValue = false mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = false let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() XCTAssertTrue(mockStopAction.wasStopCalled) } - func testUserNotAuthenticated_thenStopAgentIsCalled() async { + func testInvalidEntitlement_andUserIsNotAuthenticated_andUserIsFreemium_thenStopAgentIsNotCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = false + mockAuthenticationManager.hasValidEntitlementValue = false + mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = true + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertFalse(mockStopAction.wasStopCalled) + } + + func testInvalidEntitlement_andUserIsAuthenticated_andUserIsNotFreemium_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = false + mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = false + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testInvalidEntitlement_andUserIsAuthenticated_andUserIsFreemium_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = false + mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = true + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testUserNotAuthenticated_andUserIsNotFreemium_thenStopAgentIsCalled() async { mockAuthenticationManager.isUserAuthenticatedValue = false mockAuthenticationManager.hasValidEntitlementValue = true mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = false let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() XCTAssertTrue(mockStopAction.wasStopCalled) } + func testUserNotAuthenticated_andUserIsFreemium_thenStopAgentIsNotCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = false + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = true + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertFalse(mockStopAction.wasStopCalled) + } + func testErrorEntitlement_thenStopAgentIsNotCalled() async { mockAuthenticationManager.isUserAuthenticatedValue = true mockAuthenticationManager.shouldThrowEntitlementError = true @@ -109,40 +238,62 @@ final class DataBrokerProtectionAgentStopperTests: XCTestCase { entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertFalse(mockStopAction.wasStopCalled) + } + + func testValidEntitlement_andUserIsNotFreemium_thenStopAgentIsNotCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = false + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() XCTAssertFalse(mockStopAction.wasStopCalled) } - func testValidEntitlement_thenStopAgentIsNotCalled() async { + func testValidEntitlement_andUserIsFreemium_thenStopAgentIsNotCalled() async { mockAuthenticationManager.isUserAuthenticatedValue = true mockAuthenticationManager.hasValidEntitlementValue = true mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = false let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() XCTAssertFalse(mockStopAction.wasStopCalled) } - func testEntitlementMonitorWithValidResult_thenStopAgentIsNotCalled() { + func testEntitlementMonitorWithValidResult_andUserIsNotFreemium_thenStopAgentIsNotCalled() { mockAuthenticationManager.isUserAuthenticatedValue = true mockAuthenticationManager.hasValidEntitlementValue = true mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = false let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) let expectation = XCTestExpectation(description: "Wait for monitor") - stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: 0.1) + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: 0.1) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in XCTAssertFalse(mockStopAction.wasStopCalled) @@ -152,19 +303,45 @@ final class DataBrokerProtectionAgentStopperTests: XCTestCase { wait(for: [expectation], timeout: 3) } - func testEntitlementMonitorWithInValidResult_thenStopAgentIsCalled() { + func testEntitlementMonitorWithValidResult_andUserIsFreemium_thenStopAgentIsNotCalled() { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = true + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + let expectation = XCTestExpectation(description: "Wait for monitor") + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: 0.1) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in + XCTAssertFalse(mockStopAction.wasStopCalled) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3) + } + + func testEntitlementMonitorWithInValidResult_andUserIsNotFreemium_thenStopAgentIsCalled() { mockAuthenticationManager.isUserAuthenticatedValue = true mockAuthenticationManager.hasValidEntitlementValue = false mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = false let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) let expectation = XCTestExpectation(description: "Wait for monitor") - stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: 0.1) + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: 0.1) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in XCTAssertTrue(mockStopAction.wasStopCalled) @@ -174,6 +351,30 @@ final class DataBrokerProtectionAgentStopperTests: XCTestCase { wait(for: [expectation], timeout: 3) } + func testEntitlementMonitorWithInValidResult_andUserIsFreemium_thenStopAgentIsNotCalled() { + mockAuthenticationManager.isUserAuthenticatedValue = false + mockAuthenticationManager.hasValidEntitlementValue = false + mockDataManager.profileToReturn = fakeProfile + mockFreemiumDBPUserStateManager.didActivate = true + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + let expectation = XCTestExpectation(description: "Wait for monitor") + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: 0.1) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in + XCTAssertFalse(mockStopAction.wasStopCalled) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3) + } + func testEntitlementMonitorWithErrorResult_thenStopAgentIsNotCalled() { mockAuthenticationManager.isUserAuthenticatedValue = true mockAuthenticationManager.shouldThrowEntitlementError = true @@ -183,10 +384,11 @@ final class DataBrokerProtectionAgentStopperTests: XCTestCase { entitlementMonitor: mockEntitlementMonitor, authenticationManager: mockAuthenticationManager, pixelHandler: mockPixelHandler, - stopAction: mockStopAction) + stopAction: mockStopAction, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) let expectation = XCTestExpectation(description: "Wait for monitor") - stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: 0.1) + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: 0.1) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in XCTAssertFalse(mockStopAction.wasStopCalled) diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionDataManagingTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionDataManagingTests.swift new file mode 100644 index 0000000000..59fe8c41cb --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionDataManagingTests.swift @@ -0,0 +1,328 @@ +// +// DataBrokerProtectionDataManagingTests.swift +// +// 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 +@testable import DataBrokerProtection + +final class DataBrokerProtectionDataManagingTests: XCTestCase { + + private var sut: DataBrokerProtectionDataManaging! + private var mockDatabase: MockDatabase! + private var mockDBPProfileSavedNotifier: MockDBPProfileSavedNotifier! + + override func setUpWithError() throws { + mockDatabase = MockDatabase() + mockDBPProfileSavedNotifier = MockDBPProfileSavedNotifier() + sut = DataBrokerProtectionDataManager(database: mockDatabase, + profileSavedNotifier: mockDBPProfileSavedNotifier, + pixelHandler: MockPixelHandler()) + } + + func testWhenNoMatches_thenZeroMatchesAndZeroBrokersAreReturned() throws { + // Given + mockDatabase.brokerProfileQueryDataToReturn = [] + + // When + let result = try sut.matchesFoundAndBrokersCount() + + // Then + XCTAssertEqual(result.matchCount, 0) + XCTAssertEqual(result.brokerCount, 0) + } + + func testWhenMultipleProfilesAndMirrorSites_thenCorrectMatchesAndBrokersAreReturned() throws { + // Given + mockDatabase.brokerProfileQueryDataToReturn = mockQueryData + + // When + let result = try sut.matchesFoundAndBrokersCount() + + // Then + // We expect: + // - 5 matches: + // - 1 extracted profile + 2 mirror sites for Broker A (3 total) + // - 1 extracted profile + 0 mirror sites for Broker A again (1 total) + // - Broker B is deprecated, so it should be skipped (0 total) + // - 1 extracted profile + 1 mirror site for Broker C (2 total) + // - 1 extracted profile + 1 mirror site for Broker D (2 total) + // - Total = 3 + 1 + 0 + 2 + 1 = 7 + // - 6 brokers with matches (Broker A (with Mirror 1 & 2), Broker C (with Mirror 3), Broker D) + XCTAssertEqual(result.matchCount, 7) + XCTAssertEqual(result.brokerCount, 6) + } + + func testWhenAllBrokersAreDeprecated_thenZeroMatchesAndZeroBrokersAreReturned() throws { + // Given + let deprecatedBrokers = [ + BrokerProfileQueryData.mock(deprecated: true), + BrokerProfileQueryData.mock(deprecated: true) + ] + mockDatabase.brokerProfileQueryDataToReturn = deprecatedBrokers + + // When + let result = try sut.matchesFoundAndBrokersCount() + + // Then + XCTAssertEqual(result.matchCount, 0) + XCTAssertEqual(result.brokerCount, 0) + } + + func testWhenNoExtractedProfilesButMirrorSitesExist_thenCorrectMirrorSiteCountAndBrokerCountAreReturned() throws { + // Given + let brokersWithOnlyMirrorSites = [ + BrokerProfileQueryData.mock( + extractedProfile: nil, + mirrorSites: [ + MirrorSite(name: "Mirror 1", url: "https://mirror1.com", addedAt: Date(), removedAt: nil) + ] + ) + ] + mockDatabase.brokerProfileQueryDataToReturn = brokersWithOnlyMirrorSites + + // When + let result = try sut.matchesFoundAndBrokersCount() + + // Then + // No extracted profiles, so count should be zero + XCTAssertEqual(result.matchCount, 0) + XCTAssertEqual(result.brokerCount, 0) + } + + func testWhenMirrorSitesAreRemoved_thenTheyAreNotCountedAndBrokerCountIsZero() throws { + // Given + let brokerWithRemovedMirrorSites = BrokerProfileQueryData.mock( + extractedProfile: nil, + mirrorSites: [ + MirrorSite(name: "Mirror 1", url: "https://mirror1.com", addedAt: Date(), removedAt: Date()) + ] + ) + mockDatabase.brokerProfileQueryDataToReturn = [brokerWithRemovedMirrorSites] + + // When + let result = try sut.matchesFoundAndBrokersCount() + + // Then + // No profiles and removed mirror site, so matchCount should be 0 and brokerCount should be 0. + XCTAssertEqual(result.matchCount, 0) + XCTAssertEqual(result.brokerCount, 0) + } + + func testWhenProfileIsSaved_thenNotifierIsCalled() async throws { + // Given + let profile = mockProfile + mockDatabase.saveResult = .success(()) + + // When + try await sut.saveProfile(profile) + + // Then + XCTAssertTrue(mockDBPProfileSavedNotifier.didCallPostProfileSavedNotificationIfPermitted) + } + + func testWhenSavingProfileFails_thenNotifierIsNotCalled() async { + // Given + let profile = mockProfile + mockDatabase.saveResult = .failure(MockDatabase.MockError.saveFailed) + + // When + do { + try await sut.saveProfile(profile) + XCTFail("Expected saveProfile to throw an error but it succeeded.") + } catch {} + + // Then + XCTAssertFalse(mockDBPProfileSavedNotifier.didCallPostProfileSavedNotificationIfPermitted) + } +} + +private extension DataBrokerProtectionDataManagingTests { + + var mockProfile: DataBrokerProtectionProfile { + let name = DataBrokerProtectionProfile.Name( + firstName: "John", + lastName: "Doe", + middleName: "M", + suffix: "Jr" + ) + + let address = DataBrokerProtectionProfile.Address( + city: "New York", + state: "NY", + street: "123 Main St", + zipCode: "10001" + ) + + let phones = ["123-456-7890"] + + let birthYear = 1985 + + let profile = DataBrokerProtectionProfile( + names: [name], + addresses: [address], + phones: phones, + birthYear: birthYear + ) + + return profile + } + + var mockQueryData: [BrokerProfileQueryData] { + [ + // First item: Active broker with 1 extracted profile and 2 mirror sites + BrokerProfileQueryData.mock( + dataBrokerName: "Broker A", + url: "https://broker-a.com", + extractedProfile: ExtractedProfile( + id: 1, + name: "John Doe", + alternativeNames: nil, + addressFull: nil, + addresses: nil, + phoneNumbers: nil, + relatives: nil, + profileUrl: nil, + reportId: nil, + age: nil, + email: nil, + removedDate: nil, + identifier: "id1" + ), scanHistoryEvents: [ + HistoryEvent( + extractedProfileId: 1, + brokerId: 1, + profileQueryId: 1, + type: .scanStarted, + date: Date() + ) + ], mirrorSites: [ + MirrorSite(name: "Mirror 1", url: "https://mirror1.com", addedAt: Date(), removedAt: nil), + MirrorSite(name: "Mirror 2", url: "https://mirror2.com", addedAt: Date(), removedAt: nil) + ], + deprecated: false + ), + + // Second item: Broker A again + BrokerProfileQueryData.mock( + dataBrokerName: "Broker A", + url: "https://broker-a.com", + extractedProfile: ExtractedProfile( + id: 1, + name: "John Doe", + alternativeNames: nil, + addressFull: nil, + addresses: nil, + phoneNumbers: nil, + relatives: nil, + profileUrl: nil, + reportId: nil, + age: nil, + email: nil, + removedDate: nil, + identifier: "id1" + ), scanHistoryEvents: [ + HistoryEvent( + extractedProfileId: 1, + brokerId: 1, + profileQueryId: 1, + type: .scanStarted, + date: Date() + ) + ], mirrorSites: [], + deprecated: false + ), + + // Third item: Deprecated broker with no matches + BrokerProfileQueryData.mock( + dataBrokerName: "Broker B", + url: "https://broker-b.com", + extractedProfile: nil, scanHistoryEvents: [ + HistoryEvent( + extractedProfileId: nil, + brokerId: 2, + profileQueryId: 2, + type: .scanStarted, + date: Date() + ) + ], mirrorSites: [], + deprecated: true + ), + + // Fourth item: Active broker with 2 extracted profiles and 1 mirror site + BrokerProfileQueryData.mock( + dataBrokerName: "Broker C", + url: "https://broker-c.com", + extractedProfile: ExtractedProfile( + id: 2, + name: "Alice", + alternativeNames: nil, + addressFull: nil, + addresses: nil, + phoneNumbers: nil, + relatives: nil, + profileUrl: nil, + reportId: nil, + age: nil, + email: nil, + removedDate: nil, + identifier: "id2" + ), scanHistoryEvents: [ + HistoryEvent( + extractedProfileId: 2, + brokerId: 3, + profileQueryId: 3, + type: .scanStarted, + date: Date() + ) + ], mirrorSites: [ + MirrorSite(name: "Mirror 3", url: "https://mirror3.com", addedAt: Date(), removedAt: nil) + ], + deprecated: false + ), + + // Third item: Active broker with 2 extracted profiles and 1 mirror site + BrokerProfileQueryData.mock( + dataBrokerName: "Broker D", + url: "https://broker-d.com", + extractedProfile: ExtractedProfile( + id: 2, + name: "Alice", + alternativeNames: nil, + addressFull: nil, + addresses: nil, + phoneNumbers: nil, + relatives: nil, + profileUrl: nil, + reportId: nil, + age: nil, + email: nil, + removedDate: nil, + identifier: "id2" + ), scanHistoryEvents: [ + HistoryEvent( + extractedProfileId: 2, + brokerId: 3, + profileQueryId: 3, + type: .scanStarted, + date: Date() + ) + ], mirrorSites: [], + deprecated: false + ) + ] + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionQueueManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionQueueManagerTests.swift index 2e04273131..75e9bbfe00 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionQueueManagerTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionQueueManagerTests.swift @@ -53,23 +53,77 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { userNotificationService: mockUserNotification) } - func testWhenStartImmediateScan_andScanCompletesWithErrors_thenErrorHandleIsCalledWithErrors_followedByCompletionBlock() async throws { + func testWhenStartImmediateScanOperations_thenCreatorIsCalledWithManualScanOperationType() async throws { // Given sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, operationsCreator: mockOperationsCreator, mismatchCalculator: mockMismatchCalculator, brokerUpdater: mockUpdater, pixelHandler: mockPixelHandler) - let mockOperation = MockDataBrokerOperation(id: 1, operationType: .scan, errorDelegate: sut) - let mockOperationWithError = MockDataBrokerOperation(id: 2, operationType: .scan, errorDelegate: sut, shouldError: true) + + // When + sut.startImmediateScanOperationsIfPermitted(showWebView: false, + operationDependencies: mockDependencies, + errorHandler: nil, + completion: nil) + + // Then + XCTAssertEqual(mockOperationsCreator.createdType, .manualScan) + } + + func testWhenStartScheduledAllOperations_thenCreatorIsCalledWithAllOperationType() async throws { + // Given + sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, + operationsCreator: mockOperationsCreator, + mismatchCalculator: mockMismatchCalculator, + brokerUpdater: mockUpdater, + pixelHandler: mockPixelHandler) + + // When + sut.startScheduledAllOperationsIfPermitted(showWebView: false, + operationDependencies: mockDependencies, + errorHandler: nil, + completion: nil) + + // Then + XCTAssertEqual(mockOperationsCreator.createdType, .all) + } + + func testWhenStartScheduledScanOperations_thenCreatorIsCalledWithScheduledScanOperationType() async throws { + // Given + sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, + operationsCreator: mockOperationsCreator, + mismatchCalculator: mockMismatchCalculator, + brokerUpdater: mockUpdater, + pixelHandler: mockPixelHandler) + + // When + sut.startScheduledScanOperationsIfPermitted(showWebView: false, + operationDependencies: mockDependencies, + errorHandler: nil, + completion: nil) + + // Then + XCTAssertEqual(mockOperationsCreator.createdType, .scheduledScan) + } + + func testWhenStartImmediateScan_andScanCompletesWithErrors_thenCompletionIsCalledWithErrors() async throws { + // Given + sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, + operationsCreator: mockOperationsCreator, + mismatchCalculator: mockMismatchCalculator, + brokerUpdater: mockUpdater, + pixelHandler: mockPixelHandler) + let mockOperation = MockDataBrokerOperation(id: 1, operationType: .manualScan, errorDelegate: sut) + let mockOperationWithError = MockDataBrokerOperation(id: 2, operationType: .manualScan, errorDelegate: sut, shouldError: true) mockOperationsCreator.operationCollections = [mockOperation, mockOperationWithError] let expectation = expectation(description: "Expected completion to be called") var errorCollection: DataBrokerProtectionAgentErrorCollection! - let expectedConcurrentOperations = DataBrokerExecutionConfig().concurrentOperationsFor(.scan) + let expectedConcurrentOperations = DataBrokerExecutionConfig().concurrentOperationsFor(.manualScan) var errorHandlerCalled = false // When - sut.startImmediateOperationsIfPermitted(showWebView: false, + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollection = errors errorHandlerCalled = true @@ -87,15 +141,15 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { XCTAssertEqual(mockQueue.maxConcurrentOperationCount, expectedConcurrentOperations) } - func testWhenStartScheduledScan_andScanCompletesWithErrors_thenErrorHandlerIsCalledWithErrors_followedByCompletionBlock() async throws { + func testWhenStartScheduledAllOperations_andOperationsCompleteWithErrors_thenErrorHandlerIsCalledWithErrors_followedByCompletionBlock() async throws { // Given sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, operationsCreator: mockOperationsCreator, mismatchCalculator: mockMismatchCalculator, brokerUpdater: mockUpdater, pixelHandler: mockPixelHandler) - let mockOperation = MockDataBrokerOperation(id: 1, operationType: .scan, errorDelegate: sut) - let mockOperationWithError = MockDataBrokerOperation(id: 2, operationType: .scan, errorDelegate: sut, shouldError: true) + let mockOperation = MockDataBrokerOperation(id: 1, operationType: .all, errorDelegate: sut) + let mockOperationWithError = MockDataBrokerOperation(id: 2, operationType: .all, errorDelegate: sut, shouldError: true) mockOperationsCreator.operationCollections = [mockOperation, mockOperationWithError] let expectation = expectation(description: "Expected completion to be called") var errorCollection: DataBrokerProtectionAgentErrorCollection! @@ -103,7 +157,41 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { var errorHandlerCalled = false // When - sut.startScheduledOperationsIfPermitted(showWebView: false, + sut.startScheduledAllOperationsIfPermitted(showWebView: false, + operationDependencies: mockDependencies) { errors in + errorCollection = errors + errorHandlerCalled = true + } completion: { + XCTAssertTrue(errorHandlerCalled) + expectation.fulfill() + } + + mockQueue.completeAllOperations() + + // Then + await fulfillment(of: [expectation], timeout: 5) + XCTAssert(errorCollection.operationErrors?.count == 1) + XCTAssertNotNil(mockOperationsCreator.priorityDate) + XCTAssertEqual(mockQueue.maxConcurrentOperationCount, expectedConcurrentOperations) + } + + func testWhenStartScheduledScanOperations_andOperationsCompleteWithErrors_thenCompletionIsCalledWithErrors() async throws { + // Given + sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, + operationsCreator: mockOperationsCreator, + mismatchCalculator: mockMismatchCalculator, + brokerUpdater: mockUpdater, + pixelHandler: mockPixelHandler) + let mockOperation = MockDataBrokerOperation(id: 1, operationType: .scheduledScan, errorDelegate: sut) + let mockOperationWithError = MockDataBrokerOperation(id: 2, operationType: .scheduledScan, errorDelegate: sut, shouldError: true) + mockOperationsCreator.operationCollections = [mockOperation, mockOperationWithError] + let expectation = expectation(description: "Expected errors to be returned in completion") + var errorCollection: DataBrokerProtectionAgentErrorCollection! + let expectedConcurrentOperations = DataBrokerExecutionConfig().concurrentOperationsFor(.scheduledScan) + var errorHandlerCalled = false + + // When + sut.startScheduledScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollection = errors errorHandlerCalled = true @@ -128,13 +216,13 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { mismatchCalculator: mockMismatchCalculator, brokerUpdater: mockUpdater, pixelHandler: mockPixelHandler) - let mockOperationsWithError = (1...2).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut, shouldError: true) } - var mockOperations = (3...4).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + let mockOperationsWithError = (1...2).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut, shouldError: true) } + var mockOperations = (3...4).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } mockOperationsCreator.operationCollections = mockOperationsWithError + mockOperations var errorCollection: DataBrokerProtectionAgentErrorCollection! // When - sut.startScheduledOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in + sut.startScheduledAllOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollection = errors } completion: { // no-op @@ -146,11 +234,11 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { XCTAssert(mockQueue.operationCount == 2) // Given - mockOperations = (5...8).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + mockOperations = (5...8).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } mockOperationsCreator.operationCollections = mockOperations // When - sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } completion: { // no-op } @@ -171,13 +259,13 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { mismatchCalculator: mockMismatchCalculator, brokerUpdater: mockUpdater, pixelHandler: mockPixelHandler) - let mockOperationsWithError = (1...2).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut, shouldError: true) } - var mockOperations = (3...4).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + let mockOperationsWithError = (1...2).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut, shouldError: true) } + var mockOperations = (3...4).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } mockOperationsCreator.operationCollections = mockOperationsWithError + mockOperations var errorCollection: DataBrokerProtectionAgentErrorCollection! // When - sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollection = errors } completion: { // no-op @@ -189,11 +277,11 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { XCTAssert(mockQueue.operationCount == 2) // Given - mockOperations = (5...8).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + mockOperations = (5...8).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } mockOperationsCreator.operationCollections = mockOperations // When - sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } completion: { // no-op } @@ -214,13 +302,13 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { mismatchCalculator: mockMismatchCalculator, brokerUpdater: mockUpdater, pixelHandler: mockPixelHandler) - var mockOperationsWithError = (1...2).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut, shouldError: true) } - var mockOperations = (3...4).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + var mockOperationsWithError = (1...2).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut, shouldError: true) } + var mockOperations = (3...4).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } mockOperationsCreator.operationCollections = mockOperationsWithError + mockOperations var errorCollectionFirst: DataBrokerProtectionAgentErrorCollection! // When - sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollectionFirst = errors } completion: { // no-op @@ -233,12 +321,12 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { // Given var errorCollectionSecond: DataBrokerProtectionAgentErrorCollection! - mockOperationsWithError = (5...6).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut, shouldError: true) } - mockOperations = (7...8).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + mockOperationsWithError = (5...6).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut, shouldError: true) } + mockOperations = (7...8).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } mockOperationsCreator.operationCollections = mockOperationsWithError + mockOperations // When - sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollectionSecond = errors } completion: { // no-op @@ -252,23 +340,23 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { XCTAssert(mockQueue.didCallCancelCount == 1) } - func testWhenStartScheduledScan_andCurrentModeIsImmediate_thenCurrentOperationsAreNotInterrupted_andNewCompletionIsCalledWithError() throws { + func testWhenStartScheduledAllOperations_andCurrentModeIsImmediate_thenCurrentOperationsAreNotInterrupted_andNewCompletionIsCalledWithError() throws { // Given sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, operationsCreator: mockOperationsCreator, mismatchCalculator: mockMismatchCalculator, brokerUpdater: mockUpdater, pixelHandler: mockPixelHandler) - var mockOperations = (1...5).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + var mockOperations = (1...5).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } var mockOperationsWithError = (6...10).map { MockDataBrokerOperation(id: $0, - operationType: .scan, + operationType: .manualScan, errorDelegate: sut, shouldError: true) } mockOperationsCreator.operationCollections = mockOperations + mockOperationsWithError var errorCollection: DataBrokerProtectionAgentErrorCollection! // When - sut.startImmediateOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } completion: { // no-op } @@ -277,9 +365,58 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { XCTAssert(mockQueue.operationCount == 10) // Given - mockOperations = (11...15).map { MockDataBrokerOperation(id: $0, operationType: .scan, errorDelegate: sut) } + mockOperations = (11...15).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } + mockOperationsWithError = (16...20).map { MockDataBrokerOperation(id: $0, + operationType: .manualScan, + errorDelegate: sut, + shouldError: true) } + mockOperationsCreator.operationCollections = mockOperations + mockOperationsWithError + let expectedError = DataBrokerProtectionQueueError.cannotInterrupt + var completionCalled = false + + // When + sut.startScheduledAllOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in + errorCollection = errors + completionCalled.toggle() + } completion: { + // no-op + } + + // Then + XCTAssert(mockQueue.didCallCancelCount == 0) + XCTAssert(mockQueue.operations.filter { !$0.isCancelled }.count == 10) + XCTAssert(mockQueue.operations.filter { $0.isCancelled }.count == 0) + XCTAssertEqual((errorCollection.oneTimeError as? DataBrokerProtectionQueueError), expectedError) + XCTAssert(completionCalled) + } + + func testWhenStartScheduledScanOperations_andCurrentModeIsImmediate_thenCurrentOperationsAreNotInterrupted_andNewCompletionIsCalledWithError() throws { + // Given + sut = DefaultDataBrokerProtectionQueueManager(operationQueue: mockQueue, + operationsCreator: mockOperationsCreator, + mismatchCalculator: mockMismatchCalculator, + brokerUpdater: mockUpdater, + pixelHandler: mockPixelHandler) + var mockOperations = (1...5).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } + var mockOperationsWithError = (6...10).map { MockDataBrokerOperation(id: $0, + operationType: .manualScan, + errorDelegate: sut, + shouldError: true) } + mockOperationsCreator.operationCollections = mockOperations + mockOperationsWithError + var errorCollection: DataBrokerProtectionAgentErrorCollection! + + // When + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { _ in } completion: { + // no-op + } + + // Then + XCTAssert(mockQueue.operationCount == 10) + + // Given + mockOperations = (11...15).map { MockDataBrokerOperation(id: $0, operationType: .manualScan, errorDelegate: sut) } mockOperationsWithError = (16...20).map { MockDataBrokerOperation(id: $0, - operationType: .scan, + operationType: .manualScan, errorDelegate: sut, shouldError: true) } mockOperationsCreator.operationCollections = mockOperations + mockOperationsWithError @@ -287,7 +424,7 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { var completionCalled = false // When - sut.startScheduledOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in + sut.startScheduledScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollection = errors } completion: { completionCalled.toggle() @@ -313,7 +450,7 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { var errorCollection: DataBrokerProtectionAgentErrorCollection! // When - sut.startImmediateOperationsIfPermitted(showWebView: false, + sut.startImmediateScanOperationsIfPermitted(showWebView: false, operationDependencies: mockDependencies) { errors in errorCollection = errors } completion: { @@ -333,7 +470,7 @@ final class DataBrokerProtectionQueueManagerTests: XCTestCase { brokerUpdater: mockUpdater, pixelHandler: mockPixelHandler) let expectedConcurrentOperations = DataBrokerExecutionConfig().concurrentOperationsFor(.optOut) - XCTAssert(mockOperationsCreator.createdType == .scan) + XCTAssert(mockOperationsCreator.createdType == .manualScan) // When sut.execute(.startOptOutOperations(showWebView: false, diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift index 4f70f8934a..93145f1be4 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUpdaterTests.swift @@ -111,7 +111,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { if let vault = self.vault { let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) repository.lastCheckedVersion = nil - resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock)] + resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock, optOutUrl: "")] vault.shouldReturnOldVersionBroker = true sut.checkForUpdatesInBrokerJSONFiles() @@ -129,7 +129,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { if let vault = self.vault { let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) repository.lastCheckedVersion = nil - resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock)] + resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock, optOutUrl: "")] vault.shouldReturnNewVersionBroker = true sut.checkForUpdatesInBrokerJSONFiles() @@ -146,7 +146,7 @@ final class DataBrokerProtectionUpdaterTests: XCTestCase { if let vault = self.vault { let sut = DefaultDataBrokerProtectionBrokerUpdater(repository: repository, resources: resources, vault: vault) repository.lastCheckedVersion = nil - resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.0", schedulingConfig: .mock)] + resources.brokersList = [.init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.0", schedulingConfig: .mock, optOutUrl: "")] vault.profileQueries = [.mock] sut.checkForUpdatesInBrokerJSONFiles() diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUserNotificationServiceTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUserNotificationServiceTests.swift new file mode 100644 index 0000000000..9bf7263a40 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionUserNotificationServiceTests.swift @@ -0,0 +1,73 @@ +// +// DataBrokerProtectionUserNotificationServiceTests.swift +// +// 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 DataBrokerProtection +import UserNotifications + +final class DataBrokerProtectionUserNotificationServiceTests: XCTestCase { + + private var mockAuthenticationManager: MockAuthenticationManager! + private var mockDBPUserNotificationCenter: MockDBPUserNotificationCenter! + private var sut: DataBrokerProtectionUserNotificationService! + + override func setUpWithError() throws { + mockAuthenticationManager = MockAuthenticationManager() + mockDBPUserNotificationCenter = MockDBPUserNotificationCenter() + sut = DefaultDataBrokerProtectionUserNotificationService(pixelHandler: MockPixelHandler(), + userNotificationCenter: mockDBPUserNotificationCenter, + authenticationManager: mockAuthenticationManager) + } + + func test_sendFirstScanCompletedNotification_WhenUserNotAuthenticated_ShouldSendFirstFreemiumScanNotification() { + // Given + mockAuthenticationManager.isUserAuthenticatedValue = false + + // When + sut.sendFirstScanCompletedNotification() + + // Then + XCTAssertEqual(mockDBPUserNotificationCenter.addedRequest?.identifier, "dbp.freemium.scan.complete") + } + + func test_sendFirstScanCompletedNotification_WhenUserAuthenticated_ShouldSendFirstScanCompleteNotification() { + // Given + mockAuthenticationManager.isUserAuthenticatedValue = true + + // When + sut.sendFirstScanCompletedNotification() + + // Then + XCTAssertEqual(mockDBPUserNotificationCenter.addedRequest?.identifier, "dbp.scan.complete") + } +} + +final private class MockDBPUserNotificationCenter: DBPUserNotificationCenter { + + var addedRequest: UNNotificationRequest? + + var delegate: (any UNUserNotificationCenterDelegate)? + + func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: (((any Error)?) -> Void)? = nil) { + addedRequest = request + } + + func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void) {} + + func requestAuthorization(options: UNAuthorizationOptions, completionHandler: @escaping (Bool, (any Error)?) -> Void) {} +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToModelTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToModelTests.swift new file mode 100644 index 0000000000..c3a70fa5de --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToModelTests.swift @@ -0,0 +1,118 @@ +// +// MapperToModelTests.swift +// +// Copyright © 2023 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 +@testable import DataBrokerProtection + +final class MapperToModelTests: XCTestCase { + + private var sut = MapperToModel(mechanism: {_ in Data()}) + private var jsonDecoder: JSONDecoder! + private var jsonEncoder: JSONEncoder! + + override func setUpWithError() throws { + jsonDecoder = JSONDecoder() + jsonEncoder = JSONEncoder() + } + + func testMapToModel_validData() throws { + // Given + let brokerData = DataBroker( + id: 1, + name: "TestBroker", + url: "https://example.com", + steps: [], + version: "1.0", + schedulingConfig: DataBrokerScheduleConfig(retryError: 1, confirmOptOutScan: 2, maintenanceScan: 3), + parent: "ParentBroker", + mirrorSites: [], + optOutUrl: "https://example.com/opt-out" + ) + let jsonData = try jsonEncoder.encode(brokerData) + let brokerDB = BrokerDB(id: 1, name: "TestBroker", json: jsonData, version: "1.0", url: "https://example.com") + + // When + let result = try sut.mapToModel(brokerDB) + + // Then + XCTAssertEqual(result.id, brokerDB.id) + XCTAssertEqual(result.name, brokerDB.name) + XCTAssertEqual(result.url, brokerData.url) + XCTAssertEqual(result.version, brokerData.version) + XCTAssertEqual(result.steps.count, brokerData.steps.count) + XCTAssertEqual(result.parent, brokerData.parent) + XCTAssertEqual(result.mirrorSites.count, brokerData.mirrorSites.count) + XCTAssertEqual(result.optOutUrl, brokerData.optOutUrl) + } + + func testMapToModel_missingOptionalFields() throws { + // Given + let brokerData = """ + { + "name": "TestBroker", + "url": "https://example.com", + "steps": [], + "version": "1.0", + "schedulingConfig": {"retryError": 1, "confirmOptOutScan": 2, "maintenanceScan": 3} + } + """.data(using: .utf8)! + let brokerDB = BrokerDB(id: 1, name: "TestBroker", json: brokerData, version: "1.0", url: "https://example.com") + + // When + let result = try sut.mapToModel(brokerDB) + + // Then + XCTAssertNil(result.parent) + XCTAssertEqual(result.mirrorSites.count, 0) + XCTAssertEqual(result.optOutUrl, "") + } + + func testMapToModel_invalidJSONStructure() throws { + // Given + let invalidJsonData = """ + { + "invalidKey": "value" + } + """.data(using: .utf8)! + let brokerDB = BrokerDB(id: 1, name: "InvalidBroker", json: invalidJsonData, version: "1.0", url: "https://example.com") + + // When & Then + XCTAssertThrowsError(try sut.mapToModel(brokerDB)) { error in + XCTAssertTrue(error is DecodingError) + } + } + + func testMapToModel_missingUrlFallbackToName() throws { + // Given + let brokerData = """ + { + "name": "TestBroker", + "steps": [], + "version": "1.0", + "schedulingConfig": {"retryError": 1, "confirmOptOutScan": 2, "maintenanceScan": 3} + } + """.data(using: .utf8)! + let brokerDB = BrokerDB(id: 1, name: "TestBroker", json: brokerData, version: "1.0", url: "") + + // When + let result = try sut.mapToModel(brokerDB) + + // Then + XCTAssertEqual(result.url, brokerDB.name) + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift index a47d13d87a..edff8837e8 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MapperToUITests.swift @@ -394,6 +394,73 @@ final class MapperToUITests: XCTestCase { XCTAssertEqual(result.scanProgress.scannedBrokers, expected) } + // MARK: - `maintenanceScanState` Broker OptOut URL & Name tests + + func testMaintenanceScanState_childBrokerWithOwnOptOutUrl() { + // Given + let extractedProfile = ExtractedProfile(id: 2, name: "Another Sample", profileUrl: "anotherprofile.com", removedDate: nil) + + let childBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ChildBrokerWithOwnOptOut", + url: "child.com", + parentURL: "parent.com", + optOutUrl: "child.com/optout", + extractedProfile: extractedProfile + ) + + let parentBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ParentBroker", + url: "parent.com", + optOutUrl: "parent.com/optout", + extractedProfile: extractedProfile + ) + + // When + let state = sut.maintenanceScanState([childBroker, parentBroker]) + + // Then + XCTAssertEqual(state.inProgressOptOuts.count, 2) + XCTAssertEqual(state.completedOptOuts.count, 0) + + let childProfile = state.inProgressOptOuts.first { $0.dataBroker.name == "ChildBrokerWithOwnOptOut" } + XCTAssertEqual(childProfile?.dataBroker.optOutUrl, "child.com/optout") + + let parentProfile = state.inProgressOptOuts.first { $0.dataBroker.name == "ParentBroker" } + XCTAssertEqual(parentProfile?.dataBroker.optOutUrl, "parent.com/optout") + } + + func testMaintenanceScanState_childBrokerWithParentOptOutUrl() { + // Given + let extractedProfile = ExtractedProfile(id: 1, name: "Sample Name", profileUrl: "profile.com", removedDate: nil) + + let childBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ChildBroker", + url: "child.com", + parentURL: "parent.com", + optOutUrl: "parent.com/optout", + extractedProfile: extractedProfile + ) + + let parentBroker = BrokerProfileQueryData.mock( + dataBrokerName: "ParentBroker", + url: "parent.com", + optOutUrl: "parent.com/optout", + extractedProfile: extractedProfile + ) + + // When + let state = sut.maintenanceScanState([childBroker, parentBroker]) + + // Then + XCTAssertEqual(state.inProgressOptOuts.count, 2) + XCTAssertEqual(state.completedOptOuts.count, 0) + + let childProfile = state.inProgressOptOuts.first { $0.dataBroker.name == "ChildBroker" } + XCTAssertEqual(childProfile?.dataBroker.optOutUrl, "parent.com/optout") + + let parentProfile = state.inProgressOptOuts.first { $0.dataBroker.name == "ParentBroker" } + XCTAssertEqual(childProfile?.dataBroker.optOutUrl, "parent.com/optout") + } } extension DBPUIScanProgress.ScannedBroker { diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift index fb2af7862b..06ef01eae4 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/MismatchCalculatorUseCaseTests.swift @@ -155,7 +155,8 @@ extension BrokerProfileQueryData { url: "parent.com", steps: [Step](), version: "1.0.0", - schedulingConfig: DataBrokerScheduleConfig.mock + schedulingConfig: DataBrokerScheduleConfig.mock, + optOutUrl: "" ), profileQuery: ProfileQuery(firstName: "John", lastName: "Doe", city: "Miami", state: "FL", birthYear: 50), scanJobData: ScanJobData(brokerId: 1, profileQueryId: 1, historyEvents: historyEvents) @@ -170,7 +171,8 @@ extension BrokerProfileQueryData { steps: [Step](), version: "1.0.0", schedulingConfig: DataBrokerScheduleConfig.mock, - parent: "parent.com" + parent: "parent.com", + optOutUrl: "" ), profileQuery: ProfileQuery(firstName: "John", lastName: "Doe", city: "Miami", state: "FL", birthYear: 50), scanJobData: ScanJobData(brokerId: 2, profileQueryId: 1, historyEvents: historyEvents) diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift index d0a0bcf970..e9c98d5773 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift @@ -23,6 +23,7 @@ import Configuration import Foundation import GRDB import SecureStorage +import Freemium @testable import DataBrokerProtection @@ -32,6 +33,7 @@ extension BrokerProfileQueryData { dataBrokerName: String = "test", url: String = "test.com", parentURL: String? = nil, + optOutUrl: String? = nil, lastRunDate: Date? = nil, preferredRunDate: Date? = nil, extractedProfile: ExtractedProfile? = nil, @@ -47,7 +49,8 @@ extension BrokerProfileQueryData { version: "1.0.0", schedulingConfig: DataBrokerScheduleConfig.mock, parent: parentURL, - mirrorSites: mirrorSites + mirrorSites: mirrorSites, + optOutUrl: optOutUrl ?? "" ), profileQuery: ProfileQuery(firstName: "John", lastName: "Doe", city: "Miami", state: "FL", birthYear: 50, deprecated: deprecated), scanJobData: ScanJobData(brokerId: 1, @@ -622,9 +625,9 @@ final class DataBrokerProtectionSecureVaultMock: DataBrokerProtectionSecureVault func fetchBroker(with name: String) throws -> DataBroker? { if shouldReturnOldVersionBroker { - return .init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.0", schedulingConfig: .mock) + return .init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.0", schedulingConfig: .mock, optOutUrl: "") } else if shouldReturnNewVersionBroker { - return .init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock) + return .init(id: 1, name: "Broker", url: "broker.com", steps: [Step](), version: "1.0.1", schedulingConfig: .mock, optOutUrl: "") } return nil @@ -784,6 +787,11 @@ public class MockDataBrokerProtectionPixelsHandler: EventMapping = .success(()) + lazy var callsList: [Bool] = [ wasSaveProfileCalled, wasFetchProfileCalled, @@ -842,6 +852,12 @@ final class MockDatabase: DataBrokerProtectionRepository { func save(_ profile: DataBrokerProtectionProfile) throws { wasSaveProfileCalled = true + switch saveResult { + case .success: + return + case .failure(let error): + throw error + } } func fetchProfile() -> DataBrokerProtectionProfile? { @@ -1104,8 +1120,28 @@ final class MockRunnerProvider: JobRunnerProvider { final class MockPixelHandler: EventMapping { + var lastFiredEvent: DataBrokerProtectionPixels? + var lastPassedParameters: [String: String]? + init() { - super.init { event, _, _, _ in } + var mockMapping: Mapping! = nil + + super.init(mapping: { event, error, params, onComplete in + // Call the closure after initialization + mockMapping(event, error, params, onComplete) + }) + + // Now, set the real closure that captures self and stores parameters. + mockMapping = { [weak self] (event, error, params, onComplete) in + // Capture the inputs when fire is called + self?.lastFiredEvent = event + self?.lastPassedParameters = params + } + } + + func resetCapturedData() { + lastFiredEvent = nil + lastPassedParameters = nil } } @@ -1197,7 +1233,8 @@ extension DataBroker { retryError: 0, confirmOptOutScan: 0, maintenanceScan: 0 - ) + ), + optOutUrl: "" ) } } @@ -1205,26 +1242,34 @@ extension DataBroker { final class MockDataBrokerProtectionOperationQueueManager: DataBrokerProtectionQueueManager { var debugRunningStatusString: String { return "" } - var startImmediateOperationsIfPermittedCompletionError: DataBrokerProtectionAgentErrorCollection? - var startScheduledOperationsIfPermittedCompletionError: DataBrokerProtectionAgentErrorCollection? + var startImmediateScanOperationsIfPermittedCompletionError: DataBrokerProtectionAgentErrorCollection? + var startScheduledAllOperationsIfPermittedCompletionError: DataBrokerProtectionAgentErrorCollection? + var startScheduledScanOperationsIfPermittedCompletionError: DataBrokerProtectionAgentErrorCollection? - var startImmediateOperationsIfPermittedCalledCompletion: (() -> Void)? - var startScheduledOperationsIfPermittedCalledCompletion: (() -> Void)? + var startImmediateScanOperationsIfPermittedCalledCompletion: (() -> Void)? + var startScheduledAllOperationsIfPermittedCalledCompletion: (() -> Void)? + var startScheduledScanOperationsIfPermittedCalledCompletion: (() -> Void)? init(operationQueue: DataBrokerProtection.DataBrokerProtectionOperationQueue, operationsCreator: DataBrokerProtection.DataBrokerOperationsCreator, mismatchCalculator: DataBrokerProtection.MismatchCalculator, brokerUpdater: DataBrokerProtection.DataBrokerProtectionBrokerUpdater?, pixelHandler: Common.EventMapping) { } - func startImmediateOperationsIfPermitted(showWebView: Bool, operationDependencies: any DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { - errorHandler?(startImmediateOperationsIfPermittedCompletionError) + func startImmediateScanOperationsIfPermitted(showWebView: Bool, operationDependencies: DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { + errorHandler?(startImmediateScanOperationsIfPermittedCompletionError) completion?() - startImmediateOperationsIfPermittedCalledCompletion?() + startImmediateScanOperationsIfPermittedCalledCompletion?() } - func startScheduledOperationsIfPermitted(showWebView: Bool, operationDependencies: any DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { - errorHandler?(startScheduledOperationsIfPermittedCompletionError) + func startScheduledAllOperationsIfPermitted(showWebView: Bool, operationDependencies: DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { + errorHandler?(startScheduledAllOperationsIfPermittedCompletionError) completion?() - startScheduledOperationsIfPermittedCalledCompletion?() + startScheduledAllOperationsIfPermittedCalledCompletion?() + } + + func startScheduledScanOperationsIfPermitted(showWebView: Bool, operationDependencies: DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { + errorHandler?(startScheduledScanOperationsIfPermittedCompletionError) + completion?() + startScheduledScanOperationsIfPermittedCalledCompletion?() } func execute(_ command: DataBrokerProtection.DataBrokerProtectionQueueManagerDebugCommand) { @@ -1292,7 +1337,10 @@ final class MockDataBrokerProtectionDataManager: DataBrokerProtectionDataManagin var cache: DataBrokerProtection.InMemoryDataCache var delegate: DataBrokerProtection.DataBrokerProtectionDataManagerDelegate? - init(pixelHandler: Common.EventMapping, fakeBrokerFlag: DataBrokerProtection.DataBrokerDebugFlag) { + init(database: DataBrokerProtectionRepository? = nil, + profileSavedNotifier: DBPProfileSavedNotifier? = nil, + pixelHandler: Common.EventMapping, + fakeBrokerFlag: DataBrokerProtection.DataBrokerDebugFlag) { cache = InMemoryDataCache() } @@ -1317,6 +1365,10 @@ final class MockDataBrokerProtectionDataManager: DataBrokerProtectionDataManagin return shouldReturnHasMatches } + func matchesFoundAndBrokersCount() throws -> (matchCount: Int, brokerCount: Int) { + (0, 0) + } + func profileQueriesCount() throws -> Int { return 0 } @@ -1499,7 +1551,7 @@ final class MockDataBrokerOperationsCreator: DataBrokerOperationsCreator { var operationCollections: [DataBrokerOperation] = [] var shouldError = false var priorityDate: Date? - var createdType: OperationType = .scan + var createdType: OperationType = .manualScan init(operationCollections: [DataBrokerOperation] = []) { self.operationCollections = operationCollections @@ -1593,7 +1645,7 @@ final class MockAgentStopper: DataBrokerProtectionAgentStopper { validateRunPrerequisitesCompletion?() } - func monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: TimeInterval) { + func monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: TimeInterval) { monitorEntitlementCompletion?() } } @@ -1947,3 +1999,24 @@ struct MockMigrationsProvider: DataBrokerProtectionDatabaseMigrationsProvider { return { _ in } } } + +final class MockFreemiumDBPUserStateManager: FreemiumDBPUserStateManager { + var didActivate = false + var didPostFirstProfileSavedNotification = false + var didPostResultsNotification = false + var didDismissHomePagePromotion = false + var firstProfileSavedTimestamp: Date? + var upgradeToSubscriptionTimestamp: Date? + var firstScanResults: FreemiumDBPMatchResults? + + func resetAllState() {} +} + +final class MockDBPProfileSavedNotifier: DBPProfileSavedNotifier { + + var didCallPostProfileSavedNotificationIfPermitted = false + + func postProfileSavedNotificationIfPermitted() { + didCallPostProfileSavedNotificationIfPermitted = true + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/OperationPreferredDateUpdaterTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/OperationPreferredDateUpdaterTests.swift index e7fdb4cb45..76d2e4bb65 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/OperationPreferredDateUpdaterTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/OperationPreferredDateUpdaterTests.swift @@ -42,7 +42,8 @@ final class OperationPreferredDateUpdaterTests: XCTestCase { retryError: 1, confirmOptOutScan: confirmOptOutScanHours, maintenanceScan: 1 - ) + ), + optOutUrl: "" ) databaseMock.childBrokers = [childBroker] diff --git a/LocalPackages/Freemium/Sources/Freemium/FreemiumDBPUserStateManager.swift b/LocalPackages/Freemium/Sources/Freemium/FreemiumDBPUserStateManager.swift new file mode 100644 index 0000000000..6052920608 --- /dev/null +++ b/LocalPackages/Freemium/Sources/Freemium/FreemiumDBPUserStateManager.swift @@ -0,0 +1,205 @@ +// +// FreemiumDBPUserStateManager.swift +// +// 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 + +/// A structure representing the results of a Freemium DBP match, including the count of matches and brokers. +public struct FreemiumDBPMatchResults: Codable, Equatable { + + /// The number of matches found during the Freemium DBP process. + public let matchesCount: Int + + /// The number of brokers involved in the Freemium DBP match. + public let brokerCount: Int + + /// Initializes a new instance of `FreemiumDBPMatchResults`. + /// + /// - Parameters: + /// - matchesCount: The number of matches found. + /// - brokerCount: The number of brokers involved. + public init(matchesCount: Int, brokerCount: Int) { + self.matchesCount = matchesCount + self.brokerCount = brokerCount + } +} + +/// Protocol that manages the user's state in the FreemiumDBP feature. +/// +/// The properties in this protocol represent the various states and milestones in the user journey, +/// such as whether Freemium is activated (i.e was accessed), notifications have been posted, and important timestamps or data points. +/// +/// Conforming types are responsible for persisting and retrieving these values. +public protocol FreemiumDBPUserStateManager { + + /// A boolean value indicating whether the user has accessed the Freemium DBP feature + var didActivate: Bool { get set } + + /// A boolean value indicating whether the "First Profile Saved" notification has been posted. + var didPostFirstProfileSavedNotification: Bool { get set } + + /// A boolean value indicating whether the results notification has been posted. + var didPostResultsNotification: Bool { get set } + + /// A boolean value indicating whether the user has dismissed the homepage promotion. + var didDismissHomePagePromotion: Bool { get set } + + /// A Date value that stores the timestamp of when the user saved their first profile. + var firstProfileSavedTimestamp: Date? { get set } + + /// The results of the user's first scan, stored as a `FreemiumDBPMatchResults` object. + var firstScanResults: FreemiumDBPMatchResults? { get set } + + /// A Date value that stores the timestamp of when the user upgraded from Freemium to a Paid Subscription + var upgradeToSubscriptionTimestamp: Date? { get set } + + /// Resets all stored user state + func resetAllState() +} + +/// Default implementation of `FreemiumDBPUserStateManager` that uses `UserDefaults` for underlying storage. +/// +/// Each property in this class corresponds to a specific `UserDefaults` key to maintain persistence across app sessions. +public final class DefaultFreemiumDBPUserStateManager: FreemiumDBPUserStateManager { + + /// Keys for storing the values in `UserDefaults`. + private enum Keys { + static let didActivate = "macos.browser.freemium.dbp.did.activate" + static let didPostFirstProfileSavedNotification = "macos.browser.freemium.dbp.did.post.first.profile.saved.notification" + static let didPostResultsNotification = "macos.browser.freemium.dbp.did.post.results.notification" + static let didDismissHomePagePromotion = "macos.browser.freemium.dbp.did.post.dismiss.home.page.promotion" + static let firstProfileSavedTimestamp = "macos.browser.freemium.dbp.first.profile.saved.timestamp" + static let firstScanResults = "macos.browser.freemium.dbp.first.scan.results" + static let upgradeToSubscriptionTimestamp = "macos.browser.freemium.dbp.upgrade.to.subscription.timestamp" + } + + private let userDefaults: UserDefaults + + // MARK: - FreemiumDBPUserStateManager Properties + + /// Tracks whether the user has accessed the Freemium DBP feature. + /// + /// - Uses the `UserDefaults` key: `macos.browser.freemium.dbp.did.activate`. + public var didActivate: Bool { + get { + userDefaults.bool(forKey: Keys.didActivate) + } + set { + userDefaults.set(newValue, forKey: Keys.didActivate) + } + } + + /// Tracks the timestamp of when the user saved their first profile. + /// + /// - Uses the `UserDefaults` key: `macos.browser.freemium.dbp.first.profile.saved.timestamp`. + public var firstProfileSavedTimestamp: Date? { + get { + userDefaults.value(forKey: Keys.firstProfileSavedTimestamp) as? Date + } + set { + userDefaults.set(newValue, forKey: Keys.firstProfileSavedTimestamp) + } + } + + /// Tracks whether the "First Profile Saved" notification has been posted. + /// + /// - Uses the `UserDefaults` key: `macos.browser.freemium.dbp.did.post.first.profile.saved.notification`. + public var didPostFirstProfileSavedNotification: Bool { + get { + userDefaults.bool(forKey: Keys.didPostFirstProfileSavedNotification) + } + set { + userDefaults.set(newValue, forKey: Keys.didPostFirstProfileSavedNotification) + } + } + + /// Tracks whether the results notification has been posted. + /// + /// - Uses the `UserDefaults` key: `macos.browser.freemium.dbp.did.post.results.notification`. + public var didPostResultsNotification: Bool { + get { + userDefaults.bool(forKey: Keys.didPostResultsNotification) + } + set { + userDefaults.set(newValue, forKey: Keys.didPostResultsNotification) + } + } + + /// Tracks the results of the user's first scan. + /// + /// This value is stored as a `FreemiumDBPMatchResults` object, encoded and decoded using `JSONEncoder` and `JSONDecoder`. + /// - Uses the `UserDefaults` key: `macos.browser.freemium.dbp.first.scan.results`. + public var firstScanResults: FreemiumDBPMatchResults? { + get { + guard let data = userDefaults.object(forKey: Keys.firstScanResults) as? Data, + let decoded = try? JSONDecoder().decode(FreemiumDBPMatchResults.self, from: data) else { return nil } + return decoded + } + set { + if let encoded = try? JSONEncoder().encode(newValue) { + userDefaults.set(encoded, forKey: Keys.firstScanResults) + } + } + } + + /// Tracks whether the user has dismissed the homepage promotion. + /// + /// - Uses the `UserDefaults` key: `macos.browser.freemium.dbp.did.post.dismiss.home.page.promotion`. + public var didDismissHomePagePromotion: Bool { + get { + userDefaults.bool(forKey: Keys.didDismissHomePagePromotion) + } + set { + userDefaults.set(newValue, forKey: Keys.didDismissHomePagePromotion) + } + } + + /// Tracks the timestamp of when the user upgraded from Freemium to paid Subscription. + /// + /// - Uses the `UserDefaults` key: `macos.browser.freemium.dbp.upgrade.to.subscription.timestamp`. + public var upgradeToSubscriptionTimestamp: Date? { + get { + userDefaults.value(forKey: Keys.upgradeToSubscriptionTimestamp) as? Date + } + set { + userDefaults.set(newValue, forKey: Keys.upgradeToSubscriptionTimestamp) + } + } + + // MARK: - Initialization + + /// Initializes a new instance of `DefaultFreemiumDBPUserStateManager`. + /// + /// - Parameter userDefaults: The `UserDefaults` instance used to store and retrieve the user's state data. + /// + /// - Note: Ensure the same `UserDefaults` instance is passed throughout the app to maintain consistency in state management. + public init(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + } + + /// Resets all stored user state by clearing or resetting the relevant keys in `UserDefaults`. + public func resetAllState() { + // Reset each stored value to its default state + userDefaults.removeObject(forKey: Keys.didActivate) + userDefaults.removeObject(forKey: Keys.firstProfileSavedTimestamp) + userDefaults.removeObject(forKey: Keys.didPostFirstProfileSavedNotification) + userDefaults.removeObject(forKey: Keys.didPostResultsNotification) + userDefaults.removeObject(forKey: Keys.firstScanResults) + userDefaults.removeObject(forKey: Keys.didDismissHomePagePromotion) + userDefaults.removeObject(forKey: Keys.upgradeToSubscriptionTimestamp) + } +} diff --git a/LocalPackages/Freemium/Sources/Freemium/FreemiumPIRState.swift b/LocalPackages/Freemium/Sources/Freemium/FreemiumPIRState.swift deleted file mode 100644 index cbf7b9bf77..0000000000 --- a/LocalPackages/Freemium/Sources/Freemium/FreemiumPIRState.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// FreemiumPIRState.swift -// -// 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 - -/// `FreemiumPIRState` types provide access to Freemium PIR-related state -protocol FreemiumPIRState { - var didOnboard: Bool { get set } -} - -/// Default implementation of `FreemiumPIRState`. `UserDefaults` is used as underlying storage. -public final class DefaultFreemiumPIRState: FreemiumPIRState { - - private let userDefaults: UserDefaults - private let key = "macos.browser.freemium.pir" - - public var didOnboard: Bool { - get { - userDefaults.bool(forKey: key) - } set { - userDefaults.set(newValue, forKey: key) - } - } - - public init(userDefaults: UserDefaults) { - self.userDefaults = userDefaults - } -} diff --git a/LocalPackages/Freemium/Tests/FreemiumTests/FreemiumDBPUserStateManagerTests.swift b/LocalPackages/Freemium/Tests/FreemiumTests/FreemiumDBPUserStateManagerTests.swift new file mode 100644 index 0000000000..b7f7eb789b --- /dev/null +++ b/LocalPackages/Freemium/Tests/FreemiumTests/FreemiumDBPUserStateManagerTests.swift @@ -0,0 +1,256 @@ +// +// FreemiumDBPUserStateManagerTests.swift +// +// 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 +@testable import Freemium + +final class FreemiumDBPUserStateManagerTests: XCTestCase { + + private enum Keys { + static let didActivate = "macos.browser.freemium.dbp.did.activate" + static let didPostFirstProfileSavedNotification = "macos.browser.freemium.dbp.did.post.first.profile.saved.notification" + static let didPostResultsNotification = "macos.browser.freemium.dbp.did.post.results.notification" + static let didDismissHomePagePromotion = "macos.browser.freemium.dbp.did.post.dismiss.home.page.promotion" + static let firstProfileSavedTimestamp = "macos.browser.freemium.dbp.first.profile.saved.timestamp" + static let firstScanResults = "macos.browser.freemium.dbp.first.scan.results" + static let upgradeToSubscriptionTimestamp = "macos.browser.freemium.dbp.upgrade.to.subscription.timestamp" + } + + private static let testSuiteName = "test.defaults.freemium.user.state.tests" + private let testUserDefaults = UserDefaults(suiteName: FreemiumDBPUserStateManagerTests.testSuiteName)! + + override func setUpWithError() throws { + testUserDefaults.removePersistentDomain(forName: FreemiumDBPUserStateManagerTests.testSuiteName) + } + + func testSetsdidActivate() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(testUserDefaults.bool(forKey: Keys.didActivate)) + + // When + sut.didActivate = true + + // Then + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didActivate)) + } + + func testGetsdidActivate() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(sut.didActivate) + testUserDefaults.setValue(true, forKey: Keys.didActivate) + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didActivate)) + + // When + let result = sut.didActivate + + // Then + XCTAssertTrue(result) + } + + func testSetsfirstProfileSavedTimestamp() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertNil(testUserDefaults.value(forKey: Keys.firstProfileSavedTimestamp)) + + // When + sut.firstProfileSavedTimestamp = Date() + + // Then + XCTAssertNotNil(testUserDefaults.value(forKey: Keys.firstProfileSavedTimestamp)) + } + + func testGetsfirstProfileSavedTimestamp() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertNil(sut.firstProfileSavedTimestamp) + testUserDefaults.setValue(Date(), forKey: Keys.firstProfileSavedTimestamp) + XCTAssertNotNil(testUserDefaults.value(forKey: Keys.firstProfileSavedTimestamp)) + + // When + let result = sut.firstProfileSavedTimestamp + + // Then + XCTAssertNotNil(result) + } + + func testSetsDidPostFirstProfileSavedNotification() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(testUserDefaults.bool(forKey: Keys.didPostFirstProfileSavedNotification)) + + // When + sut.didPostFirstProfileSavedNotification = true + + // Then + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didPostFirstProfileSavedNotification)) + } + + func testGetsDidPostFirstProfileSavedNotification() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(sut.didPostFirstProfileSavedNotification) + testUserDefaults.setValue(true, forKey: Keys.didPostFirstProfileSavedNotification) + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didPostFirstProfileSavedNotification)) + + // When + let result = sut.didPostFirstProfileSavedNotification + + // Then + XCTAssertTrue(result) + } + + func testSetsDidPostResultsNotification() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(testUserDefaults.bool(forKey: Keys.didPostResultsNotification)) + + // When + sut.didPostResultsNotification = true + + // Then + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didPostResultsNotification)) + } + + func testGetsDidPostResultsNotification() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(sut.didPostResultsNotification) + testUserDefaults.setValue(true, forKey: Keys.didPostResultsNotification) + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didPostResultsNotification)) + + // When + let result = sut.didPostResultsNotification + + // Then + XCTAssertTrue(result) + } + + func testSetsDidDismissHomePagePromotion() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(testUserDefaults.bool(forKey: Keys.didDismissHomePagePromotion)) + + // When + sut.didDismissHomePagePromotion = true + + // Then + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didDismissHomePagePromotion)) + } + + func testGetsDidDismissHomePagePromotion() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertFalse(sut.didDismissHomePagePromotion) + testUserDefaults.setValue(true, forKey: Keys.didDismissHomePagePromotion) + XCTAssertTrue(testUserDefaults.bool(forKey: Keys.didDismissHomePagePromotion)) + + // When + let result = sut.didDismissHomePagePromotion + + // Then + XCTAssertTrue(result) + } + + func testSetsFirstScanResults() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertNil(testUserDefaults.data(forKey: Keys.firstScanResults)) + + // When + let scanResults = FreemiumDBPMatchResults(matchesCount: 3, brokerCount: 2) + sut.firstScanResults = scanResults + + // Then + let storedData = testUserDefaults.data(forKey: Keys.firstScanResults) + XCTAssertNotNil(storedData) + + // Decode and verify the result + let decodedResults = try? JSONDecoder().decode(FreemiumDBPMatchResults.self, from: storedData!) + XCTAssertEqual(decodedResults?.matchesCount, scanResults.matchesCount) + XCTAssertEqual(decodedResults?.brokerCount, scanResults.brokerCount) + } + + func testGetsFirstScanResults() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertNil(sut.firstScanResults) + + // When + let scanResults = FreemiumDBPMatchResults(matchesCount: 3, brokerCount: 2) + let encodedResults = try JSONEncoder().encode(scanResults) + testUserDefaults.set(encodedResults, forKey: Keys.firstScanResults) + + // Then + let result = sut.firstScanResults + XCTAssertNotNil(result) + XCTAssertEqual(result?.matchesCount, scanResults.matchesCount) + XCTAssertEqual(result?.brokerCount, scanResults.brokerCount) + } + + func testResetAllStateResetsAllProperties() { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + sut.didActivate = true + sut.firstProfileSavedTimestamp = Date() + sut.didPostFirstProfileSavedNotification = true + sut.didPostResultsNotification = true + sut.didDismissHomePagePromotion = true + let scanResults = FreemiumDBPMatchResults(matchesCount: 10, brokerCount: 5) + sut.firstScanResults = scanResults + + // When + sut.resetAllState() + + // Then + XCTAssertFalse(sut.didActivate) + XCTAssertNil(sut.firstProfileSavedTimestamp) + XCTAssertFalse(sut.didPostFirstProfileSavedNotification) + XCTAssertFalse(sut.didPostResultsNotification) + XCTAssertNil(sut.firstScanResults) + XCTAssertFalse(sut.didDismissHomePagePromotion) + } + + func testSetsUpgradeToSubscriptionTimestamp() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertNil(testUserDefaults.value(forKey: Keys.upgradeToSubscriptionTimestamp)) + + // When + sut.upgradeToSubscriptionTimestamp = Date() + + // Then + XCTAssertNotNil(testUserDefaults.value(forKey: Keys.upgradeToSubscriptionTimestamp)) + } + + func testGetsUpgradeToSubscriptionTimestamp() throws { + // Given + let sut = DefaultFreemiumDBPUserStateManager(userDefaults: testUserDefaults) + XCTAssertNil(sut.upgradeToSubscriptionTimestamp) + testUserDefaults.setValue(Date(), forKey: Keys.upgradeToSubscriptionTimestamp) + XCTAssertNotNil(testUserDefaults.value(forKey: Keys.upgradeToSubscriptionTimestamp)) + + // When + let result = sut.upgradeToSubscriptionTimestamp + + // Then + XCTAssertNotNil(result) + } + +} diff --git a/LocalPackages/Freemium/Tests/FreemiumTests/FreemiumPIRStateTests.swift b/LocalPackages/Freemium/Tests/FreemiumTests/FreemiumPIRStateTests.swift deleted file mode 100644 index e33f44dd1f..0000000000 --- a/LocalPackages/Freemium/Tests/FreemiumTests/FreemiumPIRStateTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// FreemiumPIRStateTests.swift -// -// 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 -@testable import Freemium - -final class FreemiumPIRStateTests: XCTestCase { - - private static let testSuiteName = "test.defaults.freemium.state.tests" - private let pir = "macos.browser.freemium.pir" - private let testUserDefaults = UserDefaults(suiteName: FreemiumPIRStateTests.testSuiteName)! - - override func setUpWithError() throws { - testUserDefaults.removePersistentDomain(forName: FreemiumPIRStateTests.testSuiteName) - } - - func testSetsHasFreemiumPIR() throws { - // Given - let sut = DefaultFreemiumPIRState(userDefaults: testUserDefaults) - XCTAssertFalse(testUserDefaults.bool(forKey: pir)) - - // When - sut.didOnboard = true - - // Then - XCTAssertTrue(testUserDefaults.bool(forKey: pir)) - } - - func testGetsHasFreemiumPIR() throws { - // Given - let sut = DefaultFreemiumPIRState(userDefaults: testUserDefaults) - XCTAssertFalse(sut.didOnboard) - testUserDefaults.setValue(true, forKey: pir) - XCTAssertTrue(testUserDefaults.bool(forKey: pir)) - - // When - let result = sut.didOnboard - - // Then - XCTAssertTrue(result) - } -} diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 684c8f86b8..4780108c7d 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -32,7 +32,7 @@ let package = Package( .library(name: "VPNAppLauncher", targets: ["VPNAppLauncher"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "201.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "203.1.0"), .package(url: "https://github.com/airbnb/lottie-spm", exact: "4.4.3"), .package(path: "../AppLauncher"), .package(path: "../UDSHelper"), diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index 2f7f0c808d..4948ae40a6 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "201.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "203.1.0"), .package(path: "../SwiftUIExtensions") ], targets: [ diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PopoverMessageView.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PopoverMessageView.swift index 918aa9a01b..809af385ce 100644 --- a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PopoverMessageView.swift +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/PopoverMessageView.swift @@ -86,7 +86,7 @@ public struct PopoverMessageView: View { .frame(minHeight: 22) .lineLimit(nil) .if(viewModel.shouldPresentMultiline) { view in - view.frame(width: 150, alignment: .leading) + view.frame(width: 160, alignment: .leading) } if let text = viewModel.buttonText, diff --git a/UITests/Common/UITests.swift b/UITests/Common/UITests.swift index a92f64ce90..ff590a386e 100644 --- a/UITests/Common/UITests.swift +++ b/UITests/Common/UITests.swift @@ -56,6 +56,7 @@ enum UITests { /// - Parameter requestedToggleState: How the autocomplete checkbox state should be set static func setAutocompleteToggleBeforeTestcaseRuns(_ requestedToggleState: Bool) { let app = XCUIApplication() + app.launchEnvironment["UITEST_MODE"] = "1" app.launch() app.typeKey(",", modifierFlags: [.command]) // Open settings @@ -96,6 +97,7 @@ enum UITests { notificationCenter.typeKey(.escape, modifierFlags: []) } let app = XCUIApplication() + app.launchEnvironment["UITEST_MODE"] = "1" app.launch() app.typeKey("n", modifierFlags: .command) app.typeKey("w", modifierFlags: [.command, .option]) diff --git a/UITests/OnboardingUITests.swift b/UITests/OnboardingUITests.swift index d83b751520..9afa760a0a 100644 --- a/UITests/OnboardingUITests.swift +++ b/UITests/OnboardingUITests.swift @@ -20,6 +20,10 @@ import XCTest final class OnboardingUITests: XCTestCase { + override func tearDownWithError() throws { + try resetApplicationData() + } + func testOnboardingToBrowsing() throws { try resetApplicationData() continueAfterFailure = false @@ -35,83 +39,70 @@ final class OnboardingUITests: XCTestCase { XCTAssertFalse(optionsButton.isEnabled) // Get Started - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Tired of being tracked online? We can help!"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - let getStartedButton = welcomeWindow.webViews["Welcome"].buttons["Get Started"] + XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Ready for a faster browser that keeps you protected?"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + + let getStartedButton = welcomeWindow.webViews["Welcome"].buttons["Let’s Do It!"] XCTAssertTrue(getStartedButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) getStartedButton.click() + // When it clicks on the button the y it's not alligned + let centerCoordinate = getStartedButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) + centerCoordinate.tap() + + // Protections activated + XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Protections activated!"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - // Default Privacy - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Unlike other browsers, DuckDuckGo comes with privacy by default"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Private Search"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Advanced Tracking Protection"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Automatic Cookie Pop-Up Blocking"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - let gotItButton = welcomeWindow.webViews["Welcome"].buttons["Got It"] - XCTAssertTrue(gotItButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - gotItButton.click() - - // Fewer ads and popups - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Private also means fewer ads and pop-ups"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["While browsing the web"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - let seeWithTrackerBlockingButton = welcomeWindow.webViews["Welcome"].buttons["See With Tracker Blocking"] - XCTAssertTrue(seeWithTrackerBlockingButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - seeWithTrackerBlockingButton.click() - XCTAssertTrue(gotItButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - gotItButton.click() - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["While watching YouTube"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - let seeWithDuckPlayerButton = welcomeWindow.webViews["Welcome"].buttons["See With Duck Player"] - XCTAssertTrue(seeWithDuckPlayerButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - seeWithDuckPlayerButton.click() - let nextGotItButton = welcomeWindow.webViews["Welcome"].buttons["Got It"] - XCTAssertTrue(nextGotItButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - nextGotItButton.click() - welcomeWindow.webViews["Welcome"].click() - let nextButton = welcomeWindow.webViews["Welcome"].buttons["Next"] - XCTAssertTrue(nextButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - nextButton.click() - - // Make Privacy your go-to - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Make privacy your go-to"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) let skipButton = welcomeWindow.webViews["Welcome"].buttons["Skip"] XCTAssertTrue(skipButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) skipButton.click() - welcomeWindow.webViews["Welcome"].click() - let importButton = welcomeWindow.webViews["Welcome"].buttons["Import"] - XCTAssertTrue(importButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - importButton.click() + + // Let’s get you set up + XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Let’s get you set up!"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + + XCTAssertTrue(skipButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) + skipButton.click() + + let importNowButton = welcomeWindow.webViews["Welcome"].buttons["Import Now"] + XCTAssertTrue(importNowButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) + importNowButton.click() + let cancelButton = welcomeWindow.sheets.buttons["Cancel"] XCTAssertTrue(cancelButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) cancelButton.click() + + let nextButtonSetUp = welcomeWindow.webViews["Welcome"].buttons["Next"] + XCTAssertTrue(nextButtonSetUp.waitForExistence(timeout: UITests.Timeouts.elementExistence)) + nextButtonSetUp.click() + + // Duck Player + XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Drowning in ads on YouTube? Not with Duck Player!"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + + let nextButtonDuckPlayer = welcomeWindow.webViews["Welcome"].buttons["Next"] + XCTAssertTrue(nextButtonDuckPlayer.waitForExistence(timeout: UITests.Timeouts.elementExistence)) + nextButtonDuckPlayer.click() + + // Customize Experience + XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Let’s customize a few things…"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + + // Session Restore XCTAssertTrue(skipButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) skipButton.click() - welcomeWindow.webViews["Welcome"].click() - XCTAssertTrue(nextButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - nextButton.click() - - // Customize your experience - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Customize your experience"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - XCTAssertTrue(welcomeWindow.webViews["Welcome"].staticTexts["Make DuckDuckGo work just the way you want."].waitForExistence(timeout: UITests.Timeouts.elementExistence)) - let showBookmarksBarButton = welcomeWindow.webViews["Welcome"].buttons["Show Bookmarks Bar"] - XCTAssertTrue(showBookmarksBarButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - showBookmarksBarButton.click() - XCTAssertTrue(welcomeWindow.collectionViews["BookmarksBarViewController.bookmarksBarCollectionView"].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + let enableSessionRestoreButton = welcomeWindow.webViews["Welcome"].buttons["Enable Session Restore"] XCTAssertTrue(enableSessionRestoreButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) enableSessionRestoreButton.click() - welcomeWindow.webViews["Welcome"].click() + let showHomeButton = welcomeWindow.webViews["Welcome"].buttons["Show Home Button"] XCTAssertTrue(showHomeButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) showHomeButton.click() - XCTAssertTrue(welcomeWindow.children(matching: .button).element(boundBy: 3).waitForExistence(timeout: UITests.Timeouts.elementExistence)) - welcomeWindow.webViews["Welcome"].click() - XCTAssertTrue(nextButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) - nextButton.click() + + // Start Browsing let startBrowsingButton = welcomeWindow.webViews["Welcome"].buttons["Start Browsing"] XCTAssertTrue(startBrowsingButton.waitForExistence(timeout: UITests.Timeouts.elementExistence)) startBrowsingButton.click() // AfterOnboarding - let duckduckgoPrivacySimplifiedWindow = app.windows["DuckDuckGo — Privacy, simplified."] - XCTAssertTrue(duckduckgoPrivacySimplifiedWindow.webViews["DuckDuckGo — Privacy, simplified."].waitForExistence(timeout: UITests.Timeouts.elementExistence)) + let duckduckgoPrivacySimplifiedWindow = app.windows["DuckDuckGo — Your protection, our priority."] + XCTAssertTrue(duckduckgoPrivacySimplifiedWindow.webViews["DuckDuckGo — Your protection, our priority."].waitForExistence(timeout: UITests.Timeouts.elementExistence)) XCTAssertTrue(duckduckgoPrivacySimplifiedWindow.buttons["NavigationBarViewController.optionsButton"].isEnabled) } diff --git a/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift b/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift index b64e193a4b..f82095119b 100644 --- a/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift +++ b/UnitTests/DBP/Tests/DataBrokerProtectionFeatureGatekeeperTests.swift @@ -28,6 +28,7 @@ final class DataBrokerProtectionFeatureGatekeeperTests: XCTestCase { private var mockFeatureDisabler: MockFeatureDisabler! private var mockFeatureAvailability: MockFeatureAvailability! private var mockAccountManager: MockAccountManager! + private var mockFreemiumDBPUserStateManager: MockFreemiumDBPUserStateManager! private func userDefaults() -> UserDefaults { UserDefaults(suiteName: "testing_\(UUID().uuidString)")! @@ -37,16 +38,19 @@ final class DataBrokerProtectionFeatureGatekeeperTests: XCTestCase { mockFeatureDisabler = MockFeatureDisabler() mockFeatureAvailability = MockFeatureAvailability() mockAccountManager = MockAccountManager() + mockFreemiumDBPUserStateManager = MockFreemiumDBPUserStateManager() + mockFreemiumDBPUserStateManager.didActivate = false } - func testWhenNoAccessTokenIsFound_butEntitlementIs_thenFeatureIsDisabled() async { + func testWhenNoAccessTokenIsFound_butEntitlementIs_andIsNotActiveFreemiumUser_thenFeatureIsDisabled() async { // Given mockAccountManager.accessToken = nil mockAccountManager.hasEntitlementResult = .success(true) sut = DefaultDataBrokerProtectionFeatureGatekeeper(featureDisabler: mockFeatureDisabler, userDefaults: userDefaults(), subscriptionAvailability: mockFeatureAvailability, - accountManager: mockAccountManager) + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) // When let result = await sut.arePrerequisitesSatisfied() @@ -55,14 +59,16 @@ final class DataBrokerProtectionFeatureGatekeeperTests: XCTestCase { XCTAssertFalse(result) } - func testWhenAccessTokenIsFound_butNoEntitlementIs_thenFeatureIsDisabled() async { + func testWhenAccessTokenIsFound_butNoEntitlementIs_andIsNotActiveFreemiumUser_thenFeatureIsDisabled() async { // Given mockAccountManager.accessToken = "token" mockAccountManager.hasEntitlementResult = .failure(MockError.someError) + mockFreemiumDBPUserStateManager.didActivate = false sut = DefaultDataBrokerProtectionFeatureGatekeeper(featureDisabler: mockFeatureDisabler, userDefaults: userDefaults(), subscriptionAvailability: mockFeatureAvailability, - accountManager: mockAccountManager) + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) // When let result = await sut.arePrerequisitesSatisfied() @@ -71,14 +77,34 @@ final class DataBrokerProtectionFeatureGatekeeperTests: XCTestCase { XCTAssertFalse(result) } - func testWhenAccessTokenAndEntitlementAreNotFound_thenFeatureIsDisabled() async { + func testWhenAccessTokenIsFound_butNoEntitlementIs_andIsActiveFreemiumUser_thenFeatureIsDisabled() async { + // Given + mockAccountManager.accessToken = "token" + mockAccountManager.hasEntitlementResult = .failure(MockError.someError) + mockFreemiumDBPUserStateManager.didActivate = true + sut = DefaultDataBrokerProtectionFeatureGatekeeper(featureDisabler: mockFeatureDisabler, + userDefaults: userDefaults(), + subscriptionAvailability: mockFeatureAvailability, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) + + // When + let result = await sut.arePrerequisitesSatisfied() + + // Then + XCTAssertFalse(result) + } + + func testWhenAccessTokenAndEntitlementAreNotFound_andIsNotActiveFreemiumUser_thenFeatureIsDisabled() async { // Given mockAccountManager.accessToken = nil mockAccountManager.hasEntitlementResult = .failure(MockError.someError) + mockFreemiumDBPUserStateManager.didActivate = false sut = DefaultDataBrokerProtectionFeatureGatekeeper(featureDisabler: mockFeatureDisabler, userDefaults: userDefaults(), subscriptionAvailability: mockFeatureAvailability, - accountManager: mockAccountManager) + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) // When let result = await sut.arePrerequisitesSatisfied() @@ -87,14 +113,16 @@ final class DataBrokerProtectionFeatureGatekeeperTests: XCTestCase { XCTAssertFalse(result) } - func testWhenAccessTokenAndEntitlementAreFound_thenFeatureIsEnabled() async { + func testWhenAccessTokenAndEntitlementAreFound_andIsNotActiveFreemiumUser_thenFeatureIsEnabled() async { // Given mockAccountManager.accessToken = "token" mockAccountManager.hasEntitlementResult = .success(true) + mockFreemiumDBPUserStateManager.didActivate = false sut = DefaultDataBrokerProtectionFeatureGatekeeper(featureDisabler: mockFeatureDisabler, userDefaults: userDefaults(), subscriptionAvailability: mockFeatureAvailability, - accountManager: mockAccountManager) + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) // When let result = await sut.arePrerequisitesSatisfied() @@ -102,22 +130,28 @@ final class DataBrokerProtectionFeatureGatekeeperTests: XCTestCase { // Then XCTAssertTrue(result) } -} -private enum MockError: Error { - case someError -} + func testWhenAccessTokenAndEntitlementAreNotFound_andIsActiveFreemiumUser_thenFeatureIsEnabled() async { + // Given + mockAccountManager.accessToken = nil + mockAccountManager.hasEntitlementResult = .failure(MockError.someError) + mockFreemiumDBPUserStateManager.didActivate = true + sut = DefaultDataBrokerProtectionFeatureGatekeeper(featureDisabler: mockFeatureDisabler, + userDefaults: userDefaults(), + subscriptionAvailability: mockFeatureAvailability, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager) -private class MockFeatureDisabler: DataBrokerProtectionFeatureDisabling { - var disableAndDeleteWasCalled = false + // When + let result = await sut.arePrerequisitesSatisfied() - func disableAndDelete() { - disableAndDeleteWasCalled = true + // Then + XCTAssertTrue(result) } +} - func reset() { - disableAndDeleteWasCalled = false - } +private enum MockError: Error { + case someError } private class MockFeatureAvailability: SubscriptionFeatureAvailability { diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_brave.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_brave.html index 7ba22d7cdd..d6c224a11b 100644 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_brave.html +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_brave.html @@ -47,7 +47,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome.html index 08fca4e9b7..54f9bdb6ae 100644 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome.html +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_chrome.html @@ -48,7 +48,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_android.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_android.html index 8a87fa9f41..1e7cb6bf92 100644 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_android.html +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_android.html @@ -47,7 +47,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_ios.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_ios.html index 9d592f0a73..5897360127 100644 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_ios.html +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_ios.html @@ -13,7 +13,7 @@

Bookmarks

FolderA-2

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

FolderB

@@ -22,4 +22,4 @@

Bookmarks

Wikipedia

- \ No newline at end of file + diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_macos.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_macos.html index 9965f7d234..d3027ccc37 100644 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_macos.html +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_ddg_macos.html @@ -42,7 +42,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox.html index 92c60fcd13..24f2804799 100644 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox.html +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_firefox.html @@ -55,7 +55,7 @@

Bookmarks Menu

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari.html b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari.html index 560cbf6533..cf1e87b8be 100644 --- a/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari.html +++ b/UnitTests/DataImport/DataImportResources/TestBookmarksData/bookmarks_safari.html @@ -53,7 +53,7 @@

Bookmarks

The Wall Street Journal - Breaking News, Business, Financial & Economic News, World News and Video

-

DuckDuckGo — Privacy, simplified. +
DuckDuckGo — Your protection, our priority.

DupeFolderNameContents

MacRumors: Apple News and Rumors diff --git a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_brave.json b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_brave.json index 80fbf50a09..15976f795f 100644 --- a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_brave.json +++ b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_brave.json @@ -109,7 +109,7 @@ "type" : "folder" }, { - "name" : "DuckDuckGo — Privacy, simplified.", + "name" : "DuckDuckGo — Your protection, our priority.", "type" : "bookmark", "url" : "https:\/\/duckduckgo.com\/" }, diff --git a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_chrome.json b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_chrome.json index cbcd35b844..baed9b3f74 100644 --- a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_chrome.json +++ b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_chrome.json @@ -116,7 +116,7 @@ "type" : "folder" }, { - "name" : "DuckDuckGo — Privacy, simplified.", + "name" : "DuckDuckGo — Your protection, our priority.", "type" : "bookmark", "url" : "https:\/\/duckduckgo.com\/" }, diff --git a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_android.json b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_android.json index cfd7ed9645..fea09751a1 100644 --- a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_android.json +++ b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_android.json @@ -109,7 +109,7 @@ "type" : "folder" }, { - "name" : "DuckDuckGo — Privacy, simplified.", + "name" : "DuckDuckGo — Your protection, our priority.", "type" : "bookmark", "url" : "https:\/\/duckduckgo.com\/" }, diff --git a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_ios.json b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_ios.json index 51dd407681..941910f466 100644 --- a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_ios.json +++ b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_ios.json @@ -39,7 +39,7 @@ { "children" : [ { - "name" : "DuckDuckGo — Privacy, simplified.", + "name" : "DuckDuckGo — Your protection, our priority.", "type" : "bookmark", "url" : "https:\/\/duckduckgo.com" } diff --git a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_macos.json b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_macos.json index 390cac5521..dc6afebdc5 100644 --- a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_macos.json +++ b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_ddg_macos.json @@ -116,7 +116,7 @@ "type" : "folder" }, { - "name" : "DuckDuckGo — Privacy, simplified.", + "name" : "DuckDuckGo — Your protection, our priority.", "type" : "bookmark", "url" : "https:\/\/duckduckgo.com\/" }, diff --git a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_firefox.json b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_firefox.json index d3428b67b2..bca8960b81 100644 --- a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_firefox.json +++ b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_firefox.json @@ -135,7 +135,7 @@ "type" : "folder" }, { - "name" : "DuckDuckGo — Privacy, simplified.", + "name" : "DuckDuckGo — Your protection, our priority.", "type" : "bookmark", "url" : "https:\/\/duckduckgo.com\/" }, diff --git a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_safari.json b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_safari.json index 9431af6f7b..f84e20f19e 100644 --- a/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_safari.json +++ b/UnitTests/DataImport/__Snapshots__/BookmarksHTMLReaderTests/snapshot.bookmarks_safari.json @@ -138,7 +138,7 @@ "type" : "folder" }, { - "name" : "DuckDuckGo — Privacy, simplified.", + "name" : "DuckDuckGo — Your protection, our priority.", "type" : "bookmark", "url" : "https:\/\/duckduckgo.com\/" }, diff --git a/UnitTests/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManagingTests.swift b/UnitTests/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManagingTests.swift new file mode 100644 index 0000000000..a0f6038443 --- /dev/null +++ b/UnitTests/Freemium/DBP/Experiment/FreemiumDBPPixelExperimentManagingTests.swift @@ -0,0 +1,217 @@ +// +// FreemiumDBPPixelExperimentManagingTests.swift +// +// 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 SubscriptionTestingUtilities +import Subscription +@testable import DuckDuckGo_Privacy_Browser + +final class FreemiumDBPPixelExperimentManagingTests: XCTestCase { + + private var sut: FreemiumDBPPixelExperimentManaging! + private var mockAccountManager: MockAccountManager! + private var mockSubscriptionManager: SubscriptionManagerMock! + private var mockUserDefaults: MockUserDefaults! + + override func setUp() { + super.setUp() + mockAccountManager = MockAccountManager() + let mockSubscriptionService = SubscriptionEndpointServiceMock() + let mockAuthService = AuthEndpointServiceMock() + let mockStorePurchaseManager = StorePurchaseManagerMock() + + let currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore) + + mockSubscriptionManager = SubscriptionManagerMock(accountManager: mockAccountManager, + subscriptionEndpointService: mockSubscriptionService, + authEndpointService: mockAuthService, + storePurchaseManager: mockStorePurchaseManager, + currentEnvironment: currentEnvironment, + canPurchase: false) + mockUserDefaults = MockUserDefaults() + let testLocale = Locale(identifier: "en_US") + sut = FreemiumDBPPixelExperimentManager(subscriptionManager: mockSubscriptionManager, userDefaults: mockUserDefaults, locale: testLocale) + } + + override func tearDown() { + mockSubscriptionManager = nil + mockUserDefaults = nil + sut = nil + super.tearDown() + } + + // MARK: - Cohort Assignment Tests + + func testAssignUserToCohort_whenUserEligibleAndNotEnrolled_assignsToCohort() { + // Given + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockUserDefaults.removeObject(forKey: MockUserDefaults.Keys.experimentCohort) + mockUserDefaults.removeObject(forKey: MockUserDefaults.Keys.enrollmentDate) + + // When + sut.assignUserToCohort() + + // Then + let assignedCohortRaw = mockUserDefaults.string(forKey: MockUserDefaults.Keys.experimentCohort) + let assignedCohort = FreemiumDBPPixelExperimentManager.Cohort(rawValue: assignedCohortRaw ?? "") + XCTAssertNotNil(assignedCohort) + XCTAssertTrue(assignedCohort == .control || assignedCohort == .treatment) + + let enrollmentDate = mockUserDefaults.object(forKey: MockUserDefaults.Keys.enrollmentDate) as? Date + XCTAssertNotNil(enrollmentDate) + } + + func testAssignUserToCohort_whenUserAlreadyEnrolled_doesNotAssign() { + // Given + let existingCohort = FreemiumDBPPixelExperimentManager.Cohort.control + let existingDate = Date(timeIntervalSince1970: 1000000) + mockUserDefaults.set(existingCohort.rawValue, forKey: MockUserDefaults.Keys.experimentCohort) + mockUserDefaults.set(existingDate, forKey: MockUserDefaults.Keys.enrollmentDate) + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + + // When + sut.assignUserToCohort() + + // Then + let assignedCohortRaw = mockUserDefaults.string(forKey: MockUserDefaults.Keys.experimentCohort) + let assignedCohort = FreemiumDBPPixelExperimentManager.Cohort(rawValue: assignedCohortRaw ?? "") + XCTAssertEqual(assignedCohort, existingCohort) + + let enrollmentDate = mockUserDefaults.object(forKey: MockUserDefaults.Keys.enrollmentDate) as? Date + XCTAssertEqual(enrollmentDate, existingDate) + } + + func testAssignUserToCohort_whenUserNotEligible_dueToSubscription_doesNotAssign() { + // Given + mockSubscriptionManager.canPurchase = false + mockAccountManager.accessToken = "some_token" + mockUserDefaults.removeObject(forKey: MockUserDefaults.Keys.experimentCohort) + mockUserDefaults.removeObject(forKey: MockUserDefaults.Keys.enrollmentDate) + + // When + sut.assignUserToCohort() + + // Then + let assignedCohortRaw = mockUserDefaults.string(forKey: MockUserDefaults.Keys.experimentCohort) + XCTAssertNil(assignedCohortRaw) + + let enrollmentDate = mockUserDefaults.object(forKey: MockUserDefaults.Keys.enrollmentDate) as? Date + XCTAssertNil(enrollmentDate) + } + + func testAssignUserToCohort_whenUserNotEligible_dueToLocale_doesNotAssign() { + // Given + let nonUSLocale = Locale(identifier: "en_GB") + sut = FreemiumDBPPixelExperimentManager(subscriptionManager: mockSubscriptionManager, userDefaults: mockUserDefaults, locale: nonUSLocale) + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockUserDefaults.removeObject(forKey: MockUserDefaults.Keys.experimentCohort) + mockUserDefaults.removeObject(forKey: MockUserDefaults.Keys.enrollmentDate) + + // When + sut.assignUserToCohort() + + // Then + let assignedCohortRaw = mockUserDefaults.string(forKey: MockUserDefaults.Keys.experimentCohort) + XCTAssertNil(assignedCohortRaw) + + let enrollmentDate = mockUserDefaults.object(forKey: MockUserDefaults.Keys.enrollmentDate) as? Date + XCTAssertNil(enrollmentDate) + } + + // MARK: - isTreatment Property Tests + + func testIsTreatment_whenCohortIsTreatment_returnsTrue() { + // Given + mockUserDefaults.set("treatment", forKey: MockUserDefaults.Keys.experimentCohort) + + // When + let isTreatment = sut.isTreatment + + // Then + XCTAssertTrue(isTreatment) + } + + func testIsTreatment_whenCohortIsControl_returnsFalse() { + // Given + mockUserDefaults.set("control", forKey: MockUserDefaults.Keys.experimentCohort) + + // When + let isTreatment = sut.isTreatment + + // Then + XCTAssertFalse(isTreatment) + } + + func testIsTreatment_whenCohortIsNil_returnsFalse() { + // Given + mockUserDefaults.removeObject(forKey: MockUserDefaults.Keys.experimentCohort) + + // When + let isTreatment = sut.isTreatment + + // Then + XCTAssertFalse(isTreatment) + } + + // MARK: - Pixel Parameter Tests + + func testReturnsCorrectEnrollmentDateParameter_whenUserIsEnrolled() throws { + // Given + let calendar = Calendar.current + let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: Date()) + mockUserDefaults.set(twoDaysAgo, forKey: MockUserDefaults.Keys.enrollmentDate) + + // When + let parameters = sut.pixelParameters + + // Then + XCTAssertEqual("2", parameters?["daysEnrolled"]) + } +} + +// MARK: - Mock Dependencies + +private final class MockUserDefaults: UserDefaults { + private var storage: [String: Any] = [:] + + /// Enum to hold the same keys as defined in the private UserDefaults extension. + enum Keys { + static let enrollmentDate = "freemium.dbp.experiment.enrollment-date" + static let experimentCohort = "freemium.dbp.experiment.cohort" + } + + override func object(forKey defaultName: String) -> Any? { + return storage[defaultName] + } + + override func set(_ value: Any?, forKey defaultName: String) { + storage[defaultName] = value + } + + override func string(forKey defaultName: String) -> String? { + return storage[defaultName] as? String + } + + override func removeObject(forKey defaultName: String) { + storage.removeValue(forKey: defaultName) + } +} diff --git a/UnitTests/Freemium/DBP/FreemiumDBPFeatureTests.swift b/UnitTests/Freemium/DBP/FreemiumDBPFeatureTests.swift new file mode 100644 index 0000000000..04ace9f251 --- /dev/null +++ b/UnitTests/Freemium/DBP/FreemiumDBPFeatureTests.swift @@ -0,0 +1,460 @@ +// +// FreemiumDBPFeatureTests.swift +// +// 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 +@testable import DuckDuckGo_Privacy_Browser +import Subscription +import BrowserServicesKit +import SubscriptionTestingUtilities +import Freemium +import Combine + +final class FreemiumDBPFeatureTests: XCTestCase { + + private var sut: FreemiumDBPFeature! + private var mockPrivacyConfigurationManager: MockPrivacyConfigurationManaging! + private var mockFreemiumDBPExperimentManager: MockFreemiumDBPExperimentManager! + private var mockAccountManager: MockAccountManager! + private var mockSubscriptionManager: SubscriptionManagerMock! + private var mockFreemiumDBPUserStateManagerManager: MockFreemiumDBPUserStateManager! + private var mockFeatureDisabler: MockFeatureDisabler! + + private var cancellables: Set = [] + + override func setUpWithError() throws { + + mockPrivacyConfigurationManager = MockPrivacyConfigurationManaging() + mockFreemiumDBPExperimentManager = MockFreemiumDBPExperimentManager() + mockAccountManager = MockAccountManager() + let mockSubscriptionService = SubscriptionEndpointServiceMock() + let mockAuthService = AuthEndpointServiceMock() + let mockStorePurchaseManager = StorePurchaseManagerMock() + + let currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore) + + mockSubscriptionManager = SubscriptionManagerMock(accountManager: mockAccountManager, + subscriptionEndpointService: mockSubscriptionService, + authEndpointService: mockAuthService, + storePurchaseManager: mockStorePurchaseManager, + currentEnvironment: currentEnvironment, + canPurchase: false) + + mockFreemiumDBPUserStateManagerManager = MockFreemiumDBPUserStateManager() + mockFeatureDisabler = MockFeatureDisabler() + + } + + func testWhenFeatureFlagDisabled_thenFreemiumDBPIsNotAvailable() throws { + // Given + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in false } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + // When + let result = sut.isAvailable + + // Then + XCTAssertFalse(result) + } + + func testWhenFeatureFlagEnabled_thenFreemiumDBPIsAvailable() throws { + // Given + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockFreemiumDBPExperimentManager.isTreatment = true + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + // When + let result = sut.isAvailable + + // Then + XCTAssertTrue(result) + } + + func testWhenPrivacyProNotAvailable_thenFreemiumDBPIsNotAvailable() throws { + // Given + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = false + mockAccountManager.accessToken = nil + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + // When + let result = sut.isAvailable + + // Then + XCTAssertFalse(result) + } + + func testWhenAllConditionsAreNotMet_thenFreemiumDBPIsNotAvailable() throws { + // Given + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in false } + mockSubscriptionManager.canPurchase = false + mockAccountManager.accessToken = "some_token" + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + // When + let result = sut.isAvailable + + // Then + XCTAssertFalse(result) + } + + func testWhenUserAlreadySubscribed_thenFreemiumDBPIsNotAvailable() throws { + // Given + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = "some_token" + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + // When + let result = sut.isAvailable + + // Then + XCTAssertFalse(result) + } + + func testWhenUserIsInTreatmentCohort_thenFreemiumDBPIsAvailable() throws { + // Given + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockFreemiumDBPExperimentManager.isTreatment = true + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + // When + let result = sut.isAvailable + + // Then + XCTAssertTrue(result) + } + + func testWhenUserIsNotInTreatmentCohort_thenFreemiumDBPIsNotAvailable() throws { + // Given + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockFreemiumDBPExperimentManager.isTreatment = false + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + // When + let result = sut.isAvailable + + // Then + XCTAssertFalse(result) + } + + func testWhenUserDidNotActivate_thenOffboardingIsNotExecuted() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = false + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in false } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + + // When + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + // Then + XCTAssertFalse(mockFeatureDisabler.disableAndDeleteWasCalled) + } + + func testWhenUserdidActivate_andFeatureIsDisabled_andUserCanPurchase_andUserIsNotSubscribed_thenOffboardingIsExecuted() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = true + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in false } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + // When + sut.subscribeToDependencyUpdates() + mockPrivacyConfigurationManager.updatesSubject.send() + + // Then + XCTAssertTrue(mockFreemiumDBPUserStateManagerManager.didCallResetAllState) + XCTAssertTrue(mockFeatureDisabler.disableAndDeleteWasCalled) + } + + func testWhenUserdidActivate_andFeatureIsDisabled_andUserCanPurchase_andUserIsSubscribed_thenOffboardingIsNotExecuted() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = true + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in false } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = "some_token" + + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + // When + sut.subscribeToDependencyUpdates() + mockPrivacyConfigurationManager.updatesSubject.send() + + // Then + XCTAssertFalse(mockFreemiumDBPUserStateManagerManager.didCallResetAllState) + XCTAssertFalse(mockFeatureDisabler.disableAndDeleteWasCalled) + } + + func testWhenUserdidActivate_andFeatureIsEnabled_andUserCanPurchase_andUserIsNotSubscribed_thenOffboardingIsNotExecuted() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = true + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + + // When + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + // Then + XCTAssertTrue(mockFreemiumDBPUserStateManagerManager.didActivate) + XCTAssertFalse(mockFeatureDisabler.disableAndDeleteWasCalled) + } + + func testWhenUserdidActivate_andFeatureIsDisabled_andUserCannotPurchase_thenOffboardingIsNotExecuted() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = true + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = false + mockAccountManager.accessToken = nil + + // When + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + // Then + XCTAssertTrue(mockFreemiumDBPUserStateManagerManager.didActivate) + XCTAssertFalse(mockFeatureDisabler.disableAndDeleteWasCalled) + } + + func testWhenFeatureFlagValueChangesToEnabled_thenIsAvailablePublisherEmitsCorrectValue() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = false + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in false } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockFreemiumDBPExperimentManager.isTreatment = true + let expectation = XCTestExpectation(description: "isAvailablePublisher emits values") + + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + XCTAssertFalse(sut.isAvailable) + + var isAvailableResult = false + sut.isAvailablePublisher + .sink { isAvailable in + isAvailableResult = isAvailable + expectation.fulfill() + } + .store(in: &cancellables) + + // When + sut.subscribeToDependencyUpdates() + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockPrivacyConfigurationManager.updatesSubject.send() + + // Then + wait(for: [expectation], timeout: 2.0) + XCTAssertTrue(isAvailableResult) + } + + func testWhenFeatureFlagValueChangesToDisabled_thenIsAvailablePublisherEmitsCorrectValue() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = true + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockFreemiumDBPExperimentManager.isTreatment = true + let expectation = XCTestExpectation(description: "isAvailablePublisher emits values") + + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + XCTAssertTrue(sut.isAvailable) + + var isAvailableResult = false + sut.isAvailablePublisher + .sink { isAvailable in + isAvailableResult = isAvailable + expectation.fulfill() + } + .store(in: &cancellables) + + // When + sut.subscribeToDependencyUpdates() + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in false } + mockPrivacyConfigurationManager.updatesSubject.send() + + // Then + wait(for: [expectation], timeout: 2.0) + XCTAssertFalse(isAvailableResult) + } + + func testSubscriptionStatusChangesToSubscribed_thenIsAvailablePublisherEmitsCorrectValue() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = true + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = nil + mockFreemiumDBPExperimentManager.isTreatment = true + let expectation = XCTestExpectation(description: "isAvailablePublisher emits values") + + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + XCTAssertTrue(sut.isAvailable) + + var isAvailableResult = false + sut.isAvailablePublisher + .sink { isAvailable in + isAvailableResult = isAvailable + expectation.fulfill() + } + .store(in: &cancellables) + + // When + sut.subscribeToDependencyUpdates() + mockAccountManager.accessToken = "some_token" + NotificationCenter.default.post(name: .subscriptionDidChange, object: nil) + + // Then + wait(for: [expectation], timeout: 2.0) + XCTAssertFalse(isAvailableResult) + } + + func testSubscriptionStatusChangesToUnsubscribed_thenIsAvailablePublisherEmitsCorrectValue() { + // Given + mockFreemiumDBPUserStateManagerManager.didActivate = true + mockPrivacyConfigurationManager.mockConfig.isSubfeatureKeyEnabled = { _, _ in true } + mockSubscriptionManager.canPurchase = true + mockAccountManager.accessToken = "some_token" + mockFreemiumDBPExperimentManager.isTreatment = true + let expectation = XCTestExpectation(description: "isAvailablePublisher emits values") + + sut = DefaultFreemiumDBPFeature(privacyConfigurationManager: mockPrivacyConfigurationManager, + experimentManager: mockFreemiumDBPExperimentManager, + subscriptionManager: mockSubscriptionManager, + accountManager: mockAccountManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManagerManager, + featureDisabler: mockFeatureDisabler) + + XCTAssertFalse(sut.isAvailable) + + var isAvailableResult = false + sut.isAvailablePublisher + .sink { isAvailable in + isAvailableResult = isAvailable + expectation.fulfill() + } + .store(in: &cancellables) + + // When + sut.subscribeToDependencyUpdates() + mockAccountManager.accessToken = nil + NotificationCenter.default.post(name: .subscriptionDidChange, object: nil) + + // Then + wait(for: [expectation], timeout: 2.0) + XCTAssertTrue(isAvailableResult) + } +} + +final class MockFeatureDisabler: DataBrokerProtectionFeatureDisabling { + var disableAndDeleteWasCalled = false + + func disableAndDelete() { + disableAndDeleteWasCalled = true + } + + func reset() { + disableAndDeleteWasCalled = false + } +} + +final class MockFreemiumDBPExperimentManager: FreemiumDBPPixelExperimentManaging { + var isTreatment = false + + var pixelParameters: [String: String]? + + func assignUserToCohort() {} +} diff --git a/UnitTests/Freemium/DBP/FreemiumDBPFirstProfileSavedNotifierTests.swift b/UnitTests/Freemium/DBP/FreemiumDBPFirstProfileSavedNotifierTests.swift new file mode 100644 index 0000000000..1056485bb2 --- /dev/null +++ b/UnitTests/Freemium/DBP/FreemiumDBPFirstProfileSavedNotifierTests.swift @@ -0,0 +1,106 @@ +// +// FreemiumDBPFirstProfileSavedNotifierTests.swift +// +// 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 +@testable import DuckDuckGo_Privacy_Browser + +final class FreemiumDBPFirstProfileSavedNotifierTests: XCTestCase { + + private var mockFreemiumDBPUserStateManager: MockFreemiumDBPUserStateManager! + private var mockAccountManager: MockAccountManager! + private var mockNotificationCenter: MockNotificationCenter! + private var sut: FreemiumDBPFirstProfileSavedNotifier! + + override func setUpWithError() throws { + mockFreemiumDBPUserStateManager = MockFreemiumDBPUserStateManager() + mockAccountManager = MockAccountManager() + mockNotificationCenter = MockNotificationCenter() + sut = FreemiumDBPFirstProfileSavedNotifier(freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + accountManager: mockAccountManager, + notificationCenter: mockNotificationCenter) + } + + func testWhenAllCriteriaSatisfied_thenNotificationShouldBePosted() { + // Given + mockAccountManager.accessToken = nil + mockFreemiumDBPUserStateManager.didActivate = true + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = false + + // When + sut.postProfileSavedNotificationIfPermitted() + + // Then + XCTAssertTrue(mockNotificationCenter.didCallPostNotification) + XCTAssertEqual(mockNotificationCenter.lastPostedNotification, .pirProfileSaved) + XCTAssertTrue(mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification) + } + + func testWhenUserIsAuthenticated_thenNotificationShouldNotBePosted() { + // Given + mockAccountManager.accessToken = "some_token" + mockFreemiumDBPUserStateManager.didActivate = true + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = false + + // When + sut.postProfileSavedNotificationIfPermitted() + + // Then + XCTAssertFalse(mockNotificationCenter.didCallPostNotification) + } + + func testWhenUserHasNotActivated_thenNotificationShouldNotBePosted() { + // Given + mockAccountManager.accessToken = nil + mockFreemiumDBPUserStateManager.didActivate = false + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = false + + // When + sut.postProfileSavedNotificationIfPermitted() + + // Then + XCTAssertFalse(mockNotificationCenter.didCallPostNotification) + } + + func testWhenNotificationAlreadyPosted_thenShouldNotPostAgain() { + // Given + mockAccountManager.accessToken = nil + mockFreemiumDBPUserStateManager.didActivate = true + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = true + + // When + sut.postProfileSavedNotificationIfPermitted() + + // Then + XCTAssertFalse(mockNotificationCenter.didCallPostNotification) + } + + func testWhenNotificationIsPosted_thenStateShouldBeUpdated() { + // Given + mockAccountManager.accessToken = nil + mockFreemiumDBPUserStateManager.didActivate = true + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = false + + // When + sut.postProfileSavedNotificationIfPermitted() + + // Then + XCTAssertTrue(mockNotificationCenter.didCallPostNotification) + XCTAssertEqual(mockNotificationCenter.lastPostedNotification, .pirProfileSaved) + XCTAssertTrue(mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification) + } +} diff --git a/UnitTests/Freemium/DBP/FreemiumDBPPresenterTests.swift b/UnitTests/Freemium/DBP/FreemiumDBPPresenterTests.swift new file mode 100644 index 0000000000..3a77f81b39 --- /dev/null +++ b/UnitTests/Freemium/DBP/FreemiumDBPPresenterTests.swift @@ -0,0 +1,70 @@ +// +// FreemiumDBPPresenterTests.swift +// +// 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 +@testable import DuckDuckGo_Privacy_Browser +import Combine + +final class FreemiumDBPPresenterTests: XCTestCase { + + private var mockWindowControllerManager: MockWindowControllerManager! + private var mockFreemiumDBPStateManager: MockFreemiumDBPUserStateManager! + + @MainActor + func testWhenCallShowFreemiumDBPThenShowPIRTabIsCalledAndActivatedStateIsSet() async throws { + // Given + mockWindowControllerManager = MockWindowControllerManager() + mockFreemiumDBPStateManager = MockFreemiumDBPUserStateManager() + let sut = DefaultFreemiumDBPPresenter(freemiumDBPStateManager: mockFreemiumDBPStateManager) + XCTAssertFalse(mockFreemiumDBPStateManager.didActivate) + // When + sut.showFreemiumDBPAndSetActivated(windowControllerManager: mockWindowControllerManager) + // Then + XCTAssertEqual(mockWindowControllerManager.showTabContent, Tab.Content.dataBrokerProtection) + XCTAssertTrue(mockFreemiumDBPStateManager.didActivate) + } +} + +private final class MockWindowControllerManager: WindowControllersManagerProtocol { + + var lastKeyMainWindowController: DuckDuckGo_Privacy_Browser.MainWindowController? + + var showTabContent: Tab.Content = .none + + var pinnedTabsManager: PinnedTabsManager = PinnedTabsManager() + + var didRegisterWindowController: PassthroughSubject<(MainWindowController), Never> = PassthroughSubject<(MainWindowController), Never>() + + var didUnregisterWindowController: PassthroughSubject<(MainWindowController), Never> = PassthroughSubject<(MainWindowController), Never>() + + func register(_ windowController: MainWindowController) {} + + func unregister(_ windowController: MainWindowController) {} + + func showTab(with content: Tab.TabContent) { + showTabContent = content + } + + func show(url: URL?, source: DuckDuckGo_Privacy_Browser.Tab.TabContent.URLSource, newTab: Bool) {} + + func showBookmarksTab() {} + + func openNewWindow(with tabCollectionViewModel: DuckDuckGo_Privacy_Browser.TabCollectionViewModel?, burnerMode: DuckDuckGo_Privacy_Browser.BurnerMode, droppingPoint: NSPoint?, contentSize: NSSize?, showWindow: Bool, popUp: Bool, lazyLoadTabs: Bool, isMiniaturized: Bool) -> DuckDuckGo_Privacy_Browser.MainWindow? { + nil + } +} diff --git a/UnitTests/Freemium/DBP/FreemiumDBPPromotionViewCoordinatorTests.swift b/UnitTests/Freemium/DBP/FreemiumDBPPromotionViewCoordinatorTests.swift new file mode 100644 index 0000000000..83cb849ee8 --- /dev/null +++ b/UnitTests/Freemium/DBP/FreemiumDBPPromotionViewCoordinatorTests.swift @@ -0,0 +1,363 @@ +// +// FreemiumDBPPromotionViewCoordinatorTests.swift +// +// 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 Freemium +@testable import DuckDuckGo_Privacy_Browser +import Combine +import Common +import DataBrokerProtection + +final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { + + private var sut: FreemiumDBPPromotionViewCoordinator! + private var mockUserStateManager: MockFreemiumDBPUserStateManager! + private var mockFeature: MockFreemiumDBPFeature! + private var mockPresenter: MockFreemiumDBPPresenter! + private let notificationCenter: NotificationCenter = .default + private var mockPixelHandler: MockFreemiumDBPExperimentPixelHandler! + private var cancellables: Set = [] + + @MainActor + override func setUpWithError() throws { + mockUserStateManager = MockFreemiumDBPUserStateManager() + mockFeature = MockFreemiumDBPFeature() + mockPresenter = MockFreemiumDBPPresenter() + mockPixelHandler = MockFreemiumDBPExperimentPixelHandler() + + sut = FreemiumDBPPromotionViewCoordinator( + freemiumDBPUserStateManager: mockUserStateManager, + freemiumDBPFeature: mockFeature, + freemiumDBPPresenter: mockPresenter, + notificationCenter: notificationCenter, + freemiumDBPExperimentPixelHandler: mockPixelHandler + ) + } + + override func tearDownWithError() throws { + sut = nil + mockUserStateManager = nil + mockFeature = nil + mockPresenter = nil + } + + @MainActor + func testInitialPromotionVisibility_whenFeatureIsAvailable_andNotDismissed() { + // Given + mockUserStateManager.didDismissHomePagePromotion = false + mockFeature.featureAvailable = true + + // When + sut = FreemiumDBPPromotionViewCoordinator( + freemiumDBPUserStateManager: mockUserStateManager, + freemiumDBPFeature: mockFeature, + freemiumDBPPresenter: mockPresenter + ) + + // Then + XCTAssertTrue(sut.isHomePagePromotionVisible) + } + + @MainActor + func testInitialPromotionVisibility_whenPromotionDismissed() { + // Given + mockUserStateManager.didDismissHomePagePromotion = true + mockFeature.featureAvailable = true + + // When + sut = FreemiumDBPPromotionViewCoordinator( + freemiumDBPUserStateManager: mockUserStateManager, + freemiumDBPFeature: mockFeature, + freemiumDBPPresenter: mockPresenter + ) + + // Then + XCTAssertFalse(sut.isHomePagePromotionVisible) + } + + @MainActor + func testProceedAction_dismissesPromotion_callsShowFreemium_andFiresPixel() { + // Given + mockUserStateManager.didActivate = false + + // When + let viewModel = sut.viewModel + viewModel.proceedAction() + + // Then + XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) + XCTAssertTrue(mockPresenter.didCallShowFreemium) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabScanClick) + } + + @MainActor + func testCloseAction_dismissesPromotion_andFiresPixel() { + // When + let viewModel = sut.viewModel + viewModel.closeAction() + + // Then + XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabScanDismiss) + } + + @MainActor + func testProceedAction_dismissesResults_callsShowFreemium_andFiresPixel() { + // Given + mockUserStateManager.didActivate = false + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + + // When + let viewModel = sut.viewModel + viewModel.proceedAction() + + // Then + XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) + XCTAssertTrue(mockPresenter.didCallShowFreemium) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabResultsClick) + } + + @MainActor + func testCloseAction_dismissesResults_andFiresPixel() { + // Given + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + + // When + let viewModel = sut.viewModel + viewModel.closeAction() + + // Then + XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabResultsDismiss) + } + + @MainActor + func testProceedAction_dismissesNoResults_callsShowFreemium_andFiresPixel() { + // Given + mockUserStateManager.didActivate = false + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0) + + // When + let viewModel = sut.viewModel + viewModel.proceedAction() + + // Then + XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) + XCTAssertTrue(mockPresenter.didCallShowFreemium) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabNoResultsClick) + } + + @MainActor + func testCloseAction_dismissesNoResults_andFiresPixel() { + // Given + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0) + + // When + let viewModel = sut.viewModel + viewModel.closeAction() + + // Then + XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabNoResultsDismiss) + } + + @MainActor + func testViewModel_whenResultsExist_withMatches() { + // Given + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + + // When + let viewModel = sut.viewModel + + // Then + XCTAssertEqual(viewModel.text, UserText.homePagePromotionFreemiumDBPPostScanEngagementResultPluralText(resultCount: 5, brokerCount: 2)) + } + + @MainActor + func testViewModel_whenNoResultsExist() { + // Given + mockUserStateManager.firstScanResults = nil + + // When + let viewModel = sut.viewModel + + // Then + XCTAssertEqual(viewModel.text, UserText.homePagePromotionFreemiumDBPText) + } + + func testNotificationObservation_updatesPromotionVisibility() { + // When + notificationCenter.post(name: .freemiumDBPResultPollingComplete, object: nil) + + // Then + XCTAssertFalse(mockUserStateManager.didDismissHomePagePromotion) + + // When + notificationCenter.post(name: .freemiumDBPEntryPointActivated, object: nil) + + // Then + XCTAssertFalse(mockUserStateManager.didDismissHomePagePromotion) + } + + @MainActor + func testHomePageBecomesVisible_whenFeatureBecomesAvailable_andDidDismissFalse() { + // Given + mockUserStateManager.didDismissHomePagePromotion = false + mockFeature.featureAvailable = false + let expectation = XCTestExpectation(description: "isHomePagePromotionVisible becomes true") + sut = FreemiumDBPPromotionViewCoordinator( + freemiumDBPUserStateManager: mockUserStateManager, + freemiumDBPFeature: mockFeature, + freemiumDBPPresenter: mockPresenter + ) + XCTAssertFalse(sut.isHomePagePromotionVisible) + + // When + mockFeature.isAvailableSubject.send(true) + + sut.$isHomePagePromotionVisible + .sink { isVisible in + if isVisible { + expectation.fulfill() + } + } + .store(in: &cancellables) + + wait(for: [expectation], timeout: 2.0) + + // Then + XCTAssertTrue(sut.isHomePagePromotionVisible) + } + + @MainActor + func testHomePageBecomesInVisible_whenFeatureBecomesUnAvailable_andDidDismissFalse() { + // Given + mockUserStateManager.didDismissHomePagePromotion = false + mockFeature.featureAvailable = true + let expectation = XCTestExpectation(description: "isHomePagePromotionVisible becomes true") + sut = FreemiumDBPPromotionViewCoordinator( + freemiumDBPUserStateManager: mockUserStateManager, + freemiumDBPFeature: mockFeature, + freemiumDBPPresenter: mockPresenter + ) + XCTAssertTrue(sut.isHomePagePromotionVisible) + + // When + mockFeature.isAvailableSubject.send(false) + + sut.$isHomePagePromotionVisible + .sink { isVisible in + if !isVisible { + expectation.fulfill() + } + } + .store(in: &cancellables) + + wait(for: [expectation], timeout: 2.0) + + // Then + XCTAssertFalse(sut.isHomePagePromotionVisible) + } + + @MainActor + func testHomePageDoesNotBecomeVisible_whenFeatureBecomesAvailable_andDidDismissTrue() { + // Given + mockUserStateManager.didDismissHomePagePromotion = true + mockFeature.featureAvailable = false + let expectation = XCTestExpectation(description: "isHomePagePromotionVisible becomes true") + sut = FreemiumDBPPromotionViewCoordinator( + freemiumDBPUserStateManager: mockUserStateManager, + freemiumDBPFeature: mockFeature, + freemiumDBPPresenter: mockPresenter + ) + XCTAssertFalse(sut.isHomePagePromotionVisible) + + // When + mockFeature.isAvailableSubject.send(true) + + sut.$isHomePagePromotionVisible + .sink { isVisible in + if !isVisible { + expectation.fulfill() + } + } + .store(in: &cancellables) + + wait(for: [expectation], timeout: 2.0) + + // Then + XCTAssertFalse(sut.isHomePagePromotionVisible) + } + + @MainActor + func testHomePageDoesNotBecomeVisible_whenFeatureBecomesUnAvailable_andDidDismissTrue() { + // Given + mockUserStateManager.didDismissHomePagePromotion = true + mockFeature.featureAvailable = true + let expectation = XCTestExpectation(description: "isHomePagePromotionVisible becomes true") + sut = FreemiumDBPPromotionViewCoordinator( + freemiumDBPUserStateManager: mockUserStateManager, + freemiumDBPFeature: mockFeature, + freemiumDBPPresenter: mockPresenter + ) + XCTAssertFalse(sut.isHomePagePromotionVisible) + + // When + mockFeature.isAvailableSubject.send(false) + + sut.$isHomePagePromotionVisible + .sink { isVisible in + if !isVisible { + expectation.fulfill() + } + } + .store(in: &cancellables) + + wait(for: [expectation], timeout: 2.0) + + // Then + XCTAssertFalse(sut.isHomePagePromotionVisible) + } +} + +class MockFreemiumDBPExperimentPixelHandler: EventMapping { + + var lastFiredEvent: FreemiumDBPExperimentPixel? + var lastPassedParameters: [String: String]? + + init() { + var mockMapping: Mapping! = nil + + super.init(mapping: { event, error, params, onComplete in + // Call the closure after initialization + mockMapping(event, error, params, onComplete) + }) + + // Now, set the real closure that captures self and stores parameters. + mockMapping = { [weak self] (event, error, params, onComplete) in + // Capture the inputs when fire is called + self?.lastFiredEvent = event + self?.lastPassedParameters = params + } + } + + func resetCapturedData() { + lastFiredEvent = nil + lastPassedParameters = nil + } +} diff --git a/UnitTests/Freemium/DBP/FreemiumDBPScanResultPollingTests.swift b/UnitTests/Freemium/DBP/FreemiumDBPScanResultPollingTests.swift new file mode 100644 index 0000000000..912fcb00e6 --- /dev/null +++ b/UnitTests/Freemium/DBP/FreemiumDBPScanResultPollingTests.swift @@ -0,0 +1,309 @@ +// +// FreemiumDBPScanResultPollingTests.swift +// +// 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 +@testable import DuckDuckGo_Privacy_Browser +@testable import DataBrokerProtection +import Common +import Freemium + +final class FreemiumDBPScanResultPollingTests: XCTestCase { + + private var sut: FreemiumDBPScanResultPolling! + private var mockFreemiumDBPUserStateManager: MockFreemiumDBPUserStateManager! + private var mockNotificationCenter: MockNotificationCenter! + private var mockDataManager: MockDataBrokerProtectionDataManager! + private let key = "macos.browser.freemium.dbp.first.profile.saved.timestamp" + + override func setUpWithError() throws { + mockFreemiumDBPUserStateManager = MockFreemiumDBPUserStateManager() + mockNotificationCenter = MockNotificationCenter() + mockDataManager = MockDataBrokerProtectionDataManager() + } + + func testWhenResultsAlreadyPosted_thenNoPollingOrObserving() { + // Given + mockFreemiumDBPUserStateManager.didPostResultsNotification = true + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertFalse(mockDataManager.didCallMatchesFoundCount) + XCTAssertFalse(mockNotificationCenter.didCallAddObserver) + XCTAssertNil(sut.timer) + } + + func testWhenFirstProfileIsAlreadySaved_thenPollingStartsImmediately() { + // Given + mockFreemiumDBPUserStateManager.firstProfileSavedTimestamp = Date() + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertTrue(mockDataManager.didCallMatchesFoundCount) + XCTAssertFalse(mockNotificationCenter.didCallAddObserver) + XCTAssertNotNil(sut.timer) + } + + func testWhenNoProfileIsSaved_thenObserveForNotification_andDontStartTimer() { + // Given + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertFalse(mockDataManager.didCallMatchesFoundCount) + XCTAssertTrue(mockNotificationCenter.didCallAddObserver) + XCTAssertNil(sut.timer) + } + + func testWhenIsNotifiedOfFirstProfileSaved_thenPollingStarts() { + // Given + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + mockNotificationCenter.post(name: .pirProfileSaved, object: nil) + + // Then + XCTAssertNotNil(mockFreemiumDBPUserStateManager.firstProfileSavedTimestamp) + XCTAssertTrue(mockNotificationCenter.didCallAddObserver) + XCTAssertFalse(mockDataManager.didCallMatchesFoundCount) + XCTAssertNotNil(sut.timer) + } + + func testWhenResultsFoundWithinDuration_thenResultsNotificationPosted() { + // Given + mockDataManager.matchesFoundCountValue = (3, 2) + mockFreemiumDBPUserStateManager.firstProfileSavedTimestamp = Date.nowMinus(hours: 12) + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertFalse(mockNotificationCenter.didCallAddObserver) + XCTAssertTrue(mockDataManager.didCallMatchesFoundCount) + XCTAssertEqual(mockNotificationCenter.lastPostedNotification, .freemiumDBPResultPollingComplete) + XCTAssertEqual(mockFreemiumDBPUserStateManager.firstScanResults, FreemiumDBPMatchResults(matchesCount: 3, brokerCount: 2)) + XCTAssertNil(sut.timer) + } + + func testWhenResultsFoundAndMaxDurationExpired_thenResultsNotificationPosted() { + // Given + mockDataManager.matchesFoundCountValue = (4, 2) + mockFreemiumDBPUserStateManager.firstProfileSavedTimestamp = Date.nowMinus(hours: 36) + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertEqual(mockNotificationCenter.lastPostedNotification, .freemiumDBPResultPollingComplete) + XCTAssertEqual(mockFreemiumDBPUserStateManager.firstScanResults, FreemiumDBPMatchResults(matchesCount: 4, brokerCount: 2)) + XCTAssertFalse(mockNotificationCenter.didCallAddObserver) + XCTAssertTrue(mockDataManager.didCallMatchesFoundCount) + XCTAssertNil(sut.timer) + } + + func testWhenNoResultsFoundAndMaxDurationExpired_thenResultsNotificationPosted() { + // Given + mockDataManager.matchesFoundCountValue = (0, 0) + mockFreemiumDBPUserStateManager.firstProfileSavedTimestamp = Date.nowMinus(hours: 36) + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertEqual(mockNotificationCenter.lastPostedNotification, .freemiumDBPResultPollingComplete) + XCTAssertEqual(mockFreemiumDBPUserStateManager.firstScanResults, FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0)) + XCTAssertFalse(mockNotificationCenter.didCallAddObserver) + XCTAssertTrue(mockDataManager.didCallMatchesFoundCount) + XCTAssertNil(sut.timer) + } + + func testWhenPollingIsDeinitialized_thenTimerIsInvalidated() { + // Given + var sut: DefaultFreemiumDBPScanResultPolling? = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + sut?.startPollingOrObserving() + + // When + sut = nil + + // Then + XCTAssertNil(sut?.timer) + } + + func testWhenTimerAlreadyExists_thenSecondTimerIsNotCreated() { + // Given + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + sut.startPollingOrObserving() + let existingTimer = sut.timer + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertEqual(sut.timer, existingTimer) + } + + func testWhenDataManagerThrowsError_thenPollingContinuesGracefully() { + // Given + mockDataManager.matchesFoundCountValue = (0, 0) + mockDataManager.didCallMatchesFoundCount = false + mockFreemiumDBPUserStateManager.firstProfileSavedTimestamp = Date.nowMinus(hours: 1) + + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + XCTAssertNoThrow(try mockDataManager.matchesFoundAndBrokersCount()) + + // Then + XCTAssertFalse(mockNotificationCenter.didCallPostNotification) + XCTAssertTrue(mockDataManager.didCallMatchesFoundCount) + } + + func testWhenProfileSavedButNoResultsBeforeMaxDuration_thenNoResultsNotificationNotPosted() { + // Given + mockDataManager.matchesFoundCountValue = (0, 0) + mockFreemiumDBPUserStateManager.firstProfileSavedTimestamp = Date.nowMinus(hours: 12) + + let sut = DefaultFreemiumDBPScanResultPolling( + dataManager: mockDataManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + notificationCenter: mockNotificationCenter + ) + + // When + sut.startPollingOrObserving() + + // Then + XCTAssertFalse(mockNotificationCenter.didCallPostNotification) + XCTAssertTrue(mockDataManager.didCallMatchesFoundCount) + XCTAssertNotNil(sut.timer) + } +} + +private extension Date { + private static func nowMinusHour(_ hour: Int) -> Date? { + let calendar = Calendar.current + return calendar.date(byAdding: .hour, value: -hour, to: Date()) + } +} + +final class MockNotificationCenter: NotificationCenter { + + var didCallAddObserver = false + var didCallPostNotification = false + var lastPostedNotification: Notification.Name? + + override func addObserver(forName name: NSNotification.Name?, object obj: Any?, queue: OperationQueue?, using block: @escaping (Notification) -> Void) -> any NSObjectProtocol { + didCallAddObserver = true + return super.addObserver(forName: name, object: obj, queue: queue, using: block) + } + + override func post(name aName: NSNotification.Name, object anObject: Any?) { + didCallPostNotification = true + lastPostedNotification = aName + super.post(name: aName, object: nil) + } +} + +private final class MockDataBrokerProtectionDataManager: DataBrokerProtectionDataManaging { + + var didCallMatchesFoundCount = false + var matchesFoundCountValue = (0, 0) + + var cache = InMemoryDataCache() + var delegate: DataBrokerProtection.DataBrokerProtectionDataManagerDelegate? + + init(database: DataBrokerProtectionRepository? = nil, + profileSavedNotifier: DBPProfileSavedNotifier? = nil, + pixelHandler: EventMapping, + fakeBrokerFlag: DataBrokerProtection.DataBrokerDebugFlag) { + } + + init() {} + + func saveProfile(_ profile: DataBrokerProtection.DataBrokerProtectionProfile) async throws { } + + func fetchProfile() throws -> DataBrokerProtection.DataBrokerProtectionProfile? { nil } + + func prepareProfileCache() throws { } + + func fetchBrokerProfileQueryData(ignoresCache: Bool) throws -> [DataBrokerProtection.BrokerProfileQueryData] { [] } + + func prepareBrokerProfileQueryDataCache() throws {} + + func hasMatches() throws -> Bool { true } + + func matchesFoundAndBrokersCount() throws -> (matchCount: Int, brokerCount: Int) { + didCallMatchesFoundCount = true + return matchesFoundCountValue + } + + func profileQueriesCount() throws -> Int { 0 } +} diff --git a/UnitTests/History/Model/HistoryCoordinatingMock.swift b/UnitTests/History/Model/HistoryCoordinatingMock.swift index 5e74455cb6..d16af67900 100644 --- a/UnitTests/History/Model/HistoryCoordinatingMock.swift +++ b/UnitTests/History/Model/HistoryCoordinatingMock.swift @@ -95,4 +95,10 @@ final class HistoryCoordinatingMock: HistoryCoordinating { trackerFoundCalled = true } + var removeUrlEntryCalled = false + func removeUrlEntry(_ url: URL, completion: (((any Error)?) -> Void)?) { + removeUrlEntryCalled = true + completion?(nil) + } + } diff --git a/UnitTests/Menus/MoreOptionsMenuTests.swift b/UnitTests/Menus/MoreOptionsMenuTests.swift index a060256258..6b61ac2d29 100644 --- a/UnitTests/Menus/MoreOptionsMenuTests.swift +++ b/UnitTests/Menus/MoreOptionsMenuTests.swift @@ -23,6 +23,7 @@ import XCTest import Subscription import SubscriptionTestingUtilities import BrowserServicesKit +import DataBrokerProtection @testable import DuckDuckGo_Privacy_Browser @@ -38,6 +39,12 @@ final class MoreOptionsMenuTests: XCTestCase { var subscriptionManager: SubscriptionManagerMock! + private var mockFreemiumDBPPresenter = MockFreemiumDBPPresenter() + private var mockFreemiumDBPFeature: MockFreemiumDBPFeature! + private var mockNotificationCenter: MockNotificationCenter! + private var mockPixelHandler: MockFreemiumDBPExperimentPixelHandler! + private var mockFreemiumDBPUserStateManager: MockFreemiumDBPUserStateManager! + var moreOptionsMenu: MoreOptionsMenu! @MainActor @@ -59,6 +66,11 @@ final class MoreOptionsMenuTests: XCTestCase { purchasePlatform: .appStore), canPurchase: false) + mockFreemiumDBPFeature = MockFreemiumDBPFeature() + + mockNotificationCenter = MockNotificationCenter() + mockPixelHandler = MockFreemiumDBPExperimentPixelHandler() + mockFreemiumDBPUserStateManager = MockFreemiumDBPUserStateManager() } @MainActor @@ -81,11 +93,18 @@ final class MoreOptionsMenuTests: XCTestCase { usesUnifiedFeedbackForm: false), sharingMenu: NSMenu(), internalUserDecider: internalUserDecider, - subscriptionManager: subscriptionManager) + subscriptionManager: subscriptionManager, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + freemiumDBPFeature: mockFreemiumDBPFeature, + freemiumDBPPresenter: mockFreemiumDBPPresenter, + notificationCenter: mockNotificationCenter, + freemiumDBPExperimentPixelHandler: mockPixelHandler) moreOptionsMenu.actionDelegate = capturingActionDelegate } + // MARK: - Subscription & Freemium + private func mockAuthentication() { subscriptionManager.accountManager.storeAuthToken(token: "") subscriptionManager.accountManager.storeAccount(token: "", email: "", externalID: "") @@ -125,9 +144,10 @@ final class MoreOptionsMenuTests: XCTestCase { } @MainActor - func testThatMoreOptionMenuHasTheExpectedItemsWhenUnauthenticatedAndCanPurchaseSubscription() { + func testThatMoreOptionMenuHasTheExpectedItemsWhenFreemiumFeatureUnavailable() { subscriptionManager.canPurchase = true subscriptionManager.currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .stripe) + mockFreemiumDBPFeature.featureAvailable = false setupMoreOptionsMenu() @@ -156,12 +176,15 @@ final class MoreOptionsMenuTests: XCTestCase { } @MainActor - func testThatMoreOptionMenuHasTheExpectedItemsWhenSubscriptionIsActive() { - mockAuthentication() + func testThatMoreOptionMenuHasTheExpectedItemsWhenFreemiumFeatureAvailable() { + subscriptionManager.canPurchase = true + subscriptionManager.currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .stripe) + mockFreemiumDBPFeature.featureAvailable = true setupMoreOptionsMenu() - XCTAssertTrue(subscriptionManager.accountManager.isUserAuthenticated) + XCTAssertFalse(subscriptionManager.accountManager.isUserAuthenticated) + XCTAssertTrue(subscriptionManager.canPurchase) XCTAssertEqual(moreOptionsMenu.items[0].title, UserText.sendFeedback) XCTAssertTrue(moreOptionsMenu.items[1].isSeparatorItem) @@ -177,18 +200,53 @@ final class MoreOptionsMenuTests: XCTestCase { XCTAssertTrue(moreOptionsMenu.items[11].isSeparatorItem) XCTAssertEqual(moreOptionsMenu.items[12].title, UserText.emailOptionsMenuItem) XCTAssertTrue(moreOptionsMenu.items[13].isSeparatorItem) - XCTAssertEqual(moreOptionsMenu.items[14].title, UserText.subscriptionOptionsMenuItem) - XCTAssertTrue(moreOptionsMenu.items[14].hasSubmenu) - XCTAssertEqual(moreOptionsMenu.items[14].submenu?.items[0].title, UserText.networkProtection) - XCTAssertEqual(moreOptionsMenu.items[14].submenu?.items[1].title, UserText.dataBrokerProtectionOptionsMenuItem) - XCTAssertEqual(moreOptionsMenu.items[14].submenu?.items[2].title, UserText.identityTheftRestorationOptionsMenuItem) - XCTAssertTrue(moreOptionsMenu.items[14].submenu!.items[3].isSeparatorItem) - XCTAssertEqual(moreOptionsMenu.items[14].submenu?.items[4].title, UserText.subscriptionSettingsOptionsMenuItem) + XCTAssertFalse(moreOptionsMenu.items[14].hasSubmenu) + XCTAssertEqual(moreOptionsMenu.items[15].title, UserText.freemiumDBPOptionsMenuItem) + XCTAssertTrue(moreOptionsMenu.items[16].isSeparatorItem) + XCTAssertEqual(moreOptionsMenu.items[17].title, UserText.mainMenuHelp) + XCTAssertEqual(moreOptionsMenu.items[18].title, UserText.settings) + } - XCTAssertTrue(moreOptionsMenu.items[15].isSeparatorItem) - XCTAssertEqual(moreOptionsMenu.items[16].title, UserText.mainMenuHelp) - XCTAssertEqual(moreOptionsMenu.items[17].title, UserText.settings) + @MainActor + func testWhenClickingFreemiumDBPOptionThenFreemiumPresenterIsCalledAndNotificationIsPostedAndPixelFired() throws { + // Given + subscriptionManager.canPurchase = true + subscriptionManager.currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .stripe) + mockFreemiumDBPFeature.featureAvailable = true + setupMoreOptionsMenu() + + let freemiumItemIndex = try XCTUnwrap(moreOptionsMenu.indexOfItem(withTitle: UserText.freemiumDBPOptionsMenuItem)) + + // When + moreOptionsMenu.performActionForItem(at: freemiumItemIndex) + + // Then + XCTAssertTrue(mockFreemiumDBPPresenter.didCallShowFreemium) + XCTAssertTrue(mockNotificationCenter.didCallPostNotification) + XCTAssertEqual(mockNotificationCenter.lastPostedNotification, .freemiumDBPEntryPointActivated) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.overFlowScan) + } + + @MainActor + func testWhenClickingFreemiumDBPOptionAndFreemiumActivatedThenFreemiumPresenterIsCalledAndNotificationIsPostedAndPixelFired() throws { + // Given + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = true + subscriptionManager.canPurchase = true + subscriptionManager.currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .stripe) + mockFreemiumDBPFeature.featureAvailable = true + setupMoreOptionsMenu() + + let freemiumItemIndex = try XCTUnwrap(moreOptionsMenu.indexOfItem(withTitle: UserText.freemiumDBPOptionsMenuItem)) + + // When + moreOptionsMenu.performActionForItem(at: freemiumItemIndex) + + // Then + XCTAssertTrue(mockFreemiumDBPPresenter.didCallShowFreemium) + XCTAssertTrue(mockNotificationCenter.didCallPostNotification) + XCTAssertEqual(mockNotificationCenter.lastPostedNotification, .freemiumDBPEntryPointActivated) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.overFlowResults) } // MARK: Zoom @@ -272,3 +330,26 @@ final class NetworkProtectionVisibilityMock: VPNFeatureGatekeeper { // Intentional no-op } } + +final class MockFreemiumDBPFeature: FreemiumDBPFeature { + var featureAvailable = false + var isAvailableSubject = PassthroughSubject() + + var isAvailable: Bool { + featureAvailable + } + + var isAvailablePublisher: AnyPublisher { + return isAvailableSubject.eraseToAnyPublisher() + } + + func subscribeToDependencyUpdates() {} +} + +final class MockFreemiumDBPPresenter: FreemiumDBPPresenter { + var didCallShowFreemium = false + + func showFreemiumDBPAndSetActivated(windowControllerManager: WindowControllersManagerProtocol? = nil) { + didCallShowFreemium = true + } +} diff --git a/UnitTests/Onboarding/Mocks/MockContentBlocking.swift b/UnitTests/Onboarding/Mocks/MockContentBlocking.swift index 9b331408e5..b601f19144 100644 --- a/UnitTests/Onboarding/Mocks/MockContentBlocking.swift +++ b/UnitTests/Onboarding/Mocks/MockContentBlocking.swift @@ -43,10 +43,18 @@ class MockEmbeddedDataProvider: EmbeddedDataProvider { class MockPrivacyConfigurationManaging: PrivacyConfigurationManaging { var currentConfig: Data = Data() - var updatesPublisher: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() + let updatesSubject = PassthroughSubject() + + var updatesPublisher: AnyPublisher { + updatesSubject.eraseToAnyPublisher() + } var privacyConfig: PrivacyConfiguration = MockPrivacyConfiguration() + var mockConfig: MockPrivacyConfiguration { + privacyConfig as! MockPrivacyConfiguration + } + var internalUserDecider: InternalUserDecider = InternalUserDeciderMock() func reload(etag: String?, data: Data?) -> PrivacyConfigurationManager.ReloadResult { diff --git a/UnitTests/Preferences/AppearancePreferencesTests.swift b/UnitTests/Preferences/AppearancePreferencesTests.swift index 3420c65451..6257ff7dae 100644 --- a/UnitTests/Preferences/AppearancePreferencesTests.swift +++ b/UnitTests/Preferences/AppearancePreferencesTests.swift @@ -21,6 +21,7 @@ import XCTest @testable import DuckDuckGo_Privacy_Browser struct AppearancePreferencesPersistorMock: AppearancePreferencesPersistor { + var isFavoriteVisible: Bool var isContinueSetUpVisible: Bool var isRecentActivityVisible: Bool @@ -33,6 +34,7 @@ struct AppearancePreferencesPersistorMock: AppearancePreferencesPersistor { var homeButtonPosition: HomeButtonPosition var homePageCustomBackground: String? var centerAlignedBookmarksBar: Bool + var didDismissHomePagePromotion: Bool init( showFullURL: Bool = false, @@ -46,7 +48,8 @@ struct AppearancePreferencesPersistorMock: AppearancePreferencesPersistor { bookmarksBarAppearance: BookmarksBarAppearance = .alwaysOn, homeButtonPosition: HomeButtonPosition = .right, homePageCustomBackground: String? = nil, - centerAlignedBookmarksBar: Bool = true + centerAlignedBookmarksBar: Bool = true, + didDismissHomePagePromotion: Bool = true ) { self.showFullURL = showFullURL self.currentThemeName = currentThemeName @@ -60,6 +63,7 @@ struct AppearancePreferencesPersistorMock: AppearancePreferencesPersistor { self.homeButtonPosition = homeButtonPosition self.homePageCustomBackground = homePageCustomBackground self.centerAlignedBookmarksBar = centerAlignedBookmarksBar + self.didDismissHomePagePromotion = didDismissHomePagePromotion } } diff --git a/UnitTests/RecentlyClosed/RecentlyClosedCoordinatorTests.swift b/UnitTests/RecentlyClosed/RecentlyClosedCoordinatorTests.swift index 13914ab876..9ff00af06c 100644 --- a/UnitTests/RecentlyClosed/RecentlyClosedCoordinatorTests.swift +++ b/UnitTests/RecentlyClosed/RecentlyClosedCoordinatorTests.swift @@ -88,7 +88,6 @@ private extension RecentlyClosedWindow { } final class WindowControllersManagerMock: WindowControllersManagerProtocol { - var pinnedTabsManager = PinnedTabsManager(tabCollection: .init()) var didRegisterWindowController = PassthroughSubject<(MainWindowController), Never>() @@ -121,5 +120,5 @@ final class WindowControllersManagerMock: WindowControllersManagerProtocol { openNewWindowCalled = .init(contents: tabCollectionViewModel?.tabs.map(\.content), burnerMode: burnerMode, droppingPoint: droppingPoint, contentSize: contentSize, showWindow: showWindow, popUp: popUp, lazyLoadTabs: lazyLoadTabs, isMiniaturized: isMiniaturized) return nil } - + func showTab(with content: DuckDuckGo_Privacy_Browser.Tab.TabContent) { } } diff --git a/UnitTests/RemoteMessaging/RemoteMessagingClientTests.swift b/UnitTests/RemoteMessaging/RemoteMessagingClientTests.swift index 60cec311d6..f1cfe0ddc5 100644 --- a/UnitTests/RemoteMessaging/RemoteMessagingClientTests.swift +++ b/UnitTests/RemoteMessaging/RemoteMessagingClientTests.swift @@ -20,6 +20,7 @@ import Bookmarks import Foundation import Persistence import RemoteMessaging +import Freemium import XCTest @testable import DuckDuckGo_Privacy_Browser @@ -38,6 +39,22 @@ final class MockRemoteMessagingConfigFetcher: RemoteMessagingConfigFetching { } } +final class MockFreemiumDBPUserStateManager: FreemiumDBPUserStateManager { + var didCallResetAllState = false + + var didActivate = false + var didPostFirstProfileSavedNotification = false + var didPostResultsNotification = false + var didDismissHomePagePromotion = false + var firstProfileSavedTimestamp: Date? + var upgradeToSubscriptionTimestamp: Date? + var firstScanResults: FreemiumDBPMatchResults? + + func resetAllState() { + didCallResetAllState = true + } +} + final class RemoteMessagingClientTests: XCTestCase { var client: RemoteMessagingClient! diff --git a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift index f0f037c1bb..65d57d5dd6 100644 --- a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift +++ b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTests.swift @@ -26,6 +26,7 @@ import UserScript @testable import PixelKit import PixelKitTestingUtilities import os.log +import DataBrokerProtection @available(macOS 12.0, *) final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { @@ -88,10 +89,15 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { var appStoreAccountManagementFlow: AppStoreAccountManagementFlow! var stripePurchaseFlow: StripePurchaseFlow! + var subscriptionAttributionPixelHandler: SubscriptionAttributionPixelHandler! + var subscriptionFeatureAvailability: SubscriptionFeatureAvailabilityMock! var accountManager: AccountManager! var subscriptionManager: SubscriptionManager! + var mockFreemiumDBPExperimentManager: MockFreemiumDBPExperimentManager! + private var mockPixelHandler: MockFreemiumDBPExperimentPixelHandler! + private var mockFreemiumDBPUserStateManager: MockFreemiumDBPUserStateManager! var feature: SubscriptionPagesUseSubscriptionFeature! @@ -156,6 +162,8 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { authEndpointService: authService, accountManager: accountManager) + subscriptionAttributionPixelHandler = PrivacyProSubscriptionAttributionPixelHandler() + subscriptionFeatureAvailability = SubscriptionFeatureAvailabilityMock(isFeatureAvailable: true, isSubscriptionPurchaseAllowed: true, usesUnifiedFeedbackForm: false) @@ -167,10 +175,18 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { authEndpointService: authService, subscriptionEnvironment: subscriptionEnvironment) + mockFreemiumDBPExperimentManager = MockFreemiumDBPExperimentManager() + mockPixelHandler = MockFreemiumDBPExperimentPixelHandler() + mockFreemiumDBPUserStateManager = MockFreemiumDBPUserStateManager() + feature = SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, + subscriptionSuccessPixelHandler: subscriptionAttributionPixelHandler, stripePurchaseFlow: stripePurchaseFlow, uiHandler: uiHandler, - subscriptionFeatureAvailability: subscriptionFeatureAvailability) + subscriptionFeatureAvailability: subscriptionFeatureAvailability, + freemiumDBPUserStateManager: mockFreemiumDBPUserStateManager, + freemiumDBPPixelExperimentManager: mockFreemiumDBPExperimentManager, + freemiumDBPExperimentPixelHandler: mockPixelHandler) feature.with(broker: broker) } @@ -423,6 +439,45 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { PrivacyProPixel.privacyProSubscriptionActivated.name]) } + func testSubscriptionSelectedSuccessWhenPurchasingFirstTimeAndUserIsFreemium() async throws { + // Given + mockFreemiumDBPUserStateManager.didActivate = true + mockFreemiumDBPExperimentManager.pixelParameters = ["daysEnrolled": "1"] + ensureUserUnauthenticatedState() + XCTAssertEqual(subscriptionEnvironment.purchasePlatform, .appStore) + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = nil + + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = try await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertEqual(uiEventsHappened, [.didPresentProgressViewController, + .didUpdateProgressViewController, + .didDismissProgressViewController]) + XCTAssertNil(result) + XCTAssertPrivacyPixelsFired([PrivacyProPixel.privacyProPurchaseAttempt.name + "_d", + PrivacyProPixel.privacyProPurchaseAttempt.name + "_c", + PrivacyProPixel.privacyProPurchaseSuccess.name + "_d", + PrivacyProPixel.privacyProPurchaseSuccess.name + "_c", + PrivacyProPixel.privacyProSubscriptionActivated.name]) + XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.subscription) + XCTAssertEqual(mockPixelHandler.lastPassedParameters?["daysEnrolled"], "1") + } + func testSubscriptionSelectedSuccessWhenRepurchasingForExpiredAppleSubscription() async throws { // Given ensureUserAuthenticatedState() @@ -977,6 +1032,137 @@ final class SubscriptionPagesUseSubscriptionFeatureTests: XCTestCase { XCTAssertTrue(tokenResponse.isEmpty) XCTAssertPrivacyPixelsFired([]) } + + func testSubscriptionUpgradeNotificationSentWhenSubscriptionSelectedSuccessFromFreemium() async throws { + // Given + ensureUserUnauthenticatedState() + XCTAssertEqual(subscriptionEnvironment.purchasePlatform, .appStore) + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = nil + + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + mockFreemiumDBPUserStateManager.didActivate = true + feature.with(broker: broker) + let notificationPostedExpectation = expectation(forNotification: .subscriptionUpgradeFromFreemium, object: nil) + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = try await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + await fulfillment(of: [notificationPostedExpectation], timeout: 1) + + // Then + XCTAssertNil(result) + } + + func testSubscriptionUpgradeNotificationNotSentWhenSubscriptionSelectedSuccessNotFromFreemium() async throws { + // Given + ensureUserUnauthenticatedState() + XCTAssertEqual(subscriptionEnvironment.purchasePlatform, .appStore) + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = nil + + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + mockFreemiumDBPUserStateManager.didActivate = false + feature.with(broker: broker) + let notificationPostedExpectation = expectation(forNotification: .subscriptionUpgradeFromFreemium, object: nil) + notificationPostedExpectation.isInverted = true + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = try await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + await fulfillment(of: [notificationPostedExpectation], timeout: 1) + + // Then + XCTAssertNil(result) + } + + func testFreemiumPixelOriginSetWhenSubscriptionSelectedSuccessFromFreemium() async throws { + // Given + ensureUserUnauthenticatedState() + XCTAssertEqual(subscriptionEnvironment.purchasePlatform, .appStore) + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = nil + + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = true + feature.with(broker: broker) + let freeiumOrigin = PrivacyProSubscriptionAttributionPixelHandler.Consts.freemiumOrigin + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = try await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertNil(result) + XCTAssertEqual(subscriptionAttributionPixelHandler.origin, freeiumOrigin) + } + + func testFreemiumPixelOriginNotSetWhenSubscriptionSelectedSuccessNotFromFreemium() async throws { + // Given + ensureUserUnauthenticatedState() + XCTAssertEqual(subscriptionEnvironment.purchasePlatform, .appStore) + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.hasActiveSubscriptionResult = false + storePurchaseManager.mostRecentTransactionResult = nil + + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + authService.getAccessTokenResult = .success(AccessTokenResponse(accessToken: Constants.accessToken)) + authService.validateTokenResult = .success(Constants.validateTokenResponse) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.mostRecentTransactionJWS) + subscriptionService.confirmPurchaseResult = .success(ConfirmPurchaseResponse(email: Constants.email, + entitlements: Constants.entitlements, + subscription: SubscriptionMockFactory.subscription)) + + mockFreemiumDBPUserStateManager.didPostFirstProfileSavedNotification = false + feature.with(broker: broker) + let freeiumOrigin = PrivacyProSubscriptionAttributionPixelHandler.Consts.freemiumOrigin + + // When + let subscriptionSelectedParams = ["id": "some-subscription-id"] + let result = try await feature.subscriptionSelected(params: subscriptionSelectedParams, original: Constants.mockScriptMessage) + + // Then + XCTAssertNil(result) + XCTAssertNotEqual(subscriptionAttributionPixelHandler.origin, freeiumOrigin) + } } @available(macOS 12.0, *) diff --git a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift index d0cc0cf33b..272b32ad5a 100644 --- a/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift +++ b/UnitTests/Subscription/SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift @@ -103,6 +103,7 @@ final class SubscriptionPagesUseSubscriptionFeatureTestsForStripe: XCTestCase { var accountManager: AccountManager! var subscriptionManager: SubscriptionManager! + var mockFreemiumDBPExperimentManager: MockFreemiumDBPExperimentManager! var feature: SubscriptionPagesUseSubscriptionFeature! @@ -178,10 +179,13 @@ final class SubscriptionPagesUseSubscriptionFeatureTestsForStripe: XCTestCase { authEndpointService: authService, subscriptionEnvironment: subscriptionEnvironment) + mockFreemiumDBPExperimentManager = MockFreemiumDBPExperimentManager() + feature = SubscriptionPagesUseSubscriptionFeature(subscriptionManager: subscriptionManager, stripePurchaseFlow: stripePurchaseFlow, uiHandler: uiHandler, - subscriptionFeatureAvailability: subscriptionFeatureAvailability) + subscriptionFeatureAvailability: subscriptionFeatureAvailability, + freemiumDBPPixelExperimentManager: mockFreemiumDBPExperimentManager) feature.with(broker: broker) } diff --git a/UnitTests/Suggestions/Model/SuggestionContainerTests.swift b/UnitTests/Suggestions/Model/SuggestionContainerTests.swift index fbd8316ade..c2f35053c2 100644 --- a/UnitTests/Suggestions/Model/SuggestionContainerTests.swift +++ b/UnitTests/Suggestions/Model/SuggestionContainerTests.swift @@ -62,4 +62,34 @@ final class SuggestionContainerTests: XCTestCase { XCTAssertNil(suggestionContainer.result) } + func testSuggestionLoadingCacheClearing() { + let suggestionLoadingMock = SuggestionLoadingMock() + let historyCoordinatingMock = HistoryCoordinatingMock() + let suggestionContainer = SuggestionContainer(suggestionLoading: suggestionLoadingMock, + historyCoordinating: historyCoordinatingMock, + bookmarkManager: LocalBookmarkManager.shared) + + XCTAssertNil(suggestionContainer.suggestionDataCache) + let e = expectation(description: "Suggestions updated") + suggestionContainer.suggestionLoading(suggestionLoadingMock, suggestionDataFromUrl: URL.testsServer, withParameters: [:]) { data, error in + XCTAssertNotNil(suggestionContainer.suggestionDataCache) + e.fulfill() + + // Test the cache is not cleared if useCachedData is true + XCTAssertFalse(suggestionLoadingMock.getSuggestionsCalled) + suggestionContainer.getSuggestions(for: "test", useCachedData: true) + XCTAssertNotNil(suggestionContainer.suggestionDataCache) + XCTAssert(suggestionLoadingMock.getSuggestionsCalled) + + suggestionLoadingMock.getSuggestionsCalled = false + + // Test the cache is cleared if useCachedData is false + XCTAssertFalse(suggestionLoadingMock.getSuggestionsCalled) + suggestionContainer.getSuggestions(for: "test", useCachedData: false) + XCTAssertNil(suggestionContainer.suggestionDataCache) + XCTAssert(suggestionLoadingMock.getSuggestionsCalled) + } + + waitForExpectations(timeout: 1) + } } diff --git a/UnitTests/Sync/Mocks/MockAppearancePreferencesPersistor.swift b/UnitTests/Sync/Mocks/MockAppearancePreferencesPersistor.swift index eaf5e85592..210bee5230 100644 --- a/UnitTests/Sync/Mocks/MockAppearancePreferencesPersistor.swift +++ b/UnitTests/Sync/Mocks/MockAppearancePreferencesPersistor.swift @@ -49,4 +49,5 @@ class MockAppearancePreferencesPersistor: AppearancePreferencesPersistor { var centerAlignedBookmarksBar: Bool = false + var didDismissHomePagePromotion = true } diff --git a/UnitTests/Updates/BinaryOwnershipCheckerTests.swift b/UnitTests/Updates/BinaryOwnershipCheckerTests.swift deleted file mode 100644 index 76a8029c2f..0000000000 --- a/UnitTests/Updates/BinaryOwnershipCheckerTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// BinaryOwnershipCheckerTests.swift -// -// 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 BrowserServicesKit -import XCTest - -@testable import DuckDuckGo_Privacy_Browser - -class BinaryOwnershipCheckerTests: XCTestCase { - - func testWhenUserIsOwner_ThenIsCurrentUserOwnerReturnsTrue() { - let mockFileManager = MockFileManager() - mockFileManager.attributes = [ - .ownerAccountID: NSNumber(value: getuid()) - ] - let checker = BinaryOwnershipChecker(fileManager: mockFileManager) - let isOwner = checker.isCurrentUserOwner() - - XCTAssertTrue(isOwner, "Expected the current user to be identified as the owner.") - } - - func testWhenUserIsNotOwner_ThenIsCurrentUserOwnerReturnsFalse() { - let mockFileManager = MockFileManager() - mockFileManager.attributes = [ - .ownerAccountID: NSNumber(value: getuid() + 1) // Simulate a different user - ] - let checker = BinaryOwnershipChecker(fileManager: mockFileManager) - let isOwner = checker.isCurrentUserOwner() - - XCTAssertFalse(isOwner, "Expected the current user not to be identified as the owner.") - } - - func testWhenFileManagerThrowsError_ThenIsCurrentUserOwnerReturnsFalse() { - let mockFileManager = MockFileManager() - mockFileManager.shouldThrowError = true - let checker = BinaryOwnershipChecker(fileManager: mockFileManager) - let isOwner = checker.isCurrentUserOwner() - - XCTAssertFalse(isOwner, "Expected the ownership check to fail and return false when an error occurs.") - } - - func testWhenOwnershipIsCheckedMultipleTimes_ThenResultIsCached() { - let mockFileManager = MockFileManager() - mockFileManager.attributes = [ - .ownerAccountID: NSNumber(value: getuid()) - ] - let checker = BinaryOwnershipChecker(fileManager: mockFileManager) - let isOwnerFirstCheck = checker.isCurrentUserOwner() - - mockFileManager.attributes = [ - .ownerAccountID: NSNumber(value: getuid() + 1) - ] - let isOwnerSecondCheck = checker.isCurrentUserOwner() - - XCTAssertTrue(isOwnerFirstCheck, "Expected the current user to be identified as the owner on first check.") - XCTAssertTrue(isOwnerSecondCheck, "Expected the cached result to be used, so the second check should return the same result as the first.") - } -} - -// Mock FileManager to simulate different file attributes and errors -class MockFileManager: FileManager { - - var attributes: [FileAttributeKey: Any]? - var shouldThrowError = false - - override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { - if shouldThrowError { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoSuchFileError, userInfo: nil) - } - return attributes ?? [:] - } -} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5fddefc1c4..61158943f5 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -160,7 +160,8 @@ platform :mac do lane :upload_metadata do |options| deliver(common_deliver_arguments(options).merge({ skip_binary_upload: true, - skip_metadata: false + skip_metadata: false, + version_check_wait_retry_limit: 1 })) end