From e705831ea0e1cc0ff69efb1cf45794207c377102 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Fri, 24 Nov 2023 21:08:41 -0800 Subject: [PATCH] NetP waitlist (#2160) Task/Issue URL: https://app.asana.com/0/0/1205955597252314/f Tech Design URL: CC: Description: This PR adds the iOS NetP waitlist. --- Core/FeatureFlag.swift | 4 +- Core/UserDefaultsPropertyWrapper.swift | 1 + DuckDuckGo.xcodeproj/project.pbxproj | 48 +++ DuckDuckGo/AppDelegate+Waitlists.swift | 97 +++++ DuckDuckGo/AppDelegate.swift | 32 +- .../VPN Waitlist/Card-16.imageset/Card-16.pdf | Bin 0 -> 3961 bytes .../Card-16.imageset/Contents.json | 15 + .../Waitlist/VPN Waitlist/Contents.json | 6 + .../InvitedVPNWaitlist.imageset/Contents.json | 12 + .../InvitedVPNWaitlist.imageset/Gift-96.pdf | Bin 0 -> 9684 bytes .../JoinVPNWaitlist.imageset/Contents.json | 12 + .../Network-Protection-VPN-96.pdf | Bin 0 -> 15349 bytes .../JoinedVPNWaitlist.imageset/Contents.json | 12 + .../JoinedVPNWaitlist.imageset/Success-96.pdf | Bin 0 -> 13451 bytes .../Rocket-16.imageset/Contents.json | 15 + .../Rocket-16.imageset/Rocket-16.pdf | Bin 0 -> 4812 bytes .../Shield-16.imageset/Contents.json | 15 + .../Shield-16.imageset/Shield-16.pdf | Bin 0 -> 2579 bytes .../VPN-Ended.imageset/Contents.json | 12 + .../VPN-Ended.imageset/VPN-Ended.pdf | Bin 0 -> 11455 bytes DuckDuckGo/Debug.storyboard | 115 +++++- DuckDuckGo/Info.plist | 1 + DuckDuckGo/MacBrowserWaitlist.swift | 2 +- .../NetworkProtectionAccessController.swift | 125 ++++++ ...orkProtectionTermsAndConditionsStore.swift | 32 ++ .../NetworkProtectionTunnelController.swift | 4 + DuckDuckGo/SettingsViewController.swift | 47 ++- DuckDuckGo/UserText.swift | 47 ++- DuckDuckGo/VPNWaitlist.swift | 118 ++++++ .../VPNWaitlistDebugViewController.swift | 199 +++++++++ ...listTermsAndConditionsViewController.swift | 73 ++++ DuckDuckGo/VPNWaitlistUserText.swift | 67 +++ DuckDuckGo/VPNWaitlistView.swift | 387 ++++++++++++++++++ DuckDuckGo/VPNWaitlistViewController.swift | 153 +++++++ DuckDuckGo/WaitlistViews.swift | 12 +- DuckDuckGo/WindowsBrowserWaitlist.swift | 2 +- DuckDuckGo/en.lproj/Localizable.strings | 92 ++++- ...tworkProtectionAccessControllerTests.swift | 183 +++++++++ .../WindowsBrowserWaitlistTests.swift | 1 - .../Network/ProductWaitlistRequest.swift | 2 +- .../Waitlist/Views/RoundedButtonStyle.swift | 34 +- .../Waitlist/Sources/Waitlist/Waitlist.swift | 4 +- .../Sources/WaitlistMocks/TestWaitlist.swift | 2 +- 43 files changed, 1920 insertions(+), 63 deletions(-) create mode 100644 DuckDuckGo/AppDelegate+Waitlists.swift create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Card-16.imageset/Card-16.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Card-16.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/InvitedVPNWaitlist.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/InvitedVPNWaitlist.imageset/Gift-96.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinVPNWaitlist.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinVPNWaitlist.imageset/Network-Protection-VPN-96.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinedVPNWaitlist.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinedVPNWaitlist.imageset/Success-96.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Rocket-16.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Rocket-16.imageset/Rocket-16.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Shield-16.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Shield-16.imageset/Shield-16.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/VPN-Ended.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/VPN-Ended.imageset/VPN-Ended.pdf create mode 100644 DuckDuckGo/NetworkProtectionAccessController.swift create mode 100644 DuckDuckGo/NetworkProtectionTermsAndConditionsStore.swift create mode 100644 DuckDuckGo/VPNWaitlist.swift create mode 100644 DuckDuckGo/VPNWaitlistDebugViewController.swift create mode 100644 DuckDuckGo/VPNWaitlistTermsAndConditionsViewController.swift create mode 100644 DuckDuckGo/VPNWaitlistUserText.swift create mode 100644 DuckDuckGo/VPNWaitlistView.swift create mode 100644 DuckDuckGo/VPNWaitlistViewController.swift create mode 100644 DuckDuckGoTests/NetworkProtectionAccessControllerTests.swift diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index c31da129d1..2f0819907d 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -32,12 +32,14 @@ public enum FeatureFlag: String { case incontextSignup case appTrackingProtection case networkProtection + case networkProtectionWaitlistAccess + case networkProtectionWaitlistActive } extension FeatureFlag: FeatureFlagSourceProviding { public var source: FeatureFlagSource { switch self { - case .debugMenu, .sync, .appTrackingProtection, .networkProtection: + case .debugMenu, .sync, .appTrackingProtection, .networkProtection, .networkProtectionWaitlistAccess, .networkProtectionWaitlistActive: return .internalOnly case .autofillCredentialInjecting: return .remoteReleasable(.subfeature(AutofillSubfeature.credentialsAutofill)) diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 47c17546f9..d675ee1bfa 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -103,6 +103,7 @@ public struct UserDefaultsWrapper { case syncCredentialsPausedErrorDisplayed = "com.duckduckgo.ios.sync-credentialsPausedErrorDisplayed" case networkProtectionDebugOptionAlwaysOnDisabled = "com.duckduckgo.network-protection.always-on.disabled" + case networkProtectionWaitlistTermsAndConditionsAccepted = "com.duckduckgo.ios.vpn.terms-and-conditions-accepted" case addressBarPosition = "com.duckduckgo.ios.addressbarposition" case showFullSiteAddress = "com.duckduckgo.ios.showfullsiteaddress" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index b851f0ca1e..72d2198df7 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -296,8 +296,18 @@ 4B948E2629DCCDB9002531FA /* Persistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4B948E2529DCCDB9002531FA /* Persistence */; }; 4BB7CBB02AF59C310014A35F /* VPNWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB7CBAF2AF59C310014A35F /* VPNWidget.swift */; }; 4BBBBA872B02E85400D965DA /* DesignResourcesKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4BBBBA862B02E85400D965DA /* DesignResourcesKit */; }; + 4BBBBA8D2B031B4200D965DA /* VPNWaitlistDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBBBA892B031B4200D965DA /* VPNWaitlistDebugViewController.swift */; }; + 4BBBBA8E2B031B4200D965DA /* VPNWaitlistViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBBBA8A2B031B4200D965DA /* VPNWaitlistViewController.swift */; }; + 4BBBBA8F2B031B4200D965DA /* VPNWaitlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBBBA8B2B031B4200D965DA /* VPNWaitlistView.swift */; }; + 4BBBBA902B031B4200D965DA /* VPNWaitlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBBBA8C2B031B4200D965DA /* VPNWaitlist.swift */; }; + 4BBBBA922B03291700D965DA /* VPNWaitlistUserText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBBBA912B03291700D965DA /* VPNWaitlistUserText.swift */; }; 4BC21A2F27238B7500229F0E /* RunLoopExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC21A2C272388BD00229F0E /* RunLoopExtensionTests.swift */; }; 4BC6DD1C2A60E6AD001EC129 /* ReportBrokenSiteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC6DD1B2A60E6AD001EC129 /* ReportBrokenSiteView.swift */; }; + 4BCD14632B05AF2B000B1E4C /* NetworkProtectionAccessController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCD14622B05AF2B000B1E4C /* NetworkProtectionAccessController.swift */; }; + 4BCD14672B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCD14662B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift */; }; + 4BCD14692B05BDD5000B1E4C /* AppDelegate+Waitlists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCD14682B05BDD5000B1E4C /* AppDelegate+Waitlists.swift */; }; + 4BCD146B2B05C4B5000B1E4C /* VPNWaitlistTermsAndConditionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCD146A2B05C4B5000B1E4C /* VPNWaitlistTermsAndConditionsViewController.swift */; }; + 4BCD146D2B05DB09000B1E4C /* NetworkProtectionAccessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCD146C2B05DB09000B1E4C /* NetworkProtectionAccessControllerTests.swift */; }; 4BE2756827304F57006B20B0 /* URLRequestExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE27566272F878F006B20B0 /* URLRequestExtension.swift */; }; 4BEF65692989C2FC00B650CB /* AdapterSocketEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D307A2989C0C400918636 /* AdapterSocketEvent.swift */; }; 4BEF656A2989C2FC00B650CB /* ProxyServerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D307C2989C0C600918636 /* ProxyServerEvent.swift */; }; @@ -1318,8 +1328,18 @@ 4B83397229AFB8D2003F7EA9 /* AppTrackingProtectionFeedbackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionFeedbackModel.swift; sourceTree = ""; }; 4B83397429AFBCE6003F7EA9 /* AppTrackingProtectionFeedbackModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionFeedbackModelTests.swift; sourceTree = ""; }; 4BB7CBAF2AF59C310014A35F /* VPNWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNWidget.swift; sourceTree = ""; }; + 4BBBBA892B031B4200D965DA /* VPNWaitlistDebugViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNWaitlistDebugViewController.swift; sourceTree = ""; }; + 4BBBBA8A2B031B4200D965DA /* VPNWaitlistViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNWaitlistViewController.swift; sourceTree = ""; }; + 4BBBBA8B2B031B4200D965DA /* VPNWaitlistView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNWaitlistView.swift; sourceTree = ""; }; + 4BBBBA8C2B031B4200D965DA /* VPNWaitlist.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNWaitlist.swift; sourceTree = ""; }; + 4BBBBA912B03291700D965DA /* VPNWaitlistUserText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNWaitlistUserText.swift; sourceTree = ""; }; 4BC21A2C272388BD00229F0E /* RunLoopExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunLoopExtensionTests.swift; sourceTree = ""; }; 4BC6DD1B2A60E6AD001EC129 /* ReportBrokenSiteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportBrokenSiteView.swift; sourceTree = ""; }; + 4BCD14622B05AF2B000B1E4C /* NetworkProtectionAccessController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionAccessController.swift; sourceTree = ""; }; + 4BCD14662B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionTermsAndConditionsStore.swift; sourceTree = ""; }; + 4BCD14682B05BDD5000B1E4C /* AppDelegate+Waitlists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Waitlists.swift"; sourceTree = ""; }; + 4BCD146A2B05C4B5000B1E4C /* VPNWaitlistTermsAndConditionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNWaitlistTermsAndConditionsViewController.swift; sourceTree = ""; }; + 4BCD146C2B05DB09000B1E4C /* NetworkProtectionAccessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionAccessControllerTests.swift; sourceTree = ""; }; 4BE27566272F878F006B20B0 /* URLRequestExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = URLRequestExtension.swift; path = ../DuckDuckGo/URLRequestExtension.swift; sourceTree = ""; }; 4BFB911A29B7D9530014D4B7 /* AppTrackingProtectionStoringModelPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionStoringModelPerformanceTests.swift; sourceTree = ""; }; 56244C1C2A137B1900EDF259 /* WaitlistViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistViews.swift; sourceTree = ""; }; @@ -3441,6 +3461,7 @@ 56244C1C2A137B1900EDF259 /* WaitlistViews.swift */, 37FCAAA0299117F9000E420A /* MacBrowser */, 37FCAAA129911801000E420A /* WindowsBrowser */, + 4BBBBA882B031B3300D965DA /* VPN */, 8524AAAB2A3888FE00EEC6D2 /* Waitlist.xcassets */, ); name = Waitlist; @@ -3466,6 +3487,18 @@ name = AppTrackingProtection; sourceTree = ""; }; + 4BBBBA882B031B3300D965DA /* VPN */ = { + isa = PBXGroup; + children = ( + 4BBBBA8C2B031B4200D965DA /* VPNWaitlist.swift */, + 4BBBBA892B031B4200D965DA /* VPNWaitlistDebugViewController.swift */, + 4BBBBA8B2B031B4200D965DA /* VPNWaitlistView.swift */, + 4BBBBA8A2B031B4200D965DA /* VPNWaitlistViewController.swift */, + 4BCD146A2B05C4B5000B1E4C /* VPNWaitlistTermsAndConditionsViewController.swift */, + ); + name = VPN; + sourceTree = ""; + }; 830FA79B1F8E81FB00FCE105 /* ContentBlocker */ = { isa = PBXGroup; children = ( @@ -4442,6 +4475,8 @@ EE0153E02A6EABE0002A8B26 /* NetworkProtectionConvenienceInitialisers.swift */, EE458D0C2AB1DA4600FC651A /* EventMapping+NetworkProtectionError.swift */, EE9D68DB2AE16AE100B55EF4 /* NotificationsAuthorizationController.swift */, + 4BCD14622B05AF2B000B1E4C /* NetworkProtectionAccessController.swift */, + 4BCD14662B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift */, ); name = Helpers; sourceTree = ""; @@ -4506,6 +4541,7 @@ EEFE9C722A603CE9005B0A26 /* NetworkProtectionStatusViewModelTests.swift */, EE0153EA2A6FF970002A8B26 /* NetworkProtectionRootViewModelTests.swift */, EE41BD182A729E9C00546C57 /* NetworkProtectionInviteViewModelTests.swift */, + 4BCD146C2B05DB09000B1E4C /* NetworkProtectionAccessControllerTests.swift */, EEC02C152B065BE00045CE11 /* NetworkProtectionVPNLocationViewModelTests.swift */, ); name = NetworkProtection; @@ -5107,6 +5143,7 @@ CB24F70E29A3EB15006DCC58 /* AppConfigurationURLProvider.swift */, 84E341951E2F7EFB00BDBA6F /* AppDelegate.swift */, 85DB12EC2A1FED0C000A4A72 /* AppDelegate+AppDeepLinks.swift */, + 4BCD14682B05BDD5000B1E4C /* AppDelegate+Waitlists.swift */, 98B31291218CCB8C00E54DE1 /* AppDependencyProvider.swift */, 85BA58591F3506AE00C6E8CA /* AppSettings.swift */, 85BA58541F34F49E00C6E8CA /* AppUserDefaults.swift */, @@ -5221,6 +5258,7 @@ F143C32C1E4A9A4800CFDE3A /* UIViewControllerExtension.swift */, F1DE78591E5CD2A70058895A /* UIViewExtension.swift */, F1F5337B1F26A9EF00D80D4F /* UserText.swift */, + 4BBBBA912B03291700D965DA /* VPNWaitlistUserText.swift */, 986DA94924884B18004A7E39 /* WebViewTransition.swift */, EE9D68D72AE15AD600B55EF4 /* UIApplicationExtension.swift */, ); @@ -6224,6 +6262,7 @@ C1CDA3162AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift in Sources */, F1CA3C371F045878005FADB3 /* PrivacyStore.swift in Sources */, 37FCAAC029930E26000E420A /* FailedAssertionView.swift in Sources */, + 4BBBBA922B03291700D965DA /* VPNWaitlistUserText.swift in Sources */, F4E1936625AF722F001D2666 /* HighlightCutOutView.swift in Sources */, 1E162605296840D80004127F /* Triangle.swift in Sources */, B609D5522862EAFF0088CAC2 /* InlineWKDownloadDelegate.swift in Sources */, @@ -6247,6 +6286,7 @@ B652DF12287C336E00C12A9C /* ContentBlockingUpdating.swift in Sources */, 314C92BA27C3E7CB0042EC96 /* QuickLookContainerViewController.swift in Sources */, 855D914D2063EF6A00C4B448 /* TabSwitcherTransition.swift in Sources */, + 4BBBBA8F2B031B4200D965DA /* VPNWaitlistView.swift in Sources */, CB258D1229A4F24900DEBA24 /* ConfigurationManager.swift in Sources */, 8546A54A2A672959003929BF /* MainViewController+Email.swift in Sources */, F4F6DFB226E6AEC100ED7E12 /* AddOrEditBookmarkViewController.swift in Sources */, @@ -6315,6 +6355,7 @@ 85DFEDED24C7CCA500973FE7 /* AppWidthObserver.swift in Sources */, 4B6484F327FD1E350050A7A1 /* MenuControllerView.swift in Sources */, 1EE7C299294227EC0026C8CB /* AutoconsentSettingsViewController.swift in Sources */, + 4BCD14632B05AF2B000B1E4C /* NetworkProtectionAccessController.swift in Sources */, 1E8AD1D527C2E22900ABA377 /* DownloadsListSectionViewModel.swift in Sources */, 4BC6DD1C2A60E6AD001EC129 /* ReportBrokenSiteView.swift in Sources */, 31584616281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift in Sources */, @@ -6380,6 +6421,7 @@ 02341FA62A4379CC008A1531 /* OnboardingStepViewModel.swift in Sources */, 850365F323DE087800D0F787 /* UIImageViewExtension.swift in Sources */, 373608922ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */, + 4BBBBA8D2B031B4200D965DA /* VPNWaitlistDebugViewController.swift in Sources */, C160544129D6044D00B715A1 /* AutofillInterfaceUsernameTruncator.swift in Sources */, 02A54A9A2A094A17000C8FED /* AppTPHomeView.swift in Sources */, 31C70B5528045E3500FB6AD1 /* SecureVaultErrorReporter.swift in Sources */, @@ -6407,6 +6449,7 @@ 85AE6690209724120014CF04 /* NotificationView.swift in Sources */, 1EA51376286596A000493C6A /* PrivacyIconLogic.swift in Sources */, 980891A92238504B00313A70 /* UILabelExtension.swift in Sources */, + 4BCD146B2B05C4B5000B1E4C /* VPNWaitlistTermsAndConditionsViewController.swift in Sources */, 984D035A24ACCC7D0066CFB8 /* TabViewCell.swift in Sources */, 31951E8E2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift in Sources */, F194FAED1F14E2B3009B4DF8 /* UIFontExtension.swift in Sources */, @@ -6438,6 +6481,7 @@ C17B595A2A03AAD30055F2D1 /* PasswordGenerationPromptViewController.swift in Sources */, 8531A08E1F9950E6000484F0 /* UnprotectedSitesViewController.swift in Sources */, CBD4F13C279EBF4A00B20FD7 /* HomeMessage.swift in Sources */, + 4BBBBA8E2B031B4200D965DA /* VPNWaitlistViewController.swift in Sources */, 3132FA2C27A07A1B00DD7A12 /* FilePreview.swift in Sources */, 85C861E628FF1B5F00189466 /* HomeViewSectionRenderersExtension.swift in Sources */, F1D477C61F2126CC0031ED49 /* OmniBarState.swift in Sources */, @@ -6455,6 +6499,7 @@ 31C70B5B2804C61000FB6AD1 /* SaveAutofillLoginManager.swift in Sources */, 85449EFD23FDA71F00512AAF /* KeyboardSettings.swift in Sources */, 980891A222369ADB00313A70 /* FeedbackUserText.swift in Sources */, + 4BCD14692B05BDD5000B1E4C /* AppDelegate+Waitlists.swift in Sources */, 988F3DD3237DE8D900AEE34C /* ForgetDataAlert.swift in Sources */, 850ABD012AC3961100A733DF /* MainViewController+Segues.swift in Sources */, 9817C9C321EF594700884F65 /* AutoClear.swift in Sources */, @@ -6462,11 +6507,13 @@ 85EE7F572246685B000FE757 /* WebContainerViewController.swift in Sources */, 1EC458462948932500CB2B13 /* UIHostingControllerExtension.swift in Sources */, 1E4DCF4E27B6A69600961E25 /* DownloadsListHostingController.swift in Sources */, + 4BCD14672B05B682000B1E4C /* NetworkProtectionTermsAndConditionsStore.swift in Sources */, 020108A129A5610C00644F9D /* AppTPActivityHostingViewController.swift in Sources */, C1F341C92A6926920032057B /* EmailAddressPromptViewController.swift in Sources */, 02025B0F29884DC500E694E7 /* AppTrackerDataParser.swift in Sources */, 027F48742A4B5904001A1C6C /* AppTPAboutView.swift in Sources */, 311BD1B12836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift in Sources */, + 4BBBBA902B031B4200D965DA /* VPNWaitlist.swift in Sources */, B652DF13287C373A00C12A9C /* ScriptSourceProviding.swift in Sources */, 854A012B2A54412600FCC628 /* ActivityViewController.swift in Sources */, F1CA3C391F045885005FADB3 /* PrivacyUserDefaults.swift in Sources */, @@ -6682,6 +6729,7 @@ F1D477C91F2139410031ED49 /* SmallOmniBarStateTests.swift in Sources */, 987130C9294AAB9F00AB05E0 /* BookmarkUtilsTests.swift in Sources */, C1BF0BA929B63E2200482B73 /* AutofillLoginPromptViewModelTests.swift in Sources */, + 4BCD146D2B05DB09000B1E4C /* NetworkProtectionAccessControllerTests.swift in Sources */, EE3B226B29DE0F110082298A /* MockInternalUserStoring.swift in Sources */, 987130C8294AAB9F00AB05E0 /* BookmarksTestHelpers.swift in Sources */, F198D7981E3A45D90088DA8A /* WKWebViewConfigurationExtensionTests.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate+Waitlists.swift b/DuckDuckGo/AppDelegate+Waitlists.swift new file mode 100644 index 0000000000..83f5a84a24 --- /dev/null +++ b/DuckDuckGo/AppDelegate+Waitlists.swift @@ -0,0 +1,97 @@ +// +// AppDelegate+Waitlists.swift +// DuckDuckGo +// +// 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 BackgroundTasks +import NetworkProtection + +extension AppDelegate { + + func checkWaitlists() { + checkWindowsWaitlist() + +#if NETWORK_PROTECTION + checkNetworkProtectionWaitlist() +#endif + checkWaitlistBackgroundTasks() + + } + + private func checkWindowsWaitlist() { + WindowsBrowserWaitlist.shared.fetchInviteCodeIfAvailable { error in + guard error == nil else { return } + WindowsBrowserWaitlist.shared.sendInviteCodeAvailableNotification() + } + } + +#if NETWORK_PROTECTION + private func checkNetworkProtectionWaitlist() { + VPNWaitlist.shared.fetchInviteCodeIfAvailable { [weak self] error in + guard error == nil else { +#if !DEBUG + // If the user already has an invite code but their auth token has gone missing, attempt to redeem it again. + let tokenStore = NetworkProtectionKeychainTokenStore() + let waitlistStorage = VPNWaitlist.shared.waitlistStorage + if error == .alreadyHasInviteCode, + let inviteCode = waitlistStorage.getWaitlistInviteCode(), + !tokenStore.isFeatureActivated { + self?.fetchVPNWaitlistAuthToken(inviteCode: inviteCode) + } +#endif + return + + } + + guard let inviteCode = VPNWaitlist.shared.waitlistStorage.getWaitlistInviteCode() else { + return + } + + self?.fetchVPNWaitlistAuthToken(inviteCode: inviteCode) + } + } +#endif + + private func checkWaitlistBackgroundTasks() { + BGTaskScheduler.shared.getPendingTaskRequests { tasks in + let hasWindowsBrowserWaitlistTask = tasks.contains { $0.identifier == WindowsBrowserWaitlist.backgroundRefreshTaskIdentifier } + if !hasWindowsBrowserWaitlistTask { + WindowsBrowserWaitlist.shared.scheduleBackgroundRefreshTask() + } + +#if NETWORK_PROTECTION + let hasVPNWaitlistTask = tasks.contains { $0.identifier == VPNWaitlist.backgroundRefreshTaskIdentifier } + if !hasVPNWaitlistTask { + VPNWaitlist.shared.scheduleBackgroundRefreshTask() + } +#endif + } + } + +#if NETWORK_PROTECTION + func fetchVPNWaitlistAuthToken(inviteCode: String) { + Task { + do { + try await NetworkProtectionCodeRedemptionCoordinator().redeem(inviteCode) + VPNWaitlist.shared.sendInviteCodeAvailableNotification() + } catch {} + } + } +#endif + +} diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 3633513de4..f9671de5dd 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -330,6 +330,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Having both in `didBecomeActive` can sometimes cause the exception when running on a physical device, so registration happens here. AppConfigurationFetch.registerBackgroundRefreshTaskHandler() WindowsBrowserWaitlist.shared.registerBackgroundRefreshTaskHandler() + +#if NETWORK_PROTECTION + VPNWaitlist.shared.registerBackgroundRefreshTaskHandler() +#endif + RemoteMessaging.registerBackgroundRefreshTaskHandler( bookmarksDatabase: bookmarksDatabase, favoritesDisplayMode: AppDependencyProvider.shared.appSettings.favoritesDisplayMode @@ -349,6 +354,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { #if NETWORK_PROTECTION widgetRefreshModel.beginObservingVPNStatus() + NetworkProtectionAccessController().refreshNetworkProtectionAccess() #endif return true @@ -431,18 +437,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - WindowsBrowserWaitlist.shared.fetchInviteCodeIfAvailable { error in - guard error == nil else { return } - WindowsBrowserWaitlist.shared.sendInviteCodeAvailableNotification() - } - - BGTaskScheduler.shared.getPendingTaskRequests { tasks in - let hasWindowsBrowserWaitlistTask = tasks.contains { $0.identifier == WindowsBrowserWaitlist.backgroundRefreshTaskIdentifier } - if !hasWindowsBrowserWaitlistTask { - WindowsBrowserWaitlist.shared.scheduleBackgroundRefreshTask() - } - } - + checkWaitlists() syncService.scheduler.notifyAppLifecycleEvent() fireFailedCompilationsPixelIfNeeded() refreshShortcuts() @@ -838,7 +833,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == UNNotificationDefaultActionIdentifier { let identifier = response.notification.request.identifier - if identifier == WindowsBrowserWaitlist.notificationIdentitier { + if identifier == WindowsBrowserWaitlist.notificationIdentifier { presentWindowsBrowserWaitlistSettingsModal() } @@ -846,6 +841,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate { if NetworkProtectionNotificationIdentifier(rawValue: identifier) != nil { presentNetworkProtectionStatusSettingsModal() } + + if identifier == VPNWaitlist.notificationIdentifier { + presentNetworkProtectionWaitlistModal() + } #endif } @@ -858,6 +857,13 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } #if NETWORK_PROTECTION + private func presentNetworkProtectionWaitlistModal() { + if #available(iOS 15, *) { + let networkProtectionRoot = VPNWaitlistViewController(nibName: nil, bundle: nil) + presentSettings(with: networkProtectionRoot) + } + } + func presentNetworkProtectionStatusSettingsModal() { if #available(iOS 15, *) { let networkProtectionRoot = NetworkProtectionRootViewController() diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Card-16.imageset/Card-16.pdf b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Card-16.imageset/Card-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ead00161852b20d05c5bb2ff0a1871277fdf914d GIT binary patch literal 3961 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 H^uvDv9PwaX literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinVPNWaitlist.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinVPNWaitlist.imageset/Contents.json new file mode 100644 index 0000000000..e9f9e938f0 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinVPNWaitlist.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Network-Protection-VPN-96.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinVPNWaitlist.imageset/Network-Protection-VPN-96.pdf b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinVPNWaitlist.imageset/Network-Protection-VPN-96.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2e31d6e5ae561f6dee47d54a3eec79ec0e4d5458 GIT binary patch literal 15349 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 SBE!6KT@OF|*=N7|)&Bw;x74u! literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinedVPNWaitlist.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinedVPNWaitlist.imageset/Contents.json new file mode 100644 index 0000000000..3d42417002 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinedVPNWaitlist.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Success-96.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinedVPNWaitlist.imageset/Success-96.pdf b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/JoinedVPNWaitlist.imageset/Success-96.pdf new file mode 100644 index 0000000000000000000000000000000000000000..98e6385c2434da37526178fc927ecc729ad24316 GIT binary patch literal 13451 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 Bc@qEt literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Rocket-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Rocket-16.imageset/Contents.json new file mode 100644 index 0000000000..b23b52c784 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Rocket-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Rocket-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Rocket-16.imageset/Rocket-16.pdf b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Rocket-16.imageset/Rocket-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c7dd245c4f70b7d00e0f11741f062be0d312edad GIT binary patch literal 4812 zcmd^@TaOz>5QX38SM*CHK-zKjy<0+&$W;gsVV4Ia#Dn8Zk_E3FYzHO$dcNwh$6oKE zfOy0YyW=x`sXBG4s@o4-Xc*S~~`D*)az8O}FkGI}Ge7Ilg>ix@I+w|Fb zbF;@Thwb|PW-)Aai)rMeN9zyfXCu9|&+);g19W`)g{7Fe-ED^XO*eZo{BgY)E}uU# zkFQR@Ew_avez7HYi>P+ojNyRd7f=VBydY?|_F1p-jZws3fy;5BX-W(z6lXqUSnWjo_ zLsY95-L!-B)wvX$IoffACWB9ATGv}~u42iw!wEJSa-X-0?ydS80ZOB*eoSe*6U#$2lux0yDv$c__M@U=|uh3ztz*@;RH5v|Es8!YD z74{u0ImHe(pG;TrhRBj0zL^(#VpTE6 z^=RE9_hlEc-orSUO*uMUG$wLhmyqmy)QbQBk2??X|@E*Q5>1k1VzXz^hO^F@qk?^wP+M_CJ7bER6Jw> z5ipG~;0sC|JGA%(_zlH)La$QNkOn8Z1_FUzN=%y?EYXU99)kiJU>pGaAh_z%s2X1( zMIi)IsbNfI<>V0)4rWwC=#j7Zz(~w2Y#|K_sRjtOO}|7z=fbY!Lij;T&H(}x^MUf& zvY=}5X;`4{ViiGl$VdaoGd?D*_Mi|0rL2=hRIp%)!dpeLJISVbPDUkUm5C?=OQ@tE zlM)ID@D&2}#u1Q=;wz<9xv^FVCEO5s!pqd;5^>dr6C*APm7dxdvZ#efs}fg-_A~ry zlCdj+hE^7V8d0Y_%pnNgs+bFx~6iA9%5YWBGxbQ$)XZ$`Vr8Nh9qD)84>f$f}sz?#q z4Q+s;j&rQ$G&UZ7=wc)wdFSb@PHPZV-qTmddK2+BRg6Rs1OTrf?`-`g0V;hFY9#U1 z@)E5!Bn+sbie{}x(GbD*Hjn}!G@HFaQF|=gL$IcEYso1l0*jIONw783&ezvnk&7g% z8L5K#Atg-ZIN{RRxwxP_QSpBO;4viwG1;g{X3#UA378in{~Mc;z@( zG7hRLYy$<9;aWS&w4OF9N954dz$)mDGZbAR(HymoCn*A#%D!X^G1!*0mo=h`cEh$S z{UT9P@s;A4sp)j}Ohdv~jZw;&gqn4GMj?E3*YX09sR0cnJxXf<67?LS<2aCb^m&gA zngH8DsT)?W_D6W0^qSJ?HDtW6kffpm8q6``pIu|Z|NRtCfryq9DuXgj@ z(B40_?cddR^X+f#X}tOB*IW10<8}7-)9krX$?Y%1x8L3QHsdDz)$-lj`KOmY+;;D* zp}_unK6&zY%RPKJy3JyT{GddgFtxwZ=L19k=$br(vp3sr2DMMt++yxYPF-+}Qb^O* zoFq@S{%W;a@3!V=y`UdmdNF@9?1ArwSJ(4L|MIG@D;<9|P+1&)s(8F!?S>UQDgy`b zh5jP3Gj$K0@TAb_f&Luw&GjoxHR{2oIavI7{hqn@j{RGZla(m9r%S1a+iAz6=a1*R z`Evc{_S_Gf;dRG(z;MyPI!zjUCps-Gc{Dzkl`@f>htx literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Shield-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Shield-16.imageset/Contents.json new file mode 100644 index 0000000000..510acba745 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Shield-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Shield-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Shield-16.imageset/Shield-16.pdf b/DuckDuckGo/Assets.xcassets/Waitlist/VPN Waitlist/Shield-16.imageset/Shield-16.pdf new file mode 100644 index 0000000000000000000000000000000000000000..677edb434b04e64e0c5890edd3905cb8c1120d2d GIT binary patch literal 2579 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 - + - + @@ -100,9 +100,29 @@ - + + + + + + + + + + + + + + + @@ -121,7 +141,7 @@ - + @@ -141,7 +161,7 @@ - + @@ -161,7 +181,7 @@ - + @@ -181,7 +201,7 @@ - + @@ -190,7 +210,7 @@ - + @@ -199,7 +219,7 @@ - + @@ -208,7 +228,7 @@ - + @@ -217,7 +237,7 @@ - + @@ -226,7 +246,7 @@ - + @@ -660,7 +680,7 @@ - + @@ -707,20 +727,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + diff --git a/DuckDuckGo/Info.plist b/DuckDuckGo/Info.plist index d681c054b6..94ad683aca 100644 --- a/DuckDuckGo/Info.plist +++ b/DuckDuckGo/Info.plist @@ -4,6 +4,7 @@ BGTaskSchedulerPermittedIdentifiers + com.duckduckgo.app.vpnWaitlistStatus com.duckduckgo.app.windowsBrowserWaitlistStatus com.duckduckgo.app.configurationRefresh com.duckduckgo.app.remoteMessageRefresh diff --git a/DuckDuckGo/MacBrowserWaitlist.swift b/DuckDuckGo/MacBrowserWaitlist.swift index 60a0bfb413..0779de05b9 100644 --- a/DuckDuckGo/MacBrowserWaitlist.swift +++ b/DuckDuckGo/MacBrowserWaitlist.swift @@ -34,7 +34,7 @@ struct MacBrowserWaitlist: Waitlist { static let backgroundTaskName = "Mac Browser Waitlist Status Task" static let backgroundRefreshTaskIdentifier = "com.duckduckgo.app.macBrowserWaitlistStatus" - static let notificationIdentitier = "com.duckduckgo.ios.mac-browser.invite-code-available" + static let notificationIdentifier = "com.duckduckgo.ios.mac-browser.invite-code-available" static let inviteAvailableNotificationTitle = UserText.macWaitlistAvailableNotificationTitle static let inviteAvailableNotificationBody = UserText.waitlistAvailableNotificationBody diff --git a/DuckDuckGo/NetworkProtectionAccessController.swift b/DuckDuckGo/NetworkProtectionAccessController.swift new file mode 100644 index 0000000000..7b1faaacce --- /dev/null +++ b/DuckDuckGo/NetworkProtectionAccessController.swift @@ -0,0 +1,125 @@ +// +// NetworkProtectionAccessController.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Foundation +import BrowserServicesKit +import ContentBlocking +import Core +import NetworkProtection +import Waitlist + +enum NetworkProtectionAccessType { + /// Used if the user does not have waitlist feature flag access + case none + + /// Used if the user has waitlist feature flag access, but has not joined the waitlist + case waitlistAvailable + + /// Used if the user has waitlist feature flag access, and has joined the waitlist + case waitlistJoined + + /// Used if the user has been invited via the waitlist, but needs to accept the Privacy Policy and Terms of Service + case waitlistInvitedPendingTermsAcceptance + + /// Used if the user has been invited via the waitlist and has accepted the Privacy Policy and Terms of Service + case waitlistInvited + + /// Used if the user has been invited to test Network Protection directly + case inviteCodeInvited +} + +protocol NetworkProtectionAccess { + func networkProtectionAccessType() -> NetworkProtectionAccessType +} + +struct NetworkProtectionAccessController: NetworkProtectionAccess { + + private let networkProtectionActivation: NetworkProtectionFeatureActivation + private let networkProtectionWaitlistStorage: WaitlistStorage + private let networkProtectionTermsAndConditionsStore: NetworkProtectionTermsAndConditionsStore + private let featureFlagger: FeatureFlagger + + init( + networkProtectionActivation: NetworkProtectionFeatureActivation = NetworkProtectionKeychainTokenStore(), + networkProtectionWaitlistStorage: WaitlistStorage = WaitlistKeychainStore(waitlistIdentifier: VPNWaitlist.identifier), + networkProtectionTermsAndConditionsStore: NetworkProtectionTermsAndConditionsStore = NetworkProtectionTermsAndConditionsUserDefaultsStore(), + featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger + ) { + self.networkProtectionActivation = networkProtectionActivation + self.networkProtectionWaitlistStorage = networkProtectionWaitlistStorage + self.networkProtectionTermsAndConditionsStore = networkProtectionTermsAndConditionsStore + self.featureFlagger = featureFlagger + } + + func networkProtectionAccessType() -> NetworkProtectionAccessType { + // First, check for users who have activated the VPN via an invite code: + if networkProtectionActivation.isFeatureActivated && !networkProtectionWaitlistStorage.isInvited { + return .inviteCodeInvited + } + + // Next, check if the waitlist is still active; if not, the user has no access. + let isWaitlistActive = featureFlagger.isFeatureOn(.networkProtectionWaitlistActive) + if !isWaitlistActive { + return .none + } + + // Next, check if a waitlist user has NetP access and whether they need to accept T&C. + if networkProtectionActivation.isFeatureActivated && networkProtectionWaitlistStorage.isInvited { + if networkProtectionTermsAndConditionsStore.networkProtectionWaitlistTermsAndConditionsAccepted { + return .waitlistInvited + } else { + return .waitlistInvitedPendingTermsAcceptance + } + } + + // Next, check if the user has waitlist access at all and whether they've already joined. + let hasWaitlistAccess = featureFlagger.isFeatureOn(.networkProtectionWaitlistAccess) + if hasWaitlistAccess { + if networkProtectionWaitlistStorage.isOnWaitlist { + return .waitlistJoined + } else { + return .waitlistAvailable + } + } + + return .none + } + + func refreshNetworkProtectionAccess() { + guard networkProtectionActivation.isFeatureActivated else { + return + } + + if !featureFlagger.isFeatureOn(.networkProtectionWaitlistActive) { + networkProtectionWaitlistStorage.deleteWaitlistState() + try? NetworkProtectionKeychainTokenStore().deleteToken() + + Task { + let controller = NetworkProtectionTunnelController() + await controller.stop() + await controller.removeVPN() + } + } + } + +} + +#endif diff --git a/DuckDuckGo/NetworkProtectionTermsAndConditionsStore.swift b/DuckDuckGo/NetworkProtectionTermsAndConditionsStore.swift new file mode 100644 index 0000000000..46a6c024e2 --- /dev/null +++ b/DuckDuckGo/NetworkProtectionTermsAndConditionsStore.swift @@ -0,0 +1,32 @@ +// +// NetworkProtectionTermsAndConditionsStore.swift +// DuckDuckGo +// +// 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 Core + +protocol NetworkProtectionTermsAndConditionsStore { + var networkProtectionWaitlistTermsAndConditionsAccepted: Bool { get set } +} + +struct NetworkProtectionTermsAndConditionsUserDefaultsStore: NetworkProtectionTermsAndConditionsStore { + + @UserDefaultsWrapper(key: .networkProtectionWaitlistTermsAndConditionsAccepted, defaultValue: false) + var networkProtectionWaitlistTermsAndConditionsAccepted: Bool + +} diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index 96e53f989e..2a1d1b6f8c 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -67,6 +67,10 @@ final class NetworkProtectionTunnelController: TunnelController { tunnelManager.connection.stopVPNTunnel() } + func removeVPN() async { + try? await tunnelManager?.removeFromPreferences() + } + // MARK: - Connection Status Querying /// Queries Network Protection to know if its VPN is connected. diff --git a/DuckDuckGo/SettingsViewController.swift b/DuckDuckGo/SettingsViewController.swift index 4820200190..3a8a70a8a6 100644 --- a/DuckDuckGo/SettingsViewController.swift +++ b/DuckDuckGo/SettingsViewController.swift @@ -184,6 +184,10 @@ class SettingsViewController: UITableViewController { configureWindowsBrowserWaitlistCell() configureSyncCell() +#if NETWORK_PROTECTION + updateNetPCellSubtitle(connectionStatus: connectionObserver.recentValue) +#endif + // Make sure multiline labels are correctly presented tableView.setNeedsLayout() tableView.layoutIfNeeded() @@ -355,22 +359,30 @@ class SettingsViewController: UITableViewController { private func configureNetPCell() { netPCell.isHidden = !shouldShowNetPCell #if NETWORK_PROTECTION + updateNetPCellSubtitle(connectionStatus: connectionObserver.recentValue) connectionObserver.publisher .receive(on: DispatchQueue.main) .sink { [weak self] status in - let detailText: String - switch status { - case .connected: - detailText = UserText.netPCellConnected - default: - detailText = UserText.netPCellDisconnected - } - self?.netPCell.detailTextLabel?.text = detailText + self?.updateNetPCellSubtitle(connectionStatus: status) } .store(in: &cancellables) #endif } +#if NETWORK_PROTECTION + private func updateNetPCellSubtitle(connectionStatus: ConnectionStatus) { + switch NetworkProtectionAccessController().networkProtectionAccessType() { + case .none, .waitlistAvailable, .waitlistJoined, .waitlistInvitedPendingTermsAcceptance: + netPCell.detailTextLabel?.text = VPNWaitlist.shared.settingsSubtitle + case .waitlistInvited, .inviteCodeInvited: + switch connectionStatus { + case .connected: netPCell.detailTextLabel?.text = UserText.netPCellConnected + default: netPCell.detailTextLabel?.text = UserText.netPCellDisconnected + } + } + } +#endif + private func configureDebugCell() { debugCell.isHidden = !shouldShowDebugCell } @@ -426,14 +438,21 @@ class SettingsViewController: UITableViewController { #if NETWORK_PROTECTION @available(iOS 15, *) private func showNetP() { - // This will be tidied up as part of https://app.asana.com/0/0/1205084446087078/f - let rootViewController = NetworkProtectionRootViewController { [weak self] in - self?.navigationController?.popViewController(animated: true) - let newRootViewController = NetworkProtectionRootViewController() - self?.pushNetP(newRootViewController) + switch NetworkProtectionAccessController().networkProtectionAccessType() { + case .inviteCodeInvited, .waitlistInvited: + // This will be tidied up as part of https://app.asana.com/0/0/1205084446087078/f + let rootViewController = NetworkProtectionRootViewController { [weak self] in + self?.navigationController?.popViewController(animated: true) + let newRootViewController = NetworkProtectionRootViewController() + self?.pushNetP(newRootViewController) + } + + pushNetP(rootViewController) + default: + navigationController?.pushViewController(VPNWaitlistViewController(nibName: nil, bundle: nil), animated: true) } - pushNetP(rootViewController) } + @available(iOS 15, *) private func pushNetP(_ rootViewController: NetworkProtectionRootViewController) { navigationController?.pushViewController( diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 836fd5538a..6b15c281f1 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -622,7 +622,7 @@ In addition to the details entered into this form, your app issue report will co public static let netPCellConnected = NSLocalizedString("netP.cell.connected", value: "Connected", comment: "String indicating NetP is connected when viewed from the settings screen") public static let netPCellDisconnected = NSLocalizedString("netP.cell.disconnected", value: "Not connected", comment: "String indicating NetP is disconnected when viewed from the settings screen") - static let netPInviteTitle = NSLocalizedString("network.protection.invite.dialog.title", value: "You're invited to try Network Protection", comment: "Title for the network protection invite screen") + static let netPInviteTitle = NSLocalizedString("network.protection.invite.dialog.title", value: "You’re invited to try Network Protection", comment: "Title for the network protection invite screen") static let netPInviteMessage = NSLocalizedString("network.protection.invite.dialog.message", value: "Enter your invite code to get started.", comment: "Message for the network protection invite dialog") static let netPInviteFieldPrompt = NSLocalizedString("network.protection.invite.field.prompt", value: "Invite Code", comment: "Prompt for the network protection invite code text field") static let netPInviteSuccessTitle = NSLocalizedString("network.protection.invite.success.title", value: "Success! You’re in.", comment: "Title for the network protection invite success view") @@ -889,4 +889,49 @@ But if you *do* want a peek under the hood, you can find more information about static let emailProtectionSignInBody = NSLocalizedString("error.email-protection-sign-in.body", value: "Sorry, please sign in again to re-enable Email Protection features on this browser.", comment: "Alert message") static let emailProtectionSignInAction = NSLocalizedString("error.email-protection-sign-in.action", value: "Sign In", comment: "Button title to Sign In") + // MARK: - VPN Waitlist + + static let networkProtectionWaitlistJoinTitle = NSLocalizedString("network-protection.waitlist.join.title", value: "Network Protection Early Access", comment: "Title for Network Protection join waitlist screen") + static let networkProtectionWaitlistJoinSubtitle1 = NSLocalizedString("network-protection.waitlist.join.subtitle.1", value: "Secure your connection anytime, anywhere with Network Protection, the VPN from DuckDuckGo.", comment: "First subtitle for Network Protection join waitlist screen") + static let networkProtectionWaitlistJoinSubtitle2 = NSLocalizedString("network-protection.waitlist.join.subtitle.2", value: "Join the waitlist, and we’ll notify you when it’s your turn.", comment: "Second subtitle for Network Protection join waitlist screen") + + static let networkProtectionWaitlistJoinedTitle = NSLocalizedString("network-protection.waitlist.joined.title", value: "You’re on the list!", comment: "Title for Network Protection joined waitlist screen") + static let networkProtectionWaitlistJoinedWithNotificationsSubtitle1 = NSLocalizedString("network-protection.waitlist.joined.with-notifications.subtitle.1", value: "New invites are sent every few days, on a first come, first served basis.", comment: "Subtitle 1 for Network Protection joined waitlist screen when notifications are enabled") + static let networkProtectionWaitlistJoinedWithNotificationsSubtitle2 = NSLocalizedString("network-protection.waitlist.joined.with-notifications.subtitle.2", value: "We’ll notify you when your invite is ready.", comment: "Subtitle 2 for Network Protection joined waitlist screen when notifications are enabled") + + static let networkProtectionWaitlistNotificationTitle = NSLocalizedString("network-protection.waitlist.notification.title", value: "Network Protection is ready!", comment: "Title for Network Protection waitlist notification") + static let networkProtectionWaitlistNotificationText = NSLocalizedString("network-protection.waitlist.notification.text", value: "Open your invite", comment: "Title for Network Protection waitlist notification") + + static let networkProtectionWaitlistInvitedTitle = NSLocalizedString("network-protection.waitlist.invited.title", value: "You’re invited to try\nNetwork Protection early access!", comment: "Title for Network Protection invited screen") + static let networkProtectionWaitlistInvitedSubtitle = NSLocalizedString("network-protection.waitlist.invited.subtitle", value: "Get an extra layer of protection online with the VPN built for speed and simplicity. Encrypt your internet connection across your entire device and hide your location and IP address from sites you visit.", comment: "Subtitle for Network Protection invited screen") + + static let networkProtectionWaitlistInvitedSection1Title = NSLocalizedString("network-protection.waitlist.invited.section-1.title", value: "Full-device coverage", comment: "Title for section 1 of the Network Protection invited screen") + static let networkProtectionWaitlistInvitedSection1Subtitle = NSLocalizedString("network-protection.waitlist.invited.section-1.subtitle", value: "Encrypt online traffic across your browsers and apps.", comment: "Subtitle for section 1 of the Network Protection invited screen") + + static let networkProtectionWaitlistInvitedSection2Title = NSLocalizedString("network-protection.waitlist.invited.section-2.title", value: "Fast, reliable, and easy to use", comment: "Title for section 2 of the Network Protection invited screen") + static let networkProtectionWaitlistInvitedSection2Subtitle = NSLocalizedString("network-protection.waitlist.invited.section-2.subtitle", value: "No need for a separate app. Connect in one click and see your connection status at a glance.", comment: "Subtitle for section 2 of the Network Protection invited screen") + + static let networkProtectionWaitlistInvitedSection3Title = NSLocalizedString("network-protection.waitlist.invited.section-3.title", value: "Strict no-logging policy", comment: "Title for section 3 of the Network Protection invited screen") + static let networkProtectionWaitlistInvitedSection3Subtitle = NSLocalizedString("network-protection.waitlist.invited.section-3.subtitle", value: "We do not log or save any data that can connect you to your online activity.", comment: "Subtitle for section 3 of the Network Protection invited screen") + + static let networkProtectionWaitlistButtonEnableNotifications = NSLocalizedString("network-protection.waitlist.button.enable-notifications", value: "Enable Notifications", comment: "Enable Notifications button for Network Protection joined waitlist screen") + 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 networkProtectionWaitlistButtonExistingInviteCode = NSLocalizedString("network-protection.waitlist.button.existing-invite-code", value: "I Have an Invite Code", comment: "Button title for users who already have an invite code") + + static let networkProtectionWaitlistAvailabilityDisclaimer = NSLocalizedString("network-protection.waitlist.availability-disclaimer", value: "Network Protection is free to use during early access.", comment: "Availability disclaimer for Network Protection join waitlist screen") + + static let networkProtectionPrivacyPolicyTitle = NSLocalizedString("network-protection.privacy-policy.title", value: "Privacy Policy", comment: "Privacy Policy title for Network Protection") + + static let networkProtectionWaitlistNotificationAlertDescription = NSLocalizedString("network-protection.waitlist.notification-alert.description", value: "We’ll send you a notification when your invite to test Network Protection is ready.", comment: "Body text for the alert to enable notifications") + + static let networkProtectionWaitlistGetStarted = NSLocalizedString("network-protection.waitlist.get-started", value: "Get Started", comment: "Button title text for the Network Protection waitlist confirmation prompt") + static let networkProtectionWaitlistAgreeAndContinue = NSLocalizedString("network-protection.waitlist.agree-and-continue", value: "Agree and Continue", comment: "Title text for the Network Protection terms and conditions accept button") + + static let networkProtectionSettingsSubtitleNotJoined = NSLocalizedString("network-protection.waitlist.settings-subtitle.waitlist-not-joined", value: "Join the private waitlist", comment: "Subtitle text for the Network Protection settings row") + static let networkProtectionSettingsSubtitleJoinedButNotInvited = NSLocalizedString("network-protection.waitlist.settings-subtitle.joined-but-not-invited", value: "You’re on the list!", comment: "Subtitle text for the Network Protection settings row") + static let networkProtectionSettingsSubtitleJoinedAndInvited = NSLocalizedString("network-protection.waitlist.settings-subtitle.joined-and-invited", value: "Your invite is ready!", comment: "Subtitle text for the Network Protection settings row") + + static let networkProtectionNotificationPromptTitle = NSLocalizedString("network-protection.waitlist.notification-prompt-title", value: "Know the instant you're invited", comment: "Title for the alert to confirm enabling notifications") + static let networkProtectionNotificationPromptDescription = NSLocalizedString("network-protection.waitlist.notification-prompt-description", value: "Get a notification when your copy of Network Protection early access is ready.", comment: "Subtitle for the alert to confirm enabling notifications") } diff --git a/DuckDuckGo/VPNWaitlist.swift b/DuckDuckGo/VPNWaitlist.swift new file mode 100644 index 0000000000..522a31a414 --- /dev/null +++ b/DuckDuckGo/VPNWaitlist.swift @@ -0,0 +1,118 @@ +// +// VPNWaitlist.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Foundation +import BrowserServicesKit +import Combine +import Core +import Waitlist +import NetworkProtection + +final class VPNWaitlist: Waitlist { + + enum AccessType { + /// Used if the user does not have waitlist feature flag access + case none + + /// Used if the user has waitlist feature flag access, but has not joined the waitlist + case waitlistAvailable + + /// Used if the user has waitlist feature flag access, and has joined the waitlist + case waitlistJoined + + /// Used if the user has been invited via the waitlist, but needs to accept the Privacy Policy and Terms of Service + case waitlistInvitedPendingTermsAcceptance + + /// Used if the user has been invited via the waitlist and has accepted the Privacy Policy and Terms of Service + case waitlistInvited + + /// Used if the user has been invited to test Network Protection directly + case inviteCodeInvited + } + + static let identifier: String = "vpn" + static let apiProductName: String = "networkprotection_ios" + static let downloadURL: URL = URL.windows + + static let shared: VPNWaitlist = .init() + + static let backgroundTaskName = "VPN Waitlist Status Task" + static let backgroundRefreshTaskIdentifier = "com.duckduckgo.app.vpnWaitlistStatus" + static let notificationIdentifier = "com.duckduckgo.ios.vpn.invite-code-available" + static let inviteAvailableNotificationTitle = UserText.networkProtectionWaitlistNotificationTitle + static let inviteAvailableNotificationBody = UserText.networkProtectionWaitlistNotificationText + + var isAvailable: Bool { + let hasWaitlistAccess = featureFlagger.isFeatureOn(.networkProtectionWaitlistAccess) + let isWaitlistActive = featureFlagger.isFeatureOn(.networkProtectionWaitlistActive) + return hasWaitlistAccess && isWaitlistActive + } + + var isWaitlistRemoved: Bool { + return false + } + + let waitlistStorage: WaitlistStorage + let waitlistRequest: WaitlistRequest + private let featureFlagger: FeatureFlagger + private let networkProtectionAccess: NetworkProtectionAccess + + init(store: WaitlistStorage, request: WaitlistRequest, featureFlagger: FeatureFlagger, networkProtectionAccess: NetworkProtectionAccess) { + self.waitlistStorage = store + self.waitlistRequest = request + self.featureFlagger = featureFlagger + self.networkProtectionAccess = networkProtectionAccess + } + + convenience init(store: WaitlistStorage, request: WaitlistRequest) { + self.init( + store: store, + request: request, + featureFlagger: AppDependencyProvider.shared.featureFlagger, + networkProtectionAccess: NetworkProtectionAccessController() + ) + } + + var settingsSubtitle: String { + switch networkProtectionAccess.networkProtectionAccessType() { + case .none: + return "" + case .waitlistAvailable: + return UserText.networkProtectionSettingsSubtitleNotJoined + case .waitlistJoined: + return UserText.networkProtectionSettingsSubtitleJoinedButNotInvited + case .waitlistInvitedPendingTermsAcceptance: + return UserText.networkProtectionSettingsSubtitleJoinedAndInvited + case .waitlistInvited, .inviteCodeInvited: + assertionFailure("These states should use the VPN connection status") + return "" + } + } + +} + +extension WaitlistViewModel.ViewCustomAction { + static var openNetworkProtectionInviteCodeScreen = WaitlistViewModel.ViewCustomAction(identifier: "openNetworkProtectionInviteCodeScreen") + static var openNetworkProtectionPrivacyPolicyScreen = WaitlistViewModel.ViewCustomAction(identifier: "openNetworkProtectionPrivacyPolicyScreen") + static var acceptNetworkProtectionTerms = WaitlistViewModel.ViewCustomAction(identifier: "acceptNetworkProtectionTerms") +} + +#endif diff --git a/DuckDuckGo/VPNWaitlistDebugViewController.swift b/DuckDuckGo/VPNWaitlistDebugViewController.swift new file mode 100644 index 0000000000..6b3fa717ef --- /dev/null +++ b/DuckDuckGo/VPNWaitlistDebugViewController.swift @@ -0,0 +1,199 @@ +// +// VPNWaitlistDebugViewController.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import UIKit +import Core +import BackgroundTasks +import Waitlist + +final class VPNWaitlistDebugViewController: UITableViewController { + + enum Sections: Int, CaseIterable { + + case waitlistInformation + case debuggingActions + + } + + private let waitlistInformationTitles = [ + WaitlistInformationRows.waitlistTimestamp: "Timestamp", + WaitlistInformationRows.waitlistToken: "Token", + WaitlistInformationRows.waitlistInviteCode: "Invite Code", + WaitlistInformationRows.backgroundTask: "Earliest Refresh Date" + ] + + enum WaitlistInformationRows: Int, CaseIterable { + + case waitlistTimestamp + case waitlistToken + case waitlistInviteCode + case backgroundTask + + } + + private let debuggingActionTitles = [ + DebuggingActionRows.resetTermsAndConditionsAcceptance: "Reset T&C Acceptance", + DebuggingActionRows.scheduleWaitlistNotification: "Fire Waitlist Notification in 3 seconds", + DebuggingActionRows.setMockInviteCode: "Set Mock Invite Code", + DebuggingActionRows.deleteInviteCode: "Delete Invite Code" + ] + + enum DebuggingActionRows: Int, CaseIterable { + + case resetTermsAndConditionsAcceptance + case scheduleWaitlistNotification + case setMockInviteCode + case deleteInviteCode + + } + + private let storage = WaitlistKeychainStore(waitlistIdentifier: VPNWaitlist.identifier) + + private var backgroundTaskExecutionDate: String? + + override func viewDidLoad() { + super.viewDidLoad() + + let clearDataItem = UIBarButtonItem(image: UIImage(systemName: "trash")!, + style: .done, + target: self, + action: #selector(presentClearDataPrompt(_:))) + clearDataItem.tintColor = .systemRed + navigationItem.rightBarButtonItem = clearDataItem + + BGTaskScheduler.shared.getPendingTaskRequests { tasks in + if let task = tasks.first(where: { $0.identifier == VPNWaitlist.backgroundRefreshTaskIdentifier }) { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + + self.backgroundTaskExecutionDate = formatter.string(from: task.earliestBeginDate!) + + DispatchQueue.main.async { + self.tableView.reloadData() + } + } + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return Sections.allCases.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Sections(rawValue: section)! { + case .waitlistInformation: return WaitlistInformationRows.allCases.count + case .debuggingActions: return DebuggingActionRows.allCases.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let section = Sections(rawValue: indexPath.section)! + + switch section { + case .waitlistInformation: + let cell = tableView.dequeueReusableCell(withIdentifier: "DetailCell", for: indexPath) + let row = WaitlistInformationRows(rawValue: indexPath.row)! + cell.textLabel?.text = waitlistInformationTitles[row] + + switch row { + case .waitlistTimestamp: + if let timestamp = storage.getWaitlistTimestamp() { + cell.detailTextLabel?.text = String(timestamp) + } else { + cell.detailTextLabel?.text = "None" + } + + case .waitlistToken: + cell.detailTextLabel?.text = storage.getWaitlistToken() ?? "None" + + case .waitlistInviteCode: + cell.detailTextLabel?.text = storage.getWaitlistInviteCode() ?? "None" + + case .backgroundTask: + cell.detailTextLabel?.text = backgroundTaskExecutionDate ?? "None" + } + + return cell + + case .debuggingActions: + let cell = tableView.dequeueReusableCell(withIdentifier: "ActionCell", for: indexPath) + let row = DebuggingActionRows(rawValue: indexPath.row)! + cell.textLabel?.text = debuggingActionTitles[row] + + return cell + } + + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let section = Sections(rawValue: indexPath.section)! + + switch section { + case .waitlistInformation: break + case .debuggingActions: + let row = DebuggingActionRows(rawValue: indexPath.row)! + + switch row { + case .resetTermsAndConditionsAcceptance: + var termsAndConditionsStore = NetworkProtectionTermsAndConditionsUserDefaultsStore() + termsAndConditionsStore.networkProtectionWaitlistTermsAndConditionsAccepted = false + case .scheduleWaitlistNotification: + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) { + self.storage.store(inviteCode: "ABCD1234") + VPNWaitlist.shared.sendInviteCodeAvailableNotification() + } + case .setMockInviteCode: + storage.store(inviteCode: "ABCD1234") + case .deleteInviteCode: + storage.delete(field: .inviteCode) + tableView.reloadData() + } + } + + tableView.deselectRow(at: indexPath, animated: true) + tableView.reloadData() + } + + @objc + private func presentClearDataPrompt(_ sender: AnyObject) { + let alert = UIAlertController(title: "Clear Waitlist Data?", message: nil, preferredStyle: .actionSheet) + + if UIDevice.current.userInterfaceIdiom == .pad { + alert.popoverPresentationController?.barButtonItem = (sender as? UIBarButtonItem) + } + + alert.addAction(UIAlertAction(title: "Clear Data", style: .destructive, handler: { _ in + self.clearDataAndReload() + })) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + present(alert, animated: true) + } + + private func clearDataAndReload() { + storage.deleteWaitlistState() + tableView.reloadData() + } +} + +#endif diff --git a/DuckDuckGo/VPNWaitlistTermsAndConditionsViewController.swift b/DuckDuckGo/VPNWaitlistTermsAndConditionsViewController.swift new file mode 100644 index 0000000000..d1f80e13ca --- /dev/null +++ b/DuckDuckGo/VPNWaitlistTermsAndConditionsViewController.swift @@ -0,0 +1,73 @@ +// +// VPNWaitlistTermsAndConditionsViewController.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import UIKit +import SwiftUI +import Core +import Waitlist + +@available(iOS 15.0, *) +final class VPNWaitlistTermsAndConditionsViewController: UIViewController { + + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = UserText.networkProtectionWaitlistJoinTitle + addHostingControllerToViewHierarchy() + } + + private func addHostingControllerToViewHierarchy() { + let waitlistView = VPNWaitlistPrivacyPolicyView { _ in + var termsAndConditionsStore = NetworkProtectionTermsAndConditionsUserDefaultsStore() + termsAndConditionsStore.networkProtectionWaitlistTermsAndConditionsAccepted = true + + self.navigationController?.popToRootViewController(animated: true) + let networkProtectionViewController = NetworkProtectionRootViewController() + self.navigationController?.pushViewController(networkProtectionViewController, animated: true) + } + + let waitlistViewController = UIHostingController(rootView: waitlistView) + waitlistViewController.view.backgroundColor = UIColor(designSystemColor: .background) + + addChild(waitlistViewController) + waitlistViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(waitlistViewController.view) + waitlistViewController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + waitlistViewController.view.widthAnchor.constraint(equalTo: view.widthAnchor), + waitlistViewController.view.heightAnchor.constraint(equalTo: view.heightAnchor), + waitlistViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + waitlistViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + +} + +#endif diff --git a/DuckDuckGo/VPNWaitlistUserText.swift b/DuckDuckGo/VPNWaitlistUserText.swift new file mode 100644 index 0000000000..08c685910a --- /dev/null +++ b/DuckDuckGo/VPNWaitlistUserText.swift @@ -0,0 +1,67 @@ +// +// VPNWaitlistUserText.swift +// DuckDuckGo +// +// 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 + +// swiftlint:disable line_length +struct VPNWaitlistUserText { + + static let networkProtectionPrivacyPolicySection1Title = "We don’t ask for any personal information from you in order to use this beta service." + static let networkProtectionPrivacyPolicySection1ListMarkdown = "This Privacy Policy is for our limited waitlist beta VPN product.\n\nOur main [Privacy Policy](https://duckduckgo.com/privacy) also applies here." + + static let networkProtectionPrivacyPolicySection2Title = "We don’t keep any logs of your online activity." + static let networkProtectionPrivacyPolicySection2List = "That means we have no way to tie what you do online to you as an individual and we don’t have any record of things like:\n • Website visits\n • DNS requests\n • Connections made\n • IP addresses used\n • Session lengths" + + static let networkProtectionPrivacyPolicySection3Title = "We only keep anonymous performance metrics that we cannot connect to your online activity." + static let networkProtectionPrivacyPolicySection3List = "Our servers store generic usage (for example, CPU load) and diagnostic data (for example, errors), but none of that data is connected to any individual’s activity.\n\nWe use this non-identifying information to monitor and ensure the performance and quality of the service, for example to make sure servers aren’t overloaded." + + static let networkProtectionPrivacyPolicySection4Title = "We use dedicated servers for all VPN traffic." + static let networkProtectionPrivacyPolicySection4List = "Dedicated servers means they are not shared with anyone else.\n\nWe rent our servers from providers we carefully selected because they meet our privacy requirements.\n\nWe have strict access controls in place so that only limited DuckDuckGo team members have access to our servers." + + static let networkProtectionPrivacyPolicySection5Title = "We protect and limit use of your data when you communicate directly with DuckDuckGo." + static let networkProtectionPrivacyPolicySection5List = "If you reach out to us for support by submitting a bug report or through email and agree to be contacted to troubleshoot the issue, we’ll contact you using the information you provide.\n\nIf you participate in a voluntary product survey or questionnaire and agree to provide further feedback, we may contact you using the information you provide.\n\nWe will permanently delete all personal information you provided to us (email, contact information), within 30 days after closing a support case or, in the case of follow up feedback, within 60 days after ending this beta service." + + static let networkProtectionTermsOfServiceTitle = "Terms of Service" + + static let networkProtectionTermsOfServiceSection1Title = "The service is for limited and personal use only." + static let networkProtectionTermsOfServiceSection1List = "This service is provided for your personal use only.\n\nYou are responsible for all activity in the service that occurs on or through your device.\n\nThis service may only be used through the DuckDuckGo app on the device on which you are given access. If you delete the DuckDuckGo app, you will lose access to the service.\n\nYou may not use this service through a third-party client." + + static let networkProtectionTermsOfServiceSection2Title = "You agree to comply with all applicable laws, rules, and regulations." + static let networkProtectionTermsOfServiceSection2ListMarkdown = "You agree that you will not use the service for any unlawful, illicit, criminal, or fraudulent purpose, or in any manner that could give rise to civil or criminal liability under applicable law.\n\nYou agree to comply with our [DuckDuckGo Terms of Service](https://duckduckgo.com/terms), which are incorporated by reference." + + static let networkProtectionTermsOfServiceSection3Title = "You must be eligible to use this service." + static let networkProtectionTermsOfServiceSection3List = "Access to this beta is randomly awarded. You are responsible for ensuring eligibility.\n\nYou must be at least 18 years old and live in a location where use of a VPN is legal in order to be eligible to use this service." + + static let networkProtectionTermsOfServiceSection4Title = "We provide this beta service as-is and without warranty." + static let networkProtectionTermsOfServiceSection4List = "This service is provided as-is and without warranties or guarantees of any kind.\n\nTo 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.\n\nWe 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." + + static let networkProtectionTermsOfServiceSection5Title = "We may terminate access at any time." + static let networkProtectionTermsOfServiceSection5List = "We reserve the right to revoke access to the service at any time in our sole discretion.\n\nWe may also terminate access for violation of these terms, including for repeated infringement of the intellectual property rights of others." + + static let networkProtectionTermsOfServiceSection6Title = "The service is free during the beta period." + static let networkProtectionTermsOfServiceSection6List = "Access to this service is currently free of charge, but that is limited to this beta period.\n\nYou understand and agree that this service is provided on a temporary, testing basis only." + + static let networkProtectionTermsOfServiceSection7Title = "We are continually updating the service." + static let networkProtectionTermsOfServiceSection7List = "The service is in beta, and we are regularly changing it.\n\nService coverage, speed, server locations, and quality may vary without warning." + + static let networkProtectionTermsOfServiceSection8Title = "We need your feedback." + static let networkProtectionTermsOfServiceSection8List = "You may be asked during the beta period to provide feedback about your experience. Doing so is optional and your feedback may be used to improve the service.\n\nIf you have enabled notifications for the DuckDuckGo app, we may use notifications to ask about your experience. You can disable notifications if you do not want to receive them." + +} +// swiftlint:enable line_length diff --git a/DuckDuckGo/VPNWaitlistView.swift b/DuckDuckGo/VPNWaitlistView.swift new file mode 100644 index 0000000000..3e5bb91b92 --- /dev/null +++ b/DuckDuckGo/VPNWaitlistView.swift @@ -0,0 +1,387 @@ +// +// VPNWaitlistView.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import SwiftUI +import Core +import Waitlist +import DesignResourcesKit + +@available(iOS 15.0, *) +struct VPNWaitlistView: View { + + @EnvironmentObject var viewModel: WaitlistViewModel + + var body: some View { + switch viewModel.viewState { + case .notJoinedQueue: + VPNWaitlistSignUpView(requestInFlight: false) { action in + Task { await viewModel.perform(action: action) } + } + case .joiningQueue: + VPNWaitlistSignUpView(requestInFlight: true) { action in + Task { await viewModel.perform(action: action) } + } + case .joinedQueue(let state): + VPNWaitlistJoinedWaitlistView(notificationState: state) { action in + Task { await viewModel.perform(action: action) } + } + case .invited: + VPNWaitlistInvitedView { action in + Task { await viewModel.perform(action: action) } + } + case .waitlistRemoved: + fatalError("State not supported for VPN waitlists") + } + } +} + +@available(iOS 15.0, *) +struct VPNWaitlistSignUpView: View { + + let requestInFlight: Bool + + let action: WaitlistViewActionHandler + + var body: some View { + GeometryReader { proxy in + ScrollView { + VStack(alignment: .center, spacing: 8) { + HeaderView(imageName: "JoinVPNWaitlist", title: UserText.networkProtectionWaitlistJoinTitle) + + Text(UserText.networkProtectionWaitlistJoinSubtitle1) + .daxBodyRegular() + .foregroundColor(.waitlistTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(6) + + Text(UserText.networkProtectionWaitlistJoinSubtitle2) + .daxBodyRegular() + .foregroundColor(.waitlistTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(6) + .padding(.top, 18) + + Button(UserText.networkProtectionWaitlistButtonJoinWaitlist, action: { action(.joinQueue) }) + .buttonStyle(RoundedButtonStyle(enabled: !requestInFlight)) + .padding(.top, 24) + + Button(UserText.networkProtectionWaitlistButtonExistingInviteCode, action: { + action(.custom(.openNetworkProtectionInviteCodeScreen)) + }) + .buttonStyle(RoundedButtonStyle(enabled: true, style: .bordered)) + .padding(.top, 18) + + if requestInFlight { + HStack { + Text(UserText.waitlistJoining) + .daxSubheadRegular() + .foregroundColor(.waitlistTextSecondary) + + ActivityIndicator(style: .medium) + } + .padding(.top, 14) + } + + Text(UserText.networkProtectionWaitlistAvailabilityDisclaimer) + .font(.footnote) + .foregroundStyle(Color.secondary) + .padding(.top, 24) + + Spacer() + } + .padding([.leading, .trailing], 24) + .frame(minHeight: proxy.size.height) + } + } + } + +} + +// MARK: - Joined Waitlist Views + +@available(iOS 15.0, *) +struct VPNWaitlistJoinedWaitlistView: View { + + let notificationState: WaitlistViewModel.NotificationPermissionState + + let action: (WaitlistViewModel.ViewAction) -> Void + + var body: some View { + VStack(spacing: 16) { + HeaderView(imageName: "JoinedVPNWaitlist", title: UserText.networkProtectionWaitlistJoinedTitle) + + switch notificationState { + case .notificationAllowed: + Text(UserText.networkProtectionWaitlistJoinedWithNotificationsSubtitle1) + .daxBodyRegular() + .foregroundColor(.waitlistTextSecondary) + .lineSpacing(6) + + Text(UserText.networkProtectionWaitlistJoinedWithNotificationsSubtitle2) + .daxBodyRegular() + .foregroundColor(.waitlistTextSecondary) + .lineSpacing(6) + + default: + Text(UserText.networkProtectionWaitlistJoinedWithNotificationsSubtitle1) + .daxBodyRegular() + .foregroundColor(.waitlistTextSecondary) + .lineSpacing(6) + + if notificationState == .notificationsDisabled { + AllowNotificationsView(action: action) + .padding(.top, 4) + } else { + Button(UserText.waitlistNotifyMe) { + action(.requestNotificationPermission) + } + .buttonStyle(RoundedButtonStyle(enabled: true)) + .padding(.top, 32) + } + } + + Spacer() + } + .padding([.leading, .trailing], 24) + .multilineTextAlignment(.center) + } + +} + +@available(iOS 15.0, *) +private struct AllowNotificationsView: View { + + let action: (WaitlistViewModel.ViewAction) -> Void + + var body: some View { + + VStack(spacing: 20) { + + Text(UserText.waitlistNotificationDisabled) + .daxBodyRegular() + .foregroundColor(.waitlistTextSecondary) + .fixMultilineScrollableText() + .lineSpacing(5) + + Button(UserText.waitlistAllowNotifications) { + action(.openNotificationSettings) + } + .buttonStyle(RoundedButtonStyle(enabled: true)) + + } + .padding(24) + .background(Color.waitlistNotificationBackground) + .cornerRadius(8) + .shadow(color: .black.opacity(0.05), radius: 12, x: 0, y: 4) + + } + +} + +// MARK: - Invite Available Views + +private struct ShareButtonFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) {} +} + +@available(iOS 15.0, *) +struct VPNWaitlistInvitedView: View { + + let benefitsList: [VPNWaitlistBenefit] = [ + .init( + imageName: "Shield-16", + title: UserText.networkProtectionWaitlistInvitedSection1Title, + subtitle: UserText.networkProtectionWaitlistInvitedSection1Subtitle + ), + .init( + imageName: "Rocket-16", + title: UserText.networkProtectionWaitlistInvitedSection2Title, + subtitle: UserText.networkProtectionWaitlistInvitedSection2Subtitle + ), + .init( + imageName: "Card-16", + title: UserText.networkProtectionWaitlistInvitedSection3Title, + subtitle: UserText.networkProtectionWaitlistInvitedSection3Subtitle + ), + ] + + let action: WaitlistViewActionHandler + + @State private var shareButtonFrame: CGRect = .zero + + var body: some View { + GeometryReader { proxy in + ScrollView { + VStack(alignment: .center, spacing: 0) { + HeaderView(imageName: "InvitedVPNWaitlist", title: UserText.networkProtectionWaitlistInvitedTitle) + + Text(UserText.networkProtectionWaitlistInvitedSubtitle) + .daxBodyRegular() + .foregroundColor(.waitlistTextSecondary) + .padding(.top, 16) + .lineSpacing(6) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 16.0) { + ForEach(benefitsList) { WaitlistListEntryView(viewData: $0) } + } + .padding(.top, 24) + + Button(UserText.networkProtectionWaitlistGetStarted, action: { action(.custom(.openNetworkProtectionPrivacyPolicyScreen)) }) + .buttonStyle(RoundedButtonStyle(enabled: true)) + .padding(.top, 32) + + Text(UserText.networkProtectionWaitlistAvailabilityDisclaimer) + .font(.footnote) + .foregroundStyle(Color.secondary) + .padding(.top, 24) + + Spacer() + + } + .frame(maxWidth: .infinity, minHeight: proxy.size.height) + .padding([.leading, .trailing], 18) + .multilineTextAlignment(.center) + } + } + } +} + +@available(iOS 15.0, *) +struct VPNWaitlistPrivacyPolicyView: View { + + let action: WaitlistViewActionHandler + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(UserText.networkProtectionPrivacyPolicyTitle) + .font(.system(size: 17, weight: .bold)) + .multilineTextAlignment(.leading) + + Group { + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection1Title).titleStyle() + + Text(.init(VPNWaitlistUserText.networkProtectionPrivacyPolicySection1ListMarkdown)).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection2Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection2List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection3Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection3List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection4Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection4List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection5Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionPrivacyPolicySection5List).bodyStyle() + } + + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceTitle) + .font(.system(size: 17, weight: .bold)) + .multilineTextAlignment(.leading) + .padding(.top, 28) + .padding(.bottom, 14) + + Group { + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection1Title).titleStyle(topPadding: 0) + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection1List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection2Title).titleStyle() + Text(.init(VPNWaitlistUserText.networkProtectionTermsOfServiceSection2ListMarkdown)).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection3Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection3List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection4Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection4List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection5Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection5List).bodyStyle() + } + + Group { + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection6Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection6List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection7Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection7List).bodyStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection8Title).titleStyle() + Text(VPNWaitlistUserText.networkProtectionTermsOfServiceSection8List).bodyStyle() + } + + Button(UserText.networkProtectionWaitlistAgreeAndContinue, action: { action(.custom(.acceptNetworkProtectionTerms)) }) + .buttonStyle(RoundedButtonStyle(enabled: true)) + .padding(.top, 24) + } + .padding(.all, 20) + } + } + +} + +struct VPNWaitlistBenefit: Identifiable { + let id = UUID() + let imageName: String + let title: String + let subtitle: String +} + +private struct WaitlistListEntryView: View { + + let viewData: VPNWaitlistBenefit + + var body: some View { + HStack(alignment: .center, spacing: 16) { + Image(viewData.imageName) + .frame(maxWidth: 16, maxHeight: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(viewData.title) + .font(.system(size: 13, weight: .bold)) + .opacity(0.8) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(viewData.subtitle) + .font(.system(size: 13)) + .opacity(0.6) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + + Spacer() + } + } + +} + +private extension Text { + + func titleStyle(topPadding: CGFloat = 24, bottomPadding: CGFloat = 14) -> some View { + self + .font(.system(size: 13, weight: .bold)) + .multilineTextAlignment(.leading) + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + } + + func bodyStyle() -> some View { + self + .font(.system(size: 13)) + } + +} + +#endif diff --git a/DuckDuckGo/VPNWaitlistViewController.swift b/DuckDuckGo/VPNWaitlistViewController.swift new file mode 100644 index 0000000000..465a47e34e --- /dev/null +++ b/DuckDuckGo/VPNWaitlistViewController.swift @@ -0,0 +1,153 @@ +// +// VPNWaitlistViewController.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import UIKit +import SwiftUI +import Core +import Waitlist + +@available(iOS 15.0, *) +final class VPNWaitlistViewController: UIViewController { + + private let viewModel: WaitlistViewModel + + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + self.viewModel = WaitlistViewModel(waitlist: VPNWaitlist.shared) + super.init(nibName: nil, bundle: nil) + self.viewModel.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = UserText.networkProtectionWaitlistJoinTitle + + addHostingControllerToViewHierarchy() + + NotificationCenter.default.addObserver(self, + selector: #selector(updateViewState), + name: UIApplication.didBecomeActiveNotification, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(updateViewState), + name: WaitlistKeychainStore.inviteCodeDidChangeNotification, + object: VPNWaitlist.identifier) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + Task { + await self.viewModel.updateViewState() + } + } + + @objc + private func updateViewState() { + Task { + await self.viewModel.updateViewState() + } + } + + private func addHostingControllerToViewHierarchy() { + let waitlistView = VPNWaitlistView().environmentObject(viewModel) + let waitlistViewController = UIHostingController(rootView: waitlistView) + waitlistViewController.view.backgroundColor = UIColor(designSystemColor: .background) + + addChild(waitlistViewController) + waitlistViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(waitlistViewController.view) + waitlistViewController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + waitlistViewController.view.widthAnchor.constraint(equalTo: view.widthAnchor), + waitlistViewController.view.heightAnchor.constraint(equalTo: view.heightAnchor), + waitlistViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + waitlistViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + +} + +@available(iOS 15.0, *) +extension VPNWaitlistViewController: WaitlistViewModelDelegate { + + func waitlistViewModelDidAskToReceiveJoinedNotification(_ viewModel: WaitlistViewModel) async -> Bool { + return await withCheckedContinuation { continuation in + let alertController = UIAlertController(title: UserText.networkProtectionNotificationPromptTitle, + message: UserText.networkProtectionNotificationPromptDescription, + preferredStyle: .alert) + alertController.overrideUserInterfaceStyle() + + alertController.addAction(title: UserText.waitlistNoThanks) { + continuation.resume(returning: false) + } + let notifyMeAction = UIAlertAction(title: UserText.waitlistNotifyMe, style: .default) { _ in + continuation.resume(returning: true) + } + + alertController.addAction(notifyMeAction) + alertController.preferredAction = notifyMeAction + + present(alertController, animated: true) + } + } + + func waitlistViewModelDidJoinQueueWithNotificationsAllowed(_ viewModel: WaitlistViewModel) { + VPNWaitlist.shared.scheduleBackgroundRefreshTask() + } + + func waitlistViewModel(_ viewModel: WaitlistViewModel, didTriggerCustomAction action: WaitlistViewModel.ViewCustomAction) { + if action == .openNetworkProtectionInviteCodeScreen { + let networkProtectionViewController = NetworkProtectionRootViewController { [weak self] in + guard let self = self, let rootViewController = self.navigationController?.viewControllers.first else { + assertionFailure("Failed to show NetP status view") + return + } + + let networkProtectionRootViewController = NetworkProtectionRootViewController() + self.navigationController?.setViewControllers([rootViewController, networkProtectionRootViewController], animated: true) + } + + self.navigationController?.pushViewController(networkProtectionViewController, animated: true) + } + + if action == .openNetworkProtectionPrivacyPolicyScreen { + let termsAndConditionsViewController = VPNWaitlistTermsAndConditionsViewController() + self.navigationController?.pushViewController(termsAndConditionsViewController, animated: true) + } + } + + func waitlistViewModelDidOpenInviteCodeShareSheet(_ viewModel: WaitlistViewModel, inviteCode: String, senderFrame: CGRect) { + // The VPN waitlist doesn't support the share sheet + } + + func waitlistViewModelDidOpenDownloadURLShareSheet(_ viewModel: WaitlistViewModel, senderFrame: CGRect) { + // The VPN waitlist doesn't support the share sheet + } + +} + +#endif diff --git a/DuckDuckGo/WaitlistViews.swift b/DuckDuckGo/WaitlistViews.swift index fcede8ef56..0ced5a5e9f 100644 --- a/DuckDuckGo/WaitlistViews.swift +++ b/DuckDuckGo/WaitlistViews.swift @@ -23,11 +23,11 @@ import Waitlist struct WaitlistDownloadBrowserContentView: View { let action: WaitlistViewActionHandler - let constants: BrowserDowloadLinkConstants + let constants: BrowserDownloadLinkConstants - init(platform: BrowserDowloadLink, action: @escaping WaitlistViewActionHandler) { + init(platform: BrowserDownloadLink, action: @escaping WaitlistViewActionHandler) { self.action = action - self.constants = BrowserDowloadLinkConstants(platform: platform) + self.constants = BrowserDownloadLinkConstants(platform: platform) } @State private var shareButtonFrame: CGRect = .zero @@ -115,13 +115,13 @@ private struct ShareButtonFramePreferenceKey: PreferenceKey { static func reduce(value: inout CGRect, nextValue: () -> CGRect) {} } -enum BrowserDowloadLink { +enum BrowserDownloadLink { case windows case mac } -struct BrowserDowloadLinkConstants { - let platform: BrowserDowloadLink +struct BrowserDownloadLinkConstants { + let platform: BrowserDownloadLink var imageName: String { switch platform { diff --git a/DuckDuckGo/WindowsBrowserWaitlist.swift b/DuckDuckGo/WindowsBrowserWaitlist.swift index 1cab06d50a..ad628591e1 100644 --- a/DuckDuckGo/WindowsBrowserWaitlist.swift +++ b/DuckDuckGo/WindowsBrowserWaitlist.swift @@ -32,7 +32,7 @@ final class WindowsBrowserWaitlist: Waitlist { static let backgroundTaskName = "Windows Browser Waitlist Status Task" static let backgroundRefreshTaskIdentifier = "com.duckduckgo.app.windowsBrowserWaitlistStatus" - static let notificationIdentitier = "com.duckduckgo.ios.windows-browser.invite-code-available" + static let notificationIdentifier = "com.duckduckgo.ios.windows-browser.invite-code-available" static let inviteAvailableNotificationTitle = UserText.windowsWaitlistAvailableNotificationTitle static let inviteAvailableNotificationBody = UserText.waitlistAvailableNotificationBody diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 222c2d7150..f10a4c5602 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1441,11 +1441,101 @@ https://duckduckgo.com/mac"; /* Title for the Network Protection feature */ "netP.title" = "Network Protection"; +/* Privacy Policy title for Network Protection */ +"network-protection.privacy-policy.title" = "Privacy Policy"; + +/* Title text for the Network Protection terms and conditions accept button */ +"network-protection.waitlist.agree-and-continue" = "Agree and Continue"; + +/* Availability disclaimer for Network Protection join waitlist screen */ +"network-protection.waitlist.availability-disclaimer" = "Network Protection is free to use during early access."; + +/* Agree and Continue button for Network Protection join waitlist screen */ +"network-protection.waitlist.button.agree-and-continue" = "Agree and Continue"; + +/* Enable Notifications button for Network Protection joined waitlist screen */ +"network-protection.waitlist.button.enable-notifications" = "Enable Notifications"; + +/* Button title for users who already have an invite code */ +"network-protection.waitlist.button.existing-invite-code" = "I Have an Invite Code"; + +/* Join Waitlist button for Network Protection join waitlist screen */ +"network-protection.waitlist.button.join-waitlist" = "Join the Waitlist"; + +/* Button title text for the Network Protection waitlist confirmation prompt */ +"network-protection.waitlist.get-started" = "Get Started"; + +/* Subtitle for section 1 of the Network Protection invited screen */ +"network-protection.waitlist.invited.section-1.subtitle" = "Encrypt online traffic across your browsers and apps."; + +/* Title for section 1 of the Network Protection invited screen */ +"network-protection.waitlist.invited.section-1.title" = "Full-device coverage"; + +/* Subtitle for section 2 of the Network Protection invited screen */ +"network-protection.waitlist.invited.section-2.subtitle" = "No need for a separate app. Connect in one click and see your connection status at a glance."; + +/* Title for section 2 of the Network Protection invited screen */ +"network-protection.waitlist.invited.section-2.title" = "Fast, reliable, and easy to use"; + +/* Subtitle for section 3 of the Network Protection invited screen */ +"network-protection.waitlist.invited.section-3.subtitle" = "We do not log or save any data that can connect you to your online activity."; + +/* Title for section 3 of the Network Protection invited screen */ +"network-protection.waitlist.invited.section-3.title" = "Strict no-logging policy"; + +/* Subtitle for Network Protection invited screen */ +"network-protection.waitlist.invited.subtitle" = "Get an extra layer of protection online with the VPN built for speed and simplicity. Encrypt your internet connection across your entire device and hide your location and IP address from sites you visit."; + +/* Title for Network Protection invited screen */ +"network-protection.waitlist.invited.title" = "You’re invited to try\nNetwork Protection early access!"; + +/* First subtitle for Network Protection join waitlist screen */ +"network-protection.waitlist.join.subtitle.1" = "Secure your connection anytime, anywhere with Network Protection, the VPN from DuckDuckGo."; + +/* Second subtitle for Network Protection join waitlist screen */ +"network-protection.waitlist.join.subtitle.2" = "Join the waitlist, and we’ll notify you when it’s your turn."; + +/* Title for Network Protection join waitlist screen */ +"network-protection.waitlist.join.title" = "Network Protection Early Access"; + +/* Title for Network Protection joined waitlist screen */ +"network-protection.waitlist.joined.title" = "You’re on the list!"; + +/* Subtitle 1 for Network Protection joined waitlist screen when notifications are enabled */ +"network-protection.waitlist.joined.with-notifications.subtitle.1" = "New invites are sent every few days, on a first come, first served basis."; + +/* Subtitle 2 for Network Protection joined waitlist screen when notifications are enabled */ +"network-protection.waitlist.joined.with-notifications.subtitle.2" = "We’ll notify you when your invite is ready."; + +/* Body text for the alert to enable notifications */ +"network-protection.waitlist.notification-alert.description" = "We’ll send you a notification when your invite to test Network Protection is ready."; + +/* Subtitle for the alert to confirm enabling notifications */ +"network-protection.waitlist.notification-prompt-description" = "Get a notification when your copy of Network Protection early access is ready."; + +/* Title for the alert to confirm enabling notifications */ +"network-protection.waitlist.notification-prompt-title" = "Know the instant you're invited"; + +/* Title for Network Protection waitlist notification */ +"network-protection.waitlist.notification.text" = "Open your invite"; + +/* Title for Network Protection waitlist notification */ +"network-protection.waitlist.notification.title" = "Network Protection is ready!"; + +/* Subtitle text for the Network Protection settings row */ +"network-protection.waitlist.settings-subtitle.joined-and-invited" = "Your invite is ready!"; + +/* Subtitle text for the Network Protection settings row */ +"network-protection.waitlist.settings-subtitle.joined-but-not-invited" = "You’re on the list!"; + +/* Subtitle text for the Network Protection settings row */ +"network-protection.waitlist.settings-subtitle.waitlist-not-joined" = "Join the private waitlist"; + /* Message for the network protection invite dialog */ "network.protection.invite.dialog.message" = "Enter your invite code to get started."; /* Title for the network protection invite screen */ -"network.protection.invite.dialog.title" = "You're invited to try Network Protection"; +"network.protection.invite.dialog.title" = "You’re invited to try Network Protection"; /* Prompt for the network protection invite code text field */ "network.protection.invite.field.prompt" = "Invite Code"; diff --git a/DuckDuckGoTests/NetworkProtectionAccessControllerTests.swift b/DuckDuckGoTests/NetworkProtectionAccessControllerTests.swift new file mode 100644 index 0000000000..0e8d5cae7e --- /dev/null +++ b/DuckDuckGoTests/NetworkProtectionAccessControllerTests.swift @@ -0,0 +1,183 @@ +// +// NetworkProtectionAccessControllerTests.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import XCTest +import BrowserServicesKit +import NetworkProtection +import NetworkExtension +import NetworkProtectionTestUtils +import WaitlistMocks +@testable import DuckDuckGo + +final class NetworkProtectionAccessControllerTests: XCTestCase { + + var internalUserDeciderStore: MockInternalUserStoring! + + override func setUp() { + super.setUp() + internalUserDeciderStore = MockInternalUserStoring() + + // True by default until NetP ships, as it is not visible at all to external users. + internalUserDeciderStore.isInternalUser = true + } + + override func tearDown() { + internalUserDeciderStore = nil + super.tearDown() + } + + func testWhenFeatureFlagsAreDisabled_AndTheUserHasNotBeenDirectlyInvited_ThenNetworkProtectionIsNotAccessible() { + let controller = createMockAccessController( + isInternalUser: false, + featureActivated: false, + termsAccepted: false, + featureFlagsEnabled: false, + hasJoinedWaitlist: false, + hasBeenInvited: false + ) + + XCTAssertEqual(controller.networkProtectionAccessType(), .none) + } + + func testWhenFeatureFlagsAreDisabled_AndTheUserHasBeenDirectlyInvited_ThenNetworkProtectionIsNotAccessible() { + let controller = createMockAccessController( + featureActivated: true, + termsAccepted: false, + featureFlagsEnabled: false, + hasJoinedWaitlist: false, + hasBeenInvited: false + ) + + XCTAssertEqual(controller.networkProtectionAccessType(), .inviteCodeInvited) + } + + func testWhenFeatureFlagsAreEnabled_AndTheUserHasNotSignedUp_ThenNetworkProtectionIsAccessible() { + let controller = createMockAccessController( + featureActivated: false, + termsAccepted: false, + featureFlagsEnabled: true, + hasJoinedWaitlist: false, + hasBeenInvited: false + ) + + XCTAssertEqual(controller.networkProtectionAccessType(), .waitlistAvailable) + } + + func testWhenFeatureFlagsAreEnabled_AndTheUserHasSignedUp_ThenNetworkProtectionIsAccessible() { + let controller = createMockAccessController( + featureActivated: false, + termsAccepted: false, + featureFlagsEnabled: true, + hasJoinedWaitlist: true, + hasBeenInvited: false + ) + + XCTAssertEqual(controller.networkProtectionAccessType(), .waitlistJoined) + } + + func testWhenFeatureFlagsAreEnabled_AndTheUserHasBeenInvited_ThenNetworkProtectionIsAccessible() { + let controller = createMockAccessController( + featureActivated: true, + termsAccepted: false, + featureFlagsEnabled: true, + hasJoinedWaitlist: true, + hasBeenInvited: true + ) + + XCTAssertEqual(controller.networkProtectionAccessType(), .waitlistInvitedPendingTermsAcceptance) + } + + func testWhenFeatureFlagsAreEnabled_AndTheUserHasAcceptedTerms_ThenNetworkProtectionIsAccessible() { + let controller = createMockAccessController( + featureActivated: true, + termsAccepted: true, + featureFlagsEnabled: true, + hasJoinedWaitlist: true, + hasBeenInvited: true + ) + + XCTAssertEqual(controller.networkProtectionAccessType(), .waitlistInvited) + } + + // MARK: - Mock Creation + + private func createMockAccessController( + isInternalUser: Bool = true, + featureActivated: Bool, + termsAccepted: Bool, + featureFlagsEnabled: Bool, + hasJoinedWaitlist: Bool, + hasBeenInvited: Bool + ) -> NetworkProtectionAccessController { + internalUserDeciderStore.isInternalUser = isInternalUser + + let mockActivation = MockNetworkProtectionFeatureActivation() + mockActivation.isFeatureActivated = featureActivated + + let mockWaitlistStorage = MockWaitlistStorage() + + if hasJoinedWaitlist { + mockWaitlistStorage.store(waitlistTimestamp: 1) + mockWaitlistStorage.store(waitlistToken: "token") + + if hasBeenInvited { + mockWaitlistStorage.store(inviteCode: "INVITECODE") + } + } + + let mockTermsAndConditionsStore = MockNetworkProtectionTermsAndConditionsStore() + mockTermsAndConditionsStore.networkProtectionWaitlistTermsAndConditionsAccepted = termsAccepted + let mockFeatureFlagger = createFeatureFlagger(withSubfeatureEnabled: featureFlagsEnabled) + + return NetworkProtectionAccessController( + networkProtectionActivation: mockActivation, + networkProtectionWaitlistStorage: mockWaitlistStorage, + networkProtectionTermsAndConditionsStore: mockTermsAndConditionsStore, + featureFlagger: mockFeatureFlagger + ) + } + + private func createFeatureFlagger(withSubfeatureEnabled enabled: Bool) -> DefaultFeatureFlagger { + let mockManager = MockPrivacyConfigurationManager() + mockManager.privacyConfig = mockConfiguration(subfeatureEnabled: enabled) + + let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) + return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfig: mockManager.privacyConfig) + } + + private func mockConfiguration(subfeatureEnabled: Bool) -> PrivacyConfiguration { + let mockPrivacyConfiguration = MockPrivacyConfiguration() + mockPrivacyConfiguration.isSubfeatureKeyEnabled = { _, _ in + return subfeatureEnabled + } + + return mockPrivacyConfiguration + } + +} + +private class MockNetworkProtectionTermsAndConditionsStore: NetworkProtectionTermsAndConditionsStore { + + var networkProtectionWaitlistTermsAndConditionsAccepted: Bool = false + +} + +#endif diff --git a/DuckDuckGoTests/WindowsBrowserWaitlistTests.swift b/DuckDuckGoTests/WindowsBrowserWaitlistTests.swift index 388295dbba..b7369ffdb0 100644 --- a/DuckDuckGoTests/WindowsBrowserWaitlistTests.swift +++ b/DuckDuckGoTests/WindowsBrowserWaitlistTests.swift @@ -61,7 +61,6 @@ class WindowsBrowserWaitlistTests: XCTestCase { let request = MockWaitlistRequest.failure() let privacyConfigurationManager: PrivacyConfigurationManagerMock = PrivacyConfigurationManagerMock() let privacyConfig = privacyConfigurationManager.privacyConfig as! PrivacyConfigurationMock // swiftlint:disable:this force_cast - var enabledFeatures = privacyConfig.enabledFeaturesForVersions privacyConfig.enabledFeaturesForVersions[.windowsDownloadLink] = Set([AppVersionProvider().appVersion()!]) let waitlist = WindowsBrowserWaitlist(store: store, request: request, privacyConfigurationManager: privacyConfigurationManager) diff --git a/LocalPackages/Waitlist/Sources/Waitlist/Network/ProductWaitlistRequest.swift b/LocalPackages/Waitlist/Sources/Waitlist/Network/ProductWaitlistRequest.swift index 5b9bb09df9..63dab2041f 100644 --- a/LocalPackages/Waitlist/Sources/Waitlist/Network/ProductWaitlistRequest.swift +++ b/LocalPackages/Waitlist/Sources/Waitlist/Network/ProductWaitlistRequest.swift @@ -133,7 +133,7 @@ public class ProductWaitlistRequest: WaitlistRequest { private var endpoint: URL { #if DEBUG - return URL(string: "https://quackdev.duckduckgo.com/api/auth/waitlist/")! + return URL(string: "https://quack.duckduckgo.com/api/auth/waitlist/")! #else return URL(string: "https://quack.duckduckgo.com/api/auth/waitlist/")! #endif diff --git a/LocalPackages/Waitlist/Sources/Waitlist/Views/RoundedButtonStyle.swift b/LocalPackages/Waitlist/Sources/Waitlist/Views/RoundedButtonStyle.swift index 8d75337bd6..835d4c073c 100644 --- a/LocalPackages/Waitlist/Sources/Waitlist/Views/RoundedButtonStyle.swift +++ b/LocalPackages/Waitlist/Sources/Waitlist/Views/RoundedButtonStyle.swift @@ -21,20 +21,46 @@ import SwiftUI public struct RoundedButtonStyle: ButtonStyle { + public enum Style { + case solid + case bordered + } + public let enabled: Bool + private let style: Style - public init(enabled: Bool) { + public init(enabled: Bool, style: Style = .solid) { self.enabled = enabled + self.style = style } public func makeBody(configuration: Self.Configuration) -> some View { - configuration.label + let backgroundColor: Color + let foregroundColor: Color + let borderColor: Color + let borderWidth: CGFloat + + switch style { + case .solid: + backgroundColor = enabled ? Color.waitlistBlue : Color.waitlistBlue.opacity(0.2) + foregroundColor = Color.waitlistButtonText + borderColor = Color.clear + borderWidth = 0 + case .bordered: + backgroundColor = Color.clear + foregroundColor = Color.waitlistBlue + borderColor = Color.waitlistBlue + borderWidth = 2 + } + + return configuration.label .daxHeadline() .frame(maxWidth: .infinity) .padding([.top, .bottom], 16) - .background(enabled ? Color.waitlistBlue : Color.waitlistBlue.opacity(0.2)) - .foregroundColor(.waitlistButtonText) + .background(backgroundColor) + .foregroundColor(foregroundColor) .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(borderColor, lineWidth: borderWidth)) } } diff --git a/LocalPackages/Waitlist/Sources/Waitlist/Waitlist.swift b/LocalPackages/Waitlist/Sources/Waitlist/Waitlist.swift index 0e4d53d398..2d2e0bf270 100644 --- a/LocalPackages/Waitlist/Sources/Waitlist/Waitlist.swift +++ b/LocalPackages/Waitlist/Sources/Waitlist/Waitlist.swift @@ -30,7 +30,7 @@ public protocol WaitlistConstants { static var backgroundRefreshTaskIdentifier: String { get } static var minimumConfigurationRefreshInterval: TimeInterval { get } - static var notificationIdentitier: String { get } + static var notificationIdentifier: String { get } static var inviteAvailableNotificationTitle: String { get } static var inviteAvailableNotificationBody: String { get } } @@ -189,7 +189,7 @@ public extension Waitlist { notificationContent.title = Self.inviteAvailableNotificationTitle notificationContent.body = Self.inviteAvailableNotificationBody - let notificationIdentifier = Self.notificationIdentitier + let notificationIdentifier = Self.notificationIdentifier let request = UNNotificationRequest(identifier: notificationIdentifier, content: notificationContent, trigger: nil) UNUserNotificationCenter.current().add(request) diff --git a/LocalPackages/Waitlist/Sources/WaitlistMocks/TestWaitlist.swift b/LocalPackages/Waitlist/Sources/WaitlistMocks/TestWaitlist.swift index 9bc1059a64..51236cdf43 100644 --- a/LocalPackages/Waitlist/Sources/WaitlistMocks/TestWaitlist.swift +++ b/LocalPackages/Waitlist/Sources/WaitlistMocks/TestWaitlist.swift @@ -42,7 +42,7 @@ public struct TestWaitlist: Waitlist { public static var backgroundTaskName: String = "BG Task" public static var backgroundRefreshTaskIdentifier: String = "bgtask" - public static var notificationIdentitier: String = "notification" + public static var notificationIdentifier: String = "notification" public static var inviteAvailableNotificationTitle: String = "Title" public static var inviteAvailableNotificationBody: String = "Body" }