diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 439d8e79ff..2c1d846c15 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2802,6 +2802,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 */; }; @@ -2810,6 +2818,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 */; }; @@ -2824,6 +2836,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 */; }; @@ -2836,6 +2862,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 */; }; @@ -2846,6 +2880,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 */; }; @@ -4634,10 +4670,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 = ""; }; @@ -4645,15 +4687,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 = ""; }; @@ -6924,6 +6978,7 @@ 569277C029DDCBB500B633EF /* HomePageContinueSetUpModel.swift */, 56D145EA29E6C99B00E3488A /* DataImportStatusProviding.swift */, 3768D83A2C24C0A8004120AE /* RemoteMessageViewModel.swift */, + C181945E2C7CDD0E00381092 /* PromotionViewModel.swift */, ); path = Model; sourceTree = ""; @@ -7546,6 +7601,7 @@ 85A0115D25AF1C4700FA6A0C /* FindInPage */, AA6820E825503A21005ED0D5 /* Fire */, 4B02197B25E05FAC00ED7DEA /* Fireproofing */, + C1858CCF2C7C95DB00C9BEAB /* Freemium */, B65536902684409300085A79 /* Geolocation */, AAE75275263B036300B973F8 /* History */, AAE71DB225F66A0900D74437 /* HomePage */, @@ -7621,6 +7677,7 @@ 8553FF50257523630029327F /* FileDownload */, AA9C361D25518AAB004B1BA3 /* Fire */, 4B02199725E063DE00ED7DEA /* Fireproofing */, + C18194562C7CA98500381092 /* Freemium */, B68172AC269EB415006D1092 /* Geolocation */, AAEC74AE2642C47300C2EFBC /* History */, 4BF6961B28BE90E800D402D4 /* HomePage */, @@ -8505,6 +8562,7 @@ 85F0FF1227CFAB04001C7C6E /* RecentlyVisitedView.swift */, 3768D8372C24BFF5004120AE /* RemoteMessageView.swift */, 3707EC492C47E36A00B67CBE /* CloseButton.swift */, + C181945B2C7CDCC700381092 /* PromotionView.swift */, ); path = View; sourceTree = ""; @@ -9173,6 +9231,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 = ( @@ -10781,6 +10917,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 */, @@ -10883,6 +11020,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 */, @@ -10928,6 +11066,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 */, @@ -11027,6 +11166,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 */, @@ -11071,6 +11211,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 */, @@ -11135,10 +11276,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 */, @@ -11307,6 +11450,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 */, @@ -11378,6 +11522,7 @@ 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 */, 9F6434622BEC82B700D2D8A0 /* AttributionPixelHandler.swift in Sources */, 3706FC32293F65D500E42796 /* MoreOptionsMenu.swift in Sources */, @@ -11400,6 +11545,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 */, @@ -11541,6 +11687,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 */, @@ -11567,6 +11714,7 @@ 8400DC4C2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */, 4B9DB02D2A983B24000927DB /* WaitlistKeychainStorage.swift in Sources */, EECE10E629DD77E60044D027 /* FeatureFlag.swift in Sources */, + C181945D2C7CDCC700381092 /* PromotionView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -11690,6 +11838,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 */, @@ -11711,6 +11860,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 */, @@ -11767,6 +11917,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 */, @@ -11836,6 +11987,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 */, @@ -11868,6 +12020,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 */, @@ -11897,6 +12050,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 */, @@ -12310,6 +12464,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 */, @@ -12450,6 +12605,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 */, B6B1E87E26D5DA0E0062C350 /* DownloadsPopover.swift in Sources */, @@ -12523,6 +12679,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 */, @@ -12591,6 +12748,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 */, @@ -12634,6 +12792,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 */, @@ -12779,6 +12938,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 */, @@ -12925,6 +13085,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 */, @@ -12965,6 +13126,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 */, @@ -13033,6 +13195,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 */, @@ -13053,6 +13216,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 */, @@ -13117,6 +13281,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 */, @@ -13148,6 +13313,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 */, @@ -13205,6 +13371,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 */, @@ -13219,6 +13386,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 */, @@ -13349,6 +13517,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 */, @@ -13402,6 +13571,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 */, @@ -13450,6 +13620,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 */, @@ -13480,6 +13651,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 */, diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index b7fa9ca764..25de9e0999 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 { @@ -97,13 +98,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { public let subscriptionManager: SubscriptionManager public let subscriptionUIHandler: SubscriptionUIHandling - 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 { @@ -271,6 +277,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) { @@ -295,6 +315,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { networkProtectionSubscriptionEventHandler = NetworkProtectionSubscriptionEventHandler(subscriptionManager: subscriptionManager, tunnelController: tunnelController, vpnUninstaller: vpnUninstaller) + + // Freemium DBP + freemiumDBPFeature.subscribeToDependencyUpdates() } func applicationDidFinishLaunching(_ notification: Notification) { @@ -386,7 +409,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() @@ -408,6 +437,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,9 +465,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { 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 { 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/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index d9ec142e79..ac7f6cd5b0 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -1352,5 +1352,47 @@ 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" } diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index 04c38f1941..508b4ff1fd 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" 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 82979817cc..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 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/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/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index edbc4c20a2..e3f67e7aa9 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -663,6 +663,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/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 0297f7330f..8d05402487 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 { @@ -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: "") @@ -139,7 +162,7 @@ final class MoreOptionsMenu: NSMenu { addItem(NSMenuItem.separator()) - addSubscriptionItems() + addSubscriptionAndFreemiumDBPItems() addPageItems() @@ -263,6 +286,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() @@ -344,6 +380,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 } @@ -362,17 +406,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, diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index f60524be52..03d4b6de9a 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -27,6 +27,7 @@ import NetworkProtectionIPC import NetworkProtectionUI import Subscription import SubscriptionUI +import Freemium final class NavigationBarViewController: NSViewController { @@ -295,11 +296,14 @@ final class NavigationBarViewController: NSViewController { @IBAction func optionsButtonAction(_ sender: NSButton) { let internalUserDecider = NSApp.delegateTyped.internalUserDecider + let freemiumDBPFeature = Application.appDelegate.freemiumDBPFeature let menu = MoreOptionsMenu(tabCollectionViewModel: tabCollectionViewModel, passwordManagerCoordinator: PasswordManagerCoordinator.shared, vpnFeatureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager), internalUserDecider: internalUserDecider, - subscriptionManager: subscriptionManager) + subscriptionManager: subscriptionManager, + freemiumDBPFeature: freemiumDBPFeature) + menu.actionDelegate = self let location = NSPoint(x: -menu.size.width + sender.bounds.width, y: sender.bounds.height + 4) menu.popUp(positioning: nil, at: location, in: sender) diff --git a/DuckDuckGo/RemoteMessaging/RemoteMessagingConfigMatcherProvider.swift b/DuckDuckGo/RemoteMessaging/RemoteMessagingConfigMatcherProvider.swift index c82d6de241..3d0e49a0c5 100644 --- a/DuckDuckGo/RemoteMessaging/RemoteMessagingConfigMatcherProvider.swift +++ b/DuckDuckGo/RemoteMessaging/RemoteMessagingConfigMatcherProvider.swift @@ -23,6 +23,7 @@ import Bookmarks import RemoteMessaging import NetworkProtection import Subscription +import Freemium extension DefaultWaitlistActivationDateStore: VPNActivationDateProviding {} @@ -136,6 +137,9 @@ final class RemoteMessagingConfigMatcherProvider: RemoteMessagingConfigMatcherPr let deprecatedRemoteMessageStorage = DefaultSurveyRemoteMessagingStorage.surveys() + let freemiumDBPUserStateManager = DefaultFreemiumDBPUserStateManager(userDefaults: .dbp) + let isCurrentFreemiumDBPUser = !subscriptionManager.accountManager.isUserAuthenticated && freemiumDBPUserStateManager.didActivate + return RemoteMessagingConfigMatcher( appAttributeMatcher: AppAttributeMatcher(statisticsStore: statisticsStore, variantManager: variantManager, @@ -161,7 +165,7 @@ final class RemoteMessagingConfigMatcherProvider: RemoteMessagingConfigMatcherPr hasCustomHomePage: startupPreferencesPersistor().launchToCustomHomePage, isDuckPlayerOnboarded: duckPlayerPreferencesPersistor.youtubeOverlayAnyButtonPressed, isDuckPlayerEnabled: duckPlayerPreferencesPersistor.duckPlayerModeBool != false, - isCurrentFreemiumPIRUser: false, + isCurrentFreemiumPIRUser: isCurrentFreemiumDBPUser, dismissedDeprecatedMacRemoteMessageIds: deprecatedRemoteMessageStorage.dismissedMessageIDs() ), percentileStore: RemoteMessagingPercentileUserDefaultsStore(keyValueStore: UserDefaults.standard), diff --git a/DuckDuckGo/Subscription/SubscriptionAttributionPixelHandler.swift b/DuckDuckGo/Subscription/SubscriptionAttributionPixelHandler.swift index a6361d0427..fd9018ef44 100644 --- a/DuckDuckGo/Subscription/SubscriptionAttributionPixelHandler.swift +++ b/DuckDuckGo/Subscription/SubscriptionAttributionPixelHandler.swift @@ -27,6 +27,11 @@ protocol SubscriptionAttributionPixelHandler: AnyObject { // MARK: - SubscriptionAttributionPixelHandler final class PrivacyProSubscriptionAttributionPixelHandler: SubscriptionAttributionPixelHandler { + + enum Consts { + static let freemiumOrigin = "funnel_pro_mac_freemium" + } + var origin: String? private let decoratedAttributionPixelHandler: AttributionPixelHandler 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/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/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index c640e98947..e6746eab09 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -32,6 +32,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "200.3.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..ffb525774a 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) 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/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..24fe6b6a1a 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. @@ -212,6 +224,57 @@ extension DBPUIDataBrokerProfileMatch { dataBrokerParentURL: dataBroker.parent, parentBrokerOptOutJobData: parentBrokerOptOutJobData) } + + /// 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)) + + // 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) + } + return nil + } + profiles.append(contentsOf: mirrorSitesMatches) + } + } + + return profiles + } + } } /// Protocol to represent a message that can be passed from the host to the UI diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Model/DataBroker.swift index afb6d99efc..9014bcb185 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 @@ -141,7 +155,7 @@ struct DataBroker: Codable, Sendable { self.mirrorSites = mirrorSites } - 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) @@ -207,11 +221,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/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/FreemiumDBPExperimentPixel.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/FreemiumDBPExperimentPixel.swift new file mode 100644 index 0000000000..490337ffae --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/FreemiumDBPExperimentPixel.swift @@ -0,0 +1,104 @@ +// +// FreemiumDBPExperimentPixel.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 PixelKit +import Common + +public class FreemiumDBPExperimentPixelHandler: 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/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/UI/DBPUICommunicationLayer.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/UI/DBPUICommunicationLayer.swift index 1438e0b0dc..16f78bfe45 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 = 7 } 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..4636f3b58d 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]() @@ -519,14 +479,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/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/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/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/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/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/Mocks.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift index d0a0bcf970..3647654770 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 @@ -784,6 +785,11 @@ public class MockDataBrokerProtectionPixelsHandler: EventMapping = .success(()) + lazy var callsList: [Bool] = [ wasSaveProfileCalled, wasFetchProfileCalled, @@ -842,6 +850,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 +1118,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 } } @@ -1205,26 +1239,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?() + startImmediateScanOperationsIfPermittedCalledCompletion?() + } + + func startScheduledAllOperationsIfPermitted(showWebView: Bool, operationDependencies: DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { + errorHandler?(startScheduledAllOperationsIfPermittedCompletionError) completion?() - startImmediateOperationsIfPermittedCalledCompletion?() + startScheduledAllOperationsIfPermittedCalledCompletion?() } - func startScheduledOperationsIfPermitted(showWebView: Bool, operationDependencies: any DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { - errorHandler?(startScheduledOperationsIfPermittedCompletionError) + func startScheduledScanOperationsIfPermitted(showWebView: Bool, operationDependencies: DataBrokerProtection.DataBrokerOperationDependencies, errorHandler: ((DataBrokerProtection.DataBrokerProtectionAgentErrorCollection?) -> Void)?, completion: (() -> Void)?) { + errorHandler?(startScheduledScanOperationsIfPermittedCompletionError) completion?() - startScheduledOperationsIfPermittedCalledCompletion?() + startScheduledScanOperationsIfPermittedCalledCompletion?() } func execute(_ command: DataBrokerProtection.DataBrokerProtectionQueueManagerDebugCommand) { @@ -1292,7 +1334,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 +1362,10 @@ final class MockDataBrokerProtectionDataManager: DataBrokerProtectionDataManagin return shouldReturnHasMatches } + func matchesFoundAndBrokersCount() throws -> (matchCount: Int, brokerCount: Int) { + (0, 0) + } + func profileQueriesCount() throws -> Int { return 0 } @@ -1499,7 +1548,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 +1642,7 @@ final class MockAgentStopper: DataBrokerProtectionAgentStopper { validateRunPrerequisitesCompletion?() } - func monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: TimeInterval) { + func monitorEntitlementAndStopAgentIfEntitlementIsInvalidAndUserIsNotFreemium(interval: TimeInterval) { monitorEntitlementCompletion?() } } @@ -1947,3 +1996,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/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/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/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/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/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 }