From 2982a234f2081e8ff72494e225aecc42d68e2120 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Fri, 15 Dec 2023 22:24:23 +0100 Subject: [PATCH] VPN Geoswitching - initial draft (#1978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/0/1206183910299715/f Description: This adds an initial implementation of location switching for the VPN. Known issues: Some of the layout / spacing is not quite right The tap area of the items on the VPN Location selection screen is too small The styling of the list sections on the VPN Location screen is not finished. There seems to be a bug where the initial selection is not respected when connecting after selecting There is a momentary delay before the list items load. Needs unit tests (I promise I will add them, they are very similar to the iOS ones so it won’t take long. I just ran out of time before the holidays). --- DuckDuckGo.xcodeproj/project.pbxproj | 48 ++++ DuckDuckGo/Common/Localizables/UserText.swift | 24 ++ .../FeatureFlagging/Model/FeatureFlag.swift | 4 + ...tworkProtectionVPNCountryLabelsModel.swift | 45 ++++ .../VPNLocationPreferenceItem.swift | 67 +++++ .../VPNLocationPreferenceItemModel.swift | 49 ++++ .../VPNLocation/VPNLocationView.swift | 245 ++++++++++++++++++ .../VPNLocation/VPNLocationViewModel.swift | 185 +++++++++++++ .../Model/VPNPreferencesModel.swift | 17 +- .../Preferences/View/PreferencesVPNView.swift | 7 + 10 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/NetworkProtectionVPNCountryLabelsModel.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift create mode 100644 DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index edfbe159f2..d4f7e19267 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2950,11 +2950,26 @@ EE7295EB2A545BFC008C0991 /* NetworkProtection in Frameworks */ = {isa = PBXBuildFile; productRef = EE7295EA2A545BFC008C0991 /* NetworkProtection */; }; EE7295ED2A545C0A008C0991 /* NetworkProtection in Frameworks */ = {isa = PBXBuildFile; productRef = EE7295EC2A545C0A008C0991 /* NetworkProtection */; }; EE7295EF2A545C12008C0991 /* NetworkProtection in Frameworks */ = {isa = PBXBuildFile; productRef = EE7295EE2A545C12008C0991 /* NetworkProtection */; }; + EEA3EEB12B24EBD000E8333A /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EEB02B24EBD000E8333A /* NetworkProtectionVPNCountryLabelsModel.swift */; }; + EEA3EEB32B24EC0600E8333A /* VPNLocationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EEB22B24EC0600E8333A /* VPNLocationViewModel.swift */; }; EEAD7A7A2A1D3E20002A24E7 /* AppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */; }; EEAD7A7B2A1D3E20002A24E7 /* AppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */; }; EEAD7A7C2A1D3E20002A24E7 /* AppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */; }; EEC111E4294D06020086524F /* JSAlert.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EEC111E3294D06020086524F /* JSAlert.storyboard */; }; EEC111E6294D06290086524F /* JSAlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC111E5294D06290086524F /* JSAlertViewModel.swift */; }; + EEC4A65E2B277E8D00F7C0AA /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EEB02B24EBD000E8333A /* NetworkProtectionVPNCountryLabelsModel.swift */; }; + EEC4A65F2B277EE100F7C0AA /* VPNLocationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EEB22B24EC0600E8333A /* VPNLocationViewModel.swift */; }; + EEC4A6602B277F0D00F7C0AA /* VPNLocationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EEB22B24EC0600E8333A /* VPNLocationViewModel.swift */; }; + EEC4A6612B277F1100F7C0AA /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA3EEB02B24EBD000E8333A /* NetworkProtectionVPNCountryLabelsModel.swift */; }; + EEC4A6692B2C87D300F7C0AA /* VPNLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A6682B2C87D300F7C0AA /* VPNLocationView.swift */; }; + EEC4A66A2B2C87D300F7C0AA /* VPNLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A6682B2C87D300F7C0AA /* VPNLocationView.swift */; }; + EEC4A66B2B2C87D300F7C0AA /* VPNLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A6682B2C87D300F7C0AA /* VPNLocationView.swift */; }; + EEC4A66D2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A66C2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift */; }; + EEC4A66E2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A66C2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift */; }; + EEC4A66F2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A66C2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift */; }; + EEC4A6712B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A6702B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift */; }; + EEC4A6722B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A6702B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift */; }; + EEC4A6732B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC4A6702B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift */; }; EEC589D92A4F1CE300BCD60C /* AppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */; }; EEC589DA2A4F1CE400BCD60C /* AppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */; }; EEC589DB2A4F1CE700BCD60C /* AppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */; }; @@ -4197,9 +4212,14 @@ EAE427FF275D47FA00DAC26B /* ClickToLoadModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClickToLoadModel.swift; sourceTree = ""; }; EAFAD6C92728BD1200F9DF00 /* clickToLoad.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = clickToLoad.js; sourceTree = ""; }; EE339227291BDEFD009F62C1 /* JSAlertController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSAlertController.swift; sourceTree = ""; }; + EEA3EEB02B24EBD000E8333A /* NetworkProtectionVPNCountryLabelsModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionVPNCountryLabelsModel.swift; sourceTree = ""; }; + EEA3EEB22B24EC0600E8333A /* VPNLocationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNLocationViewModel.swift; sourceTree = ""; }; EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppLauncher.swift; sourceTree = ""; }; EEC111E3294D06020086524F /* JSAlert.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = JSAlert.storyboard; sourceTree = ""; }; EEC111E5294D06290086524F /* JSAlertViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSAlertViewModel.swift; sourceTree = ""; }; + EEC4A6682B2C87D300F7C0AA /* VPNLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNLocationView.swift; sourceTree = ""; }; + EEC4A66C2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNLocationPreferenceItemModel.swift; sourceTree = ""; }; + EEC4A6702B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNLocationPreferenceItem.swift; sourceTree = ""; }; EECE10E429DD77E60044D027 /* FeatureFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlag.swift; sourceTree = ""; }; EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPacketTunnelProvider.swift; sourceTree = ""; }; EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSAlertViewModelTests.swift; sourceTree = ""; }; @@ -5107,6 +5127,7 @@ 4B4D60632A0B29FA00BCD287 /* BothAppTargets */ = { isa = PBXGroup; children = ( + EEA3EEAF2B24EB5100E8333A /* VPNLocation */, 9D9AE8682AA76CDC0026E7DC /* LoginItem+NetworkProtection.swift */, 7BE146062A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift */, 7B05829D2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift */, @@ -7839,6 +7860,18 @@ path = fonts; sourceTree = ""; }; + EEA3EEAF2B24EB5100E8333A /* VPNLocation */ = { + isa = PBXGroup; + children = ( + EEA3EEB22B24EC0600E8333A /* VPNLocationViewModel.swift */, + EEA3EEB02B24EBD000E8333A /* NetworkProtectionVPNCountryLabelsModel.swift */, + EEC4A6682B2C87D300F7C0AA /* VPNLocationView.swift */, + EEC4A66C2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift */, + EEC4A6702B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift */, + ); + path = VPNLocation; + sourceTree = ""; + }; EEAEA3F4294D05CF00D04DF3 /* JSAlert */ = { isa = PBXGroup; children = ( @@ -9067,6 +9100,7 @@ 3706FA7F293F65D500E42796 /* TabIndex.swift in Sources */, 3706FA80293F65D500E42796 /* TabLazyLoaderDataSource.swift in Sources */, 3706FA81293F65D500E42796 /* LoginImport.swift in Sources */, + EEC4A66E2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */, 3706FA83293F65D500E42796 /* LazyLoadable.swift in Sources */, 3706FA84293F65D500E42796 /* ClickToLoadModel.swift in Sources */, 3706FA85293F65D500E42796 /* KeyedCodingExtension.swift in Sources */, @@ -9165,6 +9199,7 @@ 3706FADC293F65D500E42796 /* FirefoxLoginReader.swift in Sources */, 3706FADD293F65D500E42796 /* AtbParser.swift in Sources */, 3706FADE293F65D500E42796 /* PreferencesDuckPlayerView.swift in Sources */, + EEC4A65E2B277E8D00F7C0AA /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, B66260E729ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift in Sources */, 3706FADF293F65D500E42796 /* AddFolderModalViewController.swift in Sources */, 3706FAE0293F65D500E42796 /* BookmarkSidebarTreeController.swift in Sources */, @@ -9302,6 +9337,7 @@ 3706FB4D293F65D500E42796 /* GrammarFeaturesManager.swift in Sources */, 3706FB50293F65D500E42796 /* SafariFaviconsReader.swift in Sources */, 3706FB51293F65D500E42796 /* NSScreenExtension.swift in Sources */, + EEC4A66A2B2C87D300F7C0AA /* VPNLocationView.swift in Sources */, 3706FB52293F65D500E42796 /* NSBezierPathExtension.swift in Sources */, 3706FB53293F65D500E42796 /* WebsiteDataStore.swift in Sources */, 3706FB54293F65D500E42796 /* PermissionContextMenu.swift in Sources */, @@ -9391,6 +9427,7 @@ 4B4032852AAAC24400CCA602 /* WaitlistActivationDateStore.swift in Sources */, 3706FB93293F65D500E42796 /* PasteboardFolder.swift in Sources */, 3706FB94293F65D500E42796 /* CookieManagedNotificationView.swift in Sources */, + EEC4A65F2B277EE100F7C0AA /* VPNLocationViewModel.swift in Sources */, 370A34B22AB24E3700C77F7C /* SyncDebugMenu.swift in Sources */, 4B4D60BE2A0C848A00BCD287 /* NetworkProtection+ConvenienceInitializers.swift in Sources */, 3706FB95293F65D500E42796 /* PermissionType.swift in Sources */, @@ -9492,6 +9529,7 @@ 37197EA12942441700394917 /* Tab+UIDelegate.swift in Sources */, 56D6A3D729DB2BAB0055215A /* ContinueSetUpView.swift in Sources */, 3706FBDF293F65D500E42796 /* String+Punycode.swift in Sources */, + EEC4A6722B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 3706FBE0293F65D500E42796 /* NSException+Catch.m in Sources */, 3706FBE1293F65D500E42796 /* AppStateRestorationManager.swift in Sources */, 3706FBE2293F65D500E42796 /* ClickToLoadUserScript.swift in Sources */, @@ -10331,6 +10369,7 @@ B68D21D22ACBCA01002DA3C2 /* ContentBlockerRulesManagerMock.swift in Sources */, 4B9579F62AC7AE700062CA31 /* ChromiumFaviconsReader.swift in Sources */, 4B9579F72AC7AE700062CA31 /* SuggestionTableRowView.swift in Sources */, + EEC4A6732B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 4B9579F82AC7AE700062CA31 /* DownloadsPreferences.swift in Sources */, 4B9579F92AC7AE700062CA31 /* PasswordManagementItemList.swift in Sources */, 4B9579FA2AC7AE700062CA31 /* Bookmark.swift in Sources */, @@ -10492,6 +10531,7 @@ 4B957A8B2AC7AE700062CA31 /* PasswordManagementListSection.swift in Sources */, 4B957A8C2AC7AE700062CA31 /* FaviconReferenceCache.swift in Sources */, 4B957A8D2AC7AE700062CA31 /* BookmarkTreeController.swift in Sources */, + EEC4A6602B277F0D00F7C0AA /* VPNLocationViewModel.swift in Sources */, 4B957A8E2AC7AE700062CA31 /* FirefoxEncryptionKeyReader.swift in Sources */, 4B957A8F2AC7AE700062CA31 /* EventMapping+NetworkProtectionError.swift in Sources */, 4B957A902AC7AE700062CA31 /* BookmarkManagementSplitViewController.swift in Sources */, @@ -10628,6 +10668,7 @@ 4B957B112AC7AE700062CA31 /* DateExtension.swift in Sources */, 4B957B122AC7AE700062CA31 /* History.xcdatamodeld in Sources */, 4B957B132AC7AE700062CA31 /* PermissionStore.swift in Sources */, + EEC4A6612B277F1100F7C0AA /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, 4B957B142AC7AE700062CA31 /* PrivacyIconViewModel.swift in Sources */, 4B957B152AC7AE700062CA31 /* ChromiumBookmarksReader.swift in Sources */, 4B957B162AC7AE700062CA31 /* Downloads.xcdatamodeld in Sources */, @@ -10667,6 +10708,7 @@ 4B957B382AC7AE700062CA31 /* ProgressView.swift in Sources */, 7BEC20472B0F505F00243D3E /* BookmarkAddFolderPopoverViewController.swift in Sources */, 4B957B392AC7AE700062CA31 /* StatisticsStore.swift in Sources */, + EEC4A66B2B2C87D300F7C0AA /* VPNLocationView.swift in Sources */, 4B957B3A2AC7AE700062CA31 /* BWInstallationService.swift in Sources */, 4B957B3B2AC7AE700062CA31 /* BookmarksBarPromptPopover.swift in Sources */, 4B957B3C2AC7AE700062CA31 /* NetworkProtectionInvitePresenter.swift in Sources */, @@ -10692,6 +10734,7 @@ 4B957B502AC7AE700062CA31 /* CoreDataStore.swift in Sources */, 4B957B512AC7AE700062CA31 /* BundleExtension.swift in Sources */, 4B957B522AC7AE700062CA31 /* NSOpenPanelExtensions.swift in Sources */, + EEC4A66F2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */, 4B957B532AC7AE700062CA31 /* FirePopover.swift in Sources */, 4B957B542AC7AE700062CA31 /* HistoryCoordinator.swift in Sources */, 4B957B552AC7AE700062CA31 /* NetworkProtectionOnboardingMenu.swift in Sources */, @@ -10892,6 +10935,7 @@ 85799C1825DEBB3F0007EC87 /* Logging.swift in Sources */, AAC30A2E268F1EE300D2D9CD /* CrashReportPromptPresenter.swift in Sources */, 1D2DC00629016798008083A1 /* BWCredential.swift in Sources */, + EEA3EEB12B24EBD000E8333A /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, 37AFCE8727DA334800471A10 /* PreferencesRootView.swift in Sources */, B684590825C9027900DC17B6 /* AppStateChangedPublisher.swift in Sources */, 4B92928F26670D1700AD2C21 /* BookmarkTableCellView.swift in Sources */, @@ -10993,6 +11037,7 @@ 37F19A6528E1B3FB00740DC6 /* PreferencesDuckPlayerView.swift in Sources */, 4B9292D22667123700AD2C21 /* AddFolderModalViewController.swift in Sources */, 4B92929E26670D2A00AD2C21 /* BookmarkSidebarTreeController.swift in Sources */, + EEC4A6712B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 85589E8727BBB8F20038AD11 /* HomePageFavoritesModel.swift in Sources */, 4BB88B4A25B7B690006F6B06 /* SequenceExtensions.swift in Sources */, B602E7CF2A93A5FF00F12201 /* WKBackForwardListExtension.swift in Sources */, @@ -11087,6 +11132,7 @@ 4BBC16A027C4859400E00A38 /* DeviceAuthenticationService.swift in Sources */, CB24F70C29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift in Sources */, 37CBCA9A2A8966E60050218F /* SyncSettingsAdapter.swift in Sources */, + EEC4A66D2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */, 3776582F27F82E62009A6B35 /* AutofillPreferences.swift in Sources */, AAD8078727B3F45600CF7703 /* WebsiteBreakage.swift in Sources */, 7BEC20422B0F505F00243D3E /* BookmarkAddPopoverViewController.swift in Sources */, @@ -11223,6 +11269,7 @@ B6DB3CFB26A17CB800D459B7 /* PermissionModel.swift in Sources */, 4B92929C26670D2A00AD2C21 /* PasteboardFolder.swift in Sources */, 3171D6B82889849F0068632A /* CookieManagedNotificationView.swift in Sources */, + EEA3EEB32B24EC0600E8333A /* VPNLocationViewModel.swift in Sources */, B6106BAB26A7BF1D0013B453 /* PermissionType.swift in Sources */, AAC6881B28626C1900D54247 /* RecentlyClosedWindow.swift in Sources */, 85707F2A276A35FE00DC0649 /* ActionSpeech.swift in Sources */, @@ -11436,6 +11483,7 @@ 4BA1A69B258B076900F6F690 /* FileStore.swift in Sources */, B6A9E47F26146A800067D1B9 /* PixelArguments.swift in Sources */, 37BF3F21286F0A7A00BD9014 /* PinnedTabsViewModel.swift in Sources */, + EEC4A6692B2C87D300F7C0AA /* VPNLocationView.swift in Sources */, AAC5E4D225D6A709007F5990 /* BookmarkList.swift in Sources */, B602E81D2A1E25B1006D261F /* NEOnDemandRuleExtension.swift in Sources */, 4B9292D12667123700AD2C21 /* BookmarkTableRowView.swift in Sources */, diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index b7a82331a4..d4084d6d47 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -302,10 +302,34 @@ struct UserText { // VPN Setting Titles + static let vpnLocationTitle = NSLocalizedString("vpn.location.title", value: "Location", comment: "Location section title in VPN settings") static let vpnGeneralTitle = NSLocalizedString("vpn.general.title", value: "General", comment: "General section title in VPN settings") static let vpnNotificationsSettingsTitle = NSLocalizedString("vpn.notifications.settings.title", value: "Notifications", comment: "Notifications section title in VPN settings") static let vpnAdvancedSettingsTitle = NSLocalizedString("vpn.advanced.settings.title", value: "Advanced", comment: "VPN Advanced section title in VPN settings") + // VPN Location + + static let vpnLocationChangeButtonTitle = NSLocalizedString("vpn.location.change.button.title", value: "Change...", comment: "Title of the VPN location preference change button") + static let vpnLocationListTitle = NSLocalizedString("vpn.location.list.title", value: "VPN Location", comment: "Title of the VPN location list screen") + static let vpnLocationRecommendedSectionTitle = NSLocalizedString("vpn.location.recommended.section.title", value: "Recommended", comment: "Title of the VPN location list recommended section") + static let vpnLocationCustomSectionTitle = NSLocalizedString("vpn.location.custom.section.title", value: "Custom", comment: "Title of the VPN location list custom section") + static let vpnLocationSubmitButtonTitle = NSLocalizedString("vpn.location.submit.button.title", value: "Submit", comment: "Title of the VPN location list submit button") + static let vpnLocationCancelButtonTitle = NSLocalizedString("vpn.location.custom.section.title", value: "Cancel", comment: "Title of the VPN location list cancel button") + static let vpnLocationNearest = NSLocalizedString( + "vpn.location.description.nearest", + value: "Nearest", + comment: "Nearest city setting description") + static let vpnLocationNearestAvailable = NSLocalizedString( + "vpn.location.description.nearest.available", + value: "Nearest Available", + comment: "Nearest available location setting description") + static let vpnLocationNearestAvailableSubtitle = NSLocalizedString("vpn.location.nearest.available.title", value: "Automatically connect to the nearest server we can find.", comment: "Subtitle underneath the nearest available vpn location preference text.") + + static func vpnLocationCountryItemFormattedCitiesCount(_ count: Int) -> String { + let message = NSLocalizedString("network.protection.vpn.location.country.item.formatted.cities.count", value: "%d cities", comment: "Subtitle of countries item when there are multiple cities, example : ") + return String(format: message, count) + } + // VPN Settings static let vpnConnectOnLoginSettingTitle = NSLocalizedString( diff --git a/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift b/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift index 8b784addef..66fba3ac63 100644 --- a/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift +++ b/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift @@ -25,6 +25,8 @@ public enum FeatureFlag: String { /// Add experimental atb parameter to SERP queries for internal users to display Privacy Reminder /// https://app.asana.com/0/1199230911884351/1205979030848528/f case appendAtbToSerpQueries + + case vpnGeoswitching } extension FeatureFlag: FeatureFlagSourceProviding { @@ -34,6 +36,8 @@ extension FeatureFlag: FeatureFlagSourceProviding { return .internalOnly case .appendAtbToSerpQueries: return .internalOnly + case .vpnGeoswitching: + return .internalOnly } } } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/NetworkProtectionVPNCountryLabelsModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/NetworkProtectionVPNCountryLabelsModel.swift new file mode 100644 index 0000000000..12bfce37da --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/NetworkProtectionVPNCountryLabelsModel.swift @@ -0,0 +1,45 @@ +// +// NetworkProtectionVPNCountryLabelsModel.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Foundation +import NetworkProtection + +struct NetworkProtectionVPNCountryLabelsModel { + let emoji: String + let title: String + + init(country: String) { + self.title = Locale.current.localizedString(forRegionCode: country) ?? country.capitalized + self.emoji = Self.flag(country: country) + } + + private static func flag(country: String) -> String { + let flagBase = UnicodeScalar("🇦").value - UnicodeScalar("A").value + + let flag = country + .uppercased() + .unicodeScalars + .compactMap({ UnicodeScalar(flagBase + $0.value)?.description }) + .joined() + return flag + } +} + +#endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift new file mode 100644 index 0000000000..ee139d7de2 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift @@ -0,0 +1,67 @@ +// +// NetworkProtectionVPNLocationPreferenceItem.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Foundation +import SwiftUI + +struct VPNLocationPreferenceItem: View { + let model: VPNLocationPreferenceItemModel + @State private var isShowingLocationSheet: Bool = false + + var body: some View { + VStack(alignment: .leading) { + HStack(spacing: 10) { + switch model.icon { + case .defaultIcon: + Image(systemName: "location.fill") + .resizable() + .frame(width: 18, height: 18) + case .emoji(let string): + Text(string).font(.system(size: 20)) + } + + VStack(alignment: .leading) { + Text(model.title) + .font(.system(size: 13)) + .foregroundColor(.primary) + if let subtitle = model.subtitle { + Text(subtitle) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + Spacer() + Button(UserText.vpnLocationChangeButtonTitle) { + isShowingLocationSheet = true + } + .sheet(isPresented: $isShowingLocationSheet) { + VPNLocationView(isPresented: $isShowingLocationSheet) + } + } + } + .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .topLeading) + .padding(10) + .background(Color("BlackWhite1")) + .roundedBorder() + } + +} + +#endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift new file mode 100644 index 0000000000..ea0e505b32 --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift @@ -0,0 +1,49 @@ +// +// NetworkProtectionLocationSettingsItemModel.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Foundation +import NetworkProtection + +struct VPNLocationPreferenceItemModel { + enum LocationIcon { + case defaultIcon + case emoji(String) + } + + let title: String + let subtitle: String? + let icon: LocationIcon + + init(selectedLocation: VPNSettings.SelectedLocation) { + switch selectedLocation { + case .nearest: + title = UserText.vpnLocationNearestAvailable + subtitle = UserText.vpnLocationNearestAvailableSubtitle + icon = .defaultIcon + case .location(let location): + let countryLabelsModel = NetworkProtectionVPNCountryLabelsModel(country: location.country) + title = countryLabelsModel.title + subtitle = selectedLocation.location?.city + icon = .emoji(countryLabelsModel.emoji) + } + } +} + +#endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift new file mode 100644 index 0000000000..a19ecca45d --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift @@ -0,0 +1,245 @@ +// +// NetworkProtectionVPNLocationView.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import SwiftUI +import SwiftUIExtensions + +struct VPNLocationView: View { + @StateObject var model = VPNLocationViewModel() + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading) { + Text(UserText.vpnLocationListTitle) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 16) { + nearest(isSelected: model.isNearestSelected) + countries() + } + .padding(0) + } + .padding(.horizontal, 56) + .padding(.top, 32) + .padding(.bottom, 20) + .frame(minWidth: 624, maxWidth: .infinity, minHeight: 514, maxHeight: 514, alignment: .top) + Spacer() + Group { + VPNLocationViewButtons( + onDone: { + model.onSubmit() + isPresented = false + }, onCancel: { + isPresented = false + }) + .navigationTitle(UserText.vpnFeedbackFormTitle) + .onAppear { + Task { + await model.onViewAppeared() + } + } + } + .background(Color.secondary.opacity(0.1)) + } + + @ViewBuilder + private func nearest(isSelected: Bool) -> some View { + PreferencePaneSection(vericalPadding: 12) { + Text(UserText.vpnLocationRecommendedSectionTitle) + .font(.system(size: 15)) + .foregroundColor(.primary) + ChecklistItem( + isSelected: isSelected, + action: { + Task { + await model.onNearestItemSelection() + } + }, label: { + Image(systemName: "location.fill") + .resizable() + .frame(width: 18, height: 18) + VStack(alignment: .leading, spacing: 4) { + Text(UserText.vpnLocationNearestAvailable) + .foregroundColor(.primary) + Text(UserText.vpnLocationNearestAvailableSubtitle) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + ) + .frame(idealWidth: .infinity, maxWidth: .infinity) + .padding(10) + .background(Color("BlackWhite1")) + .roundedBorder() + } + } + + @ViewBuilder + private func countries() -> some View { + switch model.state { + case .loading: + EmptyView() + .listRowBackground(Color.clear) + case .loaded(let countryItems): + PreferencePaneSection(vericalPadding: 12) { + Text(UserText.vpnLocationCustomSectionTitle) + .font(.system(size: 15)) + .foregroundColor(.primary) + LazyVStack(alignment: .leading) { + ForEach(countryItems) { item in + CountryItem( + itemModel: item, + action: { + Task { + await model.onCountryItemSelection(id: item.id) + } + }, cityPickerAction: { selection in + Task { + await model.onCountryItemSelection(id: item.id, cityId: selection) + } + }) + .padding(10) + } + } + .roundedBorder() + } + } + } +} + +private struct CountryItem: View { + let itemModel: VPNCountryItemModel + let action: () -> Void + let cityPickerAction: (String?) -> Void + + private var selectedCityItemBinding: Binding { + Binding { + itemModel.selectedCityItem + } set: { city in + cityPickerAction(city.id) + } + } + + init(itemModel: VPNCountryItemModel, action: @escaping () -> Void, cityPickerAction: @escaping (String?) -> Void) { + self.itemModel = itemModel + self.action = action + self.cityPickerAction = cityPickerAction + } + + var body: some View { + ChecklistItem( + isSelected: itemModel.isSelected, + action: action, + label: { + Text(itemModel.emoji) + VStack(alignment: .leading, spacing: 4) { + Text(itemModel.title) + .foregroundColor(.primary) + if let subtitle = itemModel.subtitle { + Text(subtitle) + .foregroundColor(.secondary) + } + } + if itemModel.shouldShowPicker { + Spacer() + Picker("", selection: selectedCityItemBinding) { + Text(itemModel.nearestCityPickerItem.name) + .tag(itemModel.nearestCityPickerItem) + Divider() + ForEach(itemModel.cityPickerItems) { cityItem in + Text(cityItem.name) + .tag(cityItem) + } + } + .pickerStyle(.menu) + .frame(width: 90) + } + } + ) + } +} + +private struct ChecklistItem: View where Content: View { + let isSelected: Bool + let action: () -> Void + @ViewBuilder let label: () -> Content + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "checkmark") + .foregroundColor(Color.accentColor) + .if(!isSelected) { + $0.hidden() + } + label() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + .background(Color("BlackWhite1")) + .onTapGesture { + action() + } + } +} + +private struct VPNLocationViewButtons: View { + let onDone: () -> Void + let onCancel: () -> Void + + var body: some View { + HStack { + Spacer() + button(text: UserText.vpnLocationCancelButtonTitle, action: onCancel) + .keyboardShortcut(.cancelAction) + .buttonStyle(DismissActionButtonStyle()) + + button(text: UserText.vpnLocationSubmitButtonTitle, action: onDone) + .keyboardShortcut(.defaultAction) + .buttonStyle(DefaultActionButtonStyle(enabled: true)) + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + } + + @ViewBuilder + func button(text: String, action: @escaping () -> Void) -> some View { + Button(text) { + action() + } + } + +} + +extension View { + /// Applies the given transform if the given condition evaluates to `true`. + /// - Parameters: + /// - condition: The condition to evaluate. + /// - transform: The transform to apply to the source `View`. + /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. + @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + +#endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift new file mode 100644 index 0000000000..9ad4aba68f --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift @@ -0,0 +1,185 @@ +// +// NetworkProtectionVPNLocationViewModel.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Foundation +import Combine +import NetworkProtection + +final class VPNLocationViewModel: ObservableObject { + private let locationListRepository: NetworkProtectionLocationListRepository + private let settings: VPNSettings + private var selectedLocation: VPNSettings.SelectedLocation + @Published public var state: LoadingState + @Published public var isNearestSelected: Bool + + enum ViewAction { + case cancel + case submit + } + + enum LoadingState { + case loading + case loaded(countryItems: [VPNCountryItemModel]) + + var isLoading: Bool { + switch self { + case .loading: + return true + case .loaded: + return false + } + } + } + + init(locationListRepository: NetworkProtectionLocationListRepository, settings: VPNSettings) { + self.locationListRepository = locationListRepository + self.settings = settings + state = .loading + selectedLocation = settings.selectedLocation + self.isNearestSelected = selectedLocation == .nearest + } + + func onViewAppeared() async { + await reloadList() + } + + func onNearestItemSelection() async { + selectedLocation = .nearest + await reloadList() + } + + func onCountryItemSelection(id: String, cityId: String? = nil) async { + let location = NetworkProtectionSelectedLocation(country: id, city: cityId) + selectedLocation = .location(location) + await reloadList() + } + + func onSubmit() { + settings.selectedLocation = selectedLocation + } + + @MainActor + private func reloadList() async { + guard let list = try? await locationListRepository.fetchLocationList() else { return } + let isNearestSelected = selectedLocation == .nearest + + let countryItems = list.map { currentLocation in + let isCountrySelected: Bool + var cityPickerItems: [CityItem] + let selectedCityItem: CityItem + + switch selectedLocation { + case .location(let location): + isCountrySelected = location.country == currentLocation.country + cityPickerItems = currentLocation.cities.map { currentCity in + let isCitySelected = currentCity.name == location.city + return CityItem(cityName: currentCity.name) + } + selectedCityItem = location.city.flatMap(CityItem.init(cityName:)) ?? .nearest + case .nearest: + isCountrySelected = false + cityPickerItems = currentLocation.cities.map { currentCity in + CityItem(cityName: currentCity.name) + } + selectedCityItem = .nearest + } + + return VPNCountryItemModel( + netPLocation: currentLocation, + isSelected: isCountrySelected, + cityPickerItems: cityPickerItems, + selectedCityItem: selectedCityItem + ) + } + self.isNearestSelected = isNearestSelected + state = .loaded(countryItems: countryItems) + } +} + +private typealias CountryItem = VPNCountryItemModel +private typealias CityItem = VPNCityItemModel + +struct VPNCountryItemModel: Identifiable { + private let labelsModel: NetworkProtectionVPNCountryLabelsModel + + var emoji: String { + labelsModel.emoji + } + var title: String { + labelsModel.title + } + let isSelected: Bool + var id: String + let subtitle: String? + let nearestCityPickerItem: VPNCityItemModel = .nearest + let cityPickerItems: [VPNCityItemModel] + let selectedCityItem: VPNCityItemModel + let shouldShowPicker: Bool + + fileprivate init(netPLocation: NetworkProtectionLocation, isSelected: Bool, cityPickerItems: [VPNCityItemModel], selectedCityItem: VPNCityItemModel) { + self.labelsModel = .init(country: netPLocation.country) + self.isSelected = isSelected + self.id = netPLocation.country + let hasMultipleCities = netPLocation.cities.count > 1 + self.subtitle = hasMultipleCities ? UserText.vpnLocationCountryItemFormattedCitiesCount(netPLocation.cities.count) : nil + self.cityPickerItems = cityPickerItems + self.shouldShowPicker = hasMultipleCities + self.selectedCityItem = selectedCityItem + } +} + +struct VPNCityItemModel: Identifiable, Hashable { + let id: String + let name: String + + fileprivate init(cityName: String) { + self.id = cityName + self.name = cityName + } +} + +extension VPNCityItemModel { + static var nearest: VPNCityItemModel { + Self.init(cityName: UserText.vpnLocationNearest) + } +} + +extension NetworkProtectionLocationListCompositeRepository { + convenience init() { + let settings = VPNSettings(defaults: .netP) + self.init( + environment: settings.selectedEnvironment, + tokenStore: NetworkProtectionKeychainTokenStore(), + errorEvents: .networkProtectionAppDebugEvents + ) + } +} + +extension VPNLocationViewModel { + convenience init() { + let locationListRepository = NetworkProtectionLocationListCompositeRepository() + self.init( + locationListRepository: locationListRepository, + settings: VPNSettings(defaults: .netP) + ) + } +} + +#endif diff --git a/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift b/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift index 453a505449..54c2a5420e 100644 --- a/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift +++ b/DuckDuckGo/Preferences/Model/VPNPreferencesModel.swift @@ -23,9 +23,13 @@ import Combine import Foundation import NetworkProtection import NetworkProtectionUI +import BrowserServicesKit final class VPNPreferencesModel: ObservableObject { + let shouldShowLocationItem: Bool + @Published var locationItem: VPNLocationPreferenceItemModel + @Published var alwaysON = true @Published var connectOnLogin: Bool { @@ -66,7 +70,8 @@ final class VPNPreferencesModel: ObservableObject { private var cancellables = Set() init(settings: VPNSettings = .init(defaults: .netP), - defaults: UserDefaults = .netP) { + defaults: UserDefaults = .netP, + featureFlagger: FeatureFlagger = NSApp.delegateTyped.featureFlagger) { self.settings = settings connectOnLogin = settings.connectOnLogin @@ -75,8 +80,11 @@ final class VPNPreferencesModel: ObservableObject { showInMenuBar = settings.showInMenuBar showUninstallVPN = defaults.networkProtectionOnboardingStatus != .default onboardingStatus = defaults.networkProtectionOnboardingStatus + locationItem = VPNLocationPreferenceItemModel(selectedLocation: settings.selectedLocation) + shouldShowLocationItem = featureFlagger.isFeatureOn(.vpnGeoswitching) subscribeToOnboardingStatusChanges(defaults: defaults) + subscribeToLocationSettingChanges() } func subscribeToOnboardingStatusChanges(defaults: UserDefaults) { @@ -85,6 +93,13 @@ final class VPNPreferencesModel: ObservableObject { .store(in: &cancellables) } + func subscribeToLocationSettingChanges() { + settings.selectedLocationPublisher + .map(VPNLocationPreferenceItemModel.init(selectedLocation:)) + .assign(to: \.locationItem, onWeaklyHeld: self) + .store(in: &cancellables) + } + @MainActor func uninstallVPN() async { let response = await uninstallVPNConfirmationAlert().runModal() diff --git a/DuckDuckGo/Preferences/View/PreferencesVPNView.swift b/DuckDuckGo/Preferences/View/PreferencesVPNView.swift index b30d3b8358..7b16780933 100644 --- a/DuckDuckGo/Preferences/View/PreferencesVPNView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesVPNView.swift @@ -33,6 +33,13 @@ extension Preferences { TextMenuTitle(text: UserText.vpn) + if model.shouldShowLocationItem { + PreferencePaneSection { + TextMenuItemHeader(text: UserText.vpnLocationTitle) + VPNLocationPreferenceItem(model: model.locationItem) + } + } + // SECTION: Manage VPN PreferencePaneSection {