diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 1d9de0b0aa..6213f5ca4c 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1564,6 +1564,10 @@ 566B736A2BECC02D00FF1959 /* SyncAlertsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B73682BECBF8400FF1959 /* SyncAlertsPresenter.swift */; }; 566B736C2BECC3C600FF1959 /* SyncPausedStateManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B736B2BECC3C600FF1959 /* SyncPausedStateManaging.swift */; }; 566B736D2BECC3C600FF1959 /* SyncPausedStateManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B736B2BECC3C600FF1959 /* SyncPausedStateManaging.swift */; }; + 5677A9372C9812E800DA7B0A /* TrackerMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5677A9362C9812E800DA7B0A /* TrackerMessageProvider.swift */; }; + 5677A9382C9812E800DA7B0A /* TrackerMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5677A9362C9812E800DA7B0A /* TrackerMessageProvider.swift */; }; + 5677A93C2C98414800DA7B0A /* ContextualOnboardingStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5677A9392C983FF100DA7B0A /* ContextualOnboardingStateMachineTests.swift */; }; + 5677A93D2C98414900DA7B0A /* ContextualOnboardingStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5677A9392C983FF100DA7B0A /* ContextualOnboardingStateMachineTests.swift */; }; 567A23BE2C7F539C0010F66C /* SpecialErrorPageUserScriptExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23BD2C7F539C0010F66C /* SpecialErrorPageUserScriptExtension.swift */; }; 567A23BF2C7F539C0010F66C /* SpecialErrorPageUserScriptExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23BD2C7F539C0010F66C /* SpecialErrorPageUserScriptExtension.swift */; }; 567A23C12C7F71570010F66C /* SpecialErrorPages in Frameworks */ = {isa = PBXBuildFile; productRef = 567A23C02C7F71570010F66C /* SpecialErrorPages */; }; @@ -1571,8 +1575,8 @@ 567A23CD2C80CE060010F66C /* SpecialErrorPageUserScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23CC2C80CE060010F66C /* SpecialErrorPageUserScriptTests.swift */; }; 567A23CE2C80CF3D0010F66C /* SpecialErrorPageUserScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23CC2C80CE060010F66C /* SpecialErrorPageUserScriptTests.swift */; }; 567A23CF2C80CF4B0010F66C /* ErrorPageTabExtensionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA1E7C2BAB290E001CF69F /* ErrorPageTabExtensionTest.swift */; }; - 567A23D12C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23D02C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift */; }; - 567A23D22C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23D02C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift */; }; + 567A23D12C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23D02C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift */; }; + 567A23D22C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23D02C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift */; }; 567A23D42C81E2180010F66C /* ContextualDaxDialogsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23D32C81E2180010F66C /* ContextualDaxDialogsFactory.swift */; }; 567A23D52C81E2180010F66C /* ContextualDaxDialogsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23D32C81E2180010F66C /* ContextualDaxDialogsFactory.swift */; }; 567A23D72C8871290010F66C /* OnboardingFireButtonDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567A23D62C8871290010F66C /* OnboardingFireButtonDialogViewModel.swift */; }; @@ -1631,6 +1635,8 @@ 56A054472C22536A007D8FAB /* CapturingOnboardingActionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A054462C22536A007D8FAB /* CapturingOnboardingActionsManager.swift */; }; 56A054482C22536A007D8FAB /* CapturingOnboardingActionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A054462C22536A007D8FAB /* CapturingOnboardingActionsManager.swift */; }; 56A054532C2592CE007D8FAB /* OnboardingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A054522C2592CE007D8FAB /* OnboardingUITests.swift */; }; + 56A214AF2CB583BF00E5BC0E /* TrackerMessageProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A214AE2CB583BF00E5BC0E /* TrackerMessageProviderTests.swift */; }; + 56A214B02CB583BF00E5BC0E /* TrackerMessageProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A214AE2CB583BF00E5BC0E /* TrackerMessageProviderTests.swift */; }; 56AC09C72C2D7DD6002D70E0 /* BookmarksBarViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AC09C62C2D7DD6002D70E0 /* BookmarksBarViewControllerTests.swift */; }; 56AC09C82C2D7DD6002D70E0 /* BookmarksBarViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AC09C62C2D7DD6002D70E0 /* BookmarksBarViewControllerTests.swift */; }; 56B234BF2A84EFD200F2A1CC /* NavigationBarUrlExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B234BE2A84EFD200F2A1CC /* NavigationBarUrlExtensionsTests.swift */; }; @@ -3747,9 +3753,11 @@ 566B196029CDB7C9007E38F4 /* CapturingOptionsButtonMenuDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturingOptionsButtonMenuDelegate.swift; sourceTree = ""; }; 566B73682BECBF8400FF1959 /* SyncAlertsPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAlertsPresenter.swift; sourceTree = ""; }; 566B736B2BECC3C600FF1959 /* SyncPausedStateManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPausedStateManaging.swift; sourceTree = ""; }; + 5677A9362C9812E800DA7B0A /* TrackerMessageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerMessageProvider.swift; sourceTree = ""; }; + 5677A9392C983FF100DA7B0A /* ContextualOnboardingStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingStateMachineTests.swift; sourceTree = ""; }; 567A23BD2C7F539C0010F66C /* SpecialErrorPageUserScriptExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScriptExtension.swift; sourceTree = ""; }; 567A23CC2C80CE060010F66C /* SpecialErrorPageUserScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScriptTests.swift; sourceTree = ""; }; - 567A23D02C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingDialogTypeProvider.swift; sourceTree = ""; }; + 567A23D02C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingStateMachine.swift; sourceTree = ""; }; 567A23D32C81E2180010F66C /* ContextualDaxDialogsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualDaxDialogsFactory.swift; sourceTree = ""; }; 567A23D62C8871290010F66C /* OnboardingFireButtonDialogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFireButtonDialogViewModel.swift; sourceTree = ""; }; 567A23DA2C8894CD0010F66C /* ContextualDaxDialogsFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualDaxDialogsFactoryTests.swift; sourceTree = ""; }; @@ -3779,6 +3787,7 @@ 56A054432C2252CE007D8FAB /* OnboardingUserScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingUserScriptTests.swift; sourceTree = ""; }; 56A054462C22536A007D8FAB /* CapturingOnboardingActionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturingOnboardingActionsManager.swift; sourceTree = ""; }; 56A054522C2592CE007D8FAB /* OnboardingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingUITests.swift; sourceTree = ""; }; + 56A214AE2CB583BF00E5BC0E /* TrackerMessageProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerMessageProviderTests.swift; sourceTree = ""; }; 56AC09C62C2D7DD6002D70E0 /* BookmarksBarViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewControllerTests.swift; sourceTree = ""; }; 56B234BE2A84EFD200F2A1CC /* NavigationBarUrlExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarUrlExtensionsTests.swift; sourceTree = ""; }; 56BA1E742BAAF70F001CF69F /* SpecialErrorPageTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageTabExtension.swift; sourceTree = ""; }; @@ -6475,9 +6484,10 @@ children = ( 560EB9312C78946F0080DBC8 /* ContextualOnboardingDialogs.swift */, 560EB9382C789A450080DBC8 /* OnboardingSuggestedSearchesProvider.swift */, - 567A23D02C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift */, + 567A23D02C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift */, 567A23D32C81E2180010F66C /* ContextualDaxDialogsFactory.swift */, 567A23D62C8871290010F66C /* OnboardingFireButtonDialogViewModel.swift */, + 5677A9362C9812E800DA7B0A /* TrackerMessageProvider.swift */, ); path = ContextualOnboarding; sourceTree = ""; @@ -6550,6 +6560,8 @@ 567A23DA2C8894CD0010F66C /* ContextualDaxDialogsFactoryTests.swift */, 567A23DD2C89980A0010F66C /* OnboardingNavigationDelegateTests.swift */, 567A23E02C89B1EE0010F66C /* BrowserTabViewControllerOnboardingTests.swift */, + 5677A9392C983FF100DA7B0A /* ContextualOnboardingStateMachineTests.swift */, + 56A214AE2CB583BF00E5BC0E /* TrackerMessageProviderTests.swift */, ); path = ContextualOnboarding; sourceTree = ""; @@ -11074,7 +11086,7 @@ 37CEFCAA2A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, 3706FBDE293F65D500E42796 /* EncryptionKeyStoring.swift in Sources */, EEE50C2A2C38249C003DD7FF /* OptionalExtension.swift in Sources */, - 567A23D22C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift in Sources */, + 567A23D22C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift in Sources */, 4B4D60E32A0C883A00BCD287 /* AppMain.swift in Sources */, 567A23BF2C7F539C0010F66C /* SpecialErrorPageUserScriptExtension.swift in Sources */, 37197EA12942441700394917 /* Tab+UIDelegate.swift in Sources */, @@ -11137,6 +11149,7 @@ B60C6F7829B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift in Sources */, 9F56CFB22B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */, 3707C720294B5D2900682A9F /* WKWebsiteDataStoreExtension.swift in Sources */, + 5677A9382C9812E800DA7B0A /* TrackerMessageProvider.swift in Sources */, 3706FC03293F65D500E42796 /* TabPreviewViewController.swift in Sources */, 4B9754EC2984300100D7B834 /* EmailManagerExtension.swift in Sources */, 3706FC04293F65D500E42796 /* PreferencesDataClearingView.swift in Sources */, @@ -11434,6 +11447,7 @@ 3706FDEA293F661700E42796 /* BookmarkNodeTests.swift in Sources */, 3706FDEB293F661700E42796 /* WebsiteDataStoreTests.swift in Sources */, 3706FDEC293F661700E42796 /* TabCollectionViewModelTests.swift in Sources */, + 5677A93C2C98414800DA7B0A /* ContextualOnboardingStateMachineTests.swift in Sources */, 3706FDED293F661700E42796 /* EncryptionKeyStoreMock.swift in Sources */, 4B9DB05D2A983B55000927DB /* WaitlistViewModelTests.swift in Sources */, 3706FDEF293F661700E42796 /* PinnedTabsManagerTests.swift in Sources */, @@ -11726,6 +11740,7 @@ CD33012A2C887B1C009AA127 /* URLTokenValidatorTests.swift in Sources */, 9F0660742BECC71200B8EEF1 /* SubscriptionAttributionPixelHandlerTests.swift in Sources */, 9FBD847B2BB3EC3300220859 /* MockAttributionOriginProvider.swift in Sources */, + 56A214B02CB583BF00E5BC0E /* TrackerMessageProviderTests.swift in Sources */, 3706FE82293F661700E42796 /* MockStatisticsStore.swift in Sources */, 9FBD84712BB3DD8400220859 /* MockAttributionsPixelHandler.swift in Sources */, 3706FE83293F661700E42796 /* AutofillPreferencesModelTests.swift in Sources */, @@ -12360,7 +12375,7 @@ 4BA1A6A0258B079600F6F690 /* DataEncryption.swift in Sources */, B6FA8941269C425400588ECD /* PrivacyDashboardPopover.swift in Sources */, B626A76D29928B1600053070 /* TestsClosureNavigationResponder.swift in Sources */, - 567A23D12C81E0FA0010F66C /* ContextualOnboardingDialogTypeProvider.swift in Sources */, + 567A23D12C81E0FA0010F66C /* ContextualOnboardingStateMachine.swift in Sources */, F1DA51862BF607D200CF29FA /* SubscriptionAttributionPixelHandler.swift in Sources */, 85B7184E27677CBB00B4277F /* RootView.swift in Sources */, AABEE6AF24AD22B90043105B /* AddressBarTextField.swift in Sources */, @@ -12698,6 +12713,7 @@ 7B60AFFF2C51426A008E32A3 /* VPNURLEventHandler.swift in Sources */, D64A5FF82AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, 37A6A8F62AFCCA59008580A3 /* FaviconsFetcherOnboardingViewController.swift in Sources */, + 5677A9372C9812E800DA7B0A /* TrackerMessageProvider.swift in Sources */, EE098E772C8EDE2C009EBA7F /* AutofillCredentialsImportManager.swift in Sources */, 3199AF6F2C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift in Sources */, AA3F895324C18AD500628DDE /* SuggestionViewModel.swift in Sources */, @@ -13187,6 +13203,7 @@ 85F487B5276A8F2E003CE668 /* OnboardingTests.swift in Sources */, B626A7642992506A00053070 /* SerpHeadersNavigationResponderTests.swift in Sources */, C1D8BE452C1739E70057E426 /* DataBrokerProtectionMocks.swift in Sources */, + 5677A93D2C98414900DA7B0A /* ContextualOnboardingStateMachineTests.swift in Sources */, AA652CCE25DD9071009059CC /* BookmarkListTests.swift in Sources */, 859E7D6D274548F2009C2B69 /* BookmarksExporterTests.swift in Sources */, B6A5A2A825BAA35500AA7ADA /* WindowManagerStateRestorationTests.swift in Sources */, @@ -13268,6 +13285,7 @@ B6CA4824298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */, AAEC74B22642C57200C2EFBC /* HistoryCoordinatingMock.swift in Sources */, 37D046A12C7DA9A200AEAA50 /* UserBackgroundImagesManagerTests.swift in Sources */, + 56A214AF2CB583BF00E5BC0E /* TrackerMessageProviderTests.swift in Sources */, 37CD54B927F1F8AC00F1F7B9 /* AppearancePreferencesTests.swift in Sources */, EEF53E182950CED5002D78F4 /* JSAlertViewModelTests.swift in Sources */, 376C4DB928A1A48A00CC0F5B /* FirePopoverViewModelTests.swift in Sources */, diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index 6431b1ca29..b2aaac1dd1 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -92,6 +92,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let activeRemoteMessageModel: ActiveRemoteMessageModel let homePageSettingsModel = HomePage.Models.SettingsModel() let remoteMessagingClient: RemoteMessagingClient! + let onboardingStateMachine: ContextualOnboardingStateMachine & ContextualOnboardingStateUpdater public let subscriptionManager: SubscriptionManager public let subscriptionUIHandler: SubscriptionUIHandling @@ -257,6 +258,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { privacyConfigManager: AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager ) + onboardingStateMachine = ContextualOnboardingStateMachine() + // Configure Subscription subscriptionManager = DefaultSubscriptionManager() subscriptionUIHandler = SubscriptionUIHandler(windowControllersManagerProvider: { diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index ccfcdd83b1..a19c353c35 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -1278,6 +1278,12 @@ struct UserText { static let tryASearchOptionSurpriseMeTitle = NSLocalizedString("contextual.onboarding.try-search.surprise-me-title", value: "Surprise me!", comment: "Title for a button that triggers an unknown search query for the user.") static let tryASearchOptionSurpriseMeEnglish = NSLocalizedString("contextual.onboarding.try-search.surprise-me-english", value: "chocolate chip cookie recipes", comment: "Browser Search query for chocolate chip cookie recipes") static let tryASearchOptionSurpriseMeInternational = NSLocalizedString("contextual.onboarding.try-search.surprise-me-international", value: "dinner recipes", comment: "Browser Search query for dinner recipes") + public static let daxDialogBrowsingSiteIsMajorTracker = NSLocalizedString("dax.onboarding.browsing.site.is.major.tracker", value: "Heads up! I can’t stop %1$@ from seeing your activity on %2$@.\n\nBut browse with me, and I can reduce what %1$@ knows about you overall by blocking their trackers on lots of other sites.", comment: "First parameter is a string - network name, 2nd parameter is a string - domain name") + public static let daxDialogBrowsingSiteOwnedByMajorTracker = NSLocalizedString("dax.onboarding.browsing.site.owned.by.major.tracker", value: "Heads up! Since %2$@ owns %1$@, I can’t stop them from seeing your activity here.\n\nBut browse with me, and I can reduce what %2$@ knows about you overall by blocking their trackers on lots of other sites.", comment: "Parameters are domain names (strings)") + static let daxDialogBrowsingWithOneTracker = NSLocalizedString("contextual.onboarding.browsing.one.tracker", value: "*%1$@* was trying to track you here. I blocked them!\n\n☝️ Tap the shield for more info.", comment: "Parameter is domain name (string)") + static let daxDialogBrowsingWithTwoTrackers = NSLocalizedString("contextual.onboarding.browsing.two.trackers", value: "*%1$@ and %2$@* were trying to track you here. I blocked them!", comment: "Parameters are names of the tracker networks (strings)") + static let daxDialogBrowsingWithMultipleTrackers = NSLocalizedString("contextual.onboarding.browsing.multiple.trackers", value: "*%2$@, %3$@* and others (%d) were trying to track you here. I blocked them!", comment: "First parameter is a count of additional trackers, second and third are names of the tracker networks (strings)") + public static let daxDialogBrowsingWithoutTrackers = NSLocalizedString("dax.onboarding.browsing.without.trackers", value: "As you tap and scroll, I’ll block pesky trackers.\n\nGo ahead - keep browsing!", comment: "") } // Key: "subscription.menu.item" diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index 9de38a69d9..79d9a51d4f 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -116,6 +116,7 @@ public struct UserDefaultsWrapper { case legacyStatisticsStoreDataCleared = "statistics.appretentionatb.legacy-data-cleared" case onboardingFinished = "onboarding.finished" + case contextualOnboardingState = "contextual.onboarding.state" // Home Page case homePageShowPagesOnHover = "home.page.show.pages.on.hover" diff --git a/DuckDuckGo/ContentBlocker/ContentBlocking.swift b/DuckDuckGo/ContentBlocker/ContentBlocking.swift index 7a3186b95e..ca8389d3f4 100644 --- a/DuckDuckGo/ContentBlocker/ContentBlocking.swift +++ b/DuckDuckGo/ContentBlocker/ContentBlocking.swift @@ -193,7 +193,7 @@ final class AppContentBlocking { } } -protocol ContentBlockerRulesManagerProtocol: CompiledRuleListsSource { +protocol ContentBlockerRulesManagerProtocol: CompiledRuleListsSource, EntityProviding { var updatesPublisher: AnyPublisher { get } var currentRules: [ContentBlockerRulesManager.Rules] { get } @discardableResult func scheduleCompilation() -> ContentBlockerRulesManager.CompletionToken diff --git a/DuckDuckGo/ContentBlocker/Mocks/ContentBlockerRulesManagerMock.swift b/DuckDuckGo/ContentBlocker/Mocks/ContentBlockerRulesManagerMock.swift index 60a95541ea..99f4878638 100644 --- a/DuckDuckGo/ContentBlocker/Mocks/ContentBlockerRulesManagerMock.swift +++ b/DuckDuckGo/ContentBlocker/Mocks/ContentBlockerRulesManagerMock.swift @@ -18,10 +18,15 @@ import BrowserServicesKit import Combine +import TrackerRadarKit #if DEBUG final class ContentBlockerRulesManagerMock: NSObject, ContentBlockerRulesManagerProtocol { + func entity(forHost host: String) -> Entity? { + return nil + } + func scheduleCompilation() -> BrowserServicesKit.ContentBlockerRulesManager.CompletionToken { BrowserServicesKit.ContentBlockerRulesManager.CompletionToken() } diff --git a/DuckDuckGo/Fire/View/FirePopoverViewController.swift b/DuckDuckGo/Fire/View/FirePopoverViewController.swift index 54cd06ebe4..5b13ccd6ad 100644 --- a/DuckDuckGo/Fire/View/FirePopoverViewController.swift +++ b/DuckDuckGo/Fire/View/FirePopoverViewController.swift @@ -88,7 +88,8 @@ final class FirePopoverViewController: NSViewController { historyCoordinating: historyCoordinating, fireproofDomains: fireproofDomains, faviconManagement: faviconManagement, - tld: ContentBlocking.shared.tld) + tld: ContentBlocking.shared.tld, + contextualOnboardingStateMachine: Application.appDelegate.onboardingStateMachine) super.init(coder: coder) } diff --git a/DuckDuckGo/Fire/ViewModel/FirePopoverViewModel.swift b/DuckDuckGo/Fire/ViewModel/FirePopoverViewModel.swift index 0d07e1f473..0703069db7 100644 --- a/DuckDuckGo/Fire/ViewModel/FirePopoverViewModel.swift +++ b/DuckDuckGo/Fire/ViewModel/FirePopoverViewModel.swift @@ -52,7 +52,8 @@ final class FirePopoverViewModel { fireproofDomains: FireproofDomains, faviconManagement: FaviconManagement, initialClearingOption: ClearingOption = .allData, - tld: TLD) { + tld: TLD, + contextualOnboardingStateMachine: ContextualOnboardingStateUpdater) { self.fireViewModel = fireViewModel self.tabCollectionViewModel = tabCollectionViewModel @@ -61,6 +62,7 @@ final class FirePopoverViewModel { self.faviconManagement = faviconManagement self.clearingOption = initialClearingOption self.tld = tld + self.contextualOnboardingStateMachine = contextualOnboardingStateMachine } var clearingOption = ClearingOption.allData { @@ -77,6 +79,7 @@ final class FirePopoverViewModel { private let fireproofDomains: FireproofDomains private let faviconManagement: FaviconManagement private let tld: TLD + private let contextualOnboardingStateMachine: ContextualOnboardingStateUpdater private(set) var hasOnlySingleFireproofDomain: Bool = false @Published private(set) var selectable: [Item] = [] @@ -189,6 +192,7 @@ final class FirePopoverViewModel { // MARK: - Burning func burn() { + contextualOnboardingStateMachine.fireButtonUsed() PixelKit.fire(GeneralPixel.fireButtonFirstBurn, frequency: .legacyDaily) switch (clearingOption, areAllSelected) { diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index d4cbf1d760..641eec381a 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -13602,6 +13602,42 @@ } } }, + "contextual.onboarding.browsing.multiple.trackers" : { + "comment" : "First parameter is a count of additional trackers, second and third are names of the tracker networks (strings)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "*%2$@, %3$@* and others (%d) were trying to track you here. I blocked them!" + } + } + } + }, + "contextual.onboarding.browsing.one.tracker" : { + "comment" : "Parameter is domain name (string)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "*%1$@* was trying to track you here. I blocked them!\n\n☝️ Tap the shield for more info." + } + } + } + }, + "contextual.onboarding.browsing.two.trackers" : { + "comment" : "Parameters are names of the tracker networks (strings)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "*%1$@ and %2$@* were trying to track you here. I blocked them!" + } + } + } + }, "contextual.onboarding.final-screen.button" : { "comment" : "Button on the last screen of the onboarding, it will dismiss the onboarding screen.", "extractionState" : "extracted_with_value", @@ -15928,6 +15964,41 @@ } } }, + "dax.onboarding.browsing.site.is.major.tracker" : { + "comment" : "First parameter is a string - network name, 2nd parameter is a string - domain name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Heads up! I can’t stop %1$@ from seeing your activity on %2$@.\n\nBut browse with me, and I can reduce what %1$@ knows about you overall by blocking their trackers on lots of other sites." + } + } + } + }, + "dax.onboarding.browsing.site.owned.by.major.tracker" : { + "comment" : "Parameters are domain names (strings)", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Heads up! Since %2$@ owns %1$@, I can’t stop them from seeing your activity here.\n\nBut browse with me, and I can reduce what %2$@ knows about you overall by blocking their trackers on lots of other sites." + } + } + } + }, + "dax.onboarding.browsing.without.trackers" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "As you tap and scroll, I’ll block pesky trackers.\n\nGo ahead - keep browsing!" + } + } + } + }, "default.browser.prompt.button" : { "comment" : "represents a prompt message asking the user to make DuckDuckGo their default browser.", "extractionState" : "extracted_with_value", @@ -37295,7 +37366,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "You’re all set! You can find me hanging out in the Dock anytime.\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possible 🔒" + "value" : "You’re all set! You can find me hanging out in the Dock anytime.\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possibleu{00A0}🔒" } }, "es" : { @@ -37355,7 +37426,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "You’re all set!\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possible 🔒" + "value" : "You’re all set!\n\nWant to see how I protect you? Try visiting one of your favorite sites 👆\n\nKeep watching the address bar as you go. I’ll be blocking trackers and upgrading the security of your connection when possibleu{00A0}🔒" } }, "es" : { diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 2c15093f93..c7e454208b 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -606,6 +606,8 @@ final class MainMenu: NSMenu { NSMenuItem(title: "Reset Remote Messages", action: #selector(AppDelegate.resetRemoteMessages)) NSMenuItem(title: "Reset CPM Experiment Cohort (needs restart)", action: #selector(AppDelegate.resetCpmCohort)) NSMenuItem(title: "Reset Duck Player Preferences", action: #selector(MainViewController.resetDuckPlayerPreferences)) + NSMenuItem(title: "Reset Onboarding", action: #selector(MainViewController.resetOnboarding(_:))) + NSMenuItem(title: "Reset Contextual Onboarding", action: #selector(MainViewController.resetContextualOnboarding(_:))) NSMenuItem(title: "Reset Sync Promo prompts", action: #selector(MainViewController.resetSyncPromoPrompts)) }.withAccessibilityIdentifier("MainMenu.resetData") diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 1ee24f2f43..cbf32d7dcc 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -836,6 +836,14 @@ extension MainViewController { UserDefaults.standard.set(true, forKey: UserDefaultsWrapper.Key.homePageShowEmailProtection.rawValue) } + @objc func resetOnboarding(_ sender: Any?) { + UserDefaults.standard.set(false, forKey: UserDefaultsWrapper.Key.onboardingFinished.rawValue) + } + + @objc func resetContextualOnboarding(_ sender: Any?) { + Application.appDelegate.onboardingStateMachine.state = .notStarted + } + @objc func resetDuckPlayerPreferences(_ sender: Any?) { DuckPlayerPreferences.shared.reset() } diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactory.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactory.swift index 8c7e7906a8..f4c45e863b 100644 --- a/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactory.swift +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactory.swift @@ -21,25 +21,26 @@ import SwiftUI import Onboarding protocol ContextualDaxDialogsFactory { - func makeView(for type: ContextualDialogType, delegate: OnboardingNavigationDelegate, onDismiss: @escaping () -> Void) -> AnyView + func makeView(for type: ContextualDialogType, delegate: OnboardingNavigationDelegate, onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) -> AnyView } struct DefaultContextualDaxDialogViewFactory: ContextualDaxDialogsFactory { - func makeView(for type: ContextualDialogType, delegate: any OnboardingNavigationDelegate, onDismiss: @escaping () -> Void) -> AnyView { + func makeView(for type: ContextualDialogType, delegate: any OnboardingNavigationDelegate, onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) -> AnyView { + let dialogView: AnyView switch type { case .tryASearch: dialogView = AnyView(tryASearchDialog(delegate: delegate)) case .searchDone(shouldFollowUp: let shouldFollowUp): - dialogView = AnyView(searchDoneDialog(shouldFollowUp: shouldFollowUp, delegate: delegate, onDismiss: onDismiss)) + dialogView = AnyView(searchDoneDialog(shouldFollowUp: shouldFollowUp, delegate: delegate, onDismiss: onDismiss, onGotItPressed: onGotItPressed)) case .tryASite: dialogView = AnyView(tryASiteDialog(delegate: delegate)) case .trackers(message: let message, shouldFollowUp: let shouldFollowUp): - dialogView = AnyView(trackersDialog(message: message, shouldFollowUp: shouldFollowUp, onDismiss: onDismiss)) + dialogView = AnyView(trackersDialog(message: message, shouldFollowUp: shouldFollowUp, onDismiss: onDismiss, onGotItPressed: onGotItPressed)) case .tryFireButton: - dialogView = AnyView(tryFireButtonDialog(onDismiss: onDismiss)) + dialogView = AnyView(tryFireButtonDialog(onDismiss: onDismiss, onGotItPressed: onGotItPressed)) case .highFive: - dialogView = AnyView(highFiveDialog(onDismiss: onDismiss)) + dialogView = AnyView(highFiveDialog(onDismiss: onDismiss, onGotItPressed: onGotItPressed)) } let adjustedView = { HStack { @@ -62,10 +63,10 @@ struct DefaultContextualDaxDialogViewFactory: ContextualDaxDialogsFactory { return OnboardingTrySearchDialog(viewModel: viewModel) } - private func searchDoneDialog(shouldFollowUp: Bool, delegate: OnboardingNavigationDelegate, onDismiss: @escaping () -> Void) -> some View { + private func searchDoneDialog(shouldFollowUp: Bool, delegate: OnboardingNavigationDelegate, onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) -> some View { let suggestedSitesProvider = OnboardingSuggestedSitesProvider(surpriseItemTitle: OnboardingSuggestedSitesProvider.surpriseItemTitle) let viewModel = OnboardingSiteSuggestionsViewModel(title: "", suggestedSitesProvider: suggestedSitesProvider, delegate: delegate, pixelReporter: OnboardingPixelReporter()) - let gotIt = shouldFollowUp ? {} : onDismiss + let gotIt = shouldFollowUp ? onGotItPressed : onDismiss return OnboardingFirstSearchDoneDialog(shouldFollowUp: shouldFollowUp, viewModel: viewModel, gotItAction: gotIt) } @@ -77,19 +78,23 @@ struct DefaultContextualDaxDialogViewFactory: ContextualDaxDialogsFactory { return OnboardingTryVisitingASiteDialog(viewModel: viewModel) } - private func trackersDialog(message: NSAttributedString, shouldFollowUp: Bool, onDismiss: @escaping () -> Void) -> some View { - let gotIt = shouldFollowUp ? {} : onDismiss - let viewModel = OnboardingFireButtonDialogViewModel(onDismiss: onDismiss) + private func trackersDialog(message: NSAttributedString, shouldFollowUp: Bool, onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) -> some View { + let gotIt = shouldFollowUp ? onGotItPressed : onDismiss + let viewModel = OnboardingFireButtonDialogViewModel(onDismiss: onDismiss, onGotItPressed: onGotItPressed) return OnboardingTrackersDoneDialog(shouldFollowUp: true, message: message, blockedTrackersCTAAction: gotIt, viewModel: viewModel) } - private func tryFireButtonDialog(onDismiss: @escaping () -> Void) -> some View { - let viewModel = OnboardingFireButtonDialogViewModel(onDismiss: onDismiss) + private func tryFireButtonDialog(onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) -> some View { + let viewModel = OnboardingFireButtonDialogViewModel(onDismiss: onDismiss, onGotItPressed: onGotItPressed) return OnboardingFireDialog(viewModel: viewModel) } - private func highFiveDialog(onDismiss: @escaping () -> Void) -> some View { - return OnboardingFinalDialog(highFiveAction: onDismiss) + private func highFiveDialog(onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) -> some View { + let action = { + onDismiss() + onGotItPressed() + } + return OnboardingFinalDialog(highFiveAction: action) } } diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingDialogs.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingDialogs.swift index a1dfedc643..eac55907cf 100644 --- a/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingDialogs.swift +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingDialogs.swift @@ -147,20 +147,28 @@ struct OnboardingFireButtonDialogContent: View { }() let viewModel: OnboardingFireButtonDialogViewModel + @State private var showNextScreen: Bool = false var body: some View { - ContextualDaxDialogContent( - orientation: .horizontalStack(alignment: .center), - message: attributedMessage, - messageFont: OnboardingDialogsContants.messageFont, - customActionView: AnyView(actionView)) + if showNextScreen { + OnboardingFinalDialogContent(highFiveAction: viewModel.highFive) + } else { + ContextualDaxDialogContent( + orientation: .horizontalStack(alignment: .center), + message: attributedMessage, + messageFont: OnboardingDialogsContants.messageFont, + customActionView: AnyView(actionView)) + } } @ViewBuilder private var actionView: some View { VStack { OnboardingPrimaryCTAButton(title: "Try it", action: viewModel.tryFireButton) - OnboardingSecondaryCTAButton(title: UserText.skip, action: viewModel.skip) + OnboardingSecondaryCTAButton(title: UserText.skip, action: { + showNextScreen = true + viewModel.skip() + }) } } @@ -168,13 +176,17 @@ struct OnboardingFireButtonDialogContent: View { struct OnboardingFireDialog: View { let viewModel: OnboardingFireButtonDialogViewModel + @State private var showNextScreen: Bool = false var body: some View { DaxDialogView(logoPosition: .left) { - OnboardingFireButtonDialogContent(viewModel: viewModel) + if showNextScreen { + OnboardingFinalDialogContent(highFiveAction: viewModel.highFive) + } else { + OnboardingFireButtonDialogContent(viewModel: viewModel) + } } .padding() - } } @@ -218,6 +230,22 @@ struct OnboardingTrackersDoneDialog: View { } } +struct OnboardingFinalDialogContent: View { + let title = UserText.ContextualOnboarding.onboardingFinalScreenTitle + let message = NSAttributedString(string: UserText.ContextualOnboarding.onboardingFinalScreenMessage) + let cta = UserText.ContextualOnboarding.onboardingFinalScreenButton + let highFiveAction: () -> Void + + var body: some View { + ContextualDaxDialogContent(orientation: .horizontalStack(alignment: .center), + title: title, + titleFont: OnboardingDialogsContants.titleFont, + message: message, + messageFont: OnboardingDialogsContants.messageFont, + customActionView: AnyView(OnboardingPrimaryCTAButton(title: cta, action: highFiveAction))) + } +} + struct OnboardingFinalDialog: View { let title = UserText.ContextualOnboarding.onboardingFinalScreenTitle let message = NSAttributedString(string: UserText.ContextualOnboarding.onboardingFinalScreenMessage) @@ -227,19 +255,14 @@ struct OnboardingFinalDialog: View { var body: some View { DaxDialogView(logoPosition: .left) { - ContextualDaxDialogContent(orientation: .horizontalStack(alignment: .center), - title: title, - titleFont: OnboardingDialogsContants.titleFont, - message: message, - messageFont: OnboardingDialogsContants.messageFont, - customActionView: AnyView(OnboardingPrimaryCTAButton(title: cta, action: highFiveAction))) + OnboardingFinalDialogContent(highFiveAction: highFiveAction) } } } struct OnboardingPrimaryCTAButton: View { let title: String - let action: () -> Void + let action: @MainActor () -> Void var body: some View { Button(action: action) { @@ -296,7 +319,7 @@ final class OnboardingPixelReporter: OnboardingSearchSuggestionsPixelReporting, #Preview("Try Fire Button") { DaxDialogView(logoPosition: .left) { - OnboardingFireButtonDialogContent(viewModel: OnboardingFireButtonDialogViewModel(onDismiss: {})) + OnboardingFireButtonDialogContent(viewModel: OnboardingFireButtonDialogViewModel(onDismiss: {}, onGotItPressed: {})) } .padding() } @@ -306,6 +329,6 @@ final class OnboardingPixelReporter: OnboardingSearchSuggestionsPixelReporting, let firstString = UserText.ContextualOnboarding.onboardingTryFireButtonMessage return NSMutableAttributedString(string: firstString) }() - return OnboardingTrackersDoneDialog(shouldFollowUp: true, message: message, blockedTrackersCTAAction: {}, viewModel: OnboardingFireButtonDialogViewModel(onDismiss: {})) + return OnboardingTrackersDoneDialog(shouldFollowUp: true, message: message, blockedTrackersCTAAction: {}, viewModel: OnboardingFireButtonDialogViewModel(onDismiss: {}, onGotItPressed: {})) .padding() } diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachine.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachine.swift new file mode 100644 index 0000000000..7c990e0e33 --- /dev/null +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachine.swift @@ -0,0 +1,339 @@ +// +// ContextualOnboardingStateMachine.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 PrivacyDashboard + +protocol ContextualOnboardingDialogTypeProviding { + func dialogTypeForTab(_ tab: Tab, privacyInfo: PrivacyInfo?) -> ContextualDialogType? +} + +protocol ContextualOnboardingStateUpdater { + func updateStateFor(tab: Tab) + func gotItPressed() + func fireButtonUsed() +} + +enum ContextualDialogType: Equatable { + case tryASearch + case searchDone(shouldFollowUp: Bool) + case tryASite + case trackers(message: NSAttributedString, shouldFollowUp: Bool) + case tryFireButton + case highFive +} + +enum ContextualOnboardingState: String { + + // The contextual onboarding has not started. This state should apply only during the linear onboarding. + case notStarted + + // State as soon as we load the initial page after onboarding. + // It will show the "Try a search" dialog after the first visit. + // From this state, after a website visit, it will show a "Tracker" dialog. + // From this state, after a search, it will show the "Try visit a site" dialog. + case showTryASearch + + // State applied after the first search if no website visit occurred before. + // From this state, after a website visit, it will show a "Tracker" dialog. + // From this state, after a search, it will show nothing. + case showSearchDone + + // State applied after the first time a site is visited where trackers were blocked, and no search occurred before. + // From this state, after a website visit, it will show the "Try Fire Button" dialog. + // From this state, after a search, it will show the "Search Done" dialog. + case showBlockedTrackers + + // State applied after the first time a site is visited where no trackers were blocked, and no search occurred before. + // From this state, after a website visit, it will show a "Tracker" dialog if a tracker is blocked; otherwise, nothing. + // From this state, after a search, it will show the "Search Done" dialog. + case showMajorOrNoTracker + + // State applied after the first search and the "Search Done" dialog has been seen. + // From this state, after a website visit, it will show a "Tracker" dialog. + // From this state, after a search, it will show nothing. + case showTryASite + + // State applied after the first time a site is visited where trackers were blocked, and a search occurred before. + // From this state, after a website visit, it will show the "Try Fire Button" dialog. + // From this state, after a search, it will show nothing. + case searchDoneShowBlockedTrackers + + // State applied after the first time a site is visited where no trackers were blocked, and a search occurred before. + // From this state, after a website visit, it will show a "Tracker" dialog if a tracker is blocked; otherwise, nothing. + // From this state, after a search, it will show nothing. + case searchDoneShowMajorOrNoTracker + + // State applied when, after the "Try a search" dialog is displayed, the fire button is used. + // From this state, after a website visit, it will show a "Tracker" dialog. + // From this state, after a search, it will transition to "fireUsedShowSearchDone". + case fireUsedTryASearchShown + + // State applied after a search is performed in the "fireUsedTryASearchShown" state. + // From this state, after a website visit, it will show a "Tracker" dialog. + // From this state, after a search, it will show the "Search Done" dialog. + case fireUsedShowSearchDone + + // State applied when "Got it" is pressed on a tracker or after a visit if performed after blocked trackers. + // From this state, after a website visit, it will show the "Try Fire Button" dialog. + // From this state, after a search, it will show the "Try Fire Button" dialog. + case showFireButton + + // State applied after any action once the "Try Fire Button" dialog is shown. + // From this state, after a website visit, it will show the "High Five" dialog. + // From this state, after a search, it will show the "High Five" dialog. + case showHighFive + + // State applied after any action once the "High Five" dialog is shown, indicating the end of the contextual onboarding. + // From this state, after a website visit, it will show nothing. + // From this state, after a search, it will show nothing. + case onboardingCompleted +} + +final class ContextualOnboardingStateMachine: ContextualOnboardingDialogTypeProviding, ContextualOnboardingStateUpdater { + + private let trackerMessageProvider: TrackerMessageProviding + private let startUpPreferences: StartupPreferences + + @UserDefaultsWrapper(key: .contextualOnboardingState, defaultValue: ContextualOnboardingState.onboardingCompleted.rawValue) + private var stateString: String { + didSet { + if stateString == ContextualOnboardingState.notStarted.rawValue { + startUpPreferences.launchToCustomHomePage = true + resetData() + } + if stateString == ContextualOnboardingState.onboardingCompleted.rawValue { + startUpPreferences.launchToCustomHomePage = false + resetData() + } + } + } + + var state: ContextualOnboardingState { + get { + return ContextualOnboardingState(rawValue: stateString) ?? .onboardingCompleted + } + set { + stateString = newValue.rawValue + } + } + + private var lastVisitTab: Tab? + private var lastVisitSite: URL? + private var notBlockedTrackerSeen: Bool = false + + init(trackerMessageProvider: TrackerMessageProviding = TrackerMessageProvider(), + startupPreferences: StartupPreferences = StartupPreferences.shared) { + self.trackerMessageProvider = trackerMessageProvider + self.startUpPreferences = startupPreferences + } + + func dialogTypeForTab(_ tab: Tab, privacyInfo: PrivacyInfo? = nil) -> ContextualDialogType? { + let info = privacyInfo ?? tab.privacyInfo + guard case .url = tab.content else { + return nil + } + guard let url = tab.url else { return nil } + + // This is to avoid showing a dialog immediately when the user opens a new Window + if isANewWindow(tab: tab, url: url) { + lastVisitTab = tab + lastVisitSite = url + return nil + } + + lastVisitTab = tab + lastVisitSite = url + if url.isDuckDuckGoSearch { + return dialogPerSearch() + } else { + return dialogPerSiteVisit(privacyInfo: info) + } + } + + private func dialogPerSearch() -> ContextualDialogType? { + switch state { + case .showSearchDone, .fireUsedShowSearchDone: + return .searchDone(shouldFollowUp: true) + case .showBlockedTrackers, .showMajorOrNoTracker, .searchDoneShowBlockedTrackers, .searchDoneShowMajorOrNoTracker: + return .searchDone(shouldFollowUp: false) + case .showTryASite: + return .tryASite + case .showFireButton: + return .tryFireButton + case .showHighFive: + return .highFive + default: + return nil + } + } + + private func dialogPerSiteVisit(privacyInfo: PrivacyInfo?) -> ContextualDialogType? { + switch state { + case .showTryASearch: + return .tryASearch + case .showMajorOrNoTracker, .searchDoneShowMajorOrNoTracker: + if !notBlockedTrackerSeen { + return trackerDialog(for: privacyInfo) + } + return nil + case .showBlockedTrackers, .searchDoneShowBlockedTrackers, .fireUsedShowSearchDone: + return trackerDialog(for: privacyInfo) + case .showFireButton: + return .tryFireButton + case .showHighFive: + return .highFive + default: + return nil + } + } + + private func resetData() { + lastVisitTab = nil + lastVisitSite = nil + notBlockedTrackerSeen = false + } + + // To determine if it's a new Window we do the following: + // Check if some action has been taken (e.g. it is not the start of the contextual onboarding) + // If lastVisitedTab is not the same as current tab (e.g. it's not a reload) + // And the state is not showFireButton (e.g. we have not used the Fire button on the same Window) + private func isANewWindow(tab: Tab, url: URL) -> Bool { + return lastVisitTab != nil && tab != lastVisitTab && url == URL.duckDuckGo && state != .showFireButton + } + + private func trackerDialog(for privacyInfo: PrivacyInfo?) -> ContextualDialogType? { + guard let privacyInfo else { return nil } + guard let message = trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo) else { return nil } + return .trackers(message: message, shouldFollowUp: true) + } + + func updateStateFor(tab: Tab) { + guard case .url = tab.content else { + return + } + guard let url = tab.url else { return } + + // This is to avoid updating the state immediately when the user opens a new Window (and DuckDuckGo site is loaded) + if isANewWindow(tab: tab, url: url) { + lastVisitTab = tab + lastVisitSite = url + return + } + + if tab != lastVisitTab || url != lastVisitSite { + lastVisitTab = tab + lastVisitSite = url + if url.isDuckDuckGoSearch { + searchPerformed() + } else { + siteVisited(tab: tab) + } + } + } + + private func searchPerformed() { + switch state { + case .showTryASearch: + state = .showSearchDone + case .showSearchDone: + state = .showTryASite + case .showBlockedTrackers: + state = .searchDoneShowBlockedTrackers + case .showMajorOrNoTracker: + state = .searchDoneShowMajorOrNoTracker + case .searchDoneShowBlockedTrackers, .searchDoneShowMajorOrNoTracker: + state = .showTryASite + case .showFireButton, .fireUsedShowSearchDone: + state = .showHighFive + case .showHighFive: + state = .onboardingCompleted + case .fireUsedTryASearchShown: + state = .fireUsedShowSearchDone + default: + break + } + } + + private func siteVisited(tab: Tab) { + let trackerType = trackerMessageProvider.trackersType(privacyInfo: tab.privacyInfo) + + switch state { + case .notStarted: + state = .showTryASearch + case .showTryASearch, .showTryASite, .fireUsedTryASearchShown: + if case .blockedTrackers = trackerType { + state = .showBlockedTrackers + } else if trackerType != nil { + state = .showMajorOrNoTracker + } + case .showSearchDone: + if case .blockedTrackers = trackerType { + state = .searchDoneShowBlockedTrackers + } else if trackerType != nil { + state = .searchDoneShowMajorOrNoTracker + } + case .showBlockedTrackers, .searchDoneShowBlockedTrackers: + state = .showFireButton + case .showMajorOrNoTracker, .searchDoneShowMajorOrNoTracker: + if case .blockedTrackers = trackerType { + state = .showBlockedTrackers + } else { + notBlockedTrackerSeen = true + } + case .fireUsedShowSearchDone: + state = .showFireButton + case .showFireButton: + state = .showHighFive + case .showHighFive: + state = .onboardingCompleted + case .onboardingCompleted: + break + } + } + + func gotItPressed() { + switch state { + case .showSearchDone, .fireUsedShowSearchDone: + state = .showTryASite + case .showBlockedTrackers, .showMajorOrNoTracker, .searchDoneShowBlockedTrackers, .searchDoneShowMajorOrNoTracker: + state = .showFireButton + case .showFireButton: + state = .showHighFive + case .showHighFive: + state = .onboardingCompleted + default: + break + } + } + + func fireButtonUsed() { + switch state { + case .showTryASearch: + state = .fireUsedTryASearchShown + case .fireUsedShowSearchDone: + state = .showHighFive + case .showBlockedTrackers, .showMajorOrNoTracker, .showTryASite, .searchDoneShowBlockedTrackers, .searchDoneShowMajorOrNoTracker, .showSearchDone: + state = .showFireButton + case .showHighFive: + state = .onboardingCompleted + default: + break + } + } +} diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingFireButtonDialogViewModel.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingFireButtonDialogViewModel.swift index dcc5ef6511..60265a300d 100644 --- a/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingFireButtonDialogViewModel.swift +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingFireButtonDialogViewModel.swift @@ -17,14 +17,27 @@ // import Foundation +import Combine -public struct OnboardingFireButtonDialogViewModel { - let onDismiss: () -> Void +public class OnboardingFireButtonDialogViewModel: ObservableObject { - func skip() { + private var onDismiss: () -> Void + private var onGotItPressed: () -> Void + + init(onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) { + self.onDismiss = onDismiss + self.onGotItPressed = onGotItPressed + } + + func highFive() { + onGotItPressed() onDismiss() } + func skip() { + onGotItPressed() + } + @MainActor func tryFireButton() { FireCoordinator.fireButtonAction() diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/TrackerMessageProvider.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/TrackerMessageProvider.swift new file mode 100644 index 0000000000..d265381424 --- /dev/null +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/TrackerMessageProvider.swift @@ -0,0 +1,185 @@ +// +// TrackerMessageProvider.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 PrivacyDashboard +import TrackerRadarKit +import BrowserServicesKit + +protocol TrackerMessageProviding { + func trackersType(privacyInfo: PrivacyInfo?) -> OnboardingTrackersType? + func trackerMessage(privacyInfo: PrivacyInfo?) -> NSAttributedString? +} + +struct MajorTrackers { + static let facebookDomain = "facebook.com" + static let googleDomain = "google.com" + + static let domains = [facebookDomain, googleDomain] +} + +enum OnboardingTrackersType: Equatable { + case majorTracker + case ownedByMajorTracker(owner: Entity) + case blockedTrackers(entityNames: [String]) + case noTrackers +} + +struct TrackerMessageProvider: TrackerMessageProviding { + + private var entityProviding: EntityProviding + + init(entityProviding: EntityProviding = AppPrivacyFeatures.shared.contentBlocking.contentBlockingManager) { + self.entityProviding = entityProviding + } + + func trackersType(privacyInfo: PrivacyInfo?) -> OnboardingTrackersType? { + guard let privacyInfo else { return nil } + guard let host = privacyInfo.domain else { return nil } + + if isFacebookOrGoogle(privacyInfo.url) { + return .majorTracker + } + + if let owner = isOwnedByFacebookOrGoogle(host) { + return .ownedByMajorTracker(owner: owner) + } + + if let entityNames = blockedEntityNames(privacyInfo.trackerInfo) { + return .blockedTrackers(entityNames: entityNames) + } + + return .noTrackers + } + + func trackerMessage(privacyInfo: PrivacyInfo?) -> NSAttributedString? { + guard let privacyInfo else { return nil } + guard let host = privacyInfo.domain else { return nil } + guard let trackerType = trackersType(privacyInfo: privacyInfo) else { return nil } + var message: String? + switch trackerType { + case .majorTracker: + message = majorTrackerMessage(host) + case .ownedByMajorTracker(let owner): + message = majorTrackerOwnerMessage(host, owner) + case .blockedTrackers(let entityNames): + message = trackersBlockedMessage(entityNames) + case .noTrackers: + message = UserText.ContextualOnboarding.daxDialogBrowsingWithoutTrackers + } + guard let message else { return nil } + return attributedString(from: message, fontSize: OnboardingDialogsContants.messageFontSize) + } + + private func isFacebookOrGoogle(_ url: URL) -> Bool { + return [ MajorTrackers.facebookDomain, MajorTrackers.googleDomain ].contains { domain in + return url.isPart(ofDomain: domain) + } + } + + private func isOwnedByFacebookOrGoogle(_ host: String) -> Entity? { + guard let entity = entityProviding.entity(forHost: host) else { return nil } + return entity.domains?.contains(where: { MajorTrackers.domains.contains($0) }) ?? false ? entity : nil + } + + private func majorTrackerMessage(_ host: String) -> String? { + guard let entityName = entityProviding.entity(forHost: host)?.displayName else { return nil } + let message = UserText.ContextualOnboarding.daxDialogBrowsingSiteIsMajorTracker + return String(format: message, entityName, host) + } + + private func majorTrackerOwnerMessage(_ host: String, _ majorTrackerEntity: Entity) -> String? { + guard let entityName = majorTrackerEntity.displayName, + let entityPrevalence = majorTrackerEntity.prevalence else { return nil } + let message = UserText.ContextualOnboarding.daxDialogBrowsingSiteOwnedByMajorTracker + return String(format: message, host.droppingWwwPrefix(), + entityName, + entityPrevalence) + } + + private func blockedEntityNames(_ trackerInfo: TrackerInfo) -> [String]? { + guard !trackerInfo.trackersBlocked.isEmpty else { return nil } + + return trackerInfo.trackersBlocked.removingDuplicates { $0.entityName } + .sorted(by: { $0.prevalence ?? 0.0 > $1.prevalence ?? 0.0 }) + .compactMap { $0.entityName } + } + + private func trackersBlockedMessage(_ entitiesBlocked: [String]) -> String? { + switch entitiesBlocked.count { + case 0: + return nil + + case 1: + let args = entitiesBlocked[0] + let message = UserText.ContextualOnboarding.daxDialogBrowsingWithOneTracker + return String(format: message, args) + + case 2: + let args: [CVarArg] = [entitiesBlocked[0], entitiesBlocked[1]] + let message = UserText.ContextualOnboarding.daxDialogBrowsingWithTwoTrackers + return String(format: message, args) + + default: + let args: [CVarArg] = [entitiesBlocked.count - 2, entitiesBlocked[0], entitiesBlocked[1]] + let message = UserText.ContextualOnboarding.daxDialogBrowsingWithMultipleTrackers + return String(format: message, args) + } + } + + private func attributedString(from string: String, fontSize: CGFloat) -> NSAttributedString { + let attributedString = NSMutableAttributedString() + + var isBold = false + var currentText = "" + + for character in string { + if character == "*" { + if !currentText.isEmpty { + let attributes: [NSAttributedString.Key: Any] = isBold ? + [.font: NSFont.systemFont(ofSize: fontSize, weight: .bold)] : + [:] + attributedString.append(NSAttributedString(string: currentText, attributes: attributes)) + currentText = "" + } + isBold.toggle() + } else { + currentText.append(character) + } + } + + if !currentText.isEmpty { + let attributes: [NSAttributedString.Key: Any] = isBold ? + [.font: NSFont.systemFont(ofSize: fontSize, weight: .bold)] : + [:] + attributedString.append(NSAttributedString(string: currentText, attributes: attributes)) + } + + return attributedString + } +} + +extension ContentBlockerRulesManager: EntityProviding { + func entity(forHost host: String) -> Entity? { + currentMainRules?.trackerData.findParentEntityOrFallback(forHost: host) + } +} + +protocol EntityProviding { + func entity(forHost host: String) -> Entity? +} diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 0cbe2231a0..efb8ce9bc6 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -43,7 +43,8 @@ final class BrowserTabViewController: NSViewController { private let tabCollectionViewModel: TabCollectionViewModel private let bookmarkManager: BookmarkManager private let dockCustomizer = DockCustomizer() - private let onboardingDialogTypeProvider: ContextualOnboardingDialogTypeProviding + private let onboardingDialogTypeProvider: ContextualOnboardingDialogTypeProviding & ContextualOnboardingStateUpdater + private let onboardingDialogFactory: ContextualDaxDialogsFactory private let featureFlagger: FeatureFlagger @@ -71,7 +72,7 @@ final class BrowserTabViewController: NSViewController { init(tabCollectionViewModel: TabCollectionViewModel, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, - onboardingDialogTypeProvider: ContextualOnboardingDialogTypeProviding = ContextualOnboardingDialogTypeProvider(), + onboardingDialogTypeProvider: ContextualOnboardingDialogTypeProviding & ContextualOnboardingStateUpdater = Application.appDelegate.onboardingStateMachine, onboardingDialogFactory: ContextualDaxDialogsFactory = DefaultContextualDaxDialogViewFactory(), featureFlagger: FeatureFlagger = NSApp.delegateTyped.featureFlagger) { self.tabCollectionViewModel = tabCollectionViewModel @@ -124,13 +125,27 @@ final class BrowserTabViewController: NSViewController { subscribeToTabs() subscribeToSelectedTabViewModel() + if let webViewContainer { + removeChild(in: self.containerStackView, webViewContainer: webViewContainer) + } + view.registerForDraggedTypes([.URL, .fileURL]) } + @objc func windowDidResignActive(notification: Notification) { + guard let webViewContainer else { return } + removeExistingDialog() + } + override func viewWillAppear() { super.viewWillAppear() addMouseMonitors() + + // Register for focus-related notifications + if let window = view.window { + NotificationCenter.default.addObserver(self, selector: #selector(windowDidResignActive), name: NSWindow.didResignKeyNotification, object: window) + } } override func viewWillDisappear() { @@ -295,7 +310,7 @@ final class BrowserTabViewController: NSViewController { self.subscribeToUserDialogs(of: selectedTabViewModel) self.adjustFirstResponder(force: true) - self.presentContextualOnboarding() + removeExistingDialog() } .store(in: &cancellables) } @@ -386,17 +401,21 @@ final class BrowserTabViewController: NSViewController { } var daxContextualOnboardingController: NSViewController? - private func presentContextualOnboarding() { - // Before presenting a new dialog, remove any existing ones. + private func removeExistingDialog() { containerStackView.arrangedSubviews.filter({ $0 != webViewContainer }).forEach { containerStackView.removeArrangedSubview($0) $0.removeFromSuperview() } + } + private func presentContextualOnboarding() { + // Before presenting a new dialog, remove any existing ones. + removeExistingDialog() guard featureFlagger.isFeatureOn(.highlightsOnboarding) else { return } - guard let tab = tabViewModel?.tab, - let dialogType = onboardingDialogTypeProvider.dialogTypeForTab(tab) else { + guard let tab = tabViewModel?.tab else { return } + onboardingDialogTypeProvider.updateStateFor(tab: tab) + guard let dialogType = onboardingDialogTypeProvider.dialogTypeForTab(tab, privacyInfo: tab.privacyInfo) else { return } @@ -407,7 +426,7 @@ final class BrowserTabViewController: NSViewController { self.removeChild(in: self.containerStackView, webViewContainer: webViewContainer) } } - let daxView = onboardingDialogFactory.makeView(for: dialogType, delegate: tab, onDismiss: onDismissAction) + let daxView = onboardingDialogFactory.makeView(for: dialogType, delegate: tab, onDismiss: onDismissAction, onGotItPressed: onboardingDialogTypeProvider.gotItPressed) let hostingController = NSHostingController(rootView: AnyView(daxView)) daxContextualOnboardingController = hostingController diff --git a/UnitTests/Fire/Model/FirePopoverViewModelTests.swift b/UnitTests/Fire/Model/FirePopoverViewModelTests.swift index b66546772d..12816a48e7 100644 --- a/UnitTests/Fire/Model/FirePopoverViewModelTests.swift +++ b/UnitTests/Fire/Model/FirePopoverViewModelTests.swift @@ -22,14 +22,62 @@ import XCTest final class FirePopoverViewModelTests: XCTestCase { @MainActor - private func makeViewModel(with tabCollectionViewModel: TabCollectionViewModel) -> FirePopoverViewModel { - FirePopoverViewModel( - fireViewModel: .init(), + private func makeViewModel(with tabCollectionViewModel: TabCollectionViewModel, contextualOnboardingStateMachine: ContextualOnboardingStateUpdater = ContextualOnboardingStateMachine()) -> FirePopoverViewModel { + let manager = WebCacheManagerMock() + let historyCoordinator = HistoryCoordinatingMock() + let permissionManager = PermissionManagerMock() + let faviconManager = FaviconManagerMock() + let fire = Fire(cacheManager: manager, + historyCoordinating: historyCoordinator, + permissionManager: permissionManager, + windowControllerManager: WindowControllersManager.shared, + faviconManagement: faviconManager, + tld: ContentBlocking.shared.tld) + return FirePopoverViewModel( + fireViewModel: .init(fire: fire), tabCollectionViewModel: tabCollectionViewModel, historyCoordinating: HistoryCoordinatingMock(), fireproofDomains: FireproofDomains(store: FireproofDomainsStoreMock()), faviconManagement: FaviconManagerMock(), - tld: ContentBlocking.shared.tld + tld: ContentBlocking.shared.tld, + contextualOnboardingStateMachine: contextualOnboardingStateMachine ) } + + @MainActor func testOnBurn_OnboardingStateMachineFireButtonUsedCalled() { + // Given + let tabCollectionVM = TabCollectionViewModel() + let stateMachine = CapturingContextualOnboardingStateUpdater() + let vm = makeViewModel(with: tabCollectionVM, contextualOnboardingStateMachine: stateMachine) + XCTAssertNil(stateMachine.updatedForTab) + XCTAssertFalse(stateMachine.gotItPressedCalled) + XCTAssertFalse(stateMachine.fireButtonUsedCalled) + + // When + vm.burn() + + // Then + XCTAssertNil(stateMachine.updatedForTab) + XCTAssertFalse(stateMachine.gotItPressedCalled) + XCTAssertTrue(stateMachine.fireButtonUsedCalled) + } +} + +class CapturingContextualOnboardingStateUpdater: ContextualOnboardingStateUpdater { + var updatedForTab: Tab? + var gotItPressedCalled = false + var fireButtonUsedCalled = false + + func updateStateFor(tab: Tab) { + updatedForTab = tab + } + + func gotItPressed() { + gotItPressedCalled = true + } + + func fireButtonUsed() { + fireButtonUsedCalled = true + } + } diff --git a/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift b/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift index db3eefc81f..2b67d2dc64 100644 --- a/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift +++ b/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift @@ -21,6 +21,7 @@ import SwiftUI import Onboarding import Combine import BrowserServicesKit +import PrivacyDashboard @testable import DuckDuckGo_Privacy_Browser final class BrowserTabViewControllerOnboardingTests: XCTestCase { @@ -28,7 +29,7 @@ final class BrowserTabViewControllerOnboardingTests: XCTestCase { var viewController: BrowserTabViewController! var dialogProvider: MockDialogsProvider! var factory: CapturingDialogFactory! - var tab: DuckDuckGo_Privacy_Browser.Tab! + var tab: Tab! var cancellables: Set = [] let expectation = XCTestExpectation() @@ -37,7 +38,7 @@ final class BrowserTabViewControllerOnboardingTests: XCTestCase { let tabCollectionViewModel = TabCollectionViewModel() dialogProvider = MockDialogsProvider() factory = CapturingDialogFactory(expectation: expectation) - tab = DuckDuckGo_Privacy_Browser.Tab() + tab = Tab() tab.setContent(.url(URL.duckDuckGo, credential: nil, source: .appOpenUrl)) let tabViewModel = TabViewModel(tab: tab) viewController = BrowserTabViewController(tabCollectionViewModel: tabCollectionViewModel, onboardingDialogTypeProvider: dialogProvider, onboardingDialogFactory: factory, featureFlagger: MockFeatureFlagger()) @@ -125,15 +126,22 @@ final class BrowserTabViewControllerOnboardingTests: XCTestCase { } -class MockDialogsProvider: ContextualOnboardingDialogTypeProviding { +class MockDialogsProvider: ContextualOnboardingDialogTypeProviding, ContextualOnboardingStateUpdater { + func updateStateFor(tab: DuckDuckGo_Privacy_Browser.Tab) {} + var dialog: ContextualDialogType? - func dialogTypeForTab(_ tab: DuckDuckGo_Privacy_Browser.Tab) -> ContextualDialogType? { + func dialogTypeForTab(_ tab: Tab, privacyInfo: PrivacyInfo?) -> ContextualDialogType? { return dialog } + + func gotItPressed() {} + + func fireButtonUsed() {} } class CapturingDialogFactory: ContextualDaxDialogsFactory { + let expectation: XCTestExpectation var capturedType: ContextualDialogType? var capturedDelegate: OnboardingNavigationDelegate? @@ -142,7 +150,7 @@ class CapturingDialogFactory: ContextualDaxDialogsFactory { self.expectation = expectation } - func makeView(for type: ContextualDialogType, delegate: OnboardingNavigationDelegate, onDismiss: @escaping () -> Void) -> AnyView { + func makeView(for type: ContextualDialogType, delegate: OnboardingNavigationDelegate, onDismiss: @escaping () -> Void, onGotItPressed: @escaping () -> Void) -> AnyView { capturedType = type capturedDelegate = delegate expectation.fulfill() diff --git a/UnitTests/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactoryTests.swift b/UnitTests/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactoryTests.swift index 5a2a5c7913..a0c6714041 100644 --- a/UnitTests/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactoryTests.swift +++ b/UnitTests/Onboarding/ContextualOnboarding/ContextualDaxDialogsFactoryTests.swift @@ -41,7 +41,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { let dialogType = ContextualDialogType.tryASearch // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: {}) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: {}, onGotItPressed: {}) // THEN let view = try XCTUnwrap(find(OnboardingTrySearchDialog.self, in: result)) @@ -59,11 +59,13 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { func testWhenMakeViewForSearchDoneWithShouldFollowUpThenOnboardingsearchDoneViewCreatedAndOnActionNothingOccurs() throws { // GIVEN var onDismissRun = false + var onGotItPressedRun = false let dialogType = ContextualDialogType.searchDone(shouldFollowUp: true) let onDismiss = { onDismissRun = true } + let onGotItPressed = { onGotItPressedRun = true } // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss, onGotItPressed: onGotItPressed) // THEN let view = try XCTUnwrap(find(OnboardingFirstSearchDoneDialog.self, in: result)) @@ -75,16 +77,19 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // THEN XCTAssertFalse(onDismissRun) + XCTAssertTrue(onGotItPressedRun) } func testWhenMakeViewForSearchDoneWithoutShouldFollowUpThenOnboardingsearchDoneViewCreatedAndOnActionOccurs() throws { // GIVEN var onDismissRun = false + var onGotItPressedRun = false let dialogType = ContextualDialogType.searchDone(shouldFollowUp: false) let onDismiss = { onDismissRun = true } + let onGotItPressed = { onGotItPressedRun = true } // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss, onGotItPressed: onGotItPressed) // THEN let view = try XCTUnwrap(find(OnboardingFirstSearchDoneDialog.self, in: result)) @@ -96,6 +101,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // THEN XCTAssertTrue(onDismissRun) + XCTAssertFalse(onGotItPressedRun) } func testWhenMakeViewForTryASiteThenOnboardingTrySiteDialogViewCreatedAndOnActionExpectedSearchOccurs() throws { @@ -103,7 +109,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { let dialogType = ContextualDialogType.tryASite // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: {}) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: {}, onGotItPressed: {}) // THEN let view = try XCTUnwrap(find(OnboardingTryVisitingASiteDialog.self, in: result)) @@ -121,12 +127,14 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { func testWhenMakeViewForTryASiteWithShouldFollowUpThenTrySiteDialogViewCreatedAndOnActionNothingOccurs() throws { // GIVEN var onDismissRun = false + var onGotItPressedRun = false let trackerMessage = NSMutableAttributedString(string: "some trackers") let dialogType = ContextualDialogType.trackers(message: trackerMessage, shouldFollowUp: true) let onDismiss = { onDismissRun = true } + let onGotItPressed = { onGotItPressedRun = true } // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss, onGotItPressed: onGotItPressed) // THEN let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: result)) @@ -138,17 +146,20 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // THEN XCTAssertFalse(onDismissRun) + XCTAssertTrue(onGotItPressedRun) } func testWhenMakeViewForTryASiteWithoutShouldFollowUpThenTryASiteDialogViewCreatedAndOnActionOccurs() throws { // GIVEN var onDismissRun = false + var onGotItPressedRun = false let trackerMessage = NSMutableAttributedString(string: "some trackers") let dialogType = ContextualDialogType.trackers(message: trackerMessage, shouldFollowUp: false) let onDismiss = { onDismissRun = true } + let onGotItPressed = { onGotItPressedRun = true } // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss, onGotItPressed: onGotItPressed) // THEN let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: result)) @@ -160,16 +171,19 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // THEN XCTAssertTrue(onDismissRun) + XCTAssertFalse(onGotItPressedRun) } @MainActor func testWhenMakeViewForTryFireButtonThenOnboardingTryFireButtonDialogViewCreatedAndOnActionExpectedActionOccurs() throws { // GIVEN var onDismissRun = false + var onGotItPressedRun = false let dialogType = ContextualDialogType.tryFireButton let onDismiss = { onDismissRun = true } + let onGotItPressed = { onGotItPressedRun = true } // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss, onGotItPressed: onGotItPressed) // THEN let view = try XCTUnwrap(find(OnboardingFireDialog.self, in: result)) @@ -179,7 +193,8 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { view.viewModel.tryFireButton() // THEN - XCTAssertTrue(onDismissRun) + XCTAssertFalse(onDismissRun) + XCTAssertTrue(onGotItPressedRun) let expectation = self.expectation(description: "Wait for FirePopover to appear") self.waitForPopoverToAppear(expectation: expectation) wait(for: [expectation], timeout: 3.0) @@ -189,11 +204,13 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { func testWhenMakeViewForHighFivThenFilalDialogViewCreatedAndOnActionExpectedSearchOccurs() throws { // GIVEN var onDismissRun = false + var onGotItPressedRun = false let dialogType = ContextualDialogType.highFive let onDismiss = { onDismissRun = true } + let onGotItPressed = { onGotItPressedRun = true } // WHEN - let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss) + let result = factory.makeView(for: dialogType, delegate: delegate, onDismiss: onDismiss, onGotItPressed: onGotItPressed) // THEN let view = try XCTUnwrap(find(OnboardingFinalDialog.self, in: result)) @@ -203,6 +220,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // THEN XCTAssertTrue(onDismissRun) + XCTAssertTrue(onGotItPressedRun) } @MainActor private func waitForPopoverToAppear(expectation: XCTestExpectation) { diff --git a/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachineTests.swift b/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachineTests.swift new file mode 100644 index 0000000000..86b503b6dc --- /dev/null +++ b/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachineTests.swift @@ -0,0 +1,336 @@ +// +// ContextualOnboardingStateMachineTests.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 PrivacyDashboard +@testable import DuckDuckGo_Privacy_Browser + +@available(macOS 12.0, *) +class ContextualOnboardingStateMachineTests: XCTestCase { + + var stateMachine: ContextualOnboardingStateMachine! + var mockTrackerMessageProvider: MockTrackerMessageProvider! + var tab: Tab! + let expectation = XCTestExpectation() + + @MainActor override func setUp() { + super.setUp() + UserDefaultsWrapper.clearAll() + mockTrackerMessageProvider = MockTrackerMessageProvider(expectation: expectation) + stateMachine = ContextualOnboardingStateMachine(trackerMessageProvider: mockTrackerMessageProvider) + tab = Tab(url: URL.duckDuckGo) + } + + override func tearDown() { + stateMachine = nil + mockTrackerMessageProvider = nil + tab = nil + super.tearDown() + } + + func testDefaultStateIsOnboardingCompleted() { + XCTAssertEqual(stateMachine.state, .onboardingCompleted) + } + + func test_OnSearch_WhenStateIsShowSearchDoneOrFireUsedShowSearchDone_returnsSearchDoneShouldFollowUp() { + let states: [ContextualOnboardingState] = [.showSearchDone, .fireUsedShowSearchDone] + tab.url = URL.makeSearchUrl(from: "query something") + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .searchDone(shouldFollowUp: true)) + } + } + + func test_OnSearch_WhenStateIsShowTryASite_returnsTryASite() { + let states: [ContextualOnboardingState] = [.showTryASite] + tab.url = URL.makeSearchUrl(from: "query something") + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .tryASite) + } + } + + func test_OnSearch_WhenStateIsShowBlockedTrackersOrShowMajorOrNoTracker_returnsSearchDoneShouldNotFollowUp() { + let states: [ContextualOnboardingState] = [.showBlockedTrackers, .showMajorOrNoTracker] + tab.url = URL.makeSearchUrl(from: "query something") + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .searchDone(shouldFollowUp: false)) + } + } + + func test_OnSearch_WhenStateIsSearchDoneShowBlockedTrackersOrSearchSoneShowMajorOrNoTracker_returnSearchDoneShouldFollowUpFalse() { + let states: [ContextualOnboardingState] = [.searchDoneShowBlockedTrackers, .searchDoneShowMajorOrNoTracker] + tab.url = URL.makeSearchUrl(from: "query something") + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .searchDone(shouldFollowUp: false)) + } + } + + func test_OnSearch_WhenStateIsShowFireButton_returnsTryFireButton() { + let states: [ContextualOnboardingState] = [.showFireButton] + tab.url = URL.makeSearchUrl(from: "query something") + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .tryFireButton) + } + } + + func test_OnSearch_WhenStateIsShowHighFive_returnsHighFive() { + let states: [ContextualOnboardingState] = [.showHighFive] + tab.url = URL.makeSearchUrl(from: "query something") + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .highFive) + } + } + + func test_OnSearch_WhenOtherStates_returnsNil() { + let states: [ContextualOnboardingState] = [ + .notStarted, + .showTryASearch, + .fireUsedTryASearchShown, + .onboardingCompleted + ] + tab.url = URL.makeSearchUrl(from: "query something") + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, nil) + } + } + + func test_OnSiteVisit_WhenStateIsShowTryASearch_returnsTryASearch() { + let states: [ContextualOnboardingState] = [.showTryASearch] + tab.url = URL.duckDuckGo + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .tryASearch) + } + } + + func test_OnSiteVisit_WhenStateIsTrackerRelatedOrFireUsedShowSearchDone_andPrivacyInfoNil_returnsNil() { + let states: [ContextualOnboardingState] = [.showBlockedTrackers, .showMajorOrNoTracker, .searchDoneShowBlockedTrackers, .searchDoneShowMajorOrNoTracker, .fireUsedShowSearchDone] + tab.url = URL.duckDuckGo + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertNil(dialogType) + } + } + + func test_OnSiteVisit_WhenStateIsTrackerRelatedOrFireUsedShowSearchDone_returnsTrackersShouldFollowUp() { + let states: [ContextualOnboardingState] = [.showBlockedTrackers, .showMajorOrNoTracker, .searchDoneShowBlockedTrackers, .searchDoneShowMajorOrNoTracker, .fireUsedShowSearchDone] + tab.url = URL.duckDuckGo + let privacyInfo = PrivacyInfo(url: tab.url!, parentEntity: nil, protectionStatus: ProtectionStatus(unprotectedTemporary: true, enabledFeatures: [], allowlisted: false, denylisted: false), isPhishing: false, shouldCheckServerTrust: true) + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab, privacyInfo: privacyInfo) + XCTAssertEqual(dialogType, .trackers(message: mockTrackerMessageProvider.message, shouldFollowUp: true)) + } + } + + func test_OnSiteVisit_WhenStateIsShowFireButton_returnsTryFireButton() { + let states: [ContextualOnboardingState] = [.showFireButton] + tab.url = URL.duckDuckGo + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .tryFireButton) + } + } + + func test_OnSiteVisit_WhenStateIsShowHighFive_returnsHighFive() { + let states: [ContextualOnboardingState] = [.showHighFive] + tab.url = URL.duckDuckGo + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, .highFive) + } + } + + func test_OnSiteVisit_WhenOtherStates_returnsNil() { + let states: [ContextualOnboardingState] = [ + .notStarted, + .showTryASite, + .fireUsedTryASearchShown, + .onboardingCompleted] + tab.url = URL.duckDuckGo + + for state in states { + stateMachine.state = state + let dialogType = stateMachine.dialogTypeForTab(tab) + XCTAssertEqual(dialogType, nil) + } + } + + func test_OnGotItPressed_WhenStateIsShowSearchDoneOrfireUsedShowSearchDone_ThenStateTransitionsToShowTryASite() { + let states: [ContextualOnboardingState] = [ + .showSearchDone, + .fireUsedShowSearchDone] + + for state in states { + stateMachine.state = state + stateMachine.gotItPressed() + XCTAssertEqual(stateMachine.state, .showTryASite) + } + } + + func test_OnGotItPressed_WhenStateIsTrackerRelated_ThenStateTransitionsToShowFireButton() { + let states: [ContextualOnboardingState] = [ + .showBlockedTrackers, + .showMajorOrNoTracker, + .searchDoneShowBlockedTrackers, + .searchDoneShowMajorOrNoTracker] + + for state in states { + stateMachine.state = state + stateMachine.gotItPressed() + XCTAssertEqual(stateMachine.state, .showFireButton) + } + } + + func test_OnGotItPressed_WhenStateIsShowFireButton_ThenStateTransitionsToShowHighFive() { + let states: [ContextualOnboardingState] = [ + .showFireButton] + + for state in states { + stateMachine.state = state + stateMachine.gotItPressed() + XCTAssertEqual(stateMachine.state, .showHighFive) + } + } + + func test_OnGotItPressed_WhenStateIsShowHighFive_ThenStateTransitionsToOnboardingCompleted() { + let states: [ContextualOnboardingState] = [ + .showHighFive] + + for state in states { + stateMachine.state = state + stateMachine.gotItPressed() + XCTAssertEqual(stateMachine.state, .onboardingCompleted) + } + } + + func test_OnGotItPressed_WhenOtherState_ThenNoStateTransition() { + let states: [ContextualOnboardingState] = [ + .notStarted, + .showTryASearch, + .showTryASite, + .fireUsedTryASearchShown, + .onboardingCompleted] + + for state in states { + stateMachine.state = state + stateMachine.gotItPressed() + XCTAssertEqual(stateMachine.state, state) + } + } + + func test_OnFireButtonUsed_WhenStateIsShowTryASearch_ThenStateTransitionsToFireUsedTryASearchShown() { + let states: [ContextualOnboardingState] = [ + .showTryASearch] + + for state in states { + stateMachine.state = state + stateMachine.fireButtonUsed() + XCTAssertEqual(stateMachine.state, .fireUsedTryASearchShown) + } + } + + func test_OnFireButtonUsed_WhenStateIsFireUsedShowSearchDone_ThenStateTransitionsToShowHighFive() { + let states: [ContextualOnboardingState] = [ + .fireUsedShowSearchDone] + + for state in states { + stateMachine.state = state + stateMachine.fireButtonUsed() + XCTAssertEqual(stateMachine.state, .showHighFive) + } + } + + func test_OnFireButtonUsed_WhenStateIsTrackerRelatedOrOrShowTryASiteOrShowSearchDone_ThenStateTransitionsToShowFireButton() { + let states: [ContextualOnboardingState] = [ + .showMajorOrNoTracker, + .showBlockedTrackers, + .showTryASite, + .searchDoneShowBlockedTrackers, + .searchDoneShowMajorOrNoTracker, + .showSearchDone] + + for state in states { + stateMachine.state = state + stateMachine.fireButtonUsed() + XCTAssertEqual(stateMachine.state, .showFireButton) + } + } + + func test_OnFireButtonUsed_WhenStateIsShowHighFive_ThenNoStateTransition() { + let states: [ContextualOnboardingState] = [ + .notStarted, + .fireUsedTryASearchShown, + .showFireButton, + .onboardingCompleted] + + for state in states { + stateMachine.state = state + stateMachine.fireButtonUsed() + XCTAssertEqual(stateMachine.state, state) + } + } + +} + +class MockTrackerMessageProvider: TrackerMessageProviding { + let expectation: XCTestExpectation + let message = NSAttributedString(string: "Trackers Detected") + + init(expectation: XCTestExpectation) { + self.expectation = expectation + } + + func trackerMessage(privacyInfo: PrivacyInfo?) -> NSAttributedString? { + expectation.fulfill() + return message + } + + func trackersType(privacyInfo: PrivacyInfo?) -> OnboardingTrackersType? { + return .blockedTrackers(entityNames: ["entuty1", "entity2"]) + } +} diff --git a/UnitTests/Onboarding/ContextualOnboarding/TrackerMessageProviderTests.swift b/UnitTests/Onboarding/ContextualOnboarding/TrackerMessageProviderTests.swift new file mode 100644 index 0000000000..9ce0676897 --- /dev/null +++ b/UnitTests/Onboarding/ContextualOnboarding/TrackerMessageProviderTests.swift @@ -0,0 +1,195 @@ +// +// TrackerMessageProviderTests.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 TrackerRadarKit +import PrivacyDashboard +import ContentBlocking +@testable import DuckDuckGo_Privacy_Browser + +final class TrackerMessageProviderTests: XCTestCase { + + var mockEntityProvider: MockEntityProviding! + var trackerMessageProvider: TrackerMessageProvider! + let googleEntity = Entity(displayName: "Google", domains: ["google.com"], prevalence: 0.9) + let facebookEntity = Entity(displayName: "Facebook", domains: ["facebook.com"], prevalence: 0.8) + let trackerEntity1 = Entity(displayName: "Tracker1", domains: ["tracker1.com"], prevalence: 0.8) + let trackerEntity2 = Entity(displayName: "Tracker2", domains: ["tracker2.com"], prevalence: 0.8) + let trackerEntity3 = Entity(displayName: "Tracker3", domains: ["tracker3.com"], prevalence: 0.8) + + override func setUp() { + super.setUp() + mockEntityProvider = MockEntityProviding(entities: [ + "google.com": googleEntity, + "facebook.com": facebookEntity, + "fbcdn.net": facebookEntity, + "ggl.net": googleEntity + ]) + trackerMessageProvider = TrackerMessageProvider(entityProviding: mockEntityProvider) + } + + func testTrackersType_WhenDomainIsGoogle_ReturnsMajorTracker() { + let expectedMessage: String = "Heads up! I can’t stop Google from seeing your activity on google.com.\n\nBut browse with me, and I can reduce what Google knows about you overall by blocking their trackers on lots of other sites." + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + let privacyInfo = PrivacyInfo(url: URL(string: "https://google.com")!, + parentEntity: googleEntity, + protectionStatus: protectionStatus) + + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo) + + XCTAssertEqual(trackerType, .majorTracker) + XCTAssertEqual(message?.string, expectedMessage) + } + + func testTrackersType_WhenDomainIsFacebook_ReturnsMajorTracker() { + let expectedMessage: String = "Heads up! I can’t stop Facebook from seeing your activity on facebook.com.\n\nBut browse with me, and I can reduce what Facebook knows about you overall by blocking their trackers on lots of other sites." + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + let privacyInfo = PrivacyInfo(url: URL(string: "https://facebook.com")!, + parentEntity: facebookEntity, + protectionStatus: protectionStatus) + + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo) + + XCTAssertEqual(trackerType, .majorTracker) + XCTAssertEqual(message?.string, expectedMessage) + } + + func testTrackersType_WhenDomainIsOwnedByFacebook_ReturnsOwnedByMajorTracker() { + let expectedMessage: String = "Heads up! Since Facebook owns fbcdn.net, I can’t stop them from seeing your activity here.\n\nBut browse with me, and I can reduce what Facebook knows about you overall by blocking their trackers on lots of other sites." + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + let privacyInfo = PrivacyInfo(url: URL(string: "https://fbcdn.net")!, + parentEntity: facebookEntity, + protectionStatus: protectionStatus) + + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo) + + XCTAssertEqual(trackerType, .ownedByMajorTracker(owner: facebookEntity)) + XCTAssertEqual(message?.string, expectedMessage) + } + + func testTrackersType_WhenDomainIsOwnedByGoogle_ReturnsOwnedByMajorTracker() { + let expectedMessage: String = "Heads up! Since Google owns ggl.net, I can’t stop them from seeing your activity here.\n\nBut browse with me, and I can reduce what Google knows about you overall by blocking their trackers on lots of other sites." + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + let privacyInfo = PrivacyInfo(url: URL(string: "https://ggl.net")!, + parentEntity: facebookEntity, + protectionStatus: protectionStatus) + + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo) + + XCTAssertEqual(trackerType, .ownedByMajorTracker(owner: googleEntity)) + XCTAssertEqual(message?.string, expectedMessage) + } + + func testTrackersType_WhenNoTrackers_ReturnsNoTrackers() { + let expectedMessage: String = "As you tap and scroll, I’ll block pesky trackers.\n\nGo ahead - keep browsing!" + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + let privacyInfo = PrivacyInfo(url: URL(string: "https://unknown.com")!, + parentEntity: nil, + protectionStatus: protectionStatus) + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo) + + XCTAssertEqual(trackerType, .noTrackers) + XCTAssertEqual(message?.string, expectedMessage) + } + + func testTrackerType_When1Tracker_ReturnsExpectedMessage() { + let expectedMessage: String = "Tracker1 was trying to track you here. I blocked them!\n\n☝️ Tap the shield for more info." + let serverTrust = MockSecurityTrust() + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + var privacyInfo = PrivacyInfo(url: URL(string: "https://site-with-tracker.com")!, + parentEntity: nil, + protectionStatus: protectionStatus) + let detectedTracker = DetectedRequest(url: "https://site-with-tracker.com", eTLDplus1: "https://site-with-tracker.com", knownTracker: nil, entity: trackerEntity1, state: .blocked, pageUrl: "https://site-with-tracker.com") + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URL(string: "https://site-with-tracker.com")!) + + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo) + + XCTAssertEqual(trackerType, .blockedTrackers(entityNames: ["Tracker1"])) + XCTAssertEqual(message?.string, expectedMessage) + } + + func testTrackerType_When2Trackers_ReturnsExpectedMessage() throws { + let expectedMessage: String = "were trying to track you here. I blocked them!" + let serverTrust = MockSecurityTrust() + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + var privacyInfo = PrivacyInfo(url: URL(string: "https://site-with-tracker.com")!, + parentEntity: nil, + protectionStatus: protectionStatus) + let detectedTracker = DetectedRequest(url: "https://site-with-tracker.com", eTLDplus1: "https://site-with-tracker.com", knownTracker: nil, entity: trackerEntity1, state: .blocked, pageUrl: "https://site-with-tracker.com") + let detectedTracker2 = DetectedRequest(url: "https://site-with-tracker2.com", eTLDplus1: "https://site-with-tracker2.com", knownTracker: nil, entity: trackerEntity2, state: .blocked, pageUrl: "https://site-with-tracker2.com") + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URL(string: "https://site-with-tracker.com")!) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker2, onPageWithURL: URL(string: "https://site-with-tracker2.com")!) + + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = try XCTUnwrap(trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo)) + let expectedEntityNames = Set(["Tracker1", "Tracker2"]) + + if case .blockedTrackers(let entityNames) = trackerType { + XCTAssertEqual(Set(entityNames), expectedEntityNames) + } else { + XCTFail("Expected .blockedTrackers, but got \(String(describing: trackerType))") + } + XCTAssertTrue(message.string.contains(expectedMessage)) + } + + func testTrackerType_When3Trackers_ReturnsExpectedMessage() throws { + let expectedMessage: String = "were trying to track you here. I blocked them!" + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + let privacyInfo = PrivacyInfo(url: URL(string: "https://site-with-tracker.com")!, + parentEntity: nil, + protectionStatus: protectionStatus) + let detectedTracker = DetectedRequest(url: "https://site-with-tracker.com", eTLDplus1: "https://site-with-tracker.com", knownTracker: nil, entity: trackerEntity1, state: .blocked, pageUrl: "https://site-with-tracker.com") + let detectedTracker2 = DetectedRequest(url: "https://site-with-tracker2.com", eTLDplus1: "https://site-with-tracker2.com", knownTracker: nil, entity: trackerEntity2, state: .blocked, pageUrl: "https://site-with-tracker2.com") + let detectedTracker3 = DetectedRequest(url: "https://site-with-tracker3.com", eTLDplus1: "https://site-with-tracker3.com", knownTracker: nil, entity: trackerEntity3, state: .blocked, pageUrl: "https://site-with-tracker3.com") + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URL(string: "https://site-with-tracker.com")!) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker2, onPageWithURL: URL(string: "https://site-with-tracker2.com")!) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker3, onPageWithURL: URL(string: "https://site-with-tracker3.com")!) + + let trackerType = trackerMessageProvider.trackersType(privacyInfo: privacyInfo) + let message = try XCTUnwrap(trackerMessageProvider.trackerMessage(privacyInfo: privacyInfo)) + let expectedEntityNames = Set(["Tracker1", "Tracker2", "Tracker3"]) + + if case .blockedTrackers(let entityNames) = trackerType { + XCTAssertEqual(Set(entityNames), expectedEntityNames) + } else { + XCTFail("Expected .blockedTrackers, but got \(String(describing: trackerType))") + } + XCTAssertTrue(message.string.contains(expectedMessage)) + } + +} + +class MockEntityProviding: EntityProviding { + private var entities: [String: Entity] + + init(entities: [String: Entity]) { + self.entities = entities + } + + func entity(forHost host: String) -> Entity? { + return entities[host] + } +} + +class MockSecurityTrust: SecurityTrust {} diff --git a/UnitTests/Onboarding/Mocks/MockContentBlocking.swift b/UnitTests/Onboarding/Mocks/MockContentBlocking.swift index b0f336185f..9b331408e5 100644 --- a/UnitTests/Onboarding/Mocks/MockContentBlocking.swift +++ b/UnitTests/Onboarding/Mocks/MockContentBlocking.swift @@ -20,6 +20,7 @@ import Foundation import BrowserServicesKit import Combine import Common +import TrackerRadarKit @testable import DuckDuckGo_Privacy_Browser class MockContentBlocking: ContentBlockingProtocol { @@ -55,6 +56,10 @@ class MockPrivacyConfigurationManaging: PrivacyConfigurationManaging { } class MockContentBlockerRulesManagerProtocol: ContentBlockerRulesManagerProtocol { + func entity(forHost host: String) -> Entity? { + return nil + } + var updatesPublisher: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() var currentRules: [BrowserServicesKit.ContentBlockerRulesManager.Rules] = []