From 6acc3b6406a97ab44e67f26ef9b1404184d70af6 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 20 Sep 2023 13:07:41 -0700 Subject: [PATCH 01/24] Remove the hardcoded beta ended notice for NetP. --- .../UserText+NetworkProtection.swift | 4 -- .../Utilities/UserDefaultsWrapper.swift | 2 +- .../Model/HomePageContinueSetUpModel.swift | 66 ------------------- .../HomePage/ContinueSetUpModelTests.swift | 28 ++++---- 4 files changed, 15 insertions(+), 85 deletions(-) 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/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index e2270b38d6..c762d3882a 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -112,7 +112,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" @@ -174,6 +173,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 f0e33aaee3..2d918c6359 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -103,9 +103,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 @@ -188,8 +185,6 @@ extension HomePage.Models { visitSurvey(day: .day0) case .surveyDay7: visitSurvey(day: .day7) - case .networkProtectionBetaEndedNotice: - removeItem(for: .networkProtectionBetaEndedNotice) } } // swiftlint:enable cyclomatic_complexity @@ -210,8 +205,6 @@ extension HomePage.Models { shouldShowSurveyDay0 = false case .surveyDay7: shouldShowSurveyDay7 = false - case .networkProtectionBetaEndedNotice: - shouldShowNetworkProtectionBetaEndedNotice = false } refreshFeaturesMatrix() } @@ -220,10 +213,6 @@ extension HomePage.Models { func refreshFeaturesMatrix() { var features: [FeatureType] = [] - if shouldNetworkProtectionBetaEndedNoticeBeVisible { - features.append(.networkProtectionBetaEndedNotice) - } - for feature in listOfFeatures { switch feature { case .defaultBrowser: @@ -254,8 +243,6 @@ 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 } } featuresMatrix = features.chunked(into: itemsPerRow) @@ -353,50 +340,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 = 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 @@ -436,7 +379,6 @@ extension HomePage.Models { case importBookmarksAndPasswords case surveyDay0 case surveyDay7 - case networkProtectionBetaEndedNotice var title: String { switch self { @@ -454,8 +396,6 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0CardTitle case .surveyDay7: return UserText.newTabSetUpSurveyDay7CardTitle - case .networkProtectionBetaEndedNotice: - return UserText.networkProtectionBetaEndedCardTitle } } @@ -475,8 +415,6 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0Summary case .surveyDay7: return UserText.newTabSetUpSurveyDay7Summary - case .networkProtectionBetaEndedNotice: - return UserText.networkProtectionBetaEndedCardText } } @@ -496,8 +434,6 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0Action case .surveyDay7: return UserText.newTabSetUpSurveyDay7Action - case .networkProtectionBetaEndedNotice: - return UserText.networkProtectionBetaEndedCardAction } } @@ -519,8 +455,6 @@ extension HomePage.Models { return NSImage(named: "Survey-128")!.resized(to: iconSize)! case .surveyDay7: return NSImage(named: "Survey-128")!.resized(to: iconSize)! - case .networkProtectionBetaEndedNotice: - return NSImage(named: "VPN-Ended")!.resized(to: iconSize)! } } } diff --git a/UnitTests/HomePage/ContinueSetUpModelTests.swift b/UnitTests/HomePage/ContinueSetUpModelTests.swift index 50003de3f1..a35878d331 100644 --- a/UnitTests/HomePage/ContinueSetUpModelTests.swift +++ b/UnitTests/HomePage/ContinueSetUpModelTests.swift @@ -99,7 +99,7 @@ final class ContinueSetUpModelTests: XCTestCase { vm.shouldShowAllFeatures = true - expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7, .networkProtectionBetaEndedNotice]) + expectedMatrix = expectedFeatureMatrixWithout(types: [.surveyDay7]) XCTAssertEqual(vm.visibleFeaturesMatrix, expectedMatrix) } @@ -164,11 +164,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 @@ -196,7 +196,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) @@ -212,7 +212,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) @@ -224,7 +224,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) @@ -246,7 +246,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) @@ -262,7 +262,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) @@ -273,7 +273,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) @@ -295,7 +295,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 @@ -312,7 +312,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 @@ -329,7 +329,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 @@ -346,7 +346,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 @@ -398,7 +398,7 @@ final class ContinueSetUpModelTests: XCTestCase { @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) From 34f735fbad390aaa5822f56d32d403ac24ba82e6 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 20 Sep 2023 14:14:48 -0700 Subject: [PATCH 02/24] Begin adding Network Protection remote messaging. --- DuckDuckGo.xcodeproj/project.pbxproj | 56 ++++++++++++ .../NetworkProtectionRemoteMessage.swift | 28 ++++++ .../NetworkProtectionRemoteMessaging.swift | 55 ++++++++++++ ...workProtectionRemoteMessagingRequest.swift | 80 ++++++++++++++++++ ...workProtectionRemoteMessagingStorage.swift | 49 +++++++++++ .../InputFilesChecker/InputFilesChecker.swift | 10 ++- .../NetworkProtectionRemoteMessageTests.swift | 61 +++++++++++++ ...dRolloutFeatureFlagTesterTests.swift.plist | Bin 42 -> 0 bytes .../network-protection-messages.json | 17 ++++ 9 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift create mode 100644 UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift delete mode 100644 UnitTests/NetworkProtection/PhasedRolloutFeatureFlagTesterTests.swift.plist create mode 100644 UnitTests/NetworkProtection/Resources/network-protection-messages.json diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index bbb2016f85..222cefde51 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2044,6 +2044,16 @@ 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 */; }; 4BD18F01283F0BC500058124 /* BookmarksBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */; }; 4BD18F05283F151F00058124 /* BookmarksBar.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */; }; 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; @@ -3411,6 +3421,12 @@ 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 = ""; }; 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableScrollView.swift; sourceTree = ""; }; @@ -4916,6 +4932,7 @@ 4B4D60612A0B29FA00BCD287 /* DeveloperIDTarget */ = { isa = PBXGroup; children = ( + 4BCF15D52ABB83D70083F6DF /* NetworkProtectionRemoteMessaging */, 4B4D60622A0B29FA00BCD287 /* SystemExtensionManager.swift */, ); path = DeveloperIDTarget; @@ -5472,6 +5489,34 @@ 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 */, + ); + path = NetworkProtection; + sourceTree = ""; + }; + 4BCF15E62ABB98A20083F6DF /* Resources */ = { + isa = PBXGroup; + children = ( + 4BCF15E92ABB99470083F6DF /* network-protection-messages.json */, + ); + path = Resources; + sourceTree = ""; + }; 4BD18F02283F0F1000058124 /* View */ = { isa = PBXGroup; children = ( @@ -6170,6 +6215,7 @@ EEF53E162950CEB6002D78F4 /* JSAlert */, 378205F9283C275E00D1D4AA /* Menus */, AA91F83627076ED100771A0D /* NavigationBar */, + 4BCF15E32ABB987F0083F6DF /* NetworkProtection */, 85F487B3276A8F1B003CE668 /* Onboarding */, 1D3B1AB7293405F5006F4388 /* PasswordManagers */, B6106BA126A7BE430013B453 /* Permissions */, @@ -8393,6 +8439,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 +8858,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 +9009,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 +9224,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 */, @@ -9325,6 +9375,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 */, @@ -10647,6 +10698,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 */, @@ -10940,6 +10992,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 +11014,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 +11193,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 */, @@ -11227,6 +11282,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 */, diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift new file mode 100644 index 0000000000..fe971d85dd --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift @@ -0,0 +1,28 @@ +// +// 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 + +struct NetworkProtectionRemoteMessage: Codable { + let id: String + let cardTitle: String + let cardDescription: String + let cardAction: String + let daysSinceNetworkProtectionEnabled: Int + let surveyURL: String? +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift new file mode 100644 index 0000000000..eeb54a775d --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -0,0 +1,55 @@ +// +// 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 + +protocol NetworkProtectionRemoteMessaging { + + func fetchRemoteMessages() + func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] + func dismissRemoteMessage(with id: String) + +} + +final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging { + + private let messageRequest: NetworkProtectionRemoteMessagingRequest + private let messageStorage: NetworkProtectionRemoteMessagingStorage + + init( + messageRequest: NetworkProtectionRemoteMessagingRequest = DefaultNetworkProtectionRemoteMessagingRequest(), + messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage() + ) { + self.messageRequest = messageRequest + self.messageStorage = messageStorage + } + + func fetchRemoteMessages() { + // 1. Fetch remote messages + // 2. Store them + } + + func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] { + return [] + } + + func dismissRemoteMessage(with id: String) { + + } + +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift new file mode 100644 index 0000000000..2c55c7a9e3 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift @@ -0,0 +1,80 @@ +// +// 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: "http://staticcdn.duckduckgo.com/macos-desktop-browser/network-protection/messages-debug.json")! + case .production: return URL(string: "http://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 { + // TODO: Handle 403/404 errors since those will be expected if no file is found + 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..596125cda2 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift @@ -0,0 +1,49 @@ +// +// 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]) + func storedMessages() -> [NetworkProtectionRemoteMessage] + + func dismissRemoteMessage(with id: String) + func dismissedMessageIDs() -> [String] + +} + +final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRemoteMessagingStorage { + + func store(messages: [NetworkProtectionRemoteMessage]) { + // TODO + } + + func storedMessages() -> [NetworkProtectionRemoteMessage] { + return [] + } + + func dismissRemoteMessage(with id: String) { + // TODO + } + + func dismissedMessageIDs() -> [String] { + return [] + } + +} diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index c10e2bf265..f53b83af79 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -34,7 +34,11 @@ let nonSandboxedExtraInputFiles: Set = [ .init("PFMoveApplication.m", .source), .init("NetworkProtectionBundle.swift", .source), .init("NetworkProtectionAppEvents.swift", .source), - .init("KeychainType+ClientDefault.swift", .source) + .init("KeychainType+ClientDefault.swift", .source), + .init("NetworkProtectionRemoteMessage.swift", .source), + .init("NetworkProtectionRemoteMessaging.swift", .source), + .init("NetworkProtectionRemoteMessagingStorage.swift", .source), + .init("NetworkProtectionRemoteMessagingRequest.swift", .source) ] /** @@ -59,7 +63,9 @@ let extraInputFiles: [TargetName: Set] = [ "Unit Tests": [ .init("BWEncryptionTests.swift", .source), - .init("WKWebViewPrivateMethodsAvailabilityTests.swift", .source) + .init("WKWebViewPrivateMethodsAvailabilityTests.swift", .source), + .init("NetworkProtectionRemoteMessageTests.swift", .source), + .init("network-protection-messages.json", .resource) ], "Integration Tests": [] diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift new file mode 100644 index 0000000000..31871185d4 --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift @@ -0,0 +1,61 @@ +// +// 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 fileURL = mockMessagesURL() + let data = try Data(contentsOf: fileURL) + + let decoder = JSONDecoder() + let decodedMessages = try decoder.decode([NetworkProtectionRemoteMessage].self, from: data) + + XCTAssertEqual(decodedMessages.count, 2) + + guard let firstMessage = decodedMessages.first(where: { $0.id == "123"}) else { + XCTFail("Failed to find expected message") + return + } + + XCTAssertEqual(firstMessage.daysSinceNetworkProtectionEnabled, 1) + XCTAssertEqual(firstMessage.cardTitle, "Title 1") + XCTAssertEqual(firstMessage.cardDescription, "Description 1") + XCTAssertEqual(firstMessage.cardAction, "Action 1") + XCTAssertNil(firstMessage.surveyURL) + + guard let secondMessage = decodedMessages.first(where: { $0.id == "456"}) else { + XCTFail("Failed to find expected message") + return + } + + XCTAssertEqual(secondMessage.daysSinceNetworkProtectionEnabled, 2) + XCTAssertEqual(secondMessage.cardTitle, "Title 2") + XCTAssertEqual(secondMessage.cardDescription, "Description 2") + XCTAssertEqual(secondMessage.cardAction, "Action 2") + XCTAssertEqual(secondMessage.surveyURL, "https://duckduckgo.com/") + } + + private func mockMessagesURL() -> URL { + let bundle = Bundle(for: NetworkProtectionRemoteMessageTests.self) + return bundle.resourceURL!.appendingPathComponent("network-protection-messages.json") + } + +} diff --git a/UnitTests/NetworkProtection/PhasedRolloutFeatureFlagTesterTests.swift.plist b/UnitTests/NetworkProtection/PhasedRolloutFeatureFlagTesterTests.swift.plist deleted file mode 100644 index 3967e063f94f2b9de2fdbeb4d90be9963443c793..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42 dcmYc)$jK}&F)+Bm!2kw~j1ZauMnky_oB)p~1JeKi diff --git a/UnitTests/NetworkProtection/Resources/network-protection-messages.json b/UnitTests/NetworkProtection/Resources/network-protection-messages.json new file mode 100644 index 0000000000..80dd6f3ee4 --- /dev/null +++ b/UnitTests/NetworkProtection/Resources/network-protection-messages.json @@ -0,0 +1,17 @@ +[ + { + "id": "123", + "daysSinceNetworkProtectionEnabled": 1, + "cardTitle": "Title 1", + "cardDescription": "Description 1", + "cardAction": "Action 1" + }, + { + "id": "456", + "daysSinceNetworkProtectionEnabled": 2, + "cardTitle": "Title 2", + "cardDescription": "Description 2", + "cardAction": "Action 2", + "surveyURL": "https://duckduckgo.com/" + } +] From 70aa93fbb7fd867de548147cb33b670a4bf7522d Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 20 Sep 2023 19:10:44 -0700 Subject: [PATCH 03/24] Continue on the remote messaging implementation. --- DuckDuckGo.xcodeproj/project.pbxproj | 8 ++ .../Model/HomePageContinueSetUpModel.swift | 38 +++++++++- .../View/HomePageViewController.swift | 2 +- .../NetworkProtectionNavBarButtonModel.swift | 4 +- .../NetworkProtectionRemoteMessage.swift | 4 +- .../NetworkProtectionRemoteMessaging.swift | 73 ++++++++++++++++++- ...workProtectionRemoteMessagingStorage.swift | 22 +++++- .../Storage/WaitlistActivationDateStore.swift | 8 +- .../InputFilesChecker/InputFilesChecker.swift | 6 +- .../HomePage/ContinueSetUpModelTests.swift | 25 ++++++- .../NetworkProtectionRemoteMessageTests.swift | 19 ++++- .../network-protection-messages.json | 12 ++- 12 files changed, 192 insertions(+), 29 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 222cefde51..4e86e2fda5 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2054,6 +2054,10 @@ 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 */; }; 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */; }; @@ -9488,6 +9492,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 */, @@ -9828,6 +9833,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 */, @@ -9871,6 +9877,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 */, @@ -10073,6 +10080,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 */, diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index 2d918c6359..376fdeca1e 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 privacyConfig: PrivacyConfiguration var isDay0SurveyEnabled: Bool { @@ -136,6 +137,7 @@ extension HomePage.Models { privacyPreferences: PrivacySecurityPreferences = PrivacySecurityPreferences.shared, cookieConsentPopoverManager: CookieConsentPopoverManager = CookieConsentPopoverManager(), duckPlayerPreferences: DuckPlayerPreferencesPersistor, + networkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging, privacyConfig: PrivacyConfiguration = AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager.privacyConfig) { self.defaultBrowserProvider = defaultBrowserProvider self.dataImportProvider = dataImportProvider @@ -144,6 +146,7 @@ extension HomePage.Models { self.privacyPreferences = privacyPreferences self.cookieConsentPopoverManager = cookieConsentPopoverManager self.duckPlayerPreferences = duckPlayerPreferences + self.networkProtectionRemoteMessaging = networkProtectionRemoteMessaging self.privacyConfig = privacyConfig refreshFeaturesMatrix() NotificationCenter.default.addObserver(self, selector: #selector(newTabOpenNotification(_:)), name: HomePage.Models.newHomePageTabOpen, object: nil) @@ -185,6 +188,14 @@ extension HomePage.Models { visitSurvey(day: .day0) case .surveyDay7: visitSurvey(day: .day7) + case .networkProtectionRemoteMessage(let message): + if let surveyURLString = message.surveyURL, let surveyURL = URL(string: surveyURLString) { + let tab = Tab(content: .url(surveyURL), shouldLoadInBackground: true) + tabCollectionViewModel.append(tab: tab) + } + + // Dismiss the message after the user opens the survey, even if they just close the tab immediately afterwards. + networkProtectionRemoteMessaging.dismissRemoteMessage(with: message.id) } } // swiftlint:enable cyclomatic_complexity @@ -205,6 +216,8 @@ extension HomePage.Models { shouldShowSurveyDay0 = false case .surveyDay7: shouldShowSurveyDay7 = false + case .networkProtectionRemoteMessage(let message): + networkProtectionRemoteMessaging.dismissRemoteMessage(with: message.id) } refreshFeaturesMatrix() } @@ -213,6 +226,10 @@ extension HomePage.Models { func refreshFeaturesMatrix() { var features: [FeatureType] = [] + for message in networkProtectionRemoteMessaging.presentableRemoteMessages() { + features.append(.networkProtectionRemoteMessage(message)) + } + for feature in listOfFeatures { switch feature { case .defaultBrowser: @@ -243,6 +260,8 @@ extension HomePage.Models { if shouldSurveyDay7BeVisible { features.append(feature) } + case .networkProtectionRemoteMessage: + break // Do nothing, NetP remote messages get appended first } } featuresMatrix = features.chunked(into: itemsPerRow) @@ -371,7 +390,15 @@ extension HomePage.Models { } // 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 @@ -379,6 +406,7 @@ extension HomePage.Models { case importBookmarksAndPasswords case surveyDay0 case surveyDay7 + case networkProtectionRemoteMessage(NetworkProtectionRemoteMessage) var title: String { switch self { @@ -396,6 +424,8 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0CardTitle case .surveyDay7: return UserText.newTabSetUpSurveyDay7CardTitle + case .networkProtectionRemoteMessage(let message): + return message.cardTitle } } @@ -415,6 +445,8 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0Summary case .surveyDay7: return UserText.newTabSetUpSurveyDay7Summary + case .networkProtectionRemoteMessage(let message): + return message.cardDescription } } @@ -434,6 +466,8 @@ extension HomePage.Models { return UserText.newTabSetUpSurveyDay0Action case .surveyDay7: return UserText.newTabSetUpSurveyDay7Action + case .networkProtectionRemoteMessage(let message): + return message.cardAction } } @@ -455,6 +489,8 @@ extension HomePage.Models { return NSImage(named: "Survey-128")!.resized(to: iconSize)! case .surveyDay7: return NSImage(named: "Survey-128")!.resized(to: iconSize)! + 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/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift index 193ec40357..8b89598611 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() diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift index fe971d85dd..fb7b045054 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift @@ -18,11 +18,11 @@ import Foundation -struct NetworkProtectionRemoteMessage: Codable { +struct NetworkProtectionRemoteMessage: Codable, Equatable, Hashable { let id: String let cardTitle: String let cardDescription: String let cardAction: String - let daysSinceNetworkProtectionEnabled: Int + let daysSinceNetworkProtectionEnabled: Int? let surveyURL: String? } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index eeb54a775d..bd8b6ce00f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -18,6 +18,10 @@ import Foundation +extension Notification.Name { + static let NetworkProtectionRemoteMessagesChanged = NSNotification.Name("NetworkProtectionRemoteMessagesChanged") +} + protocol NetworkProtectionRemoteMessaging { func fetchRemoteMessages() @@ -30,26 +34,87 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess private let messageRequest: NetworkProtectionRemoteMessagingRequest private let messageStorage: NetworkProtectionRemoteMessagingStorage + private let waitlistActivationDateStore: WaitlistActivationDateStore init( messageRequest: NetworkProtectionRemoteMessagingRequest = DefaultNetworkProtectionRemoteMessagingRequest(), - messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage() + messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage(), + waitlistActivationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore() ) { self.messageRequest = messageRequest self.messageStorage = messageStorage + self.waitlistActivationDateStore = waitlistActivationDateStore } func fetchRemoteMessages() { - // 1. Fetch remote messages - // 2. Store them +#if NETWORK_PROTECTION + // Don't fetch messages if the user hasn't used NetP + guard waitlistActivationDateStore.daysSinceActivation() != nil else { + return + } + + messageRequest.fetchNetworkProtectionRemoteMessages { [weak self] result in + guard let self else { return } + + switch result { + case .success(let messages): + self.messageStorage.store(messages: messages) + NotificationCenter.default.post(name: .NetworkProtectionRemoteMessagesChanged, object: nil) + case .failure(let error): + // TODO: Handle error, send pixel if messages can't be decoded + break + } + } +#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 = [ + NetworkProtectionRemoteMessage( + id: "1234", + cardTitle: "Title", + cardDescription: "Description", + cardAction: "Action", + daysSinceNetworkProtectionEnabled: nil, + surveyURL: "https://duckduckgo.com" + ) + ] + + // 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 daysSinceNetworkProtectionEnabled = message.daysSinceNetworkProtectionEnabled { + if daysSinceNetworkProtectionEnabled >= daysSinceActivation { + return true + } else { + return false + } + } else { + return true + } + } + + return filteredMessages +#else return [] +#endif } func dismissRemoteMessage(with id: String) { - +#if NETWORK_PROTECTION + messageStorage.dismissRemoteMessage(with: id) +#endif } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift index 596125cda2..f3d14f5f8e 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift @@ -30,6 +30,16 @@ protocol NetworkProtectionRemoteMessagingStorage { final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRemoteMessagingStorage { + private enum Constants { + static let dismissedMessageIdentifiersKey = "home.page.network-protection.dismissed-message-identifiers" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + func store(messages: [NetworkProtectionRemoteMessage]) { // TODO } @@ -39,11 +49,19 @@ final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRem } func dismissRemoteMessage(with id: String) { - // TODO + var dismissedMessages = dismissedMessageIDs() + + guard !dismissedMessages.contains(id) else { + return + } + + dismissedMessages.append(id) + userDefaults.set(dismissedMessages, forKey: Constants.dismissedMessageIdentifiersKey) } func dismissedMessageIDs() -> [String] { - return [] + let messages = userDefaults.array(forKey: Constants.dismissedMessageIdentifiersKey) as? [String] + return messages ?? [] } } diff --git a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift index 7cefd7bd51..db764b214e 100644 --- a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift +++ b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift @@ -20,7 +20,13 @@ import Foundation -struct WaitlistActivationDateStore { +protocol WaitlistActivationDateStore { + + func daysSinceActivation() -> Int? + +} + +struct DefaultWaitlistActivationDateStore: WaitlistActivationDateStore { private enum Constants { static let networkProtectionActivationDateKey = "com.duckduckgo.network-protection.activation-date" diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index f53b83af79..13decb32a7 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -34,11 +34,7 @@ let nonSandboxedExtraInputFiles: Set = [ .init("PFMoveApplication.m", .source), .init("NetworkProtectionBundle.swift", .source), .init("NetworkProtectionAppEvents.swift", .source), - .init("KeychainType+ClientDefault.swift", .source), - .init("NetworkProtectionRemoteMessage.swift", .source), - .init("NetworkProtectionRemoteMessaging.swift", .source), - .init("NetworkProtectionRemoteMessagingStorage.swift", .source), - .init("NetworkProtectionRemoteMessagingRequest.swift", .source) + .init("KeychainType+ClientDefault.swift", .source) ] /** diff --git a/UnitTests/HomePage/ContinueSetUpModelTests.swift b/UnitTests/HomePage/ContinueSetUpModelTests.swift index a35878d331..ace0c93a5c 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 { + + func fetchRemoteMessages() { + + } + + func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] { + return [] + } + + func dismissRemoteMessage(with id: String) { + + } + +} final class ContinueSetUpModelTests: XCTestCase { @@ -52,7 +68,7 @@ final class ContinueSetUpModelTests: XCTestCase { ] as! [String: String] delegate = CapturingSetUpVewModelDelegate() - vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: capturingDefaultBrowserProvider, dataImportProvider: capturingDataImportProvider, tabCollectionViewModel: tabCollectionVM, emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences, privacyConfig: privacyConfig) + vm = HomePage.Models.ContinueSetUpModel(defaultBrowserProvider: capturingDefaultBrowserProvider, dataImportProvider: capturingDataImportProvider, tabCollectionViewModel: tabCollectionVM, emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences, networkProtectionRemoteMessaging: MockNetworkProtectionRemoteMessaging(), privacyConfig: privacyConfig) vm.delegate = delegate } @@ -88,7 +104,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) } @@ -391,7 +407,7 @@ 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, [[]]) } @@ -481,6 +497,7 @@ extension HomePage.Models.ContinueSetUpModel { emailManager: emailManager, privacyPreferences: privacyPreferences, duckPlayerPreferences: duckPlayerPreferences, + networkProtectionRemoteMessaging: MockNetworkProtectionRemoteMessaging(), privacyConfig: privacyConfig) } } diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift index 31871185d4..0e68b5b9ac 100644 --- a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift @@ -28,29 +28,40 @@ final class NetworkProtectionRemoteMessageTests: XCTestCase { let decoder = JSONDecoder() let decodedMessages = try decoder.decode([NetworkProtectionRemoteMessage].self, from: data) - XCTAssertEqual(decodedMessages.count, 2) + XCTAssertEqual(decodedMessages.count, 3) guard let firstMessage = decodedMessages.first(where: { $0.id == "123"}) else { XCTFail("Failed to find expected message") return } - XCTAssertEqual(firstMessage.daysSinceNetworkProtectionEnabled, 1) XCTAssertEqual(firstMessage.cardTitle, "Title 1") XCTAssertEqual(firstMessage.cardDescription, "Description 1") XCTAssertEqual(firstMessage.cardAction, "Action 1") XCTAssertNil(firstMessage.surveyURL) + XCTAssertNil(firstMessage.daysSinceNetworkProtectionEnabled) guard let secondMessage = decodedMessages.first(where: { $0.id == "456"}) else { XCTFail("Failed to find expected message") return } - XCTAssertEqual(secondMessage.daysSinceNetworkProtectionEnabled, 2) + XCTAssertEqual(secondMessage.daysSinceNetworkProtectionEnabled, 1) XCTAssertEqual(secondMessage.cardTitle, "Title 2") XCTAssertEqual(secondMessage.cardDescription, "Description 2") XCTAssertEqual(secondMessage.cardAction, "Action 2") - XCTAssertEqual(secondMessage.surveyURL, "https://duckduckgo.com/") + XCTAssertNil(firstMessage.surveyURL) + + guard let thirdMessage = decodedMessages.first(where: { $0.id == "789"}) else { + XCTFail("Failed to find expected message") + return + } + + XCTAssertEqual(thirdMessage.daysSinceNetworkProtectionEnabled, 5) + XCTAssertEqual(thirdMessage.cardTitle, "Title 3") + XCTAssertEqual(thirdMessage.cardDescription, "Description 3") + XCTAssertEqual(thirdMessage.cardAction, "Action 3") + XCTAssertEqual(thirdMessage.surveyURL, "https://duckduckgo.com/") } private func mockMessagesURL() -> URL { diff --git a/UnitTests/NetworkProtection/Resources/network-protection-messages.json b/UnitTests/NetworkProtection/Resources/network-protection-messages.json index 80dd6f3ee4..9207e6e32f 100644 --- a/UnitTests/NetworkProtection/Resources/network-protection-messages.json +++ b/UnitTests/NetworkProtection/Resources/network-protection-messages.json @@ -1,17 +1,23 @@ [ { "id": "123", - "daysSinceNetworkProtectionEnabled": 1, "cardTitle": "Title 1", "cardDescription": "Description 1", "cardAction": "Action 1" }, { "id": "456", - "daysSinceNetworkProtectionEnabled": 2, + "daysSinceNetworkProtectionEnabled": 1, "cardTitle": "Title 2", "cardDescription": "Description 2", - "cardAction": "Action 2", + "cardAction": "Action 2" + }, + { + "id": "789", + "daysSinceNetworkProtectionEnabled": 5, + "cardTitle": "Title 3", + "cardDescription": "Description 3", + "cardAction": "Action 3", "surveyURL": "https://duckduckgo.com/" } ] From 4c662c33e61f75b7886f52e0fac3258377f6b948 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 20 Sep 2023 19:34:47 -0700 Subject: [PATCH 04/24] Get remote cards working. --- .../Model/HomePageContinueSetUpModel.swift | 1 + DuckDuckGo/Main/View/MainViewController.swift | 7 ++++ .../NetworkProtectionRemoteMessaging.swift | 32 ++++++++++--------- ...workProtectionRemoteMessagingRequest.swift | 4 +-- ...workProtectionRemoteMessagingStorage.swift | 21 +++++++++--- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index 376fdeca1e..0f3cf37b52 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -196,6 +196,7 @@ extension HomePage.Models { // Dismiss the message after the user opens the survey, even if they just close the tab immediately afterwards. networkProtectionRemoteMessaging.dismissRemoteMessage(with: message.id) + refreshFeaturesMatrix() } } // swiftlint:enable cyclomatic_complexity 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/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index bd8b6ce00f..9f9c6b2800 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -58,8 +58,15 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess switch result { case .success(let messages): - self.messageStorage.store(messages: messages) - NotificationCenter.default.post(name: .NetworkProtectionRemoteMessagesChanged, object: nil) + do { + try self.messageStorage.store(messages: messages) + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .NetworkProtectionRemoteMessagesChanged, object: nil) + } + } catch { + // TODO: Handle error + } case .failure(let error): // TODO: Handle error, send pixel if messages can't be decoded break @@ -76,31 +83,26 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess } let dismissedMessageIDs = messageStorage.dismissedMessageIDs() - let possibleMessages = [ - NetworkProtectionRemoteMessage( - id: "1234", - cardTitle: "Title", - cardDescription: "Description", - cardAction: "Action", - daysSinceNetworkProtectionEnabled: nil, - surveyURL: "https://duckduckgo.com" - ) - ] + 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) { + if dismissedMessageIDs.contains(message.id) { + print("DEBUG: Not showing message titled '\(message.cardTitle)'") return false } - if let daysSinceNetworkProtectionEnabled = message.daysSinceNetworkProtectionEnabled { - if daysSinceNetworkProtectionEnabled >= daysSinceActivation { + if let requiredDaysSinceActivation = message.daysSinceNetworkProtectionEnabled { + if requiredDaysSinceActivation <= daysSinceActivation { + print("DEBUG: Showing message titled '\(message.cardTitle)'") return true } else { + print("DEBUG: Not showing message titled '\(message.cardTitle)'") return false } } else { + print("DEBUG: Showing message titled '\(message.cardTitle)'") return true } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift index 2c55c7a9e3..384c5152e5 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift @@ -33,8 +33,8 @@ final class DefaultNetworkProtectionRemoteMessagingRequest: NetworkProtectionRem var url: URL { switch self { - case .debug: return URL(string: "http://staticcdn.duckduckgo.com/macos-desktop-browser/network-protection/messages-debug.json")! - case .production: return URL(string: "http://staticcdn.duckduckgo.com/macos-desktop-browser/network-protection/messages.json")! + 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")! } } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift index f3d14f5f8e..2d7fe3649f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift @@ -20,7 +20,7 @@ import Foundation protocol NetworkProtectionRemoteMessagingStorage { - func store(messages: [NetworkProtectionRemoteMessage]) + func store(messages: [NetworkProtectionRemoteMessage]) throws func storedMessages() -> [NetworkProtectionRemoteMessage] func dismissRemoteMessage(with id: String) @@ -32,20 +32,33 @@ final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRem 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 var messagesURL: URL { + URL.sandboxApplicationSupportURL.appendingPathComponent(Constants.networkProtectionMessagesFileName) + } init(userDefaults: UserDefaults = .standard) { self.userDefaults = userDefaults } - func store(messages: [NetworkProtectionRemoteMessage]) { - // TODO + func store(messages: [NetworkProtectionRemoteMessage]) throws { + let encoded = try JSONEncoder().encode(messages) + try encoded.write(to: messagesURL) } func storedMessages() -> [NetworkProtectionRemoteMessage] { - return [] + do { + let messagesData = try Data(contentsOf: messagesURL) + let messages = try JSONDecoder().decode([NetworkProtectionRemoteMessage].self, from: messagesData) + + return messages + } catch { + // TODO: Handle error + return [] + } } func dismissRemoteMessage(with id: String) { From 92abdc5943ac2d8433742cb450908b46a8fc0205 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 21 Sep 2023 18:40:37 -0700 Subject: [PATCH 05/24] Add debug options for the NetP activation date. --- DuckDuckGo/Menus/MainMenu.storyboard | 35 +++++++++++++++++-- DuckDuckGo/Menus/MainMenuActions.swift | 26 ++++++++++++++ .../NetworkProtectionRemoteMessaging.swift | 4 --- .../Storage/WaitlistActivationDateStore.swift | 10 +++++- 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/DuckDuckGo/Menus/MainMenu.storyboard b/DuckDuckGo/Menus/MainMenu.storyboard index 9a5c7c6d6d..3641f35caf 100644 --- a/DuckDuckGo/Menus/MainMenu.storyboard +++ b/DuckDuckGo/Menus/MainMenu.storyboard @@ -1,7 +1,7 @@ - + - + @@ -1036,6 +1036,37 @@ CQ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 725cc577ce..0989e1f0be 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -758,6 +758,32 @@ extension MainViewController { #endif } + @IBAction func resetNetworkProtectionActivationDate(_ sender: Any?) { + overrideNetworkProtectionActivationDate(to: nil) + } + + @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.removeActivationDate() + } + } + // MARK: - Developer Tools @IBAction func toggleDeveloperTools(_ sender: Any?) { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index 9f9c6b2800..e89a158a95 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -89,20 +89,16 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess // has used Network Protection for. let filteredMessages = possibleMessages.filter { message in if dismissedMessageIDs.contains(message.id) { - print("DEBUG: Not showing message titled '\(message.cardTitle)'") return false } if let requiredDaysSinceActivation = message.daysSinceNetworkProtectionEnabled { if requiredDaysSinceActivation <= daysSinceActivation { - print("DEBUG: Showing message titled '\(message.cardTitle)'") return true } else { - print("DEBUG: Not showing message titled '\(message.cardTitle)'") return false } } else { - print("DEBUG: Showing message titled '\(message.cardTitle)'") return true } } diff --git a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift index db764b214e..cadcf7e65e 100644 --- a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift +++ b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift @@ -43,7 +43,7 @@ struct DefaultWaitlistActivationDateStore: WaitlistActivationDateStore { return } - userDefaults.set(Date().timeIntervalSinceReferenceDate, forKey: Constants.networkProtectionActivationDateKey) + updateActivationDate(Date()) } func daysSinceActivation() -> Int? { @@ -60,6 +60,14 @@ struct DefaultWaitlistActivationDateStore: WaitlistActivationDateStore { return numberOfDays.day } + func removeActivationDate() { + userDefaults.removeObject(forKey: Constants.networkProtectionActivationDateKey) + } + + func updateActivationDate(_ date: Date) { + userDefaults.set(date.timeIntervalSinceReferenceDate, forKey: Constants.networkProtectionActivationDateKey) + } + } #endif From a2892b16fd04c4f95a679f1c80d0bf3ce125afad Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 21 Sep 2023 19:17:51 -0700 Subject: [PATCH 06/24] Implement pixels. --- .../Model/HomePageContinueSetUpModel.swift | 1 + .../NetworkProtectionRemoteMessaging.swift | 22 +++++++++++++++---- ...workProtectionRemoteMessagingRequest.swift | 1 - ...workProtectionRemoteMessagingStorage.swift | 3 ++- DuckDuckGo/Statistics/PixelEvent.swift | 12 ++++++++++ DuckDuckGo/Statistics/PixelParameters.swift | 3 ++- 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index 0f3cf37b52..db4468d06a 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -229,6 +229,7 @@ extension HomePage.Models { for message in networkProtectionRemoteMessaging.presentableRemoteMessages() { features.append(.networkProtectionRemoteMessage(message)) + Pixel.fire(.networkProtectionRemoteMessageDisplayed(messageID: message.id)) } for feature in listOfFeatures { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index e89a158a95..8ae8bac1a6 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -17,6 +17,7 @@ // import Foundation +import Networking extension Notification.Name { static let NetworkProtectionRemoteMessagesChanged = NSNotification.Name("NetworkProtectionRemoteMessagesChanged") @@ -32,18 +33,25 @@ protocol NetworkProtectionRemoteMessaging { final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging { + enum NetworkProtectionRemoteMessagingError { + case test + } + private let messageRequest: NetworkProtectionRemoteMessagingRequest private let messageStorage: NetworkProtectionRemoteMessagingStorage private let waitlistActivationDateStore: WaitlistActivationDateStore + private let userDefaults: UserDefaults init( messageRequest: NetworkProtectionRemoteMessagingRequest = DefaultNetworkProtectionRemoteMessagingRequest(), messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage(), - waitlistActivationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore() + waitlistActivationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore(), + userDefaults: UserDefaults = .standard ) { self.messageRequest = messageRequest self.messageStorage = messageStorage self.waitlistActivationDateStore = waitlistActivationDateStore + self.userDefaults = userDefaults } func fetchRemoteMessages() { @@ -53,6 +61,8 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess return } + // TODO: Don't fetch messages if this has already been done recently + messageRequest.fetchNetworkProtectionRemoteMessages { [weak self] result in guard let self else { return } @@ -65,11 +75,15 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess NotificationCenter.default.post(name: .NetworkProtectionRemoteMessagesChanged, object: nil) } } catch { - // TODO: Handle error + Pixel.fire(.debug(event: .networkProtectionRemoteMessageStorageFailed, error: error)) } case .failure(let error): - // TODO: Handle error, send pixel if messages can't be decoded - break + // Ignore 403 errors, those happen when a file can't be found on S3 + if case APIRequest.Error.invalidStatusCode(403) = error { + return + } + + Pixel.fire(.debug(event: .networkProtectionRemoteMessageFetchingFailed, error: error)) } } #endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift index 384c5152e5..b6406402a7 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingRequest.swift @@ -61,7 +61,6 @@ final class DefaultNetworkProtectionRemoteMessagingRequest: NetworkProtectionRem request.fetch { response, error in if let error { - // TODO: Handle 403/404 errors since those will be expected if no file is found completion(Result.failure(error)) } else if let responseData = response?.data { do { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift index 2d7fe3649f..25d8ab2401 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift @@ -56,7 +56,8 @@ final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRem return messages } catch { - // TODO: Handle error + // 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 [] } } diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 9a2cc537aa..18b9a5036f 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -161,6 +161,8 @@ extension Pixel { case networkProtectionWaitlistTermsAndConditionsDisplayed case networkProtectionWaitlistTermsAndConditionsAccepted + case networkProtectionRemoteMessageDisplayed(messageID: String) + case dailyPixel(Event, isFirst: Bool) enum Debug { @@ -305,6 +307,10 @@ extension Pixel { case invalidPayload(Configuration) case burnerTabMisplaced + + case networkProtectionRemoteMessageFetchingFailed + case networkProtectionRemoteMessageStorageFailed + #if DBP case dataBrokerProtectionError @@ -470,6 +476,9 @@ extension Pixel.Event { case .networkProtectionWaitlistTermsAndConditionsAccepted: return "m_mac_netp_ev_terms_accepted" + case .networkProtectionRemoteMessageDisplayed(let messageID): + return "m_mac_netp_remote_message_displayed_\(messageID)" + case .dailyPixel(let pixel, isFirst: let isFirst): return pixel.name + (isFirst ? "_d" : "_c") } @@ -727,6 +736,9 @@ extension Pixel.Event.Debug { 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" // Stage Pixels diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 5764a7da35..b8cb223c81 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -149,7 +149,8 @@ extension Pixel.Event { .networkProtectionWaitlistNotificationShown, .networkProtectionWaitlistNotificationTapped, .networkProtectionWaitlistTermsAndConditionsDisplayed, - .networkProtectionWaitlistTermsAndConditionsAccepted: + .networkProtectionWaitlistTermsAndConditionsAccepted, + .networkProtectionRemoteMessageDisplayed: return nil } } From 996f9164d36cef3eba8968978e334703a9a1bd2b Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 21 Sep 2023 20:23:35 -0700 Subject: [PATCH 07/24] Add extra pixels. --- DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift | 5 +++++ DuckDuckGo/Statistics/PixelEvent.swift | 6 ++++++ DuckDuckGo/Statistics/PixelParameters.swift | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index db4468d06a..a950527bfc 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -192,6 +192,9 @@ extension HomePage.Models { if let surveyURLString = message.surveyURL, let surveyURL = URL(string: surveyURLString) { let tab = Tab(content: .url(surveyURL), shouldLoadInBackground: true) tabCollectionViewModel.append(tab: tab) + Pixel.fire(.networkProtectionRemoteMessageOpened(messageID: message.id)) + } else { + Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: message.id)) } // Dismiss the message after the user opens the survey, even if they just close the tab immediately afterwards. @@ -219,6 +222,7 @@ extension HomePage.Models { shouldShowSurveyDay7 = false case .networkProtectionRemoteMessage(let message): networkProtectionRemoteMessaging.dismissRemoteMessage(with: message.id) + Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: message.id)) } refreshFeaturesMatrix() } @@ -229,6 +233,7 @@ extension HomePage.Models { for message in networkProtectionRemoteMessaging.presentableRemoteMessages() { features.append(.networkProtectionRemoteMessage(message)) + // TODO: Make this daily Pixel.fire(.networkProtectionRemoteMessageDisplayed(messageID: message.id)) } diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 18b9a5036f..47dd3485f0 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -162,6 +162,8 @@ extension Pixel { case networkProtectionWaitlistTermsAndConditionsAccepted case networkProtectionRemoteMessageDisplayed(messageID: String) + case networkProtectionRemoteMessageDismissed(messageID: String) + case networkProtectionRemoteMessageOpened(messageID: String) case dailyPixel(Event, isFirst: Bool) @@ -478,6 +480,10 @@ extension Pixel.Event { 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)" case .dailyPixel(let pixel, isFirst: let isFirst): return pixel.name + (isFirst ? "_d" : "_c") diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index b8cb223c81..1fffa3b616 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -150,7 +150,9 @@ extension Pixel.Event { .networkProtectionWaitlistNotificationTapped, .networkProtectionWaitlistTermsAndConditionsDisplayed, .networkProtectionWaitlistTermsAndConditionsAccepted, - .networkProtectionRemoteMessageDisplayed: + .networkProtectionRemoteMessageDisplayed, + .networkProtectionRemoteMessageDismissed, + .networkProtectionRemoteMessageOpened: return nil } } From e61d4931d6e8eec82c0924856db7a370504edd17 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 13:25:10 -0700 Subject: [PATCH 08/24] Resolve the daily pixel TODO. --- DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index f2a99a7cb4..7fe683600e 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -233,8 +233,11 @@ extension HomePage.Models { for message in networkProtectionRemoteMessaging.presentableRemoteMessages() { features.append(.networkProtectionRemoteMessage(message)) - // TODO: Make this daily - Pixel.fire(.networkProtectionRemoteMessageDisplayed(messageID: message.id)) + DailyPixel.fire( + pixel: .networkProtectionRemoteMessageDisplayed(messageID: message.id), + frequency: .dailyOnly, + includeAppVersionParameter: true + ) } for feature in listOfFeatures { From 6ab096df0ca9c0d0827f5222976fc07c2374c1ab Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 13:26:09 -0700 Subject: [PATCH 09/24] Resolve a compiler warning. --- .../BothAppTargets/NetworkProtectionAppEvents.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionAppEvents.swift index 680e0ee38b..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() - let keychainStore = NetworkProtectionKeychainTokenStore() - guard featureVisibility.isNetworkProtectionVisible() else { featureVisibility.disableForAllUsers() return } + let loginItemsManager = LoginItemsManager() restartNetworkProtectionIfVersionChanged(using: loginItemsManager) refreshNetworkProtectionServers() } From de976a552b1d4a04f115e9b7b49093982a558ec0 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 16:19:12 -0700 Subject: [PATCH 10/24] Rate limit how often the app can fetch NetP messages. --- DuckDuckGo.xcodeproj/project.pbxproj | 8 ++ .../Utilities/RateLimitedOperation.swift | 87 +++++++++++++++++++ .../NetworkProtectionRemoteMessaging.swift | 49 +++++------ 3 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 DuckDuckGo/Common/Utilities/RateLimitedOperation.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9b6a2f2f55..f148ce62a2 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2060,6 +2060,9 @@ 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 */; }; + 4BD57BFC2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; + 4BD57BFD2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; + 4BD57BFE2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.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 */; }; @@ -3433,6 +3436,7 @@ 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 = ""; }; + 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedOperation.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 = ""; }; @@ -5411,6 +5415,7 @@ 4BB88B4F25B7BA2B006F6B06 /* TabInstrumentation.swift */, 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */, 4B7A94B329C16294000C7D4C /* ErrorWithParameters.swift */, + 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */, ); path = Utilities; sourceTree = ""; @@ -9443,6 +9448,7 @@ 3192A1F42A4C4CFF0084EA89 /* CountryList.swift in Sources */, 3192A1F52A4C4CFF0084EA89 /* PreferencesSection.swift in Sources */, 3192A1F62A4C4CFF0084EA89 /* NetworkProtectionNavBarButtonModel.swift in Sources */, + 4BD57BFE2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */, 3192A1F72A4C4CFF0084EA89 /* AutoconsentManagement.swift in Sources */, 3192A1F82A4C4CFF0084EA89 /* UserText+NetworkProtection.swift in Sources */, 3192A1F92A4C4CFF0084EA89 /* WebViewContainerView.swift in Sources */, @@ -9554,6 +9560,7 @@ 3706FACF293F65D500E42796 /* AddEditFavoriteWindow.swift in Sources */, 7BFE95552A9DF2990081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, 3706FAD1293F65D500E42796 /* VisitViewModel.swift in Sources */, + 4BD57BFD2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */, 3706FAD2293F65D500E42796 /* Atb.swift in Sources */, 3706FAD3293F65D500E42796 /* DownloadsViewController.swift in Sources */, 3706FAD4293F65D500E42796 /* DataExtension.swift in Sources */, @@ -11189,6 +11196,7 @@ 4B677442255DBEEA00025BD8 /* Database.swift in Sources */, 4BE5336E286915A10019DBFD /* HorizontallyCenteredLayout.swift in Sources */, 4B92928B26670D1700AD2C21 /* BookmarksOutlineView.swift in Sources */, + 4BD57BFC2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */, 4BF01C00272AE74C00884A61 /* CountryList.swift in Sources */, 37CD54CC27F2FDD100F1F7B9 /* PreferencesSection.swift in Sources */, 4B4D60B62A0C847D00BCD287 /* NetworkProtectionNavBarButtonModel.swift in Sources */, diff --git a/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift b/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift new file mode 100644 index 0000000000..ce69ec2901 --- /dev/null +++ b/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift @@ -0,0 +1,87 @@ +// +// RateLimitedOperation.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 + +typealias RateLimitedOperationCompletion = () -> Void + +protocol RateLimitedOperation { + func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) +} + +final class UserDefaultsRateLimitedOperation: RateLimitedOperation { + + enum Constants { + static let userDefaultsPreviewKey = "rate-limited-operation.last-operation-timestamp" + } + + private let minimumTimeSinceLastOperation: TimeInterval + private let userDefaults: UserDefaults + + convenience init(debug: TimeInterval, release: TimeInterval) { + #if DEBUG || REVIEW + self.init(minimumTimeSinceLastOperation: debug) + #else + self.init(minimumTimeSinceLastOperation: release) + #endif + } + + init(minimumTimeSinceLastOperation: TimeInterval, userDefaults: UserDefaults = .standard) { + self.minimumTimeSinceLastOperation = minimumTimeSinceLastOperation + self.userDefaults = userDefaults + } + + // MARK: - RateLimitedOperation + + func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) { + // First, check whether there is an existing last refresh date and if it's greater than the current date when adding the minimum refresh time. + if let lastRefreshDate = lastRefreshDate(forOperationName: operationName), + lastRefreshDate.addingTimeInterval(minimumTimeSinceLastOperation) > Date() { + return + } + + operation { + self.updateLastRefreshDate(forOperationName: operationName) + } + } + + private func lastRefreshDate(forOperationName operationName: String) -> Date? { + let key = userDefaultsKey(operationName: operationName) + + guard let object = userDefaults.object(forKey: key) else { + return nil + } + + guard let date = object as? Date else { + assertionFailure("Got rate limited date, but couldn't convert it to Date") + return nil + } + + return date + } + + private func updateLastRefreshDate(forOperationName operationName: String) { + let key = userDefaultsKey(operationName: operationName) + userDefaults.setValue(Date(), forKey: key) + } + + private func userDefaultsKey(operationName: String) -> String { + return "\(Constants.userDefaultsPreviewKey).\(operationName)" + } + +} diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index 8ae8bac1a6..15707253e9 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -19,10 +19,6 @@ import Foundation import Networking -extension Notification.Name { - static let NetworkProtectionRemoteMessagesChanged = NSNotification.Name("NetworkProtectionRemoteMessagesChanged") -} - protocol NetworkProtectionRemoteMessaging { func fetchRemoteMessages() @@ -33,24 +29,27 @@ protocol NetworkProtectionRemoteMessaging { final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging { - enum NetworkProtectionRemoteMessagingError { - case test + enum Constants { + static let remoteMessagingRateLimitedOperationKey = "network-protection.remote-messaging.fetch" } private let messageRequest: NetworkProtectionRemoteMessagingRequest private let messageStorage: NetworkProtectionRemoteMessagingStorage private let waitlistActivationDateStore: WaitlistActivationDateStore + private let rateLimitedOperation: RateLimitedOperation private let userDefaults: UserDefaults init( messageRequest: NetworkProtectionRemoteMessagingRequest = DefaultNetworkProtectionRemoteMessagingRequest(), messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage(), waitlistActivationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore(), + rateLimitedOperation: RateLimitedOperation = UserDefaultsRateLimitedOperation(debug: .seconds(30), release: .hours(8)), userDefaults: UserDefaults = .standard ) { self.messageRequest = messageRequest self.messageStorage = messageStorage self.waitlistActivationDateStore = waitlistActivationDateStore + self.rateLimitedOperation = rateLimitedOperation self.userDefaults = userDefaults } @@ -61,29 +60,29 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess return } - // TODO: Don't fetch messages if this has already been done recently - - messageRequest.fetchNetworkProtectionRemoteMessages { [weak self] result in - guard let self else { return } + rateLimitedOperation.performRateLimitedOperation(operationName: Constants.remoteMessagingRateLimitedOperationKey) { completion in + self.messageRequest.fetchNetworkProtectionRemoteMessages { [weak self] result in + defer { + completion() + } - switch result { - case .success(let messages): - do { - try self.messageStorage.store(messages: messages) + guard let self else { return } - DispatchQueue.main.async { - NotificationCenter.default.post(name: .NetworkProtectionRemoteMessagesChanged, object: nil) + switch result { + case .success(let messages): + do { + try self.messageStorage.store(messages: messages) + } 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 { + return } - } 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 { - return - } - Pixel.fire(.debug(event: .networkProtectionRemoteMessageFetchingFailed, error: error)) + Pixel.fire(.debug(event: .networkProtectionRemoteMessageFetchingFailed, error: error)) + } } } #endif From 893f913e179133b9e2bc52743874e1b6860fe989 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 16:40:33 -0700 Subject: [PATCH 11/24] Begin filling out the test suite. --- DuckDuckGo.xcodeproj/project.pbxproj | 6 ++ ...workProtectionRemoteMessagingStorage.swift | 9 ++- ...rotectionRemoteMessagingStorageTests.swift | 71 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index f148ce62a2..2953185636 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2063,6 +2063,8 @@ 4BD57BFC2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; 4BD57BFD2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; 4BD57BFE2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; + 4BD57C012AC0FF7500B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */; }; + 4BD57C022AC0FFCD00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.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 */; }; @@ -3437,6 +3439,7 @@ 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 = ""; }; 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedOperation.swift; sourceTree = ""; }; + 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionRemoteMessagingStorageTests.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 = ""; }; @@ -5514,6 +5517,7 @@ children = ( 4BCF15E62ABB98A20083F6DF /* Resources */, 4BCF15E42ABB98990083F6DF /* NetworkProtectionRemoteMessageTests.swift */, + 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */, ); path = NetworkProtection; sourceTree = ""; @@ -10321,6 +10325,7 @@ 5682C69429B79B57004DE3C8 /* TabBarViewItemTests.swift in Sources */, 3706FE77293F661700E42796 /* PreferencesSidebarModelTests.swift in Sources */, 3706FE78293F661700E42796 /* HistoryCoordinatingMock.swift in Sources */, + 4BD57C022AC0FFCD00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */, 3706FE79293F661700E42796 /* AppearancePreferencesTests.swift in Sources */, 3706FE7A293F661700E42796 /* FirePopoverViewModelTests.swift in Sources */, 3706FE7B293F661700E42796 /* HistoryStoringMock.swift in Sources */, @@ -11315,6 +11320,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/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift index 25d8ab2401..fdc4380fc0 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift @@ -36,12 +36,17 @@ final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRem } private let userDefaults: UserDefaults - private var messagesURL: URL { + private let messagesURL: URL + private static var applicationSupportURL: URL { URL.sandboxApplicationSupportURL.appendingPathComponent(Constants.networkProtectionMessagesFileName) } - init(userDefaults: UserDefaults = .standard) { + init( + userDefaults: UserDefaults = .standard, + messagesURL: URL = DefaultNetworkProtectionRemoteMessagingStorage.applicationSupportURL + ) { self.userDefaults = userDefaults + self.messagesURL = messagesURL } func store(messages: [NetworkProtectionRemoteMessage]) throws { diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift new file mode 100644 index 0000000000..5753e4c799 --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift @@ -0,0 +1,71 @@ +// +// 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 { + NetworkProtectionRemoteMessage( + id: id, + cardTitle: "Title", + cardDescription: "Desc", + cardAction: "Action", + daysSinceNetworkProtectionEnabled: 0, + surveyURL: nil + ) + } + +} From 8efadfbf54ba2dcbcc45f6c7dc1589dac8685a7c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 16:43:20 -0700 Subject: [PATCH 12/24] Avoid running NetP tests in App Store builds. --- DuckDuckGo.xcodeproj/project.pbxproj | 2 -- .../Plugins/InputFilesChecker/InputFilesChecker.swift | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 2953185636..dd7adcc5a6 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2064,7 +2064,6 @@ 4BD57BFD2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; 4BD57BFE2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; 4BD57C012AC0FF7500B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */; }; - 4BD57C022AC0FFCD00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.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 */; }; @@ -10325,7 +10324,6 @@ 5682C69429B79B57004DE3C8 /* TabBarViewItemTests.swift in Sources */, 3706FE77293F661700E42796 /* PreferencesSidebarModelTests.swift in Sources */, 3706FE78293F661700E42796 /* HistoryCoordinatingMock.swift in Sources */, - 4BD57C022AC0FFCD00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift in Sources */, 3706FE79293F661700E42796 /* AppearancePreferencesTests.swift in Sources */, 3706FE7A293F661700E42796 /* FirePopoverViewModelTests.swift in Sources */, 3706FE7B293F661700E42796 /* HistoryStoringMock.swift in Sources */, diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index 13decb32a7..ae69378877 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -61,6 +61,7 @@ let extraInputFiles: [TargetName: Set] = [ .init("BWEncryptionTests.swift", .source), .init("WKWebViewPrivateMethodsAvailabilityTests.swift", .source), .init("NetworkProtectionRemoteMessageTests.swift", .source), + .init("NetworkProtectionRemoteMessagingStorageTests.swift", .source), .init("network-protection-messages.json", .resource) ], From e69499d8c0b4a362e956d79da1dca6abb71f56c0 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 17:35:34 -0700 Subject: [PATCH 13/24] Fix App Store builds. --- .../NetworkProtectionRemoteMessaging.swift | 7 +++++-- .../Waitlist/Storage/WaitlistActivationDateStore.swift | 4 ---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index 15707253e9..981d1ebc7c 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -35,6 +35,7 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess private let messageRequest: NetworkProtectionRemoteMessagingRequest private let messageStorage: NetworkProtectionRemoteMessagingStorage + private let waitlistStorage: WaitlistStorage private let waitlistActivationDateStore: WaitlistActivationDateStore private let rateLimitedOperation: RateLimitedOperation private let userDefaults: UserDefaults @@ -42,12 +43,14 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess init( messageRequest: NetworkProtectionRemoteMessagingRequest = DefaultNetworkProtectionRemoteMessagingRequest(), messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage(), + waitlistStorage: WaitlistStorage = WaitlistKeychainStore(waitlistIdentifier: ""), waitlistActivationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore(), rateLimitedOperation: RateLimitedOperation = UserDefaultsRateLimitedOperation(debug: .seconds(30), release: .hours(8)), userDefaults: UserDefaults = .standard ) { self.messageRequest = messageRequest self.messageStorage = messageStorage + self.waitlistStorage = waitlistStorage self.waitlistActivationDateStore = waitlistActivationDateStore self.rateLimitedOperation = rateLimitedOperation self.userDefaults = userDefaults @@ -55,8 +58,8 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess func fetchRemoteMessages() { #if NETWORK_PROTECTION - // Don't fetch messages if the user hasn't used NetP - guard waitlistActivationDateStore.daysSinceActivation() != nil else { + // 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 { return } diff --git a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift index cadcf7e65e..6cde01d146 100644 --- a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift +++ b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift @@ -16,8 +16,6 @@ // limitations under the License. // -#if NETWORK_PROTECTION - import Foundation protocol WaitlistActivationDateStore { @@ -69,5 +67,3 @@ struct DefaultWaitlistActivationDateStore: WaitlistActivationDateStore { } } - -#endif From d541e04ba62906ee9c8093825c80d4fcd67c15d2 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 18:57:01 -0700 Subject: [PATCH 14/24] Unit test the main remote messaging class. --- DuckDuckGo.xcodeproj/project.pbxproj | 6 + .../Model/HomePageContinueSetUpModel.swift | 4 +- .../NetworkProtectionRemoteMessaging.swift | 17 +- .../HomePage/ContinueSetUpModelTests.swift | 10 +- ...etworkProtectionRemoteMessagingTests.swift | 310 ++++++++++++++++++ 5 files changed, 333 insertions(+), 14 deletions(-) create mode 100644 UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index dd7adcc5a6..8c3a01e5b0 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2064,6 +2064,8 @@ 4BD57BFD2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; 4BD57BFE2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; 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 */; }; @@ -3439,6 +3441,7 @@ 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = BookmarksBar.storyboard; sourceTree = ""; }; 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedOperation.swift; 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 = ""; }; @@ -5517,6 +5520,7 @@ 4BCF15E62ABB98A20083F6DF /* Resources */, 4BCF15E42ABB98990083F6DF /* NetworkProtectionRemoteMessageTests.swift */, 4BD57BFF2AC0FC4E00B580EE /* NetworkProtectionRemoteMessagingStorageTests.swift */, + 4BD57C032AC112DF00B580EE /* NetworkProtectionRemoteMessagingTests.swift */, ); path = NetworkProtection; sourceTree = ""; @@ -10314,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 */, @@ -11228,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 */, diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index 7fe683600e..250bf92de5 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -198,7 +198,7 @@ extension HomePage.Models { } // Dismiss the message after the user opens the survey, even if they just close the tab immediately afterwards. - networkProtectionRemoteMessaging.dismissRemoteMessage(with: message.id) + networkProtectionRemoteMessaging.dismiss(message: message) refreshFeaturesMatrix() } } @@ -221,7 +221,7 @@ extension HomePage.Models { case .surveyDay7: shouldShowSurveyDay7 = false case .networkProtectionRemoteMessage(let message): - networkProtectionRemoteMessaging.dismissRemoteMessage(with: message.id) + networkProtectionRemoteMessaging.dismiss(message: message) Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: message.id)) } refreshFeaturesMatrix() diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index 981d1ebc7c..43d7db3521 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -21,9 +21,9 @@ import Networking protocol NetworkProtectionRemoteMessaging { - func fetchRemoteMessages() + func fetchRemoteMessages(completion: (() -> Void)?) func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] - func dismissRemoteMessage(with id: String) + func dismiss(message: NetworkProtectionRemoteMessage) } @@ -56,17 +56,19 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess self.userDefaults = userDefaults } - func fetchRemoteMessages() { + 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 { return } - rateLimitedOperation.performRateLimitedOperation(operationName: Constants.remoteMessagingRateLimitedOperationKey) { completion in + rateLimitedOperation.performRateLimitedOperation(operationName: Constants.remoteMessagingRateLimitedOperationKey) { operationCompletion in self.messageRequest.fetchNetworkProtectionRemoteMessages { [weak self] result in defer { - completion() + operationCompletion() + fetchCompletion?() } guard let self else { return } @@ -88,6 +90,7 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess } } } + #endif } @@ -125,9 +128,9 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess #endif } - func dismissRemoteMessage(with id: String) { + func dismiss(message: NetworkProtectionRemoteMessage) { #if NETWORK_PROTECTION - messageStorage.dismissRemoteMessage(with: id) + messageStorage.dismissRemoteMessage(with: message.id) #endif } diff --git a/UnitTests/HomePage/ContinueSetUpModelTests.swift b/UnitTests/HomePage/ContinueSetUpModelTests.swift index 783cffeea0..690592f9bb 100644 --- a/UnitTests/HomePage/ContinueSetUpModelTests.swift +++ b/UnitTests/HomePage/ContinueSetUpModelTests.swift @@ -22,17 +22,17 @@ import BrowserServicesKit final class MockNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging { - func fetchRemoteMessages() { + var messages: [NetworkProtectionRemoteMessage] = [] + func fetchRemoteMessages(completion fetchCompletion: (() -> Void)? = nil) { + fetchCompletion?() } func presentableRemoteMessages() -> [NetworkProtectionRemoteMessage] { - return [] + messages } - func dismissRemoteMessage(with id: String) { - - } + func dismiss(message: NetworkProtectionRemoteMessage) {} } diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift new file mode 100644 index 0000000000..efbb8f2ff6 --- /dev/null +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift @@ -0,0 +1,310 @@ +// +// 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. +// + +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 mockRateLimitedOperation = MockRateLimitedOperation() + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + rateLimitedOperation: mockRateLimitedOperation, + userDefaults: defaults + ) + + XCTAssertTrue(!waitlistStorage.isWaitlistUser) + + let expectation = expectation(description: "Remote Message Fetch") + expectation.isInverted = true + + messaging.fetchRemoteMessages { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.3) + + XCTAssertFalse(request.didFetchMessages) + XCTAssertFalse(mockRateLimitedOperation.operationRan) + } + + func testWhenFetchingRemoteMessages_AndTheUserDidSignUpViaWaitlist_ButUserHasNotActivatedNetP_ThenMessagesAreNotFetched() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + let mockRateLimitedOperation = MockRateLimitedOperation() + + waitlistStorage.store(waitlistToken: "token") + waitlistStorage.store(waitlistTimestamp: 123) + waitlistStorage.store(inviteCode: "ABCD1234") + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + rateLimitedOperation: mockRateLimitedOperation, + userDefaults: defaults + ) + + XCTAssertTrue(waitlistStorage.isWaitlistUser) + XCTAssertNil(activationDateStorage.daysSinceActivation()) + + let expectation = expectation(description: "Remote Message Fetch") + expectation.isInverted = true + + messaging.fetchRemoteMessages { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.3) + + XCTAssertFalse(request.didFetchMessages) + XCTAssertFalse(mockRateLimitedOperation.operationRan) + } + + func testWhenFetchingRemoteMessages_AndWaitlistUserHasActivatedNetP_ThenMessagesAreFetched_AndMessagesAreStored() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + let mockRateLimitedOperation = MockRateLimitedOperation() + + let messages = [mockMessage(id: "123")] + + request.result = .success(messages) + waitlistStorage.store(waitlistToken: "token") + waitlistStorage.store(waitlistTimestamp: 123) + waitlistStorage.store(inviteCode: "ABCD1234") + activationDateStorage.days = 10 + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + rateLimitedOperation: mockRateLimitedOperation, + 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) + XCTAssertTrue(mockRateLimitedOperation.operationRan) + XCTAssertEqual(storage.storedMessages(), messages) + } + + func testWhenFetchingRemoteMessages_AndWaitlistUserHasActivatedNetP_ButRateLimitedOperationCannotRunAgain_ThenMessagesAreNotFetched() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + let mockRateLimitedOperation = MockRateLimitedOperation() + + waitlistStorage.store(waitlistToken: "token") + waitlistStorage.store(waitlistTimestamp: 123) + waitlistStorage.store(inviteCode: "ABCD1234") + activationDateStorage.days = 10 + + mockRateLimitedOperation.shouldRunOperation = false + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + rateLimitedOperation: mockRateLimitedOperation, + userDefaults: defaults + ) + + XCTAssertTrue(waitlistStorage.isWaitlistUser) + XCTAssertNotNil(activationDateStorage.daysSinceActivation()) + + let expectation = expectation(description: "Remote Message Fetch") + expectation.isInverted = true + + messaging.fetchRemoteMessages { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.3) + + XCTAssertFalse(request.didFetchMessages) + XCTAssertFalse(mockRateLimitedOperation.operationRan) + XCTAssertEqual(storage.storedMessages(), []) + } + + func testWhenStoredMessagesExist_AndSomeMessagesHaveBeenDismissed_ThenPresentableMessagesDoNotIncludeDismissedMessages() { + let request = MockNetworkProtectionRemoteMessagingRequest() + let storage = MockNetworkProtectionRemoteMessagingStorage() + let waitlistStorage = MockWaitlistStorage() + let activationDateStorage = MockWaitlistActivationDateStore() + let mockRateLimitedOperation = MockRateLimitedOperation() + + let dismissedMessage = mockMessage(id: "123") + let activeMessage = mockMessage(id: "456") + try? storage.store(messages: [dismissedMessage, activeMessage]) + activationDateStorage.days = 10 + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + rateLimitedOperation: mockRateLimitedOperation, + 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 mockRateLimitedOperation = MockRateLimitedOperation() + + let hiddenMessage = mockMessage(id: "123", daysSinceNetworkProtectionEnabled: 10) + let activeMessage = mockMessage(id: "456") + try? storage.store(messages: [hiddenMessage, activeMessage]) + activationDateStorage.days = 5 + + let messaging = DefaultNetworkProtectionRemoteMessaging( + messageRequest: request, + messageStorage: storage, + waitlistStorage: waitlistStorage, + waitlistActivationDateStore: activationDateStorage, + rateLimitedOperation: mockRateLimitedOperation, + userDefaults: defaults + ) + + let presentableMessagesAfter = messaging.presentableRemoteMessages() + XCTAssertEqual(presentableMessagesAfter, [activeMessage]) + } + + private func mockMessage(id: String, daysSinceNetworkProtectionEnabled: Int = 0) -> NetworkProtectionRemoteMessage { + NetworkProtectionRemoteMessage( + id: id, + cardTitle: "Title", + cardDescription: "Desc", + cardAction: "Action", + daysSinceNetworkProtectionEnabled: daysSinceNetworkProtectionEnabled, + surveyURL: nil + ) + } + +} + +// 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 + } + +} + +private final class MockWaitlistActivationDateStore: WaitlistActivationDateStore { + + var days: Int? + + func daysSinceActivation() -> Int? { + days + } + +} + +private final class MockRateLimitedOperation: RateLimitedOperation { + + var shouldRunOperation: Bool = true + var operationRan: Bool = false + + func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) { + guard shouldRunOperation else { + return + } + + operation { + self.operationRan = true + } + } + +} From b4ff88a0940a82a75a1023c9dc6955f60e1a1f4c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 24 Sep 2023 19:06:47 -0700 Subject: [PATCH 15/24] Get the App Store test suite compiling. --- .../NetworkProtectionRemoteMessagingTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift index efbb8f2ff6..607a147da7 100644 --- a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift @@ -16,6 +16,8 @@ // limitations under the License. // +#if NETWORK_PROTECTION + import XCTest @testable import DuckDuckGo_Privacy_Browser @@ -308,3 +310,5 @@ private final class MockRateLimitedOperation: RateLimitedOperation { } } + +#endif From a0aafd1e861cd33dfc8d05a50e773194878696eb Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Mon, 25 Sep 2023 09:23:55 -0700 Subject: [PATCH 16/24] Add debug menu option to open the app container --- DuckDuckGo/Menus/MainMenu.storyboard | 8 +++++++- DuckDuckGo/Menus/MainMenuActions.swift | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/Menus/MainMenu.storyboard b/DuckDuckGo/Menus/MainMenu.storyboard index 49d627817c..8d244153e1 100644 --- a/DuckDuckGo/Menus/MainMenu.storyboard +++ b/DuckDuckGo/Menus/MainMenu.storyboard @@ -1225,7 +1225,13 @@ CQ - + + + + + + + diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 980fe77055..6602bf316f 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") } From bb57b9e2096b59966631a766221e61529c224381 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Mon, 25 Sep 2023 09:24:04 -0700 Subject: [PATCH 17/24] Fix a keychain storage bug --- .../NetworkProtectionRemoteMessaging.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index 43d7db3521..6c6a954afb 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -43,7 +43,7 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess init( messageRequest: NetworkProtectionRemoteMessagingRequest = DefaultNetworkProtectionRemoteMessagingRequest(), messageStorage: NetworkProtectionRemoteMessagingStorage = DefaultNetworkProtectionRemoteMessagingStorage(), - waitlistStorage: WaitlistStorage = WaitlistKeychainStore(waitlistIdentifier: ""), + waitlistStorage: WaitlistStorage = WaitlistKeychainStore(waitlistIdentifier: "networkprotection"), waitlistActivationDateStore: WaitlistActivationDateStore = DefaultWaitlistActivationDateStore(), rateLimitedOperation: RateLimitedOperation = UserDefaultsRateLimitedOperation(debug: .seconds(30), release: .hours(8)), userDefaults: UserDefaults = .standard From e0863fab8d44a037693861be3fccc3326ae28681 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 26 Sep 2023 18:55:55 -0700 Subject: [PATCH 18/24] Add a comment for RateLimitedOperation. --- DuckDuckGo/Common/Utilities/RateLimitedOperation.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift b/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift index ce69ec2901..7470bd28fc 100644 --- a/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift +++ b/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift @@ -24,6 +24,10 @@ protocol RateLimitedOperation { func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) } +/// This class defines a way to run a block of code no more frequently than the interval you specify. For example, you may want to have a function that fetches some remote resource occur no more +/// frequently than every 8 hours. +/// +/// - Note: This class does not yet support a way to indicate whether the operation succeeded. A future enhancement would be to add this, so that the operation can try again in the case of failure. final class UserDefaultsRateLimitedOperation: RateLimitedOperation { enum Constants { From 8716068ff00938f39b657a5b84eff74a0bf77d39 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 26 Sep 2023 19:35:31 -0700 Subject: [PATCH 19/24] Add reset options for NetP remote messaging. --- DuckDuckGo/Common/Utilities/RateLimitedOperation.swift | 5 +++++ DuckDuckGo/Menus/MainMenu.storyboard | 6 ++++++ DuckDuckGo/Menus/MainMenuActions.swift | 6 ++++++ .../BothAppTargets/NetworkProtectionDebugUtilities.swift | 3 +++ .../NetworkProtectionRemoteMessagingStorage.swift | 5 +++++ 5 files changed, 25 insertions(+) diff --git a/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift b/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift index 7470bd28fc..d880f56807 100644 --- a/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift +++ b/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift @@ -88,4 +88,9 @@ final class UserDefaultsRateLimitedOperation: RateLimitedOperation { return "\(Constants.userDefaultsPreviewKey).\(operationName)" } + func resetTimestamp(forOperationName name: String) { + let key = userDefaultsKey(operationName: name) + userDefaults.removeObject(forKey: key) + } + } diff --git a/DuckDuckGo/Menus/MainMenu.storyboard b/DuckDuckGo/Menus/MainMenu.storyboard index 8d244153e1..c0f890a13d 100644 --- a/DuckDuckGo/Menus/MainMenu.storyboard +++ b/DuckDuckGo/Menus/MainMenu.storyboard @@ -887,6 +887,12 @@ CQ + + + + + + diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 6602bf316f..f0fab5596b 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -784,6 +784,12 @@ extension MainViewController { overrideNetworkProtectionActivationDate(to: nil) } + @IBAction func resetNetworkProtectionRemoteMessages(_ sender: Any?) { + DefaultNetworkProtectionRemoteMessagingStorage().removeStoredAndDismissedMessages() + let operationName = DefaultNetworkProtectionRemoteMessaging.Constants.remoteMessagingRateLimitedOperationKey + UserDefaultsRateLimitedOperation(debug: 0, release: 0).resetTimestamp(forOperationName: operationName) + } + @IBAction func overrideNetworkProtectionActivationDateToNow(_ sender: Any?) { overrideNetworkProtectionActivationDate(to: Date()) } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift index 93ed108681..211f14a6ec 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().removeActivationDate() + DefaultNetworkProtectionRemoteMessagingStorage().removeStoredAndDismissedMessages() + UserDefaults().removeObject(forKey: UserDefaultsWrapper.Key.networkProtectionTermsAndConditionsAccepted.rawValue) NotificationCenter.default.post(name: .networkProtectionWaitlistAccessChanged, object: nil) } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift index fdc4380fc0..138d0572ac 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessagingStorage.swift @@ -83,4 +83,9 @@ final class DefaultNetworkProtectionRemoteMessagingStorage: NetworkProtectionRem return messages ?? [] } + func removeStoredAndDismissedMessages() { + userDefaults.removeObject(forKey: Constants.dismissedMessageIdentifiersKey) + try? FileManager.default.removeItem(at: messagesURL) + } + } From 69a6e031f49f4e0b70b787faa7861caed9f9bba6 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Tue, 26 Sep 2023 19:44:10 -0700 Subject: [PATCH 20/24] Add a way to reset daily pixels. --- DuckDuckGo/Menus/MainMenu.storyboard | 6 ++++++ DuckDuckGo/Menus/MainMenuActions.swift | 4 ++++ DuckDuckGo/Statistics/DailyPixel.swift | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/Menus/MainMenu.storyboard b/DuckDuckGo/Menus/MainMenu.storyboard index c0f890a13d..9e9dd04263 100644 --- a/DuckDuckGo/Menus/MainMenu.storyboard +++ b/DuckDuckGo/Menus/MainMenu.storyboard @@ -804,6 +804,12 @@ CQ + + + + + + diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index f0fab5596b..e58c97af76 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -692,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) } 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" } From b0922332639f9a0c910029c3ba71a3fffb5e8820 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 27 Sep 2023 08:45:14 -0700 Subject: [PATCH 21/24] Simplify the continue set up model. --- .../Model/HomePageContinueSetUpModel.swift | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index 250bf92de5..6ca3d26388 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -189,17 +189,7 @@ extension HomePage.Models { case .surveyDay7: visitSurvey(day: .day7) case .networkProtectionRemoteMessage(let message): - if let surveyURLString = message.surveyURL, let surveyURL = URL(string: surveyURLString) { - let tab = Tab(content: .url(surveyURL), shouldLoadInBackground: true) - tabCollectionViewModel.append(tab: tab) - Pixel.fire(.networkProtectionRemoteMessageOpened(messageID: message.id)) - } else { - Pixel.fire(.networkProtectionRemoteMessageDismissed(messageID: message.id)) - } - - // Dismiss the message after the user opens the survey, even if they just close the tab immediately afterwards. - networkProtectionRemoteMessaging.dismiss(message: message) - refreshFeaturesMatrix() + handle(remoteMessage: message) } } // swiftlint:enable cyclomatic_complexity @@ -397,6 +387,20 @@ extension HomePage.Models { } } } + + @MainActor private func handle(remoteMessage: NetworkProtectionRemoteMessage) { + if let surveyURLString = remoteMessage.surveyURL, let surveyURL = URL(string: surveyURLString) { + 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 From 3f409507cb0cf1112f874fbca36506ae598743ad Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Wed, 27 Sep 2023 09:21:45 -0700 Subject: [PATCH 22/24] Remove RateLimitedOperation. --- DuckDuckGo.xcodeproj/project.pbxproj | 8 -- .../Utilities/RateLimitedOperation.swift | 96 ------------------- DuckDuckGo/Menus/MainMenuActions.swift | 3 +- .../NetworkProtectionRemoteMessaging.swift | 89 ++++++++++++----- ...etworkProtectionRemoteMessagingTests.swift | 50 ++-------- 5 files changed, 74 insertions(+), 172 deletions(-) delete mode 100644 DuckDuckGo/Common/Utilities/RateLimitedOperation.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index a7f26a5ab9..dc1c98cc04 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2058,9 +2058,6 @@ 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 */; }; - 4BD57BFC2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; - 4BD57BFD2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; - 4BD57BFE2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */; }; 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 */; }; @@ -3439,7 +3436,6 @@ 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 = ""; }; - 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedOperation.swift; 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 = ""; }; @@ -5420,7 +5416,6 @@ 4BB88B4F25B7BA2B006F6B06 /* TabInstrumentation.swift */, 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */, 4B7A94B329C16294000C7D4C /* ErrorWithParameters.swift */, - 4BD57BFB2AC0EA1A00B580EE /* RateLimitedOperation.swift */, ); path = Utilities; sourceTree = ""; @@ -9455,7 +9450,6 @@ 3192A1F42A4C4CFF0084EA89 /* CountryList.swift in Sources */, 3192A1F52A4C4CFF0084EA89 /* PreferencesSection.swift in Sources */, 3192A1F62A4C4CFF0084EA89 /* NetworkProtectionNavBarButtonModel.swift in Sources */, - 4BD57BFE2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */, 3192A1F72A4C4CFF0084EA89 /* AutoconsentManagement.swift in Sources */, 3192A1F82A4C4CFF0084EA89 /* UserText+NetworkProtection.swift in Sources */, 3192A1F92A4C4CFF0084EA89 /* WebViewContainerView.swift in Sources */, @@ -9567,7 +9561,6 @@ 3706FACF293F65D500E42796 /* AddEditFavoriteWindow.swift in Sources */, 7BFE95552A9DF2990081ABE9 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, 3706FAD1293F65D500E42796 /* VisitViewModel.swift in Sources */, - 4BD57BFD2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */, 3706FAD2293F65D500E42796 /* Atb.swift in Sources */, 3706FAD3293F65D500E42796 /* DownloadsViewController.swift in Sources */, 3706FAD4293F65D500E42796 /* DataExtension.swift in Sources */, @@ -11204,7 +11197,6 @@ 4B677442255DBEEA00025BD8 /* Database.swift in Sources */, 4BE5336E286915A10019DBFD /* HorizontallyCenteredLayout.swift in Sources */, 4B92928B26670D1700AD2C21 /* BookmarksOutlineView.swift in Sources */, - 4BD57BFC2AC0EA1A00B580EE /* RateLimitedOperation.swift in Sources */, 4BF01C00272AE74C00884A61 /* CountryList.swift in Sources */, 37CD54CC27F2FDD100F1F7B9 /* PreferencesSection.swift in Sources */, 4B4D60B62A0C847D00BCD287 /* NetworkProtectionNavBarButtonModel.swift in Sources */, diff --git a/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift b/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift deleted file mode 100644 index d880f56807..0000000000 --- a/DuckDuckGo/Common/Utilities/RateLimitedOperation.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// RateLimitedOperation.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 - -typealias RateLimitedOperationCompletion = () -> Void - -protocol RateLimitedOperation { - func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) -} - -/// This class defines a way to run a block of code no more frequently than the interval you specify. For example, you may want to have a function that fetches some remote resource occur no more -/// frequently than every 8 hours. -/// -/// - Note: This class does not yet support a way to indicate whether the operation succeeded. A future enhancement would be to add this, so that the operation can try again in the case of failure. -final class UserDefaultsRateLimitedOperation: RateLimitedOperation { - - enum Constants { - static let userDefaultsPreviewKey = "rate-limited-operation.last-operation-timestamp" - } - - private let minimumTimeSinceLastOperation: TimeInterval - private let userDefaults: UserDefaults - - convenience init(debug: TimeInterval, release: TimeInterval) { - #if DEBUG || REVIEW - self.init(minimumTimeSinceLastOperation: debug) - #else - self.init(minimumTimeSinceLastOperation: release) - #endif - } - - init(minimumTimeSinceLastOperation: TimeInterval, userDefaults: UserDefaults = .standard) { - self.minimumTimeSinceLastOperation = minimumTimeSinceLastOperation - self.userDefaults = userDefaults - } - - // MARK: - RateLimitedOperation - - func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) { - // First, check whether there is an existing last refresh date and if it's greater than the current date when adding the minimum refresh time. - if let lastRefreshDate = lastRefreshDate(forOperationName: operationName), - lastRefreshDate.addingTimeInterval(minimumTimeSinceLastOperation) > Date() { - return - } - - operation { - self.updateLastRefreshDate(forOperationName: operationName) - } - } - - private func lastRefreshDate(forOperationName operationName: String) -> Date? { - let key = userDefaultsKey(operationName: operationName) - - guard let object = userDefaults.object(forKey: key) else { - return nil - } - - guard let date = object as? Date else { - assertionFailure("Got rate limited date, but couldn't convert it to Date") - return nil - } - - return date - } - - private func updateLastRefreshDate(forOperationName operationName: String) { - let key = userDefaultsKey(operationName: operationName) - userDefaults.setValue(Date(), forKey: key) - } - - private func userDefaultsKey(operationName: String) -> String { - return "\(Constants.userDefaultsPreviewKey).\(operationName)" - } - - func resetTimestamp(forOperationName name: String) { - let key = userDefaultsKey(operationName: name) - userDefaults.removeObject(forKey: key) - } - -} diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 8f88586bee..6efa70fdcb 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -790,8 +790,7 @@ extension MainViewController { @IBAction func resetNetworkProtectionRemoteMessages(_ sender: Any?) { DefaultNetworkProtectionRemoteMessagingStorage().removeStoredAndDismissedMessages() - let operationName = DefaultNetworkProtectionRemoteMessaging.Constants.remoteMessagingRateLimitedOperationKey - UserDefaultsRateLimitedOperation(debug: 0, release: 0).resetTimestamp(forOperationName: operationName) + DefaultNetworkProtectionRemoteMessaging(minimumRefreshInterval: 0).resetLastRefreshTimestamp() } @IBAction func overrideNetworkProtectionActivationDateToNow(_ sender: Any?) { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift index 6c6a954afb..e2888f8b9a 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessaging.swift @@ -30,29 +30,37 @@ protocol NetworkProtectionRemoteMessaging { final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMessaging { enum Constants { - static let remoteMessagingRateLimitedOperationKey = "network-protection.remote-messaging.fetch" + 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 rateLimitedOperation: RateLimitedOperation + 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(), - rateLimitedOperation: RateLimitedOperation = UserDefaultsRateLimitedOperation(debug: .seconds(30), release: .hours(8)), + minimumRefreshInterval: TimeInterval, userDefaults: UserDefaults = .standard ) { self.messageRequest = messageRequest self.messageStorage = messageStorage self.waitlistStorage = waitlistStorage self.waitlistActivationDateStore = waitlistActivationDateStore - self.rateLimitedOperation = rateLimitedOperation + self.minimumRefreshInterval = minimumRefreshInterval self.userDefaults = userDefaults } @@ -61,33 +69,38 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess // 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 } - rateLimitedOperation.performRateLimitedOperation(operationName: Constants.remoteMessagingRateLimitedOperationKey) { operationCompletion in - self.messageRequest.fetchNetworkProtectionRemoteMessages { [weak self] result in - defer { - operationCompletion() - fetchCompletion?() - } + if let lastRefreshDate = lastRefreshDate(), lastRefreshDate.addingTimeInterval(minimumRefreshInterval) > Date() { + fetchCompletion?() + return + } - guard let self else { return } - - switch result { - case .success(let messages): - do { - try self.messageStorage.store(messages: messages) - } 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 { - return - } - - Pixel.fire(.debug(event: .networkProtectionRemoteMessageFetchingFailed, error: error)) + 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)) } } @@ -134,4 +147,28 @@ final class DefaultNetworkProtectionRemoteMessaging: NetworkProtectionRemoteMess #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/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift index 607a147da7..5e0dbb9e84 100644 --- a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift @@ -36,30 +36,27 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let storage = MockNetworkProtectionRemoteMessagingStorage() let waitlistStorage = MockWaitlistStorage() let activationDateStorage = MockWaitlistActivationDateStore() - let mockRateLimitedOperation = MockRateLimitedOperation() let messaging = DefaultNetworkProtectionRemoteMessaging( messageRequest: request, messageStorage: storage, waitlistStorage: waitlistStorage, waitlistActivationDateStore: activationDateStorage, - rateLimitedOperation: mockRateLimitedOperation, + minimumRefreshInterval: 0, userDefaults: defaults ) XCTAssertTrue(!waitlistStorage.isWaitlistUser) let expectation = expectation(description: "Remote Message Fetch") - expectation.isInverted = true messaging.fetchRemoteMessages { expectation.fulfill() } - wait(for: [expectation], timeout: 0.3) + wait(for: [expectation], timeout: 1.0) XCTAssertFalse(request.didFetchMessages) - XCTAssertFalse(mockRateLimitedOperation.operationRan) } func testWhenFetchingRemoteMessages_AndTheUserDidSignUpViaWaitlist_ButUserHasNotActivatedNetP_ThenMessagesAreNotFetched() { @@ -67,7 +64,6 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let storage = MockNetworkProtectionRemoteMessagingStorage() let waitlistStorage = MockWaitlistStorage() let activationDateStorage = MockWaitlistActivationDateStore() - let mockRateLimitedOperation = MockRateLimitedOperation() waitlistStorage.store(waitlistToken: "token") waitlistStorage.store(waitlistTimestamp: 123) @@ -78,7 +74,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { messageStorage: storage, waitlistStorage: waitlistStorage, waitlistActivationDateStore: activationDateStorage, - rateLimitedOperation: mockRateLimitedOperation, + minimumRefreshInterval: 0, userDefaults: defaults ) @@ -86,16 +82,14 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { XCTAssertNil(activationDateStorage.daysSinceActivation()) let expectation = expectation(description: "Remote Message Fetch") - expectation.isInverted = true messaging.fetchRemoteMessages { expectation.fulfill() } - wait(for: [expectation], timeout: 0.3) + wait(for: [expectation], timeout: 1.0) XCTAssertFalse(request.didFetchMessages) - XCTAssertFalse(mockRateLimitedOperation.operationRan) } func testWhenFetchingRemoteMessages_AndWaitlistUserHasActivatedNetP_ThenMessagesAreFetched_AndMessagesAreStored() { @@ -103,7 +97,6 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let storage = MockNetworkProtectionRemoteMessagingStorage() let waitlistStorage = MockWaitlistStorage() let activationDateStorage = MockWaitlistActivationDateStore() - let mockRateLimitedOperation = MockRateLimitedOperation() let messages = [mockMessage(id: "123")] @@ -118,7 +111,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { messageStorage: storage, waitlistStorage: waitlistStorage, waitlistActivationDateStore: activationDateStorage, - rateLimitedOperation: mockRateLimitedOperation, + minimumRefreshInterval: 0, userDefaults: defaults ) @@ -135,7 +128,6 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { wait(for: [expectation], timeout: 1.0) XCTAssertTrue(request.didFetchMessages) - XCTAssertTrue(mockRateLimitedOperation.operationRan) XCTAssertEqual(storage.storedMessages(), messages) } @@ -144,21 +136,20 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let storage = MockNetworkProtectionRemoteMessagingStorage() let waitlistStorage = MockWaitlistStorage() let activationDateStorage = MockWaitlistActivationDateStore() - let mockRateLimitedOperation = MockRateLimitedOperation() waitlistStorage.store(waitlistToken: "token") waitlistStorage.store(waitlistTimestamp: 123) waitlistStorage.store(inviteCode: "ABCD1234") activationDateStorage.days = 10 - mockRateLimitedOperation.shouldRunOperation = false + defaults.setValue(Date(), forKey: DefaultNetworkProtectionRemoteMessaging.Constants.lastRefreshDateKey) let messaging = DefaultNetworkProtectionRemoteMessaging( messageRequest: request, messageStorage: storage, waitlistStorage: waitlistStorage, waitlistActivationDateStore: activationDateStorage, - rateLimitedOperation: mockRateLimitedOperation, + minimumRefreshInterval: .days(7), // Use a large number to hit the refresh check userDefaults: defaults ) @@ -166,16 +157,14 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { XCTAssertNotNil(activationDateStorage.daysSinceActivation()) let expectation = expectation(description: "Remote Message Fetch") - expectation.isInverted = true messaging.fetchRemoteMessages { expectation.fulfill() } - wait(for: [expectation], timeout: 0.3) + wait(for: [expectation], timeout: 1.0) XCTAssertFalse(request.didFetchMessages) - XCTAssertFalse(mockRateLimitedOperation.operationRan) XCTAssertEqual(storage.storedMessages(), []) } @@ -184,7 +173,6 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let storage = MockNetworkProtectionRemoteMessagingStorage() let waitlistStorage = MockWaitlistStorage() let activationDateStorage = MockWaitlistActivationDateStore() - let mockRateLimitedOperation = MockRateLimitedOperation() let dismissedMessage = mockMessage(id: "123") let activeMessage = mockMessage(id: "456") @@ -196,7 +184,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { messageStorage: storage, waitlistStorage: waitlistStorage, waitlistActivationDateStore: activationDateStorage, - rateLimitedOperation: mockRateLimitedOperation, + minimumRefreshInterval: 0, userDefaults: defaults ) @@ -212,7 +200,6 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let storage = MockNetworkProtectionRemoteMessagingStorage() let waitlistStorage = MockWaitlistStorage() let activationDateStorage = MockWaitlistActivationDateStore() - let mockRateLimitedOperation = MockRateLimitedOperation() let hiddenMessage = mockMessage(id: "123", daysSinceNetworkProtectionEnabled: 10) let activeMessage = mockMessage(id: "456") @@ -224,7 +211,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { messageStorage: storage, waitlistStorage: waitlistStorage, waitlistActivationDateStore: activationDateStorage, - rateLimitedOperation: mockRateLimitedOperation, + minimumRefreshInterval: 0, userDefaults: defaults ) @@ -294,21 +281,4 @@ private final class MockWaitlistActivationDateStore: WaitlistActivationDateStore } -private final class MockRateLimitedOperation: RateLimitedOperation { - - var shouldRunOperation: Bool = true - var operationRan: Bool = false - - func performRateLimitedOperation(operationName: String, operation: (@escaping RateLimitedOperationCompletion) -> Void) { - guard shouldRunOperation else { - return - } - - operation { - self.operationRan = true - } - } - -} - #endif From 5bfe65846569f06e90413da19f682948fa5767fa Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 28 Sep 2023 21:50:59 -0700 Subject: [PATCH 23/24] Add survey URL parameters. --- DuckDuckGo.xcodeproj/project.pbxproj | 8 +++ .../Common/Utilities/HardwareModel.swift | 44 +++++++++++++ .../Model/HomePageContinueSetUpModel.swift | 2 +- DuckDuckGo/Menus/MainMenuActions.swift | 2 +- .../NetworkProtectionDebugUtilities.swift | 2 +- .../NetworkProtectionNavBarButtonModel.swift | 4 +- .../NetworkProtectionRemoteMessage.swift | 66 ++++++++++++++++++- .../Storage/WaitlistActivationDateStore.swift | 32 +++++++-- .../NetworkProtectionRemoteMessageTests.swift | 41 +++++++++++- ...rotectionRemoteMessagingStorageTests.swift | 21 +++--- ...etworkProtectionRemoteMessagingTests.swift | 40 ++++++----- 11 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 DuckDuckGo/Common/Utilities/HardwareModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 1aeff4b6f5..2c620dd74f 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 */; }; @@ -3362,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 = ""; }; @@ -5416,6 +5420,7 @@ 4BB88B4F25B7BA2B006F6B06 /* TabInstrumentation.swift */, 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */, 4B7A94B329C16294000C7D4C /* ErrorWithParameters.swift */, + 4B9579202AC687170062CA31 /* HardwareModel.swift */, ); path = Utilities; sourceTree = ""; @@ -9361,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 */, @@ -9620,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 */, @@ -10869,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 */, 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/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index 6ca3d26388..d61b725f1a 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -389,7 +389,7 @@ extension HomePage.Models { } @MainActor private func handle(remoteMessage: NetworkProtectionRemoteMessage) { - if let surveyURLString = remoteMessage.surveyURL, let surveyURL = URL(string: surveyURLString) { + if let surveyURL = remoteMessage.presentableSurveyURL() { let tab = Tab(content: .url(surveyURL), shouldLoadInBackground: true) tabCollectionViewModel.append(tab: tab) Pixel.fire(.networkProtectionRemoteMessageOpened(messageID: remoteMessage.id)) diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 6efa70fdcb..cec12c4a8f 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -811,7 +811,7 @@ extension MainViewController { if let date { store.updateActivationDate(date) } else { - store.removeActivationDate() + store.removeDates() } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift index 211f14a6ec..9b0d941b82 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift @@ -63,7 +63,7 @@ final class NetworkProtectionDebugUtilities { networkProtectionFeatureDisabler.disable(keepAuthToken: keepAuthToken, uninstallSystemExtension: true) NetworkProtectionWaitlist().waitlistStorage.deleteWaitlistState() - DefaultWaitlistActivationDateStore().removeActivationDate() + DefaultWaitlistActivationDateStore().removeDates() DefaultNetworkProtectionRemoteMessagingStorage().removeStoredAndDismissedMessages() UserDefaults().removeObject(forKey: UserDefaultsWrapper.Key.networkProtectionTermsAndConditionsAccepted.rawValue) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift index 8b89598611..118c29cdaa 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarButtonModel.swift @@ -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 index fb7b045054..8c97a6c897 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionRemoteMessaging/NetworkProtectionRemoteMessage.swift @@ -17,12 +17,76 @@ // 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? - let surveyURL: String? + 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/Waitlist/Storage/WaitlistActivationDateStore.swift b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift index 6cde01d146..c150581ebb 100644 --- a/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift +++ b/DuckDuckGo/Waitlist/Storage/WaitlistActivationDateStore.swift @@ -21,6 +21,7 @@ import Foundation protocol WaitlistActivationDateStore { func daysSinceActivation() -> Int? + func daysSinceLastActive() -> Int? } @@ -28,6 +29,7 @@ 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 @@ -52,18 +54,40 @@ struct DefaultWaitlistActivationDateStore: WaitlistActivationDateStore { } let activationDate = Date(timeIntervalSinceReferenceDate: timestamp) - let currentDate = Date() + return daysSince(date: activationDate) + } - let numberOfDays = Calendar.current.dateComponents([.day], from: activationDate, to: currentDate) - return numberOfDays.day + func updateLastActiveDate() { + userDefaults.set(Date(), forKey: Constants.networkProtectionLastActiveDateKey) } - func removeActivationDate() { + 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 + } + } diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift index 0e68b5b9ac..e0f75a33cb 100644 --- a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift @@ -38,7 +38,7 @@ final class NetworkProtectionRemoteMessageTests: XCTestCase { XCTAssertEqual(firstMessage.cardTitle, "Title 1") XCTAssertEqual(firstMessage.cardDescription, "Description 1") XCTAssertEqual(firstMessage.cardAction, "Action 1") - XCTAssertNil(firstMessage.surveyURL) + XCTAssertNil(firstMessage.presentableSurveyURL()) XCTAssertNil(firstMessage.daysSinceNetworkProtectionEnabled) guard let secondMessage = decodedMessages.first(where: { $0.id == "456"}) else { @@ -50,7 +50,7 @@ final class NetworkProtectionRemoteMessageTests: XCTestCase { XCTAssertEqual(secondMessage.cardTitle, "Title 2") XCTAssertEqual(secondMessage.cardDescription, "Description 2") XCTAssertEqual(secondMessage.cardAction, "Action 2") - XCTAssertNil(firstMessage.surveyURL) + XCTAssertNil(firstMessage.presentableSurveyURL()) guard let thirdMessage = decodedMessages.first(where: { $0.id == "789"}) else { XCTFail("Failed to find expected message") @@ -61,7 +61,42 @@ final class NetworkProtectionRemoteMessageTests: XCTestCase { XCTAssertEqual(thirdMessage.cardTitle, "Title 3") XCTAssertEqual(thirdMessage.cardDescription, "Description 3") XCTAssertEqual(thirdMessage.cardAction, "Action 3") - XCTAssertEqual(thirdMessage.surveyURL, "https://duckduckgo.com/") + XCTAssertEqual(thirdMessage.presentableSurveyURL()?.absoluteString, "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 { diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift index 5753e4c799..d08f95ee36 100644 --- a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingStorageTests.swift @@ -58,14 +58,19 @@ final class NetworkProtectionRemoteMessagingStorageTests: XCTestCase { } private func mockMessage(id: String) -> NetworkProtectionRemoteMessage { - NetworkProtectionRemoteMessage( - id: id, - cardTitle: "Title", - cardDescription: "Desc", - cardAction: "Action", - daysSinceNetworkProtectionEnabled: 0, - surveyURL: nil - ) + 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 index 5e0dbb9e84..c726948080 100644 --- a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessagingTests.swift @@ -104,7 +104,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { waitlistStorage.store(waitlistToken: "token") waitlistStorage.store(waitlistTimestamp: 123) waitlistStorage.store(inviteCode: "ABCD1234") - activationDateStorage.days = 10 + activationDateStorage._daysSinceActivation = 10 let messaging = DefaultNetworkProtectionRemoteMessaging( messageRequest: request, @@ -140,7 +140,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { waitlistStorage.store(waitlistToken: "token") waitlistStorage.store(waitlistTimestamp: 123) waitlistStorage.store(inviteCode: "ABCD1234") - activationDateStorage.days = 10 + activationDateStorage._daysSinceActivation = 10 defaults.setValue(Date(), forKey: DefaultNetworkProtectionRemoteMessaging.Constants.lastRefreshDateKey) @@ -177,7 +177,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let dismissedMessage = mockMessage(id: "123") let activeMessage = mockMessage(id: "456") try? storage.store(messages: [dismissedMessage, activeMessage]) - activationDateStorage.days = 10 + activationDateStorage._daysSinceActivation = 10 let messaging = DefaultNetworkProtectionRemoteMessaging( messageRequest: request, @@ -204,7 +204,7 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { let hiddenMessage = mockMessage(id: "123", daysSinceNetworkProtectionEnabled: 10) let activeMessage = mockMessage(id: "456") try? storage.store(messages: [hiddenMessage, activeMessage]) - activationDateStorage.days = 5 + activationDateStorage._daysSinceActivation = 5 let messaging = DefaultNetworkProtectionRemoteMessaging( messageRequest: request, @@ -220,14 +220,19 @@ final class NetworkProtectionRemoteMessagingTests: XCTestCase { } private func mockMessage(id: String, daysSinceNetworkProtectionEnabled: Int = 0) -> NetworkProtectionRemoteMessage { - NetworkProtectionRemoteMessage( - id: id, - cardTitle: "Title", - cardDescription: "Desc", - cardAction: "Action", - daysSinceNetworkProtectionEnabled: daysSinceNetworkProtectionEnabled, - surveyURL: nil - ) + 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)!) } } @@ -271,12 +276,17 @@ private final class MockNetworkProtectionRemoteMessagingStorage: NetworkProtecti } -private final class MockWaitlistActivationDateStore: WaitlistActivationDateStore { +final class MockWaitlistActivationDateStore: WaitlistActivationDateStore { - var days: Int? + var _daysSinceActivation: Int? + var _daysSinceLastActive: Int? func daysSinceActivation() -> Int? { - days + _daysSinceActivation + } + + func daysSinceLastActive() -> Int? { + _daysSinceLastActive } } From 793c4f3539dcda29313f1697596f783e024f6768 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 1 Oct 2023 19:47:23 -0700 Subject: [PATCH 24/24] Fix unit tests. --- .../NetworkProtectionRemoteMessageTests.swift | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift index e0f75a33cb..f9201b2de0 100644 --- a/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift +++ b/UnitTests/NetworkProtection/NetworkProtectionRemoteMessageTests.swift @@ -22,6 +22,14 @@ import XCTest 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) @@ -35,10 +43,18 @@ final class NetworkProtectionRemoteMessageTests: XCTestCase { 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(firstMessage.presentableSurveyURL()) + XCTAssertNil(firstMessagePresentableSurveyURL) XCTAssertNil(firstMessage.daysSinceNetworkProtectionEnabled) guard let secondMessage = decodedMessages.first(where: { $0.id == "456"}) else { @@ -46,22 +62,38 @@ final class NetworkProtectionRemoteMessageTests: XCTestCase { 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(firstMessage.presentableSurveyURL()) + 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") - XCTAssertEqual(thirdMessage.presentableSurveyURL()?.absoluteString, "https://duckduckgo.com/") + XCTAssertTrue(thirdMessagePresentableSurveyURL!.absoluteString.hasPrefix("https://duckduckgo.com/")) } func testWhenGettingSurveyURL_AndSurveyURLHasParameters_ThenParametersAreReplaced() {