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 {