diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 6700d76663..4dde7ee830 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -106,6 +106,11 @@ 313B6A502A6EB1B900601B75 /* HTTPCookie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA6D0FC2A1FF9A100540406 /* HTTPCookie.swift */; }; 3154FD1428E6011A00909769 /* TabShadowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3154FD1328E6011A00909769 /* TabShadowView.swift */; }; 315AA07028CA5CC800200030 /* YoutubePlayerNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 315AA06F28CA5CC800200030 /* YoutubePlayerNavigationHandler.swift */; }; + 3168506D2AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3168506C2AF3AD1C009A2828 /* WaitlistViewControllerPresenter.swift */; }; + 3168506E2AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3168506C2AF3AD1C009A2828 /* WaitlistViewControllerPresenter.swift */; }; + 3168506F2AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3168506C2AF3AD1C009A2828 /* WaitlistViewControllerPresenter.swift */; }; + 316850702AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3168506C2AF3AD1C009A2828 /* WaitlistViewControllerPresenter.swift */; }; + 316850722AF3AD58009A2828 /* DataBrokerProtectionDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316850712AF3AD58009A2828 /* DataBrokerProtectionDebugMenu.swift */; }; 316C7A832A7E9BEE00AA3BAE /* BookmarksBarAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850E8DFA2A6FEC5E00691187 /* BookmarksBarAppearance.swift */; }; 316C7A842A7E9C1700AA3BAE /* PixelExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857E5AF42A79045800FC0FB4 /* PixelExperiment.swift */; }; 316C7A852A7E9C2300AA3BAE /* BookmarksBarMenuFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85774AFE2A713D3B00DE0561 /* BookmarksBarMenuFactory.swift */; }; @@ -819,6 +824,7 @@ 31B7C85128800A5D0049841F /* CookieConsentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B7C85028800A5D0049841F /* CookieConsentPopover.swift */; }; 31B9226C288054D5001F55B7 /* CookieConsentPopoverManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B9226B288054D5001F55B7 /* CookieConsentPopoverManager.swift */; }; 31C3CE0228EDC1E70002C24A /* CustomRoundedCornersShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C3CE0128EDC1E70002C24A /* CustomRoundedCornersShape.swift */; }; + 31C5FFB92AF64D120008A79F /* DataBrokerProtectionFeatureVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C5FFB82AF64D120008A79F /* DataBrokerProtectionFeatureVisibility.swift */; }; 31C9ADE52AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C9ADE42AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift */; }; 31C9ADE62AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C9ADE42AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift */; }; 31C9ADE72AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C9ADE42AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift */; }; @@ -3882,6 +3888,8 @@ 313AEDA0287CAD1D00E1E8F4 /* CookieConsentUserPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieConsentUserPermissionView.swift; sourceTree = ""; }; 3154FD1328E6011A00909769 /* TabShadowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabShadowView.swift; sourceTree = ""; }; 315AA06F28CA5CC800200030 /* YoutubePlayerNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YoutubePlayerNavigationHandler.swift; sourceTree = ""; }; + 3168506C2AF3AD1C009A2828 /* WaitlistViewControllerPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WaitlistViewControllerPresenter.swift; sourceTree = ""; }; + 316850712AF3AD58009A2828 /* DataBrokerProtectionDebugMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionDebugMenu.swift; sourceTree = ""; }; 3171D6B72889849F0068632A /* CookieManagedNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieManagedNotificationView.swift; sourceTree = ""; }; 3171D6B9288984D00068632A /* BadgeAnimationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeAnimationView.swift; sourceTree = ""; }; 3171D6DA2889B64D0068632A /* CookieManagedNotificationContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieManagedNotificationContainerView.swift; sourceTree = ""; }; @@ -3900,6 +3908,7 @@ 31B7C85028800A5D0049841F /* CookieConsentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieConsentPopover.swift; sourceTree = ""; }; 31B9226B288054D5001F55B7 /* CookieConsentPopoverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieConsentPopoverManager.swift; sourceTree = ""; }; 31C3CE0128EDC1E70002C24A /* CustomRoundedCornersShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRoundedCornersShape.swift; sourceTree = ""; }; + 31C5FFB82AF64D120008A79F /* DataBrokerProtectionFeatureVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionFeatureVisibility.swift; sourceTree = ""; }; 31C9ADE42AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistFeatureSetupHandler.swift; sourceTree = ""; }; 31CF3431288B0B1B0087244B /* NavigationBarBadgeAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarBadgeAnimator.swift; sourceTree = ""; }; 31D5375B291D944100407A95 /* PasswordManagementBitwardenItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordManagementBitwardenItemView.swift; sourceTree = ""; }; @@ -5371,10 +5380,12 @@ 3192EC862A4DCF0E001E97A5 /* DBP */ = { isa = PBXGroup; children = ( + 316850712AF3AD58009A2828 /* DataBrokerProtectionDebugMenu.swift */, 3192EC872A4DCF21001E97A5 /* DBPHomeViewController.swift */, 3139A1512AA4B3C000969C7D /* DataBrokerProtectionManager.swift */, 7B6D98662ADEB4B000CD35FE /* DataBrokerProtectionLoginItemScheduler.swift */, 9D8FA00B2AC5BDCE005DD0D0 /* LoginItem+DataBrokerProtection.swift */, + 31C5FFB82AF64D120008A79F /* DataBrokerProtectionFeatureVisibility.swift */, ); path = DBP; sourceTree = ""; @@ -6171,6 +6182,8 @@ children = ( 7BD8679A2A9E9E000063B9F7 /* NetworkProtectionFeatureVisibility.swift */, 4B6785432AA8DE1F008A5004 /* NetworkProtectionFeatureDisabler.swift */, + 31F2D1FE2AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift */, + 31C9ADE42AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift */, 4B9DB0072A983B23000927DB /* Waitlist.swift */, 4B9DB0082A983B23000927DB /* Networking */, 4B9DB00B2A983B24000927DB /* Models */, @@ -6194,8 +6207,6 @@ isa = PBXGroup; children = ( 4B9DB00C2A983B24000927DB /* WaitlistViewModel.swift */, - 31F2D1FE2AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift */, - 31C9ADE42AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift */, ); path = Models; sourceTree = ""; @@ -6216,6 +6227,7 @@ 4B9DB0122A983B24000927DB /* WaitlistSteps */, 4B9DB0182A983B24000927DB /* WaitlistDialogView.swift */, 4B9DB0192A983B24000927DB /* WaitlistModalViewController.swift */, + 3168506C2AF3AD1C009A2828 /* WaitlistViewControllerPresenter.swift */, 4B9DB01A2A983B24000927DB /* WaitlistRootView.swift */, ); path = Views; @@ -10077,6 +10089,7 @@ 31929FDB2A4C4CFF0084EA89 /* PixelParameters.swift in Sources */, 31929FDD2A4C4CFF0084EA89 /* FaviconImageCache.swift in Sources */, 31929FDE2A4C4CFF0084EA89 /* TabBarViewController.swift in Sources */, + 316850722AF3AD58009A2828 /* DataBrokerProtectionDebugMenu.swift in Sources */, 31929FDF2A4C4CFF0084EA89 /* BookmarkOutlineViewDataSource.swift in Sources */, 31929FE02A4C4CFF0084EA89 /* DataImportStatusProviding.swift in Sources */, 31929FE12A4C4CFF0084EA89 /* PasswordManagementBitwardenItemView.swift in Sources */, @@ -10332,6 +10345,7 @@ 3192A0CC2A4C4CFF0084EA89 /* PasswordManagementListSection.swift in Sources */, 3192A0CD2A4C4CFF0084EA89 /* FaviconReferenceCache.swift in Sources */, 31F2D2012AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift in Sources */, + 3168506F2AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */, 4B9DB0372A983B24000927DB /* WaitlistTermsAndConditionsView.swift in Sources */, 3192A0CE2A4C4CFF0084EA89 /* BookmarkTreeController.swift in Sources */, 3192A0CF2A4C4CFF0084EA89 /* FirefoxEncryptionKeyReader.swift in Sources */, @@ -10552,6 +10566,7 @@ 3192A1932A4C4CFF0084EA89 /* PinnedTabsViewModel.swift in Sources */, 3192A1942A4C4CFF0084EA89 /* BookmarkList.swift in Sources */, 3192A1952A4C4CFF0084EA89 /* NEOnDemandRuleExtension.swift in Sources */, + 31C5FFB92AF64D120008A79F /* DataBrokerProtectionFeatureVisibility.swift in Sources */, 3192A1962A4C4CFF0084EA89 /* BookmarkTableRowView.swift in Sources */, 3192A1972A4C4CFF0084EA89 /* FavoritesView.swift in Sources */, 3192A1982A4C4CFF0084EA89 /* HomePage.swift in Sources */, @@ -10913,6 +10928,7 @@ 3706FB46293F65D500E42796 /* FirePopoverWrapperViewController.swift in Sources */, 3706FB47293F65D500E42796 /* NSPasteboardItemExtension.swift in Sources */, 3706FB48293F65D500E42796 /* AutofillPreferencesModel.swift in Sources */, + 3168506E2AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */, 3706FB49293F65D500E42796 /* NSException+Catch.swift in Sources */, 3706FB4A293F65D500E42796 /* PasswordManagementNoteModel.swift in Sources */, 3706FB4B293F65D500E42796 /* CookieNotificationAnimationModel.swift in Sources */, @@ -12355,6 +12371,7 @@ 4B957B892AC7AE700062CA31 /* ToggleableScrollView.swift in Sources */, 4B957B8A2AC7AE700062CA31 /* TabCleanupPreparer.swift in Sources */, 4B957B8B2AC7AE700062CA31 /* NetworkProtectionOptionKeyExtension.swift in Sources */, + 316850702AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */, 1E2AE4CB2ACB21C800684E0A /* HardwareModel.swift in Sources */, 4B957B8C2AC7AE700062CA31 /* UserScripts.swift in Sources */, 4B957B8D2AC7AE700062CA31 /* NSWorkspaceExtension.swift in Sources */, @@ -12840,6 +12857,7 @@ B687B7CC2947A1E9001DEA6F /* ExternalAppSchemeHandler.swift in Sources */, B65536AE2685E17200085A79 /* GeolocationService.swift in Sources */, 4B02198925E05FAC00ED7DEA /* FireproofingURLExtensions.swift in Sources */, + 3168506D2AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */, 7B1E819E27C8874900FF0E60 /* ContentOverlayPopover.swift in Sources */, 3154FD1428E6011A00909769 /* TabShadowView.swift in Sources */, 1D43EB3C292B664A0065E5D6 /* BWMessageIdGenerator.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate/AppDelegate.swift b/DuckDuckGo/AppDelegate/AppDelegate.swift index 5e3a1064ef..61a2c6a8ec 100644 --- a/DuckDuckGo/AppDelegate/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate/AppDelegate.swift @@ -218,6 +218,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel NetworkProtectionAppEvents().applicationDidFinishLaunching() UNUserNotificationCenter.current().delegate = self #endif + +#if DBP + Task { + try? await DataBrokerProtectionWaitlist().redeemDataBrokerProtectionInviteCodeIfAvailable() + } +#endif } func applicationDidBecomeActive(_ notification: Notification) { @@ -233,6 +239,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel NetworkProtectionAppEvents().applicationDidBecomeActive() #endif + +#if DBP + Task { + try? await DataBrokerProtectionWaitlist().redeemDataBrokerProtectionInviteCodeIfAvailable() + } +#endif } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -373,7 +385,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel } -#if NETWORK_PROTECTION +#if NETWORK_PROTECTION || DBP extension AppDelegate: UNUserNotificationCenterDelegate { @@ -387,12 +399,24 @@ extension AppDelegate: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + +#if NETWORK_PROTECTION if response.notification.request.identifier == NetworkProtectionWaitlist.notificationIdentifier { if NetworkProtectionWaitlist().readyToAcceptTermsAndConditions { DailyPixel.fire(pixel: .networkProtectionWaitlistNotificationTapped, frequency: .dailyAndCount, includeAppVersionParameter: true) - WaitlistModalViewController.show() + NetworkProtectionWaitlistViewControllerPresenter.show() } } +#endif + +#if DBP + if response.notification.request.identifier == DataBrokerProtectionWaitlist.notificationIdentifier { + if DataBrokerProtectionWaitlist().readyToAcceptTermsAndConditions { + DailyPixel.fire(pixel: .dataBrokerProtectionWaitlistNotificationTapped, frequency: .dailyAndCount, includeAppVersionParameter: true) + DataBrokerProtectionWaitlistViewControllerPresenter.show() + } + } +#endif } completionHandler() diff --git a/DuckDuckGo/AppDelegate/URLEventHandler.swift b/DuckDuckGo/AppDelegate/URLEventHandler.swift index a7c7090384..3e2ea6eaa9 100644 --- a/DuckDuckGo/AppDelegate/URLEventHandler.swift +++ b/DuckDuckGo/AppDelegate/URLEventHandler.swift @@ -103,7 +103,7 @@ final class URLEventHandler { if url.scheme == "networkprotection" { handleNetworkProtectionURL(url) } else { - WaitlistModalViewController.dismissWaitlistModalViewControllerIfNecessary(url) + WaitlistModalDismisser.dismissWaitlistModalViewControllerIfNecessary(url) WindowControllersManager.shared.show(url: url, newTab: true) } #else diff --git a/DuckDuckGo/Assets.xcassets/Images/DataBrokerProtectionWaitlist/DBP-Icon.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/DataBrokerProtectionWaitlist/DBP-Icon.imageset/Contents.json new file mode 100644 index 0000000000..b4422a4283 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/DataBrokerProtectionWaitlist/DBP-Icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "DBP-Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/DataBrokerProtectionWaitlist/DBP-Icon.imageset/DBP-Icon.pdf b/DuckDuckGo/Assets.xcassets/Images/DataBrokerProtectionWaitlist/DBP-Icon.imageset/DBP-Icon.pdf new file mode 100644 index 0000000000..62adf48e6e Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/DataBrokerProtectionWaitlist/DBP-Icon.imageset/DBP-Icon.pdf differ diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index 4bcb0159b7..d65a1b7c87 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -136,6 +136,10 @@ public struct UserDefaultsWrapper { case firstLaunchDate = "first.app.launch.date" + // Data Broker Protection + + case dataBrokerProtectionTermsAndConditionsAccepted = "data-broker-protection.waitlist-terms-and-conditions.accepted" + // Network Protection case networkProtectionExcludedRoutes = "netp.excluded-routes" diff --git a/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift b/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift new file mode 100644 index 0000000000..c4e77414c2 --- /dev/null +++ b/DuckDuckGo/DBP/DataBrokerProtectionDebugMenu.swift @@ -0,0 +1,117 @@ +// +// DataBrokerProtectionDebugMenu.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if DBP + +import DataBrokerProtection +import Foundation +import AppKit +import Common + +@MainActor +final class DataBrokerProtectionDebugMenu: NSMenu { + + private let waitlistTokenItem = NSMenuItem(title: "Waitlist Token:") + private let waitlistTimestampItem = NSMenuItem(title: "Waitlist Timestamp:") + private let waitlistInviteCodeItem = NSMenuItem(title: "Waitlist Invite Code:") + private let waitlistTermsAndConditionsAcceptedItem = NSMenuItem(title: "T&C Accepted:") + + init() { + super.init(title: "Personal Information Removal") + + buildItems { + NSMenuItem(title: "Waitlist") { + NSMenuItem(title: "Reset Waitlist State", action: #selector(DataBrokerProtectionDebugMenu.resetWaitlistState)) + .targetting(self) + NSMenuItem(title: "Reset T&C Acceptance", action: #selector(DataBrokerProtectionDebugMenu.resetTermsAndConditionsAcceptance)) + .targetting(self) + + NSMenuItem(title: "Send Notification", action: #selector(DataBrokerProtectionDebugMenu.sendWaitlistAvailableNotification)) + .targetting(self) + + NSMenuItem(title: "Fetch Invite Code", action: #selector(DataBrokerProtectionDebugMenu.fetchInviteCode)) + .targetting(self) + NSMenuItem.separator() + + waitlistTokenItem + waitlistTimestampItem + waitlistInviteCodeItem + waitlistTermsAndConditionsAcceptedItem + } + } + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Menu State Update + + override func update() { + updateWaitlistItems() + } + + // MARK: - Menu functions + + @objc private func resetWaitlistState() { + DataBrokerProtectionWaitlist().waitlistStorage.deleteWaitlistState() + UserDefaultsAuthenticationData().reset() + UserDefaults().removeObject(forKey: UserDefaultsWrapper.Key.dataBrokerProtectionTermsAndConditionsAccepted.rawValue) + NotificationCenter.default.post(name: .dataBrokerProtectionWaitlistAccessChanged, object: nil) + os_log("DBP waitlist state cleaned", log: .dataBrokerProtection) + } + + @objc private func resetTermsAndConditionsAcceptance() { + UserDefaults().removeObject(forKey: UserDefaultsWrapper.Key.dataBrokerProtectionTermsAndConditionsAccepted.rawValue) + NotificationCenter.default.post(name: .dataBrokerProtectionWaitlistAccessChanged, object: nil) + os_log("DBP waitlist terms and conditions cleaned", log: .dataBrokerProtection) + } + + @objc private func sendWaitlistAvailableNotification() { + DataBrokerProtectionWaitlist().sendInviteCodeAvailableNotification(completion: nil) + + os_log("DBP waitlist notification sent", log: .dataBrokerProtection) + } + + @objc private func fetchInviteCode() { + os_log("Fetching invite code...", log: .dataBrokerProtection) + + Task { + try? await DataBrokerProtectionWaitlist().redeemDataBrokerProtectionInviteCodeIfAvailable() + } + } + + // MARK: - Utility Functions + + private func updateWaitlistItems() { + let waitlistStorage = WaitlistKeychainStore(waitlistIdentifier: DataBrokerProtectionWaitlist.identifier) + waitlistTokenItem.title = "Waitlist Token: \(waitlistStorage.getWaitlistToken() ?? "N/A")" + waitlistInviteCodeItem.title = "Waitlist Invite Code: \(waitlistStorage.getWaitlistInviteCode() ?? "N/A")" + + if let timestamp = waitlistStorage.getWaitlistTimestamp() { + waitlistTimestampItem.title = "Waitlist Timestamp: \(String(describing: timestamp))" + } else { + waitlistTimestampItem.title = "Waitlist Timestamp: N/A" + } + + let accepted = UserDefaults().bool(forKey: UserDefaultsWrapper.Key.dataBrokerProtectionTermsAndConditionsAccepted.rawValue) + waitlistTermsAndConditionsAcceptedItem.title = "T&C Accepted: \(accepted ? "Yes" : "No")" + } +} + +#endif diff --git a/DuckDuckGo/DBP/DataBrokerProtectionFeatureVisibility.swift b/DuckDuckGo/DBP/DataBrokerProtectionFeatureVisibility.swift new file mode 100644 index 0000000000..4fee62f580 --- /dev/null +++ b/DuckDuckGo/DBP/DataBrokerProtectionFeatureVisibility.swift @@ -0,0 +1,62 @@ +// +// DataBrokerProtectionFeatureVisibility.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 BrowserServicesKit + +protocol DataBrokerProtectionFeatureVisibility { + func isFeatureVisible() -> Bool +} + +struct DefaultDataBrokerProtectionFeatureVisibility: DataBrokerProtectionFeatureVisibility { + private let privacyConfigurationManager: PrivacyConfigurationManaging + + init(privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager) { + self.privacyConfigurationManager = privacyConfigurationManager + } + + func isFeatureVisible() -> Bool { + isUserLocaleAllowed && isFeatureEnabled + } + + private var isUserLocaleAllowed: Bool { + var regionCode: String? + if #available(macOS 13, *) { + regionCode = Locale.current.region?.identifier + } else { + regionCode = Locale.current.regionCode + } + + #if DEBUG // Always assume US for debug builds + regionCode = "US" + #endif + + return (regionCode ?? "US") == "US" + } + + private var isFeatureEnabled: Bool { + // We should check for the feature flag + return true + } + + var isWaitlistEnabled: Bool { + // We should check for the privacy config waitlist flag + + return true + } +} diff --git a/DuckDuckGo/Main/View/MainViewController.swift b/DuckDuckGo/Main/View/MainViewController.swift index 858c612fce..c3b4d407b3 100644 --- a/DuckDuckGo/Main/View/MainViewController.swift +++ b/DuckDuckGo/Main/View/MainViewController.swift @@ -139,6 +139,10 @@ final class MainViewController: NSViewController { sendActiveNetworkProtectionWaitlistUserPixel() refreshNetworkProtectionMessages() #endif + +#if DBP + sendActiveDataBrokerProtectionWaitlistUserPixel() +#endif } func windowDidResignKey() { @@ -414,6 +418,13 @@ final class MainViewController: NSViewController { } #endif +#if DBP + private func sendActiveDataBrokerProtectionWaitlistUserPixel() { + if DefaultDataBrokerProtectionFeatureVisibility().isWaitlistEnabled { + DailyPixel.fire(pixel: .dataBrokerProtectionWaitlistUserActive, frequency: .dailyOnly, includeAppVersionParameter: true) + } + } +#endif // MARK: - First responder func adjustFirstResponder() { diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 8c58e5e670..ab8bafbc28 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -560,6 +560,11 @@ import Subscription NSMenuItem(title: "Sync") .submenu(SyncDebugMenu()) +#if DBP + NSMenuItem(title: "Personal Information Removal") + .submenu(DataBrokerProtectionDebugMenu()) +#endif + #if NETWORK_PROTECTION NSMenuItem(title: "Network Protection") .submenu(NetworkProtectionDebugMenu()) diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 8a9da7f16f..4df9aa02e4 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -149,7 +149,11 @@ final class MoreOptionsMenu: NSMenu { #if DBP @objc func openDataBrokerProtection(_ sender: NSMenuItem) { - actionDelegate?.optionsButtonMenuRequestedDataBrokerProtection(self) + if DataBrokerProtectionWaitlistViewControllerPresenter.shouldPresentWaitlist() { + DataBrokerProtectionWaitlistViewControllerPresenter.show() + } else { + actionDelegate?.optionsButtonMenuRequestedDataBrokerProtection(self) + } } #endif // DBP @@ -328,26 +332,18 @@ final class MoreOptionsMenu: NSMenu { #endif // NETWORK_PROTECTION #if DBP - var regionCode: String? - if #available(macOS 13, *) { - regionCode = Locale.current.region?.identifier - } else { - regionCode = Locale.current.regionCode - } - - #if DEBUG // Always assume US for debug builds - regionCode = "US" - #endif - - // Only show Private Information Removal (DBP) for US based users - if (regionCode ?? "US") == "US" { + if DefaultDataBrokerProtectionFeatureVisibility().isFeatureVisible() { let dataBrokerProtectionItem = NSMenuItem(title: UserText.dataBrokerProtectionOptionsMenuItem, action: #selector(openDataBrokerProtection), keyEquivalent: "") .targetting(self) - .withImage(NSImage(named: "BurnerWindowIcon2")) // PLACEHOLDER: Change it once we have the final icon + .withImage(NSImage(named: "DBP-Icon")) items.append(dataBrokerProtectionItem) + + DailyPixel.fire(pixel: .dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed, frequency: .dailyAndCount, includeAppVersionParameter: true) + } + #endif // DBP #if SUBSCRIPTION diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 4f05cd4325..c84b843521 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -315,12 +315,12 @@ final class NavigationBarViewController: NSViewController { // 3. If the user has no state of any kind, show the waitlist screen. if NetworkProtectionWaitlist().shouldShowWaitlistViewController { - WaitlistModalViewController.show() + NetworkProtectionWaitlistViewControllerPresenter.show() DailyPixel.fire(pixel: .networkProtectionWaitlistIntroDisplayed, frequency: .dailyAndCount, includeAppVersionParameter: true) } else if NetworkProtectionKeychainTokenStore().isFeatureActivated { popovers.toggleNetworkProtectionPopover(usingView: networkProtectionButton, withDelegate: networkProtectionButtonModel) } else { - WaitlistModalViewController.show() + NetworkProtectionWaitlistViewControllerPresenter.show() DailyPixel.fire(pixel: .networkProtectionWaitlistIntroDisplayed, frequency: .dailyAndCount, includeAppVersionParameter: true) } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index 877775be80..a9d61924a2 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -455,7 +455,7 @@ final class NetworkProtectionDebugMenu: NSMenu { // MARK: Waitlist @objc func sendNetworkProtectionWaitlistAvailableNotification(_ sender: Any?) { - NetworkProtectionWaitlist().sendInviteCodeAvailableNotification() + NetworkProtectionWaitlist().sendInviteCodeAvailableNotification(completion: nil) } @objc func resetNetworkProtectionActivationDate(_ sender: Any?) { diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 5b074eb11d..11559ef988 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -170,6 +170,15 @@ extension Pixel { case networkProtectionRemoteMessageDismissed(messageID: String) case networkProtectionRemoteMessageOpened(messageID: String) + // DataBroker Protection Waitlist + case dataBrokerProtectionWaitlistUserActive + case dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed + case dataBrokerProtectionWaitlistIntroDisplayed + case dataBrokerProtectionWaitlistNotificationShown + case dataBrokerProtectionWaitlistNotificationTapped + case dataBrokerProtectionWaitlistTermsAndConditionsDisplayed + case dataBrokerProtectionWaitlistTermsAndConditionsAccepted + // 28-day Home Button case homeButtonHidden case homeButtonLeft @@ -468,8 +477,21 @@ extension Pixel.Event { return "m_mac_netp_remote_message_dismissed_\(messageID)" case .networkProtectionRemoteMessageOpened(let messageID): return "m_mac_netp_remote_message_opened_\(messageID)" - - // 28-day Home Button + case .dataBrokerProtectionWaitlistUserActive: + return "m_mac_dbp_waitlist_user_active" + case .dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed: + return "m_mac_dbp_imp_settings_entry_menu_item" + case .dataBrokerProtectionWaitlistIntroDisplayed: + return "m_mac_dbp_imp_intro_screen" + case .dataBrokerProtectionWaitlistNotificationShown: + return "m_mac_dbp_ev_waitlist_notification_shown" + case .dataBrokerProtectionWaitlistNotificationTapped: + return "m_mac_dbp_ev_waitlist_notification_launched" + case .dataBrokerProtectionWaitlistTermsAndConditionsDisplayed: + return "m_mac_dbp_imp_terms" + case .dataBrokerProtectionWaitlistTermsAndConditionsAccepted: + return "m_mac_dbp_ev_terms_accepted" + // 28-day Home Button case .homeButtonHidden: return "m_mac_home_button_hidden" case .homeButtonLeft: diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 899ad90321..a0b823ce3f 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -111,6 +111,13 @@ extension Pixel.Event { .networkProtectionRemoteMessageDisplayed, .networkProtectionRemoteMessageDismissed, .networkProtectionRemoteMessageOpened, + .dataBrokerProtectionWaitlistUserActive, + .dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed, + .dataBrokerProtectionWaitlistIntroDisplayed, + .dataBrokerProtectionWaitlistNotificationShown, + .dataBrokerProtectionWaitlistNotificationTapped, + .dataBrokerProtectionWaitlistTermsAndConditionsDisplayed, + .dataBrokerProtectionWaitlistTermsAndConditionsAccepted, .homeButtonLeft, .homeButtonRight, .homeButtonHidden: diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 1eec3d3f92..b4eafbeafa 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -121,6 +121,11 @@ final class BrowserTabViewController: NSViewController { selector: #selector(onCloseDataBrokerProtection), name: .dbpDidClose, object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(onDataBrokerWaitlistGetStartedPressedByUser), + name: .dataBrokerProtectionUserPressedOnGetStartedOnWaitlist, + object: nil) + #endif #if SUBSCRIPTION @@ -162,6 +167,7 @@ final class BrowserTabViewController: NSViewController { self.previouslySelectedTab = nil } +#if DBP @objc private func onCloseDataBrokerProtection(_ notification: Notification) { guard let activeTab = tabCollectionViewModel.selectedTabViewModel?.tab, @@ -175,6 +181,13 @@ final class BrowserTabViewController: NSViewController { } } + @objc + private func onDataBrokerWaitlistGetStartedPressedByUser(_ notification: Notification) { + WindowControllersManager.shared.showDataBrokerProtectionTab() + } + +#endif + #if SUBSCRIPTION @objc private func onCloseSubscriptionPage(_ notification: Notification) { diff --git a/DuckDuckGo/Waitlist/Networking/ProductWaitlistRequest.swift b/DuckDuckGo/Waitlist/Networking/ProductWaitlistRequest.swift index bb59bae8bc..329cb041cd 100644 --- a/DuckDuckGo/Waitlist/Networking/ProductWaitlistRequest.swift +++ b/DuckDuckGo/Waitlist/Networking/ProductWaitlistRequest.swift @@ -130,7 +130,11 @@ final class ProductWaitlistRequest: WaitlistRequest { private let makeHTTPRequest: ProductWaitlistMakeHTTPRequest private var endpoint: URL { - return URL(string: "https://quack.duckduckgo.com/api/auth/waitlist/")! +#if DEBUG + URL(string: "https://quackdev.duckduckgo.com/api/auth/waitlist/")! +#else + URL(string: "https://quack.duckduckgo.com/api/auth/waitlist/")! +#endif } } diff --git a/DuckDuckGo/Waitlist/Views/WaitlistModalViewController.swift b/DuckDuckGo/Waitlist/Views/WaitlistModalViewController.swift index 0c1958ccb9..ddd8f3fb73 100644 --- a/DuckDuckGo/Waitlist/Views/WaitlistModalViewController.swift +++ b/DuckDuckGo/Waitlist/Views/WaitlistModalViewController.swift @@ -26,25 +26,17 @@ extension Notification.Name { static let waitlistModalViewControllerShouldDismiss = Notification.Name(rawValue: "waitlistModalViewControllerShouldDismiss") } -final class WaitlistModalViewController: NSViewController { - - // Small hack to force the waitlist modal view controller to dismiss all instances of itself whenever the user opens a link from the T&C view. - static func dismissWaitlistModalViewControllerIfNecessary(_ url: URL) { - if ["https://duckduckgo.com/privacy", "https://duckduckgo.com/terms"].contains(url.absoluteString) { - NotificationCenter.default.post(name: .waitlistModalViewControllerShouldDismiss, object: nil) - } - } +final class WaitlistModalViewController: NSViewController { private let defaultSize = CGSize(width: 360, height: 650) private let model: WaitlistViewModel + private let contentView: ContentView private var heightConstraint: NSLayoutConstraint? - init(notificationPermissionState: WaitlistViewModel.NotificationPermissionState) { - self.model = WaitlistViewModel(waitlist: NetworkProtectionWaitlist(), - notificationPermissionState: notificationPermissionState, - termsAndConditionActionHandler: NetworkProtectionWaitlistTermsAndConditionsActionHandler(), - featureSetupHandler: NetworkProtectionWaitlistFeatureSetupHandler()) + init(viewModel: WaitlistViewModel, contentView: ContentView) { + self.model = viewModel + self.contentView = contentView super.init(nibName: nil, bundle: nil) } @@ -61,9 +53,7 @@ final class WaitlistModalViewController: NSViewController { self.model.delegate = self - let waitlistRootView = WaitlistRootView() - - let hostingView = NSHostingView(rootView: waitlistRootView.environmentObject(self.model)) + let hostingView = NSHostingView(rootView: contentView.environmentObject(self.model)) hostingView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingView) @@ -79,40 +69,18 @@ final class WaitlistModalViewController: NSViewController { hostingView.rightAnchor.constraint(equalTo: view.rightAnchor) ]) - NotificationCenter.default.addObserver(self, selector: #selector(dismissModal), name: .waitlistModalViewControllerShouldDismiss, object: nil) + NotificationCenter.default.addObserver(forName: .waitlistModalViewControllerShouldDismiss, object: nil, queue: .main) { [weak self] _ in + self?.dismissModal() + } } private func updateViewHeight(height: CGFloat) { heightConstraint?.constant = height } - - static func show(completion: (() -> Void)? = nil) { - guard let windowController = WindowControllersManager.shared.lastKeyMainWindowController, - windowController.window?.isKeyWindow == true else { - return - } - - // This is a hack to get around an issue with the waitlist notification screen showing the wrong state while it animates in, and then - // jumping to the correct state as soon as the animation is complete. This works around that problem by providing the correct state up front, - // preventing any state changing from occurring. - UNUserNotificationCenter.current().getNotificationSettings { settings in - let status = settings.authorizationStatus - let state = WaitlistViewModel.NotificationPermissionState.from(status) - - DispatchQueue.main.async { - let viewController = WaitlistModalViewController(notificationPermissionState: state) - windowController.mainViewController.beginSheet(viewController) { _ in - completion?() - } - } - } - } - } extension WaitlistModalViewController: WaitlistViewModelDelegate { - @objc func dismissModal() { self.dismiss() } @@ -123,4 +91,14 @@ extension WaitlistModalViewController: WaitlistViewModelDelegate { } +struct WaitlistModalDismisser { + + // Small hack to force the waitlist modal view controller to dismiss all instances of itself whenever the user opens a link from the T&C view. + static func dismissWaitlistModalViewControllerIfNecessary(_ url: URL) { + if ["https://duckduckgo.com/privacy", "https://duckduckgo.com/terms"].contains(url.absoluteString) { + NotificationCenter.default.post(name: .waitlistModalViewControllerShouldDismiss, object: nil) + } + } +} + #endif diff --git a/DuckDuckGo/Waitlist/Views/WaitlistRootView.swift b/DuckDuckGo/Waitlist/Views/WaitlistRootView.swift index d5d61ed482..c1fe705002 100644 --- a/DuckDuckGo/Waitlist/Views/WaitlistRootView.swift +++ b/DuckDuckGo/Waitlist/Views/WaitlistRootView.swift @@ -1,5 +1,5 @@ // -// WaitlistRootView.swift +// NetworkProtectionWaitlistRootView.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -20,7 +20,7 @@ import SwiftUI -struct WaitlistRootView: View { +struct NetworkProtectionWaitlistRootView: View { @EnvironmentObject var model: WaitlistViewModel var body: some View { @@ -46,3 +46,34 @@ struct WaitlistRootView: View { } #endif + +#if DBP + +import SwiftUI + +struct DataBrokerProtectionWaitlistRootView: View { + @EnvironmentObject var model: WaitlistViewModel + + var body: some View { + Group { + switch model.viewState { + case .notOnWaitlist, .joiningWaitlist: + JoinWaitlistView(viewData: DataBrokerProtectionJoinWaitlistViewData()) + case .joinedWaitlist(let state): + JoinedWaitlistView(viewData: DataBrokerProtectionJoinedWaitlistViewData(), + notificationsAllowed: state == .notificationAllowed) + case .invited: + InvitedToWaitlistView(viewData: DataBrokerProtectionInvitedToWaitlistViewData()) + case .termsAndConditions: + WaitlistTermsAndConditionsView(viewData: DataBrokerProtectionWaitlistTermsAndConditionsViewData()) { + DataBrokerProtectionTermsAndConditionsContentView() + } + case .readyToEnable: + EnableWaitlistFeatureView(viewData: EnableDataBrokerProtectionViewData()) + } + } + .environmentObject(model) + } +} + +#endif diff --git a/DuckDuckGo/Waitlist/Views/WaitlistSteps/WaitlistTermsAndConditionsView.swift b/DuckDuckGo/Waitlist/Views/WaitlistSteps/WaitlistTermsAndConditionsView.swift index 0f6c3d59ce..d7f231823b 100644 --- a/DuckDuckGo/Waitlist/Views/WaitlistSteps/WaitlistTermsAndConditionsView.swift +++ b/DuckDuckGo/Waitlist/Views/WaitlistSteps/WaitlistTermsAndConditionsView.swift @@ -163,35 +163,116 @@ struct NetworkProtectionWaitlistTermsAndConditionsViewData: WaitlistTermsAndCond #if DBP struct DataBrokerProtectionTermsAndConditionsContentView: View { - let text = """ -Placeholder terms and conditions

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + private let groupLeadingPadding: CGFloat = 15.0 + private let sectionBottomPadding: CGFloat = 10.0 -Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + var body: some View { + VStack(alignment: .leading, spacing: 5) { + + Text(UserText.dataBrokerProtectionPrivacyPolicyTitle) + .font(.system(size: 15, weight: .bold)) + .multilineTextAlignment(.leading) + + Text("\nWe don’t save your personal information for this service to function.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) -Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + Group { + Text("• This Privacy Policy is for our waitlist beta service.") + if #available(macOS 12.0, *) { + Text("• Our main [Privacy Policy](https://duckduckgo.com/privacy) also applies here.") + } else { + Text("• Our main Privacy Policy also applies here.") + } + } + .padding(.leading, groupLeadingPadding) -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Text("\nYour personal information is stored locally on your device.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) -Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + Group { + Text("• The information you provide when you sign-up to use this service, for example your name, age, address, and phone number is stored on your device.") + Text("• We then scan data brokers from your device to check if any sites contain your personal information.") + Text("• We may find additional information on data broker sites through this scanning process, like alternative names or phone numbers, or the names of your relatives. This information is also stored locally on your device.") + } + .padding(.leading, groupLeadingPadding) -Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + Text("\nWe submit removal requests to data broker sites on your behalf.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Group { + Text("• We submit removal requests to the data broker sites directly from your device, unlike other services where the removal process is initiated on remote servers.") + Text("• The only personal information we may receive is a confirmation email from data broker sites which is deleted within 72 hours.") + Text("• We regularly re-scan data broker sites to check on the removal status of your information. If it has reappeared, we resubmit the removal request.") + } + .padding(.leading, groupLeadingPadding) -Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + Text("\nTerms of Service") + .fontWeight(.bold) -Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -""" + Text("You must be eligible to use this service.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) - var body: some View { - Text(UserText.dataBrokerProtectionPrivacyPolicyTitle) - .font(.system(size: 15, weight: .bold)) - .multilineTextAlignment(.leading) + Group { + Text("• To use this service, you must be 18 or older.") + } + .padding(.leading, groupLeadingPadding) - Group { - Text(text).bodyStyle() - } - .padding(.all, 20) + Text("\nThe service is for limited and personal use only.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) + + Group { + Text("• The service is available for your personal use only. You represent and warrant that you will only initiate removal of your own personal information.") + Text("• This service is available on one device only.") + } + .padding(.leading, groupLeadingPadding) + + Text("\nYou give DuckDuckGo authority to act on your Here's an updated version with the remaining content:") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) + + Group { + Text("• You hereby authorize DuckDuckGo to act on your behalf to request removal of your personal information from data broker sites.") + Text("• Because data broker sites often have multi-step processes required to have information removed, and because they regularly update their databases with new personal information, this authorization includes ongoing action on your behalf solely to perform the service.") + } + .padding(.leading, groupLeadingPadding) + + Text("\nThe service cannot remove all of your information from the Internet.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) + + Group { + Text("• This service requests removal from a limited number of data broker sites only. You understand that we cannot guarantee that the third-party sites will honor the requests, or that your personal information will not reappear in the future.") + Text("• You understand that we will only be able to request the removal of information based upon the information you provide to us.") + } + .padding(.leading, groupLeadingPadding) + + Text("\nWe provide this beta service as-is, and without warranty.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) + + Group { + Text("• This service is provided as-is and without warranties or guarantees of any kind.") + Text("• To the extent possible under applicable law, DuckDuckGo will not be liable for any damage or loss arising from your use of the service. In any event, the total aggregate liability of DuckDuckGo shall not exceed $25 or the equivalent in your local currency.") + Text("• We may in the future transfer responsibility for the service to a subsidiary of DuckDuckGo. If that happens, you agree that references to “DuckDuckGo” will refer to our subsidiary, which will then become responsible for providing the service and for any liabilities relating to it.") + } + .padding(.leading, groupLeadingPadding) + + Text("\nWe may terminate access at any time.") + .fontWeight(.bold) + .padding(.bottom, sectionBottomPadding) + + Group { + Text("• This service is in beta, and your access to it is temporary.") + Text("• We reserve the right to terminate access at any time in our sole discretion, including for violation of these terms or our DuckDuckGo Terms of Service, which are incorporated by reference.") + } + .padding(.leading, groupLeadingPadding) + + }.padding(.all, 20) } } diff --git a/DuckDuckGo/Waitlist/Views/WaitlistViewControllerPresenter.swift b/DuckDuckGo/Waitlist/Views/WaitlistViewControllerPresenter.swift new file mode 100644 index 0000000000..9ab94a050d --- /dev/null +++ b/DuckDuckGo/Waitlist/Views/WaitlistViewControllerPresenter.swift @@ -0,0 +1,111 @@ +// +// WaitlistViewControllerPresenter.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 UserNotifications + +#if NETWORK_PROTECTION || DBP + +protocol WaitlistViewControllerPresenter { + static func show(completion: (() -> Void)?) +} + +extension WaitlistViewControllerPresenter { + static func show(completion: (() -> Void)? = nil) { + Self.show(completion: nil) + } +} + +#endif + +#if NETWORK_PROTECTION + +struct NetworkProtectionWaitlistViewControllerPresenter: WaitlistViewControllerPresenter { + + @MainActor + static func show(completion: (() -> Void)? = nil) { + guard let windowController = WindowControllersManager.shared.lastKeyMainWindowController, + windowController.window?.isKeyWindow == true else { + return + } + + // This is a hack to get around an issue with the waitlist notification screen showing the wrong state while it animates in, and then + // jumping to the correct state as soon as the animation is complete. This works around that problem by providing the correct state up front, + // preventing any state changing from occurring. + UNUserNotificationCenter.current().getNotificationSettings { settings in + let status = settings.authorizationStatus + let state = WaitlistViewModel.NotificationPermissionState.from(status) + + DispatchQueue.main.async { + let viewModel = WaitlistViewModel(waitlist: NetworkProtectionWaitlist(), + notificationPermissionState: state, + termsAndConditionActionHandler: NetworkProtectionWaitlistTermsAndConditionsActionHandler(), + featureSetupHandler: NetworkProtectionWaitlistFeatureSetupHandler()) + + let viewController = WaitlistModalViewController(viewModel: viewModel, contentView: NetworkProtectionWaitlistRootView()) + windowController.mainViewController.beginSheet(viewController) { _ in + completion?() + } + } + } + } +} + +#endif + +#if DBP + +struct DataBrokerProtectionWaitlistViewControllerPresenter: WaitlistViewControllerPresenter { + + static func shouldPresentWaitlist() -> Bool { + let waitlist = DataBrokerProtectionWaitlist() + + let accepted = UserDefaults().bool(forKey: UserDefaultsWrapper.Key.dataBrokerProtectionTermsAndConditionsAccepted.rawValue) + + return !(waitlist.waitlistStorage.isInvited && accepted) + } + + @MainActor + static func show(completion: (() -> Void)? = nil) { + guard let windowController = WindowControllersManager.shared.lastKeyMainWindowController else { + return + } + DailyPixel.fire(pixel: .dataBrokerProtectionWaitlistIntroDisplayed, frequency: .dailyAndCount, includeAppVersionParameter: true) + + // This is a hack to get around an issue with the waitlist notification screen showing the wrong state while it animates in, and then + // jumping to the correct state as soon as the animation is complete. This works around that problem by providing the correct state up front, + // preventing any state changing from occurring. + UNUserNotificationCenter.current().getNotificationSettings { settings in + let status = settings.authorizationStatus + let state = WaitlistViewModel.NotificationPermissionState.from(status) + DispatchQueue.main.async { + let viewModel = WaitlistViewModel(waitlist: DataBrokerProtectionWaitlist(), + notificationPermissionState: state, + termsAndConditionActionHandler: DataBrokerProtectionWaitlistTermsAndConditionsActionHandler(), + featureSetupHandler: DataBrokerProtectionWaitlistFeatureSetupHandler()) + + let viewController = WaitlistModalViewController(viewModel: viewModel, contentView: DataBrokerProtectionWaitlistRootView()) + windowController.mainViewController.beginSheet(viewController) { _ in + completion?() + } + } + } + } +} + +#endif diff --git a/DuckDuckGo/Waitlist/Waitlist.swift b/DuckDuckGo/Waitlist/Waitlist.swift index 39886b0505..bcb983f487 100644 --- a/DuckDuckGo/Waitlist/Waitlist.swift +++ b/DuckDuckGo/Waitlist/Waitlist.swift @@ -23,6 +23,7 @@ import Networking import UserNotifications import NetworkProtection import BrowserServicesKit +import Common protocol WaitlistConstants { static var identifier: String { get } @@ -40,7 +41,7 @@ protocol Waitlist: WaitlistConstants { func fetchInviteCodeIfAvailable() async -> WaitlistInviteCodeFetchError? func fetchInviteCodeIfAvailable(completion: @escaping (WaitlistInviteCodeFetchError?) -> Void) - func sendInviteCodeAvailableNotification() + func sendInviteCodeAvailableNotification(completion: (() -> Void)?) } enum WaitlistInviteCodeFetchError: Error, Equatable { @@ -63,6 +64,8 @@ enum WaitlistInviteCodeFetchError: Error, Equatable { extension Notification.Name { static let networkProtectionWaitlistAccessChanged = Notification.Name(rawValue: "networkProtectionWaitlistAccessChanged") + static let dataBrokerProtectionWaitlistAccessChanged = Notification.Name(rawValue: "dataBrokerProtectionWaitlistAccessChanged") + static let dataBrokerProtectionUserPressedOnGetStartedOnWaitlist = Notification.Name(rawValue: "dataBrokerProtectionUserPressedOnGetStartedOnWaitlist") } @@ -111,7 +114,7 @@ extension Waitlist { } } - func sendInviteCodeAvailableNotification() { + func sendInviteCodeAvailableNotification(completion: (() -> Void)?) { let notificationContent = UNMutableNotificationContent() notificationContent.title = Self.inviteAvailableNotificationTitle @@ -122,7 +125,7 @@ extension Waitlist { UNUserNotificationCenter.current().add(request) { error in if error == nil { - DailyPixel.fire(pixel: .networkProtectionWaitlistNotificationShown, frequency: .dailyAndCount, includeAppVersionParameter: true) + completion?() } } } @@ -220,7 +223,9 @@ struct NetworkProtectionWaitlist: Waitlist { do { try await networkProtectionCodeRedemption.redeem(inviteCode) NotificationCenter.default.post(name: .networkProtectionWaitlistAccessChanged, object: nil) - sendInviteCodeAvailableNotification() + sendInviteCodeAvailableNotification { + DailyPixel.fire(pixel: .networkProtectionWaitlistNotificationShown, frequency: .dailyAndCount, includeAppVersionParameter: true) + } completion(nil) } catch { assertionFailure("Failed to redeem invite code") @@ -237,3 +242,112 @@ struct NetworkProtectionWaitlist: Waitlist { } #endif + +#if DBP + +// MARK: - DataBroker Protection Waitlist + +import DataBrokerProtection + +struct DataBrokerProtectionWaitlist: Waitlist { + + static let identifier: String = "databrokerprotection" + static let apiProductName: String = "dbp" + + static let notificationIdentifier = "com.duckduckgo.macos.browser.data-broker-protection.invite-code-available" + static let inviteAvailableNotificationTitle = UserText.dataBrokerProtectionWaitlistNotificationTitle + static let inviteAvailableNotificationBody = UserText.dataBrokerProtectionWaitlistNotificationText + + let waitlistStorage: WaitlistStorage + let waitlistRequest: WaitlistRequest + + private let redeemUseCase: DataBrokerProtectionRedeemUseCase + private let redeemAuthenticationRepository: AuthenticationRepository + + var readyToAcceptTermsAndConditions: Bool { + let accepted = UserDefaults().bool(forKey: UserDefaultsWrapper.Key.dataBrokerProtectionTermsAndConditionsAccepted.rawValue) + return waitlistStorage.isInvited && !accepted + } + + init() { + self.init( + store: WaitlistKeychainStore(waitlistIdentifier: Self.identifier), + request: ProductWaitlistRequest(productName: Self.apiProductName), + redeemUseCase: RedeemUseCase(), + redeemAuthenticationRepository: UserDefaultsAuthenticationData() + ) + } + + init(store: WaitlistStorage, request: WaitlistRequest, + redeemUseCase: DataBrokerProtectionRedeemUseCase, + redeemAuthenticationRepository: AuthenticationRepository) { + self.waitlistStorage = store + self.waitlistRequest = request + self.redeemUseCase = redeemUseCase + self.redeemAuthenticationRepository = redeemAuthenticationRepository + } + + func redeemDataBrokerProtectionInviteCodeIfAvailable() async throws { + do { + guard waitlistStorage.getWaitlistToken() != nil else { + os_log("User not in DBP waitlist, returning...", log: .default) + return + } + + guard redeemAuthenticationRepository.getAccessToken() == nil else { + os_log("Invite code already redeemed, returning...", log: .default) + return + } + + var inviteCode = waitlistStorage.getWaitlistInviteCode() + + if inviteCode == nil { + os_log("No DBP invite code found, fetching...", log: .default) + inviteCode = try await fetchInviteCode() + } + + if let code = inviteCode { + try await redeemInviteCode(code) + } else { + os_log("No DBP invite code available") + throw WaitlistInviteCodeFetchError.noCodeAvailable + } + + } catch { + os_log("DBP Invite code error: %{public}@", log: .error, error.localizedDescription) + throw error + } + } + + private func fetchInviteCode() async throws -> String { + + // First check if we have it stored locally + if let inviteCode = waitlistStorage.getWaitlistInviteCode() { + return inviteCode + } + + // If not, then try to fetch it remotely + _ = await fetchInviteCodeIfAvailable() + + // Try to fetch it from storage again + if let inviteCode = waitlistStorage.getWaitlistInviteCode() { + return inviteCode + } else { + throw WaitlistInviteCodeFetchError.noCodeAvailable + } + } + + private func redeemInviteCode(_ inviteCode: String) async throws { + os_log("Redeeming DBP invite code...", log: .dataBrokerProtection) + + try await redeemUseCase.redeem(inviteCode: inviteCode) + NotificationCenter.default.post(name: .dataBrokerProtectionWaitlistAccessChanged, object: nil) + + os_log("DBP invite code redeemed", log: .dataBrokerProtection) + sendInviteCodeAvailableNotification { + DailyPixel.fire(pixel: .dataBrokerProtectionWaitlistNotificationShown, frequency: .dailyAndCount, includeAppVersionParameter: true) + } + } +} + +#endif diff --git a/DuckDuckGo/Waitlist/Models/WaitlistFeatureSetupHandler.swift b/DuckDuckGo/Waitlist/WaitlistFeatureSetupHandler.swift similarity index 72% rename from DuckDuckGo/Waitlist/Models/WaitlistFeatureSetupHandler.swift rename to DuckDuckGo/Waitlist/WaitlistFeatureSetupHandler.swift index 909b5d1a3f..4ed1287d71 100644 --- a/DuckDuckGo/Waitlist/Models/WaitlistFeatureSetupHandler.swift +++ b/DuckDuckGo/Waitlist/WaitlistFeatureSetupHandler.swift @@ -16,7 +16,7 @@ // limitations under the License. // -#if NETWORK_PROTECTION +#if NETWORK_PROTECTION || DBP import Foundation @@ -24,6 +24,10 @@ protocol WaitlistFeatureSetupHandler { func confirmFeature() } +#endif + +#if NETWORK_PROTECTION + struct NetworkProtectionWaitlistFeatureSetupHandler: WaitlistFeatureSetupHandler { func confirmFeature() { LocalPinningManager.shared.pin(.networkProtection) @@ -32,3 +36,14 @@ struct NetworkProtectionWaitlistFeatureSetupHandler: WaitlistFeatureSetupHandler } #endif + +#if DBP + +struct DataBrokerProtectionWaitlistFeatureSetupHandler: WaitlistFeatureSetupHandler { + func confirmFeature() { + NotificationCenter.default.post(name: .dataBrokerProtectionWaitlistAccessChanged, object: nil) + NotificationCenter.default.post(name: .dataBrokerProtectionUserPressedOnGetStartedOnWaitlist, object: nil) + } +} + +#endif diff --git a/DuckDuckGo/Waitlist/Models/WaitlistTermsAndConditionsActionHandler.swift b/DuckDuckGo/Waitlist/WaitlistTermsAndConditionsActionHandler.swift similarity index 65% rename from DuckDuckGo/Waitlist/Models/WaitlistTermsAndConditionsActionHandler.swift rename to DuckDuckGo/Waitlist/WaitlistTermsAndConditionsActionHandler.swift index 42071f6c56..654f877029 100644 --- a/DuckDuckGo/Waitlist/Models/WaitlistTermsAndConditionsActionHandler.swift +++ b/DuckDuckGo/Waitlist/WaitlistTermsAndConditionsActionHandler.swift @@ -16,7 +16,7 @@ // limitations under the License. // -#if NETWORK_PROTECTION +#if NETWORK_PROTECTION || DBP import Foundation import UserNotifications @@ -27,6 +27,10 @@ protocol WaitlistTermsAndConditionsActionHandler { mutating func didAccept() } +#endif + +#if NETWORK_PROTECTION + struct NetworkProtectionWaitlistTermsAndConditionsActionHandler: WaitlistTermsAndConditionsActionHandler { @UserDefaultsWrapper(key: .networkProtectionTermsAndConditionsAccepted, defaultValue: false) var acceptedTermsAndConditions: Bool @@ -45,3 +49,24 @@ struct NetworkProtectionWaitlistTermsAndConditionsActionHandler: WaitlistTermsAn } #endif + +#if DBP + +struct DataBrokerProtectionWaitlistTermsAndConditionsActionHandler: WaitlistTermsAndConditionsActionHandler { + @UserDefaultsWrapper(key: .dataBrokerProtectionTermsAndConditionsAccepted, defaultValue: false) + var acceptedTermsAndConditions: Bool + + func didShow() { + DailyPixel.fire(pixel: .dataBrokerProtectionWaitlistTermsAndConditionsDisplayed, frequency: .dailyAndCount, includeAppVersionParameter: true) + } + + mutating func didAccept() { + acceptedTermsAndConditions = true + // Remove delivered NetP notifications in case the user didn't click them. + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [DataBrokerProtectionWaitlist.notificationIdentifier]) + + DailyPixel.fire(pixel: .dataBrokerProtectionWaitlistTermsAndConditionsAccepted, frequency: .dailyAndCount, includeAppVersionParameter: true) + } +} + +#endif diff --git a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift index e44a865b5d..2e88fcbac0 100644 --- a/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift +++ b/LocalPackages/BuildToolPlugins/Plugins/InputFilesChecker/InputFilesChecker.swift @@ -55,6 +55,8 @@ let extraInputFiles: [TargetName: Set] = [ .init("DataBrokerProtectionManager.swift", .source), .init("DataBrokerProtectionLoginItemScheduler.swift", .source), .init("LoginItem+DataBrokerProtection.swift", .source), + .init("DataBrokerProtectionDebugMenu.swift", .source), + .init("DataBrokerProtectionFeatureVisibility.swift", .source), .init("DuckDuckGoDBPBackgroundAgent.app", .unknown), ]), diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift index 1c14f397d7..44379cb43c 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Services/RedeemCodeServices.swift @@ -46,6 +46,7 @@ public protocol AuthenticationRepository { func save(accessToken: String) func save(inviteCode: String) + func reset() } public protocol DataBrokerProtectionAuthenticationService { @@ -120,6 +121,11 @@ public final class UserDefaultsAuthenticationData: AuthenticationRepository { public func save(inviteCode: String) { UserDefaults.standard.set(inviteCode, forKey: Keys.inviteCodeKey) } + + public func reset() { + UserDefaults.standard.removeObject(forKey: Keys.inviteCodeKey) + UserDefaults.standard.removeObject(forKey: Keys.accessTokenKey) + } } public enum AuthenticationError: Error, Equatable { @@ -140,7 +146,11 @@ struct RedeemResponse: Codable { public struct AuthenticationService: DataBrokerProtectionAuthenticationService { private struct Constants { +#if DEBUG + static let redeemURL = "https://dbp-staging.duckduckgo.com/dbp/redeem?" +#else static let redeemURL = "https://dbp.duckduckgo.com/dbp/redeem?" +#endif } private let urlSession: URLSession