diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 2e5f2e9bfe..1948abd191 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1941,6 +1941,9 @@ 4B9292D72667124000AD2C21 /* NSPopUpButtonExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292D62667124000AD2C21 /* NSPopUpButtonExtension.swift */; }; 4B9292D92667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292D82667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift */; }; 4B9292DB2667125D00AD2C21 /* ContextualMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292DA2667125D00AD2C21 /* ContextualMenu.swift */; }; + 4B9579212AC687170062CA31 /* HardwareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9579202AC687170062CA31 /* HardwareModel.swift */; }; + 4B9579222AC687170062CA31 /* HardwareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9579202AC687170062CA31 /* HardwareModel.swift */; }; + 4B9579232AC687170062CA31 /* HardwareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9579202AC687170062CA31 /* HardwareModel.swift */; }; 4B9754EC2984300100D7B834 /* EmailManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3B848F297A0E1000A384BD /* EmailManagerExtension.swift */; }; 4B980E212817604000282EE1 /* NSNotificationName+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B980E202817604000282EE1 /* NSNotificationName+Debug.swift */; }; 4B98D27A28D95F1A003C2B6F /* ChromiumFaviconsReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B98D27928D95F1A003C2B6F /* ChromiumFaviconsReaderTests.swift */; }; @@ -2042,8 +2045,25 @@ 4BBF09232830812900EE1418 /* FileSystemDSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBF09222830812900EE1418 /* FileSystemDSL.swift */; }; 4BBF0925283083EC00EE1418 /* FileSystemDSLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBF0924283083EC00EE1418 /* FileSystemDSLTests.swift */; }; 4BC2621D293996410087A482 /* PixelEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC2621C293996410087A482 /* PixelEventTests.swift */; }; + 4BCF15D72ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D62ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift */; }; + 4BCF15D92ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D82ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift */; }; + 4BCF15DB2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15DA2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift */; }; + 4BCF15DD2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15DC2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift */; }; + 4BCF15DE2ABB970D0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D82ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift */; }; + 4BCF15DF2ABB970F0083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D62ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift */; }; + 4BCF15E02ABB97110083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15DA2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift */; }; + 4BCF15E12ABB97130083F6DF /* NetworkProtectionRemoteMessagingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15DC2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift */; }; + 4BCF15EC2ABB9AF80083F6DF /* NetworkProtectionRemoteMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15E42ABB98990083F6DF /* NetworkProtectionRemoteMessageTests.swift */; }; + 4BCF15ED2ABB9B180083F6DF /* network-protection-messages.json in Resources */ = {isa = PBXBuildFile; fileRef = 4BCF15E92ABB99470083F6DF /* network-protection-messages.json */; }; + 4BCF15EE2ABBDBFD0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D82ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift */; }; + 4BCF15EF2ABBDBFF0083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15D62ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift */; }; + 4BCF15F02ABBDC010083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15DA2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift */; }; + 4BCF15F12ABBDC030083F6DF /* NetworkProtectionRemoteMessagingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCF15DC2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift */; }; 4BD18F01283F0BC500058124 /* BookmarksBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */; }; 4BD18F05283F151F00058124 /* BookmarksBar.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */; }; + 4BD57C012AC0FF7500B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */; }; + 4BD57C042AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */; }; + 4BD57C052AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */; }; 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; 4BE0DF06267819A1006337B7 /* NSStoryboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE0DF0426781961006337B7 /* NSStoryboardExtension.swift */; }; 4BE4005327CF3DC3007D3161 /* SavePaymentMethodPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE4005227CF3DC3007D3161 /* SavePaymentMethodPopover.swift */; }; @@ -3345,6 +3365,7 @@ 4B9292D62667124000AD2C21 /* NSPopUpButtonExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSPopUpButtonExtension.swift; sourceTree = ""; }; 4B9292D82667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkListTreeControllerDataSource.swift; sourceTree = ""; }; 4B9292DA2667125D00AD2C21 /* ContextualMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextualMenu.swift; sourceTree = ""; }; + 4B9579202AC687170062CA31 /* HardwareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareModel.swift; sourceTree = ""; }; 4B980E202817604000282EE1 /* NSNotificationName+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSNotificationName+Debug.swift"; sourceTree = ""; }; 4B98D27928D95F1A003C2B6F /* ChromiumFaviconsReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumFaviconsReaderTests.swift; sourceTree = ""; }; 4B98D27B28D960DD003C2B6F /* FirefoxFaviconsReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirefoxFaviconsReaderTests.swift; sourceTree = ""; }; @@ -3411,8 +3432,16 @@ 4BBF09222830812900EE1418 /* FileSystemDSL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemDSL.swift; sourceTree = ""; }; 4BBF0924283083EC00EE1418 /* FileSystemDSLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemDSLTests.swift; sourceTree = ""; }; 4BC2621C293996410087A482 /* PixelEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelEventTests.swift; sourceTree = ""; }; + 4BCF15D62ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessaging.swift; sourceTree = ""; }; + 4BCF15D82ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessage.swift; sourceTree = ""; }; + 4BCF15DA2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessagingStorage.swift; sourceTree = ""; }; + 4BCF15DC2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessagingRequest.swift; sourceTree = ""; }; + 4BCF15E42ABB98990083F6DF /* NetworkProtectionRemoteMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessageTests.swift; sourceTree = ""; }; + 4BCF15E92ABB99470083F6DF /* network-protection-messages.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "network-protection-messages.json"; sourceTree = ""; }; 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarksBarViewController.swift; sourceTree = ""; }; 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = BookmarksBar.storyboard; sourceTree = ""; }; + 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessagingStorageTests.swift; sourceTree = ""; }; + 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessagingTests.swift; sourceTree = ""; }; 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableScrollView.swift; sourceTree = ""; }; 4BE0DF0426781961006337B7 /* NSStoryboardExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSStoryboardExtension.swift; sourceTree = ""; }; 4BE15DB12A0B0DD500898243 /* PixelKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PixelKit; sourceTree = ""; }; @@ -4916,6 +4945,7 @@ 4B4D60612A0B29FA00BCD287 /* DeveloperIDTarget */ = { isa = PBXGroup; children = ( + 4BCF15D52ABB83D70083F6DF /* NetworkProtectionRemoteMessaging */, 4B4D60622A0B29FA00BCD287 /* SystemExtensionManager.swift */, ); path = DeveloperIDTarget; @@ -5390,6 +5420,7 @@ 4BB88B4F25B7BA2B006F6B06 /* TabInstrumentation.swift */, 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */, 4B7A94B329C16294000C7D4C /* ErrorWithParameters.swift */, + 4B9579202AC687170062CA31 /* HardwareModel.swift */, ); path = Utilities; sourceTree = ""; @@ -5472,6 +5503,36 @@ path = View; sourceTree = ""; }; + 4BCF15D52ABB83D70083F6DF /* NetworkProtectionRemoteMessaging */ = { + isa = PBXGroup; + children = ( + 4BCF15D82ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift */, + 4BCF15D62ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift */, + 4BCF15DA2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift */, + 4BCF15DC2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift */, + ); + path = NetworkProtectionRemoteMessaging; + sourceTree = ""; + }; + 4BCF15E32ABB987F0083F6DF /* NetworkProtection */ = { + isa = PBXGroup; + children = ( + 4BCF15E62ABB98A20083F6DF /* Resources */, + 4BCF15E42ABB98990083F6DF /* NetworkProtectionRemoteMessageTests.swift */, + 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */, + 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */, + ); + path = NetworkProtection; + sourceTree = ""; + }; + 4BCF15E62ABB98A20083F6DF /* Resources */ = { + isa = PBXGroup; + children = ( + 4BCF15E92ABB99470083F6DF /* network-protection-messages.json */, + ); + path = Resources; + sourceTree = ""; + }; 4BD18F02283F0F1000058124 /* View */ = { isa = PBXGroup; children = ( @@ -6170,6 +6231,7 @@ EEF53E162950CEB6002D78F4 /* JSAlert */, 378205F9283C275E00D1D4AA /* Menus */, AA91F83627076ED100771A0D /* NavigationBar */, + 4BCF15E32ABB987F0083F6DF /* NetworkProtection */, 85F487B3276A8F1B003CE668 /* Onboarding */, 1D3B1AB7293405F5006F4388 /* PasswordManagers */, B6106BA126A7BE430013B453 /* Permissions */, @@ -8393,6 +8455,7 @@ 37A803DB27FD69D300052F4C /* DataImportResources in Resources */, B69B50522726CD8100758A2B /* atb.json in Resources */, 4B70C00127B0793D000386ED /* DuckDuckGo-ExampleCrash.ips in Resources */, + 4BCF15ED2ABB9B180083F6DF /* network-protection-messages.json in Resources */, B67C6C422654BF49006C872E /* DuckDuckGo-Symbol.jpg in Resources */, B69B50552726CD8100758A2B /* invalid.json in Resources */, ); @@ -8811,6 +8874,7 @@ 31929FD22A4C4CFF0084EA89 /* PasteboardWriting.swift in Sources */, 31929FD32A4C4CFF0084EA89 /* BookmarkOutlineViewCell.swift in Sources */, 31929FD42A4C4CFF0084EA89 /* UnprotectedDomains.xcdatamodeld in Sources */, + 4BCF15E02ABB97110083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, 31929FD52A4C4CFF0084EA89 /* TabInstrumentation.swift in Sources */, 31929FD62A4C4CFF0084EA89 /* BrowserImportViewController.swift in Sources */, 31929FD72A4C4CFF0084EA89 /* NSPopUpButtonExtension.swift in Sources */, @@ -8961,6 +9025,7 @@ 3192A0652A4C4CFF0084EA89 /* PasswordManagementIdentityModel.swift in Sources */, 3192A0662A4C4CFF0084EA89 /* UserDefaultsWrapper.swift in Sources */, 3192A0672A4C4CFF0084EA89 /* PasswordManagementPopover.swift in Sources */, + 4BCF15DE2ABB970D0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */, 3192A0682A4C4CFF0084EA89 /* BWCommunicator.swift in Sources */, 3192A0692A4C4CFF0084EA89 /* HomePageRecentlyVisitedModel.swift in Sources */, 3192A06A2A4C4CFF0084EA89 /* NavigationBarPopovers.swift in Sources */, @@ -9175,6 +9240,7 @@ 3192A12D2A4C4CFF0084EA89 /* String+Punycode.swift in Sources */, 3192A12E2A4C4CFF0084EA89 /* NSException+Catch.m in Sources */, 4B4032862AAAC24400CCA602 /* WaitlistActivationDateStore.swift in Sources */, + 4BCF15DF2ABB970F0083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */, 3192A12F2A4C4CFF0084EA89 /* AppStateRestorationManager.swift in Sources */, 3192A1302A4C4CFF0084EA89 /* NavigationHotkeyHandler.swift in Sources */, 3192A1312A4C4CFF0084EA89 /* ClickToLoadUserScript.swift in Sources */, @@ -9300,6 +9366,7 @@ 3192A1A02A4C4CFF0084EA89 /* FeedbackWindow.swift in Sources */, 3192A1A12A4C4CFF0084EA89 /* WorkspaceProtocol.swift in Sources */, 3192A1A22A4C4CFF0084EA89 /* RecentlyVisitedView.swift in Sources */, + 4B9579232AC687170062CA31 /* HardwareModel.swift in Sources */, 3192A1A32A4C4CFF0084EA89 /* MouseOverAnimationButton.swift in Sources */, 3192A1A42A4C4CFF0084EA89 /* TabBarScrollView.swift in Sources */, 3192A1A52A4C4CFF0084EA89 /* BookmarkListTreeControllerDataSource.swift in Sources */, @@ -9325,6 +9392,7 @@ 3192A1B62A4C4CFF0084EA89 /* NSAlertExtension.swift in Sources */, 3192A1B72A4C4CFF0084EA89 /* ThirdPartyBrowser.swift in Sources */, 3192A1B82A4C4CFF0084EA89 /* SearchNonexistentDomainNavigationResponder.swift in Sources */, + 4BCF15E12ABB97130083F6DF /* NetworkProtectionRemoteMessagingRequest.swift in Sources */, 3192A1B92A4C4CFF0084EA89 /* CircularProgressView.swift in Sources */, 3192A1BA2A4C4CFF0084EA89 /* SuggestionContainer.swift in Sources */, 3192A1BB2A4C4CFF0084EA89 /* FindInPageTabExtension.swift in Sources */, @@ -9437,6 +9505,7 @@ 3706FA98293F65D500E42796 /* SecureVaultSorting.swift in Sources */, 3706FA99293F65D500E42796 /* PreferencesSidebarModel.swift in Sources */, 3706FA9A293F65D500E42796 /* DuckPlayerURLExtension.swift in Sources */, + 4BCF15F12ABBDC030083F6DF /* NetworkProtectionRemoteMessagingRequest.swift in Sources */, 3706FA9D293F65D500E42796 /* PermissionState.swift in Sources */, 3707C724294B5D2900682A9F /* StringExtension.swift in Sources */, 3706FA9F293F65D500E42796 /* FeedbackPresenter.swift in Sources */, @@ -9557,6 +9626,7 @@ 3706FEB8293F6EFB00E42796 /* ConnectBitwardenView.swift in Sources */, 1DFAB51E2A8982A600A0F7F6 /* SetExtension.swift in Sources */, B60C6F8E29B200AB007BFAA8 /* SavePanelAccessoryView.swift in Sources */, + 4B9579222AC687170062CA31 /* HardwareModel.swift in Sources */, 3706FB03293F65D500E42796 /* PopUpWindow.swift in Sources */, CB24F70D29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift in Sources */, 3706FB05293F65D500E42796 /* Favicons.xcdatamodeld in Sources */, @@ -9776,6 +9846,7 @@ 3706FBB6293F65D500E42796 /* ChromePreferences.swift in Sources */, 3706FBB7293F65D500E42796 /* FirePopoverViewController.swift in Sources */, 3706FBB8293F65D500E42796 /* SavePaymentMethodPopover.swift in Sources */, + 4BCF15EF2ABBDBFF0083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */, 3706FBB9293F65D500E42796 /* FindInPageViewController.swift in Sources */, 3706FBBA293F65D500E42796 /* Cryptography.swift in Sources */, 3706FBBC293F65D500E42796 /* NSViewExtension.swift in Sources */, @@ -9819,6 +9890,7 @@ 3706FBDC293F65D500E42796 /* Permissions.xcdatamodeld in Sources */, 3706FBDD293F65D500E42796 /* PaddedImageButton.swift in Sources */, 37CEFCAA2A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, + 4BCF15F02ABBDC010083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, 3706FBDE293F65D500E42796 /* EncryptionKeyStoring.swift in Sources */, 4B4D60E32A0C883A00BCD287 /* Main.swift in Sources */, 37197EA12942441700394917 /* Tab+UIDelegate.swift in Sources */, @@ -10022,6 +10094,7 @@ 3706FC89293F65D500E42796 /* NSWindow+Toast.swift in Sources */, 3706FC8A293F65D500E42796 /* AutoconsentUserScript.swift in Sources */, 3706FC8B293F65D500E42796 /* BookmarksExporter.swift in Sources */, + 4BCF15EE2ABBDBFD0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */, 3706FC8C293F65D500E42796 /* FirefoxDataImporter.swift in Sources */, 3706FC8D293F65D500E42796 /* PreferencesGeneralView.swift in Sources */, 37197EA92942443D00394917 /* WebView.swift in Sources */, @@ -10245,6 +10318,7 @@ 3706FE71293F661700E42796 /* SavedStateMock.swift in Sources */, 3706FE72293F661700E42796 /* ClickToLoadTDSTests.swift in Sources */, 3706FE73293F661700E42796 /* PermissionManagerMock.swift in Sources */, + 4BD57C052AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift in Sources */, 3706FE74293F661700E42796 /* WebsiteDataStoreMock.swift in Sources */, 3706FE75293F661700E42796 /* WebsiteBreakageReportTests.swift in Sources */, 56D145EF29E6DAD900E3488A /* DataImportProviderTests.swift in Sources */, @@ -10647,6 +10721,7 @@ 3775913629AB9A1C00E26367 /* SyncManagementDialogViewController.swift in Sources */, B6C0BB6729AEFF8100AE8E3C /* BookmarkExtension.swift in Sources */, 4BE6547F271FCD4D008D1D63 /* PasswordManagementCreditCardModel.swift in Sources */, + 4BCF15DD2ABB8CFC0083F6DF /* NetworkProtectionRemoteMessagingRequest.swift in Sources */, 31B4AF532901A4F20013585E /* NSEventExtension.swift in Sources */, 85707F26276A335700DC0649 /* Onboarding.swift in Sources */, B68C92C1274E3EF4002AC6B0 /* PopUpWindow.swift in Sources */, @@ -10801,6 +10876,7 @@ B655369B268442EE00085A79 /* GeolocationProvider.swift in Sources */, B6C0B23C26E87D900031CB7F /* NSAlert+ActiveDownloadsTermination.swift in Sources */, AAECA42024EEA4AC00EFA63A /* IndexPathExtension.swift in Sources */, + 4B9579212AC687170062CA31 /* HardwareModel.swift in Sources */, 4BE65478271FCD41008D1D63 /* PasswordManagementNoteItemView.swift in Sources */, AA5C8F632591021700748EB7 /* NSApplicationExtension.swift in Sources */, AA9E9A5625A3AE8400D1959D /* NSWindowExtension.swift in Sources */, @@ -10940,6 +11016,7 @@ 85A0118225AF60E700FA6A0C /* FindInPageModel.swift in Sources */, 4B9292A226670D2A00AD2C21 /* PseudoFolder.swift in Sources */, AA7E919A2875B39300AB6B62 /* Visit.swift in Sources */, + 4BCF15D92ABB8A7F0083F6DF /* NetworkProtectionRemoteMessage.swift in Sources */, B6DA44022616B28300DD1EC2 /* PixelDataStore.swift in Sources */, 4B9DB0292A983B24000927DB /* WaitlistStorage.swift in Sources */, B6A9E45326142B070067D1B9 /* Pixel.swift in Sources */, @@ -10961,6 +11038,7 @@ 85707F31276A7DCA00DC0649 /* OnboardingViewModel.swift in Sources */, 85AC3B0525D6B1D800C7D2AA /* ScriptSourceProviding.swift in Sources */, 4BB99D0026FE191E001E4761 /* CoreDataBookmarkImporter.swift in Sources */, + 4BCF15D72ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */, AA3F895324C18AD500628DDE /* SuggestionViewModel.swift in Sources */, 4B9292A326670D2A00AD2C21 /* BookmarkManagedObject.swift in Sources */, 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */, @@ -11139,6 +11217,7 @@ AA72D5FE25FFF94E00C77619 /* NSMenuItemExtension.swift in Sources */, 4BA1A6C2258B0A1300F6F690 /* ContiguousBytesExtension.swift in Sources */, 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, + 4BCF15DB2ABB8CED0083F6DF /* NetworkProtectionRemoteMessagingStorage.swift in Sources */, 987799F62999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, 4BE53374286E39F10019DBFD /* ChromiumKeychainPrompt.swift in Sources */, B6553692268440D700085A79 /* WKProcessPool+GeolocationProvider.swift in Sources */, @@ -11154,6 +11233,7 @@ 9833913327AAAEEE00DAF119 /* EmbeddedTrackerDataTests.swift in Sources */, 3776583127F8325B009A6B35 /* AutofillPreferencesTests.swift in Sources */, B67C6C472654C643006C872E /* FileManagerExtensionTests.swift in Sources */, + 4BD57C042AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift in Sources */, B69B50482726C5C200758A2B /* StatisticsLoaderTests.swift in Sources */, 142879DA24CE1179005419BB /* SuggestionViewModelTests.swift in Sources */, 4B9292BC2667103100AD2C21 /* BookmarkSidebarTreeControllerTests.swift in Sources */, @@ -11227,6 +11307,7 @@ AA91F83927076F1900771A0D /* PrivacyIconViewModelTests.swift in Sources */, 4B723E0726B0003E00E14D75 /* CSVImporterTests.swift in Sources */, AA652CDB25DDAB32009059CC /* BookmarkStoreMock.swift in Sources */, + 4BCF15EC2ABB9AF80083F6DF /* NetworkProtectionRemoteMessageTests.swift in Sources */, B62EB47C25BAD3BB005745C6 /* WKWebViewPrivateMethodsAvailabilityTests.swift in Sources */, 4BBC16A527C488C900E00A38 /* DeviceAuthenticatorTests.swift in Sources */, 4B3F641E27A8D3BD00E0C118 /* BrowserProfileTests.swift in Sources */, @@ -11243,6 +11324,7 @@ B60C6F8129B1B4AD007BFAA8 /* TestRunHelper.swift in Sources */, 4B9DB0582A983B55000927DB /* MockNetworkProtectionCodeRedeemer.swift in Sources */, 4B9292BE2667103100AD2C21 /* PasteboardFolderTests.swift in Sources */, + 4BD57C012AC0FF7500B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */, 4B9292C52667104B00AD2C21 /* CoreDataTestUtilities.swift in Sources */, 4B723E1926B000DC00E14D75 /* TemporaryFileCreator.swift in Sources */, 98EB5D1027516A4800681FE6 /* AppPrivacyConfigurationTests.swift in Sources */, diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index 1c00f2cec2..9886421058 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -100,10 +100,6 @@ extension UserText { static let networkProtectionWaitlistButtonJoinWaitlist = NSLocalizedString("network-protection.waitlist.button.join-waitlist", value: "Join the Waitlist", comment: "Join Waitlist button for Network Protection join waitlist screen") static let networkProtectionWaitlistButtonAgreeAndContinue = NSLocalizedString("network-protection.waitlist.button.agree-and-continue", value: "Agree and Continue", comment: "Agree and Continue button for Network Protection join waitlist screen") - static let networkProtectionBetaEndedCardTitle = NSLocalizedString("network-protection.waitlist.beta-ended-card.title", value: "VPN Beta Closed", comment: "Title for the Network Protection beta ended card") - static let networkProtectionBetaEndedCardText = NSLocalizedString("network-protection.waitlist.beta-ended-card.text", value: "Thank you for participating! We look forward to sharing more with you in future product announcements.", comment: "Text for the Network Protection beta ended card") - static let networkProtectionBetaEndedCardAction = NSLocalizedString("network-protection.waitlist.beta-ended-card.action", value: "Dismiss", comment: "Action text for the Network Protection beta ended card") - } // MARK: - Network Protection Terms of Service diff --git a/DuckDuckGo/Common/Utilities/HardwareModel.swift b/DuckDuckGo/Common/Utilities/HardwareModel.swift new file mode 100644 index 0000000000..5b0ab5a74b --- /dev/null +++ b/DuckDuckGo/Common/Utilities/HardwareModel.swift @@ -0,0 +1,44 @@ +// +// HardwareModel.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import IOKit + +struct HardwareModel { + + static var model: String? { + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + var modelIdentifier: String? + + if let modelData = IORegistryEntryCreateCFProperty( + service, + "model" as CFString, + kCFAllocatorDefault, + 0 + ).takeRetainedValue() as? Data { + if let modelIdentifierCString = String(data: modelData, encoding: .utf8)?.cString(using: .utf8) { + modelIdentifier = String(cString: modelIdentifierCString) + } + } + + IOObjectRelease(service) + + return modelIdentifier + } + +} diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index e6bfa26522..bcbeba7092 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -114,7 +114,6 @@ public struct UserDefaultsWrapper { case homePageIsContinueSetupVisible = "home.page.is.continue.setup.visible" case homePageIsRecentActivityVisible = "home.page.is.recent.activity.visible" case homePageIsFirstSession = "home.page.is.first.session" - case homePageShowNetworkProtectionBetaEndedNotice = "home.page.network-protection.show-beta-ended-notice" case appIsRelaunchingAutomatically = "app-relaunching-automatically" @@ -176,6 +175,7 @@ public struct UserDefaultsWrapper { enum RemovedKeys: String, CaseIterable { case passwordManagerDoNotPromptDomains = "com.duckduckgo.passwordmanager.do-not-prompt-domains" case incrementalFeatureFlagTestHasSentPixel = "network-protection.incremental-feature-flag-test.has-sent-pixel" + case homePageShowNetworkProtectionBetaEndedNotice = "home.page.network-protection.show-beta-ended-notice" } private let key: Key diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index ee56134a65..d61b725f1a 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -34,6 +34,7 @@ extension HomePage.Models { let itemsRowCountWhenCollapsed = HomePage.featureRowCountWhenCollapsed let gridWidth = FeaturesGridDimensions.width let deleteActionTitle = UserText.newTabSetUpRemoveItemAction + let networkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging let privacyConfigurationManager: PrivacyConfigurationManaging var isDay0SurveyEnabled: Bool { @@ -103,9 +104,6 @@ extension HomePage.Models { @UserDefaultsWrapper(key: .homePageShowSurveyDay7, defaultValue: true) private var shouldShowSurveyDay7: Bool - @UserDefaultsWrapper(key: .homePageShowNetworkProtectionBetaEndedNotice, defaultValue: true) - private var shouldShowNetworkProtectionBetaEndedNotice: Bool - @UserDefaultsWrapper(key: .homePageIsFirstSession, defaultValue: true) private var isFirstSession: Bool @@ -139,6 +137,7 @@ extension HomePage.Models { privacyPreferences: PrivacySecurityPreferences = PrivacySecurityPreferences.shared, cookieConsentPopoverManager: CookieConsentPopoverManager = CookieConsentPopoverManager(), duckPlayerPreferences: DuckPlayerPreferencesPersistor, + networkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging, privacyConfigurationManager: PrivacyConfigurationManaging = AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager) { self.defaultBrowserProvider = defaultBrowserProvider self.dataImportProvider = dataImportProvider @@ -147,6 +146,7 @@ extension HomePage.Models { self.privacyPreferences = privacyPreferences self.cookieConsentPopoverManager = cookieConsentPopoverManager self.duckPlayerPreferences = duckPlayerPreferences + self.networkProtectionRemoteMessaging = networkProtectionRemoteMessaging self.privacyConfigurationManager = privacyConfigurationManager refreshFeaturesMatrix() NotificationCenter.default.addObserver(self, selector: #selector(newTabOpenNotification(_:)), name: HomePage.Models.newHomePageTabOpen, object: nil) @@ -188,8 +188,8 @@ extension HomePage.Models { visitSurvey(day: .day0) case .surveyDay7: visitSurvey(day: .day7) - case .networkProtectionBetaEndedNotice: - removeItem(for: .networkProtectionBetaEndedNotice) + case .networkProtectionRemoteMessage(let message): + handle(remoteMessage: message) } } // swiftlint:enable cyclomatic_complexity @@ -210,8 +210,9 @@ extension HomePage.Models { shouldShowSurveyDay0 = false case .surveyDay7: shouldShowSurveyDay7 = false - case .networkProtectionBetaEndedNotice: - shouldShowNetworkProtectionBetaEndedNotice = false + case .networkProtectionRemoteMessage(let message): + networkProtectionRemoteMessaging.dismiss(message: message) + Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: message.id)) } refreshFeaturesMatrix() } @@ -220,8 +221,13 @@ extension HomePage.Models { func refreshFeaturesMatrix() { var features: [FeatureType] = [] - if shouldNetworkProtectionBetaEndedNoticeBeVisible { - features.append(.networkProtectionBetaEndedNotice) + for message in networkProtectionRemoteMessaging.presentableRemoteMessages() { + features.append(.networkProtectionRemoteMessage(message)) + DailyPixel.fire( + pixel: .networkProtectionRemoteMessageDisplayed(messageID: message.id), + frequency: .dailyOnly, + includeAppVersionParameter: true + ) } for feature in listOfFeatures { @@ -254,8 +260,8 @@ extension HomePage.Models { if shouldSurveyDay7BeVisible { features.append(feature) } - case .networkProtectionBetaEndedNotice: - break // Do nothing, as the NetP beta ended notice will always be added to the start of the list + case .networkProtectionRemoteMessage: + break // Do nothing, NetP remote messages get appended first } } featuresMatrix = features.chunked(into: itemsPerRow) @@ -353,53 +359,6 @@ extension HomePage.Models { firstLaunchDate <= oneWeekAgo } - /// The Network Protection beta ended card should only be displayed under the following conditions: - /// - /// 1. The user has gone through the waitlist AND used Network Protection at least once - /// 2. The `waitlistBetaActive` flag has been set to disabled - /// 3. The user has not already dismissed the card - private var shouldNetworkProtectionBetaEndedNoticeBeVisible: Bool { -#if NETWORK_PROTECTION - // 1. The user has signed up for the waitlist AND used Network Protection at least once: - - let waitlistStorage = NetworkProtectionWaitlist().waitlistStorage - let isWaitlistUser = waitlistStorage.isWaitlistUser && waitlistStorage.isInvited - - guard isWaitlistUser else { - return false - } - - let activationStore = WaitlistActivationDateStore() - guard activationStore.daysSinceActivation() != nil else { - return false - } - - // 2. The `waitlistBetaActive` flag has been set to disabled - - let featureOverrides = DefaultWaitlistBetaOverrides() - let waitlistFlagEnabled: Bool - - switch featureOverrides.waitlistActive { - case .useRemoteValue: - waitlistFlagEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(NetworkProtectionSubfeature.waitlistBetaActive) - case .on: - waitlistFlagEnabled = true - case .off: - waitlistFlagEnabled = false - } - - guard !waitlistFlagEnabled else { - return false - } - - // 3. The user has not already dismissed the card - - return shouldShowNetworkProtectionBetaEndedNotice -#else - return false -#endif - } - private enum SurveyDay { case day0 case day7 @@ -428,10 +387,32 @@ extension HomePage.Models { } } } + + @MainActor private func handle(remoteMessage: NetworkProtectionRemoteMessage) { + if let surveyURL = remoteMessage.presentableSurveyURL() { + let tab = Tab(content: .url(surveyURL), shouldLoadInBackground: true) + tabCollectionViewModel.append(tab: tab) + Pixel.fire(.networkProtectionRemoteMessageOpened(messageID: remoteMessage.id)) + } else { + Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: remoteMessage.id)) + } + + // Dismiss the message after the user opens the survey, even if they just close the tab immediately afterwards. + networkProtectionRemoteMessaging.dismiss(message: remoteMessage) + refreshFeaturesMatrix() + } } // MARK: Feature Type - enum FeatureType: CaseIterable { + enum FeatureType: CaseIterable, Equatable, Hashable { + + // CaseIterable doesn't work with enums that have associated values, so we have to implement it manually. + // We ignore the `networkProtectionRemoteMessage` case here to avoid it getting accidentally included - it has special handling and will get + // included elsewhere. + static var allCases: [HomePage.Models.FeatureType] { + [.duckplayer, .cookiePopUp, .emailProtection, .defaultBrowser, .importBookmarksAndPasswords, .surveyDay0, .surveyDay7] + } + case duckplayer case cookiePopUp case emailProtection @@ -439,7 +420,7 @@ extension HomePage.Models { case importBookmarksAndPasswords case surveyDay0 case surveyDay7 - case networkProtectionBetaEndedNotice + case networkProtectionRemoteMessage(NetworkProtectionRemoteMessage) var title: String { switch self { @@ -457,8 +438,8 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0CardTitle case .surveyDay7: return UserText.newTabSetUpSurveyDay7CardTitle - case .networkProtectionBetaEndedNotice: - return UserText.networkProtectionBetaEndedCardTitle + case .networkProtectionRemoteMessage(let message): + return message.cardTitle } } @@ -478,8 +459,8 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0Summary case .surveyDay7: return UserText.newTabSetUpSurveyDay7Summary - case .networkProtectionBetaEndedNotice: - return UserText.networkProtectionBetaEndedCardText + case .networkProtectionRemoteMessage(let message): + return message.cardDescription } } @@ -499,8 +480,8 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0Action case .surveyDay7: return UserText.newTabSetUpSurveyDay7Action - case .networkProtectionBetaEndedNotice: - return UserText.networkProtectionBetaEndedCardAction + case .networkProtectionRemoteMessage(let message): + return message.cardAction } } @@ -522,7 +503,7 @@ extension HomePage.Models { return NSImage(named: "Survey-128")!.resized(to: iconSize)! case .surveyDay7: return NSImage(named: "Survey-128")!.resized(to: iconSize)! - case .networkProtectionBetaEndedNotice: + case .networkProtectionRemoteMessage: return NSImage(named: "VPN-Ended")!.resized(to: iconSize)! } } diff --git a/DuckDuckGo/HomePage/View/HomePageViewController.swift b/DuckDuckGo/HomePage/View/HomePageViewController.swift index 7e3f43416d..9c0de27cce 100644 --- a/DuckDuckGo/HomePage/View/HomePageViewController.swift +++ b/DuckDuckGo/HomePage/View/HomePageViewController.swift @@ -144,7 +144,7 @@ final class HomePageViewController: NSViewController { } func createFeatureModel() -> HomePage.Models.ContinueSetUpModel { - let vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: SystemDefaultBrowserProvider(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), tabCollectionViewModel: tabCollectionViewModel, duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor()) + let vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: SystemDefaultBrowserProvider(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), tabCollectionViewModel: tabCollectionViewModel, duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor(), networkProtectionRemoteMessaging: DefaultNetworkProtectionRemoteMessaging()) vm.delegate = self return vm } diff --git a/DuckDuckGo/Main/View/MainViewController.swift b/DuckDuckGo/Main/View/MainViewController.swift index 4e4e5014e5..ad796f6dd8 100644 --- a/DuckDuckGo/Main/View/MainViewController.swift +++ b/DuckDuckGo/Main/View/MainViewController.swift @@ -134,6 +134,7 @@ final class MainViewController: NSViewController { updateReloadMenuItem() updateStopMenuItem() browserTabViewController.windowDidBecomeKey() + refreshNetworkProtectionMessages() } func windowDidResignKey() { @@ -155,6 +156,12 @@ final class MainViewController: NSViewController { } } + private let networkProtectionMessaging = DefaultNetworkProtectionRemoteMessaging() + + func refreshNetworkProtectionMessages() { + networkProtectionMessaging.fetchRemoteMessages() + } + override func encodeRestorableState(with coder: NSCoder) { fatalError("Default AppKit State Restoration should not be used") } diff --git a/DuckDuckGo/Menus/MainMenu.storyboard b/DuckDuckGo/Menus/MainMenu.storyboard index abb5ebf502..b9a94b6495 100644 --- a/DuckDuckGo/Menus/MainMenu.storyboard +++ b/DuckDuckGo/Menus/MainMenu.storyboard @@ -804,6 +804,12 @@ CQ + + + + + + @@ -887,6 +893,12 @@ CQ + + + + + + @@ -1048,6 +1060,37 @@ CQ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1201,7 +1244,13 @@ CQ - + + + + + + + diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 5c873c6674..cec12c4a8f 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -627,6 +627,11 @@ extension MainViewController { UserDefaultsWrapper.clear(.grammarCheckEnabledOnce) } + @IBAction func openAppContainerInFinder(_ sender: Any?) { + let containerURL = URL.sandboxApplicationSupportURL + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: containerURL.path) + } + @IBAction func triggerFatalError(_ sender: Any?) { fatalError("Fatal error triggered from the Debug menu") } @@ -687,6 +692,10 @@ extension MainViewController { UserDefaults.standard.set(false, forKey: UserDefaultsWrapper.Key.homePageUserInteractedWithSurveyDay0.rawValue) } + @IBAction func resetDailyPixels(_ sender: Any?) { + UserDefaults.standard.removePersistentDomain(forName: DailyPixel.Constant.dailyPixelStorageIdentifier) + } + @IBAction func changeInstallDateToToday(_ sender: Any?) { UserDefaults.standard.set(Date(), forKey: UserDefaultsWrapper.Key.firstLaunchDate.rawValue) } @@ -775,6 +784,37 @@ extension MainViewController { #endif } + @IBAction func resetNetworkProtectionActivationDate(_ sender: Any?) { + overrideNetworkProtectionActivationDate(to: nil) + } + + @IBAction func resetNetworkProtectionRemoteMessages(_ sender: Any?) { + DefaultNetworkProtectionRemoteMessagingStorage().removeStoredAndDismissedMessages() + DefaultNetworkProtectionRemoteMessaging(minimumRefreshInterval: 0).resetLastRefreshTimestamp() + } + + @IBAction func overrideNetworkProtectionActivationDateToNow(_ sender: Any?) { + overrideNetworkProtectionActivationDate(to: Date()) + } + + @IBAction func overrideNetworkProtectionActivationDateTo5DaysAgo(_ sender: Any?) { + overrideNetworkProtectionActivationDate(to: Date.daysAgo(5)) + } + + @IBAction func overrideNetworkProtectionActivationDateTo10DaysAgo(_ sender: Any?) { + overrideNetworkProtectionActivationDate(to: Date.daysAgo(10)) + } + + private func overrideNetworkProtectionActivationDate(to date: Date?) { + let store = DefaultWaitlistActivationDateStore() + + if let date { + store.updateActivationDate(date) + } else { + store.removeDates() + } + } + // MARK: - Developer Tools @IBAction func toggleDeveloperTools(_ sender: Any?) { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift index 6a670dde58..abeb52d243 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift @@ -37,14 +37,12 @@ final class NetworkProtectionAppEvents { func applicationDidFinishLaunching() { migrateNetworkProtectionAuthTokenToSharedKeychainIfNecessary() - let loginItemsManager = LoginItemsManager() - _ = NetworkProtectionKeychainTokenStore() - guard featureVisibility.isNetworkProtectionVisible() else { featureVisibility.disableForAllUsers() return } + let loginItemsManager = LoginItemsManager() restartNetworkProtectionIfVersionChanged(using: loginItemsManager) refreshNetworkProtectionServers() } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift index 93ed108681..9b0d941b82 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift @@ -63,6 +63,9 @@ final class NetworkProtectionDebugUtilities { networkProtectionFeatureDisabler.disable(keepAuthToken: keepAuthToken, uninstallSystemExtension: true) NetworkProtectionWaitlist().waitlistStorage.deleteWaitlistState() + DefaultWaitlistActivationDateStore().removeDates() + DefaultNetworkProtectionRemoteMessagingStorage().removeStoredAndDismissedMessages() + UserDefaults().removeObject(forKey: UserDefaultsWrapper.Key.networkProtectionTermsAndConditionsAccepted.rawValue) NotificationCenter.default.post(name: .networkProtectionWaitlistAccessChanged, object: nil) } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift index 193ec40357..118c29cdaa 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift @@ -31,7 +31,7 @@ final class NetworkProtectionNavBarButtonModel: NSObject, ObservableObject { private let networkProtectionStatusReporter: NetworkProtectionStatusReporter private var status: NetworkProtection.ConnectionStatus = .disconnected private let popovers: NavigationBarPopovers - private let waitlistActivationDateStore: WaitlistActivationDateStore + private let waitlistActivationDateStore: DefaultWaitlistActivationDateStore // MARK: - Subscriptions @@ -93,7 +93,7 @@ final class NetworkProtectionNavBarButtonModel: NSObject, ObservableObject { isHavingConnectivityIssues = networkProtectionStatusReporter.connectivityIssuesObserver.recentValue buttonImage = .image(for: iconPublisher.icon) - self.waitlistActivationDateStore = WaitlistActivationDateStore() + self.waitlistActivationDateStore = DefaultWaitlistActivationDateStore() super.init() setupSubscriptions() @@ -137,7 +137,9 @@ final class NetworkProtectionNavBarButtonModel: NSObject, ObservableObject { } switch status { - case .connected: waitlistActivationDateStore.setActivationDateIfNecessary() + case .connected: + waitlistActivationDateStore.setActivationDateIfNecessary() + waitlistActivationDateStore.updateLastActiveDate() default: break } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift new file mode 100644 index 0000000000..8c97a6c897 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift @@ -0,0 +1,92 @@ +// +// NetworkProtectionRemoteMessage.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Common + +struct NetworkProtectionRemoteMessage: Codable, Equatable, Hashable { + + enum SurveyURLParameters: String, CaseIterable { + case atb = "atb" + case atbVariant = "var" + case daysSinceActivated = "delta" + case macosVersion = "mv" + case appVersion = "ddgv" + case hardwareModel = "mo" + case lastDayActive = "da" + } + + let id: String + let cardTitle: String + let cardDescription: String + let cardAction: String + let daysSinceNetworkProtectionEnabled: Int? + private let surveyURL: String? + + // swiftlint:disable:next cyclomatic_complexity + func presentableSurveyURL( + statisticsStore: StatisticsStore = LocalStatisticsStore(), + activationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore(), + operatingSystemVersion: String = ProcessInfo.processInfo.operatingSystemVersion.description, + appVersion: String = AppVersion.shared.versionNumber, + hardwareModel: String? = HardwareModel.model + ) -> URL? { + guard let surveyURL else { + return nil + } + + guard var components = URLComponents(string: surveyURL) else { + return URL(string: surveyURL) + } + + var queryItems = components.queryItems ?? [] + + for parameter in SurveyURLParameters.allCases { + switch parameter { + case .atb: + if let atb = statisticsStore.atb { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: atb)) + } + case .atbVariant: + if let variant = statisticsStore.variant { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: variant)) + } + case .daysSinceActivated: + if let daysSinceActivated = activationDateStore.daysSinceActivation() { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: daysSinceActivated))) + } + case .macosVersion: + queryItems.append(URLQueryItem(name: parameter.rawValue, value: operatingSystemVersion)) + case .appVersion: + queryItems.append(URLQueryItem(name: parameter.rawValue, value: appVersion)) + case .hardwareModel: + if let hardwareModel = hardwareModel?.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: hardwareModel)) + } + case .lastDayActive: + if let lastDayActive = activationDateStore.daysSinceLastActive() { + queryItems.append(URLQueryItem(name: parameter.rawValue, value: String(describing: lastDayActive))) + } + } + } + + components.queryItems = queryItems + + return components.url + } +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift new file mode 100644 index 0000000000..e2888f8b9a --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -0,0 +1,174 @@ +// +// NetworkProtectionRemoteMessaging.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Networking + +protocol NetworkProtectionRemoteMessaging { + + func fetchRemoteMessages(completion: (() -> Void)?) + func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] + func dismiss(message: NetworkProtectionRemoteMessage) + +} + +final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging { + + enum Constants { + static let lastRefreshDateKey = "network-protection.remote-messaging.last-refresh-date" + } + + private let messageRequest: NetworkProtectionRemoteMessagingRequest + private let messageStorage: NetworkProtectionRemoteMessagingStorage + private let waitlistStorage: WaitlistStorage + private let waitlistActivationDateStore: WaitlistActivationDateStore + private let minimumRefreshInterval: TimeInterval + private let userDefaults: UserDefaults + + convenience init() { + #if DEBUG || REVIEW + self.init(minimumRefreshInterval: .seconds(30)) + #else + self.init(minimumRefreshInterval: .hours(1)) + #endif + } + + init( + messageRequest: NetworkProtectionRemoteMessagingRequest = DefaultNetworkProtectionRemoteMessagingRequest(), + messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage(), + waitlistStorage: WaitlistStorage = WaitlistKeychainStore(waitlistIdentifier: "networkprotection"), + waitlistActivationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore(), + minimumRefreshInterval: TimeInterval, + userDefaults: UserDefaults = .standard + ) { + self.messageRequest = messageRequest + self.messageStorage = messageStorage + self.waitlistStorage = waitlistStorage + self.waitlistActivationDateStore = waitlistActivationDateStore + self.minimumRefreshInterval = minimumRefreshInterval + self.userDefaults = userDefaults + } + + func fetchRemoteMessages(completion fetchCompletion: (() -> Void)? = nil) { +#if NETWORK_PROTECTION + + // Don't fetch messages if the user hasn't used NetP or didn't sign up via the waitlist + guard waitlistStorage.isWaitlistUser, waitlistActivationDateStore.daysSinceActivation() != nil else { + fetchCompletion?() + return + } + + if let lastRefreshDate = lastRefreshDate(), lastRefreshDate.addingTimeInterval(minimumRefreshInterval) > Date() { + fetchCompletion?() + return + } + + self.messageRequest.fetchNetworkProtectionRemoteMessages { [weak self] result in + defer { + fetchCompletion?() + } + + guard let self else { return } + + switch result { + case .success(let messages): + do { + try self.messageStorage.store(messages: messages) + self.updateLastRefreshDate() // Update last refresh date on success, otherwise let the app try again next time + } catch { + Pixel.fire(.debug(event: .networkProtectionRemoteMessageStorageFailed, error: error)) + } + case .failure(let error): + // Ignore 403 errors, those happen when a file can't be found on S3 + if case APIRequest.Error.invalidStatusCode(403) = error { + self.updateLastRefreshDate() // Avoid refreshing constantly when the file isn't available + return + } + + Pixel.fire(.debug(event: .networkProtectionRemoteMessageFetchingFailed, error: error)) + } + } + +#endif + } + + /// Uses the "days since Network Protection activated" count combined with the set of dismissed messages to determine which messages should be displayed to the user. + func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] { +#if NETWORK_PROTECTION + guard let daysSinceActivation = waitlistActivationDateStore.daysSinceActivation() else { + return [] + } + + let dismissedMessageIDs = messageStorage.dismissedMessageIDs() + let possibleMessages = messageStorage.storedMessages() + + // Only show messages that haven't been dismissed, and check whether they have a requirement on how long the user + // has used Network Protection for. + let filteredMessages = possibleMessages.filter { message in + if dismissedMessageIDs.contains(message.id) { + return false + } + + if let requiredDaysSinceActivation = message.daysSinceNetworkProtectionEnabled { + if requiredDaysSinceActivation <= daysSinceActivation { + return true + } else { + return false + } + } else { + return true + } + } + + return filteredMessages +#else + return [] +#endif + } + + func dismiss(message: NetworkProtectionRemoteMessage) { +#if NETWORK_PROTECTION + messageStorage.dismissRemoteMessage(with: message.id) +#endif + } + + func resetLastRefreshTimestamp() { + userDefaults.removeObject(forKey: Constants.lastRefreshDateKey) + } + + // MARK: - Private + + private func lastRefreshDate() -> Date? { + guard let object = userDefaults.object(forKey: Constants.lastRefreshDateKey) else { + return nil + } + + guard let date = object as? Date else { + assertionFailure("Got rate limited date, but couldn't convert it to Date") + userDefaults.removeObject(forKey: Constants.lastRefreshDateKey) + return nil + } + + return date + } + + private func updateLastRefreshDate() { + userDefaults.setValue(Date(), forKey: Constants.lastRefreshDateKey) + } + +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift new file mode 100644 index 0000000000..b6406402a7 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift @@ -0,0 +1,79 @@ +// +// NetworkProtectionRemoteMessagingRequest.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Networking + +protocol NetworkProtectionRemoteMessagingRequest { + + func fetchNetworkProtectionRemoteMessages(completion: @escaping (Result<[NetworkProtectionRemoteMessage], Error>) -> Void) + +} + +final class DefaultNetworkProtectionRemoteMessagingRequest: NetworkProtectionRemoteMessagingRequest { + + enum Endpoint { + case debug + case production + + var url: URL { + switch self { + case .debug: return URL(string: "https://staticcdn.duckduckgo.com/macos-desktop-browser/network-protection/messages-debug.json")! + case .production: return URL(string: "https://staticcdn.duckduckgo.com/macos-desktop-browser/network-protection/messages.json")! + } + } + } + + enum NetworkProtectionRemoteMessagingRequestError: Error { + case failedToDecodeMessages + case requestCompletedWithoutErrorOrResponse + } + + private let endpointURL: URL + + init() { +#if DEBUG || REVIEW + endpointURL = Endpoint.debug.url +#else + endpointURL = Endpoint.production.url +#endif + } + + func fetchNetworkProtectionRemoteMessages(completion: @escaping (Result<[NetworkProtectionRemoteMessage], Error>) -> Void) { + let httpMethod = APIRequest.HTTPMethod.get + let configuration = APIRequest.Configuration(url: endpointURL, method: httpMethod, body: nil) + let request = APIRequest(configuration: configuration) + + request.fetch { response, error in + if let error { + completion(Result.failure(error)) + } else if let responseData = response?.data { + do { + let decoder = JSONDecoder() + let decoded = try decoder.decode([NetworkProtectionRemoteMessage].self, from: responseData) + completion(Result.success(decoded)) + } catch { + completion(.failure(NetworkProtectionRemoteMessagingRequestError.failedToDecodeMessages)) + } + } else { + completion(.failure(NetworkProtectionRemoteMessagingRequestError.requestCompletedWithoutErrorOrResponse)) + } + } + } + +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift new file mode 100644 index 0000000000..138d0572ac --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift @@ -0,0 +1,91 @@ +// +// NetworkProtectionRemoteMessagingStorage.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol NetworkProtectionRemoteMessagingStorage { + + func store(messages: [NetworkProtectionRemoteMessage]) throws + func storedMessages() -> [NetworkProtectionRemoteMessage] + + func dismissRemoteMessage(with id: String) + func dismissedMessageIDs() -> [String] + +} + +final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRemoteMessagingStorage { + + private enum Constants { + static let dismissedMessageIdentifiersKey = "home.page.network-protection.dismissed-message-identifiers" + static let networkProtectionMessagesFileName = "network-protection-messages.json" + } + + private let userDefaults: UserDefaults + private let messagesURL: URL + private static var applicationSupportURL: URL { + URL.sandboxApplicationSupportURL.appendingPathComponent(Constants.networkProtectionMessagesFileName) + } + + init( + userDefaults: UserDefaults = .standard, + messagesURL: URL = DefaultNetworkProtectionRemoteMessagingStorage.applicationSupportURL + ) { + self.userDefaults = userDefaults + self.messagesURL = messagesURL + } + + func store(messages: [NetworkProtectionRemoteMessage]) throws { + let encoded = try JSONEncoder().encode(messages) + try encoded.write(to: messagesURL) + } + + func storedMessages() -> [NetworkProtectionRemoteMessage] { + do { + let messagesData = try Data(contentsOf: messagesURL) + let messages = try JSONDecoder().decode([NetworkProtectionRemoteMessage].self, from: messagesData) + + return messages + } catch { + // Errors can occur if the file doesn't exist, or it got stored in a bad state, in which case the app will fetch the file again later and + // overwrite it. + return [] + } + } + + func dismissRemoteMessage(with id: String) { + var dismissedMessages = dismissedMessageIDs() + + guard !dismissedMessages.contains(id) else { + return + } + + dismissedMessages.append(id) + userDefaults.set(dismissedMessages, forKey: Constants.dismissedMessageIdentifiersKey) + } + + func dismissedMessageIDs() -> [String] { + let messages = userDefaults.array(forKey: Constants.dismissedMessageIdentifiersKey) as? [String] + return messages ?? [] + } + + func removeStoredAndDismissedMessages() { + userDefaults.removeObject(forKey: Constants.dismissedMessageIdentifiersKey) + try? FileManager.default.removeItem(at: messagesURL) + } + +} diff --git a/DuckDuckGo/Statistics/DailyPixel.swift b/DuckDuckGo/Statistics/DailyPixel.swift index 3ffee5dc34..93fef5c501 100644 --- a/DuckDuckGo/Statistics/DailyPixel.swift +++ b/DuckDuckGo/Statistics/DailyPixel.swift @@ -35,7 +35,7 @@ final class DailyPixel { case alreadyFired } - private enum Constant { + enum Constant { static let dailyPixelStorageIdentifier = "com.duckduckgo.daily.pixel.storage" } diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index d327aadacc..a6eefcce09 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -157,6 +157,9 @@ extension Pixel { case networkProtectionWaitlistNotificationTapped case networkProtectionWaitlistTermsAndConditionsDisplayed case networkProtectionWaitlistTermsAndConditionsAccepted + case networkProtectionRemoteMessageDisplayed(messageID: String) + case networkProtectionRemoteMessageDismissed(messageID: String) + case networkProtectionRemoteMessageOpened(messageID: String) // 28-day Home Button case enableHomeButton @@ -328,6 +331,9 @@ extension Pixel { case burnerTabMisplaced + case networkProtectionRemoteMessageFetchingFailed + case networkProtectionRemoteMessageStorageFailed + #if DBP case dataBrokerProtectionError #endif @@ -470,6 +476,12 @@ extension Pixel.Event { return "m_mac_netp_imp_terms" case .networkProtectionWaitlistTermsAndConditionsAccepted: return "m_mac_netp_ev_terms_accepted" + case .networkProtectionRemoteMessageDisplayed(let messageID): + return "m_mac_netp_remote_message_displayed_\(messageID)" + case .networkProtectionRemoteMessageDismissed(let messageID): + return "m_mac_netp_remote_message_dismissed_\(messageID)" + case .networkProtectionRemoteMessageOpened(let messageID): + return "m_mac_netp_remote_message_opened_\(messageID)" // 28-day Home Button case .enableHomeButton: @@ -752,6 +764,10 @@ extension Pixel.Event.Debug { case .invalidPayload(let configuration): return "m_d_\(configuration.rawValue)_invalid_payload".lowercased() case .burnerTabMisplaced: return "burner_tab_misplaced" + + case .networkProtectionRemoteMessageFetchingFailed: return "netp_remote_message_fetching_failed" + case .networkProtectionRemoteMessageStorageFailed: return "netp_remote_message_storage_failed" + #if DBP case .dataBrokerProtectionError: return "data_broker_error" #endif diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 8eb46dbee3..6c83a899b7 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -149,6 +149,9 @@ extension Pixel.Event { .networkProtectionWaitlistNotificationTapped, .networkProtectionWaitlistTermsAndConditionsDisplayed, .networkProtectionWaitlistTermsAndConditionsAccepted, + .networkProtectionRemoteMessageDisplayed, + .networkProtectionRemoteMessageDismissed, + .networkProtectionRemoteMessageOpened, .enableHomeButton, .disableHomeButton, .setnewHomePage: diff --git a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift index 7cefd7bd51..c150581ebb 100644 --- a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift +++ b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift @@ -16,14 +16,20 @@ // limitations under the License. // -#if NETWORK_PROTECTION - import Foundation -struct WaitlistActivationDateStore { +protocol WaitlistActivationDateStore { + + func daysSinceActivation() -> Int? + func daysSinceLastActive() -> Int? + +} + +struct DefaultWaitlistActivationDateStore: WaitlistActivationDateStore { private enum Constants { static let networkProtectionActivationDateKey = "com.duckduckgo.network-protection.activation-date" + static let networkProtectionLastActiveDateKey = "com.duckduckgo.network-protection.last-active-date" } private let userDefaults: UserDefaults @@ -37,7 +43,7 @@ struct WaitlistActivationDateStore { return } - userDefaults.set(Date().timeIntervalSinceReferenceDate, forKey: Constants.networkProtectionActivationDateKey) + updateActivationDate(Date()) } func daysSinceActivation() -> Int? { @@ -48,12 +54,40 @@ struct WaitlistActivationDateStore { } let activationDate = Date(timeIntervalSinceReferenceDate: timestamp) - let currentDate = Date() + return daysSince(date: activationDate) + } - let numberOfDays = Calendar.current.dateComponents([.day], from: activationDate, to: currentDate) + func updateLastActiveDate() { + userDefaults.set(Date(), forKey: Constants.networkProtectionLastActiveDateKey) + } + + func daysSinceLastActive() -> Int? { + let timestamp = userDefaults.double(forKey: Constants.networkProtectionLastActiveDateKey) + + if timestamp == 0 { + return nil + } + + let activationDate = Date(timeIntervalSinceReferenceDate: timestamp) + return daysSince(date: activationDate) + } + + // MARK: - Resetting + + func removeDates() { + userDefaults.removeObject(forKey: Constants.networkProtectionActivationDateKey) + userDefaults.removeObject(forKey: Constants.networkProtectionLastActiveDateKey) + } + + // MARK: - Updating + + func updateActivationDate(_ date: Date) { + userDefaults.set(date.timeIntervalSinceReferenceDate, forKey: Constants.networkProtectionActivationDateKey) + } + + private func daysSince(date storedDate: Date) -> Int? { + let numberOfDays = Calendar.current.dateComponents([.day], from: storedDate, to: Date()) return numberOfDays.day } } - -#endif diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index c10e2bf265..ae69378877 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -59,7 +59,10 @@ let extraInputFiles: [TargetName: Set] = [ "Unit Tests": [ .init("BWEncryptionTests.swift", .source), - .init("WKWebViewPrivateMethodsAvailabilityTests.swift", .source) + .init("WKWebViewPrivateMethodsAvailabilityTests.swift", .source), + .init("NetworkProtectionRemoteMessageTests.swift", .source), + .init("NetworkProtectionRemoteMessagingStorageTests.swift", .source), + .init("network-protection-messages.json", .resource) ], "Integration Tests": [] diff --git a/UnitTests/HomePage/ContinueSetUpModelTests.swift b/UnitTests/HomePage/ContinueSetUpModelTests.swift index 06d22a03d7..690592f9bb 100644 --- a/UnitTests/HomePage/ContinueSetUpModelTests.swift +++ b/UnitTests/HomePage/ContinueSetUpModelTests.swift @@ -17,8 +17,24 @@ // import XCTest -@testable import DuckDuckGo_Privacy_Browser import BrowserServicesKit +@testable import DuckDuckGo_Privacy_Browser + +final class MockNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging { + + var messages: [NetworkProtectionRemoteMessage] = [] + + func fetchRemoteMessages(completion fetchCompletion: (() -> Void)? = nil) { + fetchCompletion?() + } + + func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] { + messages + } + + func dismiss(message: NetworkProtectionRemoteMessage) {} + +} final class ContinueSetUpModelTests: XCTestCase { @@ -60,6 +76,7 @@ final class ContinueSetUpModelTests: XCTestCase { emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences, + networkProtectionRemoteMessaging: MockNetworkProtectionRemoteMessaging(), privacyConfigurationManager: privacyConfigManager ) @@ -99,7 +116,7 @@ final class ContinueSetUpModelTests: XCTestCase { capturingDataImportProvider.didImport = true duckPlayerPreferences.youtubeOverlayAnyButtonPressed = true privacyPreferences.autoconsentEnabled = true - vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: capturingDefaultBrowserProvider, dataImportProvider: capturingDataImportProvider, tabCollectionViewModel: tabCollectionVM, emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences) + vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: capturingDefaultBrowserProvider, dataImportProvider: capturingDataImportProvider, tabCollectionViewModel: tabCollectionVM, emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences, networkProtectionRemoteMessaging: MockNetworkProtectionRemoteMessaging()) XCTAssertFalse(vm.isMoreOrLessButtonNeeded) } @@ -110,7 +127,7 @@ final class ContinueSetUpModelTests: XCTestCase { vm.shouldShowAllFeatures = true - expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .networkProtectionBetaEndedNotice]) + expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7]) XCTAssertEqual(vm.visibleFeaturesMatrix, expectedMatrix) } @@ -175,11 +192,11 @@ final class ContinueSetUpModelTests: XCTestCase { XCTAssertEqual(vm.visibleFeaturesMatrix[0][0], HomePage.Models.FeatureType.defaultBrowser) // All cases minus two since it will show only one of the surveys and no NetP card - XCTAssertEqual(vm.visibleFeaturesMatrix.reduce([], +).count, HomePage.Models.FeatureType.allCases.count - 2) + XCTAssertEqual(vm.visibleFeaturesMatrix.reduce([], +).count, HomePage.Models.FeatureType.allCases.count - 1) } func testWhenTogglingShowAllFeatureThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7]) vm.shouldShowAllFeatures = true @@ -207,7 +224,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenIsDefaultBrowserAndTogglingShowAllFeatureThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.defaultBrowser, .surveyDay7, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.defaultBrowser, .surveyDay7]) capturingDefaultBrowserProvider.isDefault = true vm = HomePage.Models.ContinueSetUpModel.fixture(defaultBrowserProvider: capturingDefaultBrowserProvider) @@ -223,7 +240,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenAskedToPerformActionForImportPromptThrowsThenItOpensImportWindow() { - let numberOfFeatures = HomePage.Models.FeatureType.allCases.count - 2 + let numberOfFeatures = HomePage.Models.FeatureType.allCases.count - 1 vm.shouldShowAllFeatures = true XCTAssertEqual(vm.visibleFeaturesMatrix.flatMap { $0 }.count, numberOfFeatures) @@ -235,7 +252,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenUserHasUsedImportAndTogglingShowAllFeatureThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .importBookmarksAndPasswords, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .importBookmarksAndPasswords]) capturingDataImportProvider.didImport = true vm = HomePage.Models.ContinueSetUpModel.fixture(dataImportProvider: capturingDataImportProvider) @@ -257,7 +274,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenUserHasEmailProtectionEnabledThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .emailProtection, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .emailProtection]) emailStorage.isEmailProtectionEnabled = true vm = HomePage.Models.ContinueSetUpModel.fixture(emailManager: emailManager) @@ -273,7 +290,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenAskedToPerformActionForCookieConsentThenShowsCookiePopUp() { - let numberOfFeatures = HomePage.Models.FeatureType.allCases.count - 2 + let numberOfFeatures = HomePage.Models.FeatureType.allCases.count - 1 vm.shouldShowAllFeatures = true XCTAssertEqual(vm.visibleFeaturesMatrix.flatMap { $0 }.count, numberOfFeatures) @@ -284,7 +301,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenUserHasCookieConsentEnabledThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .cookiePopUp, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .cookiePopUp]) privacyPreferences.autoconsentEnabled = true vm = HomePage.Models.ContinueSetUpModel.fixture(privacyPreferences: privacyPreferences) @@ -306,7 +323,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenUserHasDuckPlayerEnabledAndOverlayButtonNotPressedThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .duckplayer, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .duckplayer]) duckPlayerPreferences.youtubeOverlayAnyButtonPressed = false duckPlayerPreferences.duckPlayerModeBool = true @@ -323,7 +340,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenUserHasDuckPlayerDisabledAndOverlayButtonNotPressedThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .duckplayer, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .duckplayer]) duckPlayerPreferences.youtubeOverlayAnyButtonPressed = false duckPlayerPreferences.duckPlayerModeBool = false @@ -340,7 +357,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenUserHasDuckPlayerOnAlwaysAskAndOverlayButtonNotPressedThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7]) duckPlayerPreferences.youtubeOverlayAnyButtonPressed = false duckPlayerPreferences.duckPlayerModeBool = nil @@ -357,7 +374,7 @@ final class ContinueSetUpModelTests: XCTestCase { } @MainActor func testWhenUserHasDuckPlayerOnAlwaysAskAndOverlayButtonIsPressedThenCorrectElementsAreVisible() { - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .duckplayer, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .duckplayer]) duckPlayerPreferences.youtubeOverlayAnyButtonPressed = true duckPlayerPreferences.duckPlayerModeBool = nil @@ -402,14 +419,14 @@ final class ContinueSetUpModelTests: XCTestCase { userDefaults.set(false, forKey: UserDefaultsWrapper.Key.homePageShowSurveyDay0.rawValue) userDefaults.set(false, forKey: UserDefaultsWrapper.Key.homePageShowSurveyDay7.rawValue) - vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: capturingDefaultBrowserProvider, dataImportProvider: capturingDataImportProvider, tabCollectionViewModel: tabCollectionVM, emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences) + vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: capturingDefaultBrowserProvider, dataImportProvider: capturingDataImportProvider, tabCollectionViewModel: tabCollectionVM, emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences, networkProtectionRemoteMessaging: MockNetworkProtectionRemoteMessaging()) XCTAssertEqual(vm.visibleFeaturesMatrix, [[]]) } @MainActor func testDismissedItemsAreRemovedFromVisibleMatrixAndChoicesArePersisted() { vm.shouldShowAllFeatures = true - let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .networkProtectionBetaEndedNotice]) + let expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7]) XCTAssertEqual(expectedMatrix, vm.visibleFeaturesMatrix) vm.removeItem(for: .surveyDay0) @@ -494,6 +511,7 @@ extension HomePage.Models.ContinueSetUpModel { emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences, + networkProtectionRemoteMessaging: MockNetworkProtectionRemoteMessaging(), privacyConfigurationManager: manager) } } diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift new file mode 100644 index 0000000000..f9201b2de0 --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift @@ -0,0 +1,139 @@ +// +// NetworkProtectionRemoteMessageTests.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class NetworkProtectionRemoteMessageTests: XCTestCase { + + func testWhenDecodingMessages_ThenMessagesDecodeSuccessfully() throws { + let mockStatisticsStore = MockStatisticsStore() + mockStatisticsStore.atb = "atb-123" + mockStatisticsStore.variant = "variant" + + let mockActivationDateStore = MockWaitlistActivationDateStore() + mockActivationDateStore._daysSinceActivation = 0 + mockActivationDateStore._daysSinceLastActive = 0 + + let fileURL = mockMessagesURL() + let data = try Data(contentsOf: fileURL) + + let decoder = JSONDecoder() + let decodedMessages = try decoder.decode([NetworkProtectionRemoteMessage].self, from: data) + + XCTAssertEqual(decodedMessages.count, 3) + + guard let firstMessage = decodedMessages.first(where: { $0.id == "123"}) else { + XCTFail("Failed to find expected message") + return + } + + let firstMessagePresentableSurveyURL = firstMessage.presentableSurveyURL( + statisticsStore: mockStatisticsStore, + activationDateStore: mockActivationDateStore, + operatingSystemVersion: "1.2.3", + appVersion: "4.5.6", + hardwareModel: "MacBookPro,123" + ) + + XCTAssertEqual(firstMessage.cardTitle, "Title 1") + XCTAssertEqual(firstMessage.cardDescription, "Description 1") + XCTAssertEqual(firstMessage.cardAction, "Action 1") + XCTAssertNil(firstMessagePresentableSurveyURL) + XCTAssertNil(firstMessage.daysSinceNetworkProtectionEnabled) + + guard let secondMessage = decodedMessages.first(where: { $0.id == "456"}) else { + XCTFail("Failed to find expected message") + return + } + + let secondMessagePresentableSurveyURL = secondMessage.presentableSurveyURL( + statisticsStore: mockStatisticsStore, + activationDateStore: mockActivationDateStore, + operatingSystemVersion: "1.2.3", + appVersion: "4.5.6", + hardwareModel: "MacBookPro,123" + ) + + XCTAssertEqual(secondMessage.daysSinceNetworkProtectionEnabled, 1) + XCTAssertEqual(secondMessage.cardTitle, "Title 2") + XCTAssertEqual(secondMessage.cardDescription, "Description 2") + XCTAssertEqual(secondMessage.cardAction, "Action 2") + XCTAssertNil(secondMessagePresentableSurveyURL) + + guard let thirdMessage = decodedMessages.first(where: { $0.id == "789"}) else { + XCTFail("Failed to find expected message") + return + } + + let thirdMessagePresentableSurveyURL = thirdMessage.presentableSurveyURL( + statisticsStore: mockStatisticsStore, + activationDateStore: mockActivationDateStore, + operatingSystemVersion: "1.2.3", + appVersion: "4.5.6", + hardwareModel: "MacBookPro,123" + ) + + XCTAssertEqual(thirdMessage.daysSinceNetworkProtectionEnabled, 5) + XCTAssertEqual(thirdMessage.cardTitle, "Title 3") + XCTAssertEqual(thirdMessage.cardDescription, "Description 3") + XCTAssertEqual(thirdMessage.cardAction, "Action 3") + XCTAssertTrue(thirdMessagePresentableSurveyURL!.absoluteString.hasPrefix("https://duckduckgo.com/")) + } + + func testWhenGettingSurveyURL_AndSurveyURLHasParameters_ThenParametersAreReplaced() { + let remoteMessageJSON = """ + { + "id": "1", + "daysSinceNetworkProtectionEnabled": 0, + "cardTitle": "Title", + "cardDescription": "Description", + "cardAction": "Action", + "surveyURL": "https://duckduckgo.com/" + } + """ + + let decoder = JSONDecoder() + let message = try! decoder.decode(NetworkProtectionRemoteMessage.self, from: remoteMessageJSON.data(using: .utf8)!) + + let mockStatisticsStore = MockStatisticsStore() + mockStatisticsStore.atb = "atb-123" + mockStatisticsStore.variant = "variant" + + let mockActivationDateStore = MockWaitlistActivationDateStore() + mockActivationDateStore._daysSinceActivation = 2 + mockActivationDateStore._daysSinceLastActive = 1 + + let presentableSurveyURL = message.presentableSurveyURL( + statisticsStore: mockStatisticsStore, + activationDateStore: mockActivationDateStore, + operatingSystemVersion: "1.2.3", + appVersion: "4.5.6", + hardwareModel: "MacBookPro,123" + ) + + let expectedURL = "https://duckduckgo.com/?atb=atb-123&var=variant&delta=2&mv=1.2.3&ddgv=4.5.6&mo=MacBookPro%252C123&da=1" + XCTAssertEqual(presentableSurveyURL!.absoluteString, expectedURL) + } + + private func mockMessagesURL() -> URL { + let bundle = Bundle(for: NetworkProtectionRemoteMessageTests.self) + return bundle.resourceURL!.appendingPathComponent("network-protection-messages.json") + } + +} diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift new file mode 100644 index 0000000000..d08f95ee36 --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift @@ -0,0 +1,76 @@ +// +// NetworkProtectionRemoteMessagingStorageTests.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class NetworkProtectionRemoteMessagingStorageTests: XCTestCase { + + private let temporaryFileURL: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json", isDirectory: false) + private var defaults: UserDefaults! + private let testGroupName = "remote-messaging-storage" + + override func setUp() { + try? FileManager.default.removeItem(at: temporaryFileURL) + defaults = UserDefaults(suiteName: testGroupName)! + defaults.removePersistentDomain(forName: testGroupName) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: temporaryFileURL) + } + + func testWhenStoringMessages_ThenMessagesCanBeReadFromDisk() throws { + let storage = DefaultNetworkProtectionRemoteMessagingStorage(userDefaults: defaults, messagesURL: temporaryFileURL) + let message = mockMessage(id: "123") + try storage.store(messages: [message]) + let storedMessages = storage.storedMessages() + + XCTAssertEqual(storedMessages, [message]) + } + + func testWhenStoringMessages_ThenOldMessagesAreOverwritten() throws { + let storage = DefaultNetworkProtectionRemoteMessagingStorage(userDefaults: defaults, messagesURL: temporaryFileURL) + + let message1 = mockMessage(id: "123") + let message2 = mockMessage(id: "456") + + try storage.store(messages: [message1]) + try storage.store(messages: [message2]) + let storedMessages = storage.storedMessages() + + XCTAssertEqual(storedMessages, [message2]) + } + + private func mockMessage(id: String) -> NetworkProtectionRemoteMessage { + let remoteMessageJSON = """ + { + "id": "\(id)", + "daysSinceNetworkProtectionEnabled": 0, + "cardTitle": "Title", + "cardDescription": "Description", + "cardAction": "Action", + "surveyURL": "https://duckduckgo.com/" + } + """ + + let decoder = JSONDecoder() + return try! decoder.decode(NetworkProtectionRemoteMessage.self, from: remoteMessageJSON.data(using: .utf8)!) + } + +} diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift new file mode 100644 index 0000000000..c726948080 --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift @@ -0,0 +1,294 @@ +// +// NetworkProtectionRemoteMessagingTests.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class NetworkProtectionRemoteMessagingTests: XCTestCase { + + private var defaults: UserDefaults! + private let testGroupName = "remote-messaging" + + override func setUp() { + defaults = UserDefaults(suiteName: testGroupName)! + defaults.removePersistentDomain(forName: testGroupName) + } + + func testWhenFetchingRemoteMessages_AndTheUserDidNotSignUpViaWaitlist_ThenMessagesAreNotFetched() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + minimumRefreshInterval: 0, + userDefaults: defaults + ) + + XCTAssertTrue(!waitlistStorage.isWaitlistUser) + + let expectation = expectation(description: "Remote Message Fetch") + + messaging.fetchRemoteMessages { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertFalse(request.didFetchMessages) + } + + func testWhenFetchingRemoteMessages_AndTheUserDidSignUpViaWaitlist_ButUserHasNotActivatedNetP_ThenMessagesAreNotFetched() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + + waitlistStorage.store(waitlistToken: "token") + waitlistStorage.store(waitlistTimestamp: 123) + waitlistStorage.store(inviteCode: "ABCD1234") + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + minimumRefreshInterval: 0, + userDefaults: defaults + ) + + XCTAssertTrue(waitlistStorage.isWaitlistUser) + XCTAssertNil(activationDateStorage.daysSinceActivation()) + + let expectation = expectation(description: "Remote Message Fetch") + + messaging.fetchRemoteMessages { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertFalse(request.didFetchMessages) + } + + func testWhenFetchingRemoteMessages_AndWaitlistUserHasActivatedNetP_ThenMessagesAreFetched_AndMessagesAreStored() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + + let messages = [mockMessage(id: "123")] + + request.result = .success(messages) + waitlistStorage.store(waitlistToken: "token") + waitlistStorage.store(waitlistTimestamp: 123) + waitlistStorage.store(inviteCode: "ABCD1234") + activationDateStorage._daysSinceActivation = 10 + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + minimumRefreshInterval: 0, + userDefaults: defaults + ) + + XCTAssertTrue(waitlistStorage.isWaitlistUser) + XCTAssertEqual(storage.storedMessages(), []) + XCTAssertNotNil(activationDateStorage.daysSinceActivation()) + + let expectation = expectation(description: "Remote Message Fetch") + + messaging.fetchRemoteMessages { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertTrue(request.didFetchMessages) + XCTAssertEqual(storage.storedMessages(), messages) + } + + func testWhenFetchingRemoteMessages_AndWaitlistUserHasActivatedNetP_ButRateLimitedOperationCannotRunAgain_ThenMessagesAreNotFetched() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + + waitlistStorage.store(waitlistToken: "token") + waitlistStorage.store(waitlistTimestamp: 123) + waitlistStorage.store(inviteCode: "ABCD1234") + activationDateStorage._daysSinceActivation = 10 + + defaults.setValue(Date(), forKey: DefaultNetworkProtectionRemoteMessaging.Constants.lastRefreshDateKey) + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + minimumRefreshInterval: .days(7), // Use a large number to hit the refresh check + userDefaults: defaults + ) + + XCTAssertTrue(waitlistStorage.isWaitlistUser) + XCTAssertNotNil(activationDateStorage.daysSinceActivation()) + + let expectation = expectation(description: "Remote Message Fetch") + + messaging.fetchRemoteMessages { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertFalse(request.didFetchMessages) + XCTAssertEqual(storage.storedMessages(), []) + } + + func testWhenStoredMessagesExist_AndSomeMessagesHaveBeenDismissed_ThenPresentableMessagesDoNotIncludeDismissedMessages() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + + let dismissedMessage = mockMessage(id: "123") + let activeMessage = mockMessage(id: "456") + try? storage.store(messages: [dismissedMessage, activeMessage]) + activationDateStorage._daysSinceActivation = 10 + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + minimumRefreshInterval: 0, + userDefaults: defaults + ) + + let presentableMessagesBefore = messaging.presentableRemoteMessages() + XCTAssertEqual(presentableMessagesBefore, [dismissedMessage, activeMessage]) + messaging.dismiss(message: dismissedMessage) + let presentableMessagesAfter = messaging.presentableRemoteMessages() + XCTAssertEqual(presentableMessagesAfter, [activeMessage]) + } + + func testWhenStoredMessagesExist_AndSomeMessagesRequireDaysActive_ThenPresentableMessagesDoNotIncludeInvalidMessages() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + + let hiddenMessage = mockMessage(id: "123", daysSinceNetworkProtectionEnabled: 10) + let activeMessage = mockMessage(id: "456") + try? storage.store(messages: [hiddenMessage, activeMessage]) + activationDateStorage._daysSinceActivation = 5 + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + minimumRefreshInterval: 0, + userDefaults: defaults + ) + + let presentableMessagesAfter = messaging.presentableRemoteMessages() + XCTAssertEqual(presentableMessagesAfter, [activeMessage]) + } + + private func mockMessage(id: String, daysSinceNetworkProtectionEnabled: Int = 0) -> NetworkProtectionRemoteMessage { + let remoteMessageJSON = """ + { + "id": "\(id)", + "daysSinceNetworkProtectionEnabled": \(daysSinceNetworkProtectionEnabled), + "cardTitle": "Title", + "cardDescription": "Description", + "cardAction": "Action", + "surveyURL": "https://duckduckgo.com/" + } + """ + + let decoder = JSONDecoder() + return try! decoder.decode(NetworkProtectionRemoteMessage.self, from: remoteMessageJSON.data(using: .utf8)!) + } + +} + +// MARK: - Mocks + +private final class MockNetworkProtectionRemoteMessagingRequest: NetworkProtectionRemoteMessagingRequest { + + var result: Result<[NetworkProtectionRemoteMessage], Error>! + var didFetchMessages: Bool = false + + func fetchNetworkProtectionRemoteMessages(completion: @escaping (Result<[NetworkProtectionRemoteMessage], Error>) -> Void) { + didFetchMessages = true + completion(result) + } + +} + +private final class MockNetworkProtectionRemoteMessagingStorage: NetworkProtectionRemoteMessagingStorage { + + var _storedMessages: [NetworkProtectionRemoteMessage] = [] + var _storedDismissedMessageIDs: [String] = [] + + func store(messages: [NetworkProtectionRemoteMessage]) throws { + self._storedMessages = messages + } + + func storedMessages() -> [NetworkProtectionRemoteMessage] { + _storedMessages + } + + func dismissRemoteMessage(with id: String) { + if !_storedDismissedMessageIDs.contains(id) { + _storedDismissedMessageIDs.append(id) + } + } + + func dismissedMessageIDs() -> [String] { + _storedDismissedMessageIDs + } + +} + +final class MockWaitlistActivationDateStore: WaitlistActivationDateStore { + + var _daysSinceActivation: Int? + var _daysSinceLastActive: Int? + + func daysSinceActivation() -> Int? { + _daysSinceActivation + } + + func daysSinceLastActive() -> Int? { + _daysSinceLastActive + } + +} + +#endif diff --git a/UnitTests/NetworkProtection/PhasedRolloutFeatureFlagTesterTests.swift.plist b/UnitTests/NetworkProtection/PhasedRolloutFeatureFlagTesterTests.swift.plist deleted file mode 100644 index 3967e063f9..0000000000 Binary files a/UnitTests/NetworkProtection/PhasedRolloutFeatureFlagTesterTests.swift.plist and /dev/null differ diff --git a/UnitTests/NetworkProtection/Resources/network-protection-messages.json b/UnitTests/NetworkProtection/Resources/network-protection-messages.json new file mode 100644 index 0000000000..9207e6e32f --- /dev/null +++ b/UnitTests/NetworkProtection/Resources/network-protection-messages.json @@ -0,0 +1,23 @@ +[ + { + "id": "123", + "cardTitle": "Title 1", + "cardDescription": "Description 1", + "cardAction": "Action 1" + }, + { + "id": "456", + "daysSinceNetworkProtectionEnabled": 1, + "cardTitle": "Title 2", + "cardDescription": "Description 2", + "cardAction": "Action 2" + }, + { + "id": "789", + "daysSinceNetworkProtectionEnabled": 5, + "cardTitle": "Title 3", + "cardDescription": "Description 3", + "cardAction": "Action 3", + "surveyURL": "https://duckduckgo.com/" + } +]