diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 50f9f89e011..8bc00a2b97a 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1176,6 +1176,12 @@ extension Strings { value: "Reset Configuration", comment: "Button to reset VPN configuration") + public static let settingsChangeLocation = + NSLocalizedString("vpn.settingsChangeLocation", + bundle: .braveShared, + value: "Change Location", + comment: "Button to change VPN server location") + public static let settingsContactSupport = NSLocalizedString("vpn.settingsContactSupport", bundle: .braveShared, @@ -1463,6 +1469,48 @@ extension Strings { bundle: .braveShared, value: "Subscriptions will be charged via your iTunes account.\n\nAny unused portion of the free trial, if offered, is forfeited when you buy a subscription.\n\nYour subscription will renew automatically unless it is cancelled at least at least 24 hours before the end of the current period.\n\nYou can manage your subscriptions in Settings.\n\nBy using Brave, you agree to the Terms of Use and Privacy Policy.", comment: "Disclaimer for user purchasing the VPN plan.") + + public static let regionPickerTitle = + NSLocalizedString("vpn.regionPickerTitle", + bundle: .braveShared, + value: "Server Region", + comment: "Title for vpn region selector screen") + + public static let regionPickerAutomaticModeCellText = + NSLocalizedString("vpn.regionPickerAutomaticModeCellText", + bundle: .braveShared, + value: "Automatic", + comment: "Name of automatic vpn region selector") + + public static let regionPickerAutomaticDescription = + NSLocalizedString("vpn.regionPickerAutomaticDescription", + bundle: .braveShared, + value: "A server region most proximate to you will be automatically selected, based on your system timezone. This is recommended in order to ensure fast internet speeds.", + comment: "Description of what automatic server selection does.") + + public static let regionPickerErrorTitle = + NSLocalizedString("vpn.regionPickerErrorTitle", + bundle: .braveShared, + value: "Server Error", + comment: "Title for error when we fail to switch vpn server for the user") + + public static let regionPickerErrorMessage = + NSLocalizedString("vpn.regionPickerErrorMessage", + bundle: .braveShared, + value: "Failed to switch servers, please try again later.", + comment: "Message for error when we fail to switch vpn server for the user") + + public static let regionSwitchSuccessPopupText = + NSLocalizedString("vpn.regionSwitchSuccessPopupText", + bundle: .braveShared, + value: "VPN region changed.", + comment: "Message that we show after successfully changing vpn region.") + + public static let settingsFailedToFetchServerList = + NSLocalizedString("vpn.settingsFailedToFetchServerList", + bundle: .braveShared, + value: "Failed to retrieve server list, please try again later.", + comment: "Error message shown if we failed to retrieve vpn server list.") } } diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index ddb437d3ce5..5a180c0c22c 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -60,6 +60,8 @@ 0A42F3BD25B5CFAE00BD370B /* DefaultBrowserIntroTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A42F3BC25B5CFAE00BD370B /* DefaultBrowserIntroTests.swift */; }; 0A43293021B1C7F50041625B /* AdBlockStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A43292F21B1C7F50041625B /* AdBlockStats.swift */; }; 0A43293B21B1C8D10041625B /* FifoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A43293A21B1C8D10041625B /* FifoDict.swift */; }; + 0A47389E25D2DD6F0048201A /* NSPredicate+Additions.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A47389C25D2DD6E0048201A /* NSPredicate+Additions.m */; }; + 0A47389F25D2DD6F0048201A /* NSPredicate+Additions.h in Headers */ = {isa = PBXBuildFile; fileRef = 0A47389D25D2DD6F0048201A /* NSPredicate+Additions.h */; }; 0A4B012020D02EC4004D4011 /* TabsBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A4B011F20D02EC4004D4011 /* TabsBarViewController.swift */; }; 0A4B012220D02F26004D4011 /* TabBarCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A4B012120D02F26004D4011 /* TabBarCell.swift */; }; 0A4B012420D0321A004D4011 /* UX.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A4B012320D0321A004D4011 /* UX.swift */; }; @@ -107,6 +109,8 @@ 0A91598922B834CE00CCC119 /* BVC+Rewards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A91598822B834CE00CCC119 /* BVC+Rewards.swift */; }; 0A917388231D11960069A08B /* AppReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A917387231D11960069A08B /* AppReview.swift */; }; 0A917391231D173C0069A08B /* AppReviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A917390231D173C0069A08B /* AppReviewTests.swift */; }; + 0A918DC1252C785200496088 /* VPNRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A918DC0252C785200496088 /* VPNRegion.swift */; }; + 0A918DCD252C81FA00496088 /* BraveVPNRegionPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A918DCC252C81FA00496088 /* BraveVPNRegionPickerViewController.swift */; }; 0A93F1802264C2D200A3571B /* FolderDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A93F17F2264C2D200A3571B /* FolderDetailsView.swift */; }; 0A93F1892264C72000A3571B /* BookmarkFormFieldsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A93F1882264C72000A3571B /* BookmarkFormFieldsProtocol.swift */; }; 0AA21E4A2302C4CC00358988 /* webauth_verify_key.json in Resources */ = {isa = PBXBuildFile; fileRef = 0AA21E472302C4CC00358988 /* webauth_verify_key.json */; }; @@ -1407,6 +1411,8 @@ 0A42F3BC25B5CFAE00BD370B /* DefaultBrowserIntroTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultBrowserIntroTests.swift; sourceTree = ""; }; 0A43292F21B1C7F50041625B /* AdBlockStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdBlockStats.swift; sourceTree = ""; }; 0A43293A21B1C8D10041625B /* FifoDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FifoDict.swift; sourceTree = ""; }; + 0A47389C25D2DD6E0048201A /* NSPredicate+Additions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSPredicate+Additions.m"; sourceTree = ""; }; + 0A47389D25D2DD6F0048201A /* NSPredicate+Additions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSPredicate+Additions.h"; sourceTree = ""; }; 0A4B011F20D02EC4004D4011 /* TabsBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsBarViewController.swift; sourceTree = ""; }; 0A4B012120D02F26004D4011 /* TabBarCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarCell.swift; sourceTree = ""; }; 0A4B012320D0321A004D4011 /* UX.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UX.swift; sourceTree = ""; }; @@ -1462,6 +1468,8 @@ 0A91598822B834CE00CCC119 /* BVC+Rewards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BVC+Rewards.swift"; sourceTree = ""; }; 0A917387231D11960069A08B /* AppReview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReview.swift; sourceTree = ""; }; 0A917390231D173C0069A08B /* AppReviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewTests.swift; sourceTree = ""; }; + 0A918DC0252C785200496088 /* VPNRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNRegion.swift; sourceTree = ""; }; + 0A918DCC252C81FA00496088 /* BraveVPNRegionPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveVPNRegionPickerViewController.swift; sourceTree = ""; }; 0A93F17F2264C2D200A3571B /* FolderDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailsView.swift; sourceTree = ""; }; 0A93F1882264C72000A3571B /* BookmarkFormFieldsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFormFieldsProtocol.swift; sourceTree = ""; }; 0A95EF7A23571F3A001385A3 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Storage.strings; sourceTree = ""; }; @@ -2897,6 +2905,8 @@ 0A39FE9D248660E900290ABC /* VPNConstants.h */, 0A39FE9E248660EA00290ABC /* VPNConstants.m */, 0AB6E25624C5EE290060B052 /* NSLogDisabler.h */, + 0A47389D25D2DD6F0048201A /* NSPredicate+Additions.h */, + 0A47389C25D2DD6E0048201A /* NSPredicate+Additions.m */, ); path = GRDAPI; sourceTree = ""; @@ -3115,6 +3125,7 @@ 0AF6B1E624A0EDC1005417FC /* InstallVPNViewController.swift */, 0AF6B1E924A0EE19005417FC /* InstallVPNView.swift */, 0A56108324324B2A00B39107 /* BraveVPNSettingsViewController.swift */, + 0A918DCC252C81FA00496088 /* BraveVPNRegionPickerViewController.swift */, 0A55E53124349FE70069F06A /* EnableVPNSettingHeader.swift */, 0A55E5392434EEB60069F06A /* EnableVPNPopupViewController.swift */, 0A55E543243744DC0069F06A /* BraveVPN.swift */, @@ -3124,6 +3135,7 @@ 0A8ABE18247435E30062DA81 /* BraveVPNCommonUI.swift */, 0A0A07CD247518B100591DFB /* BraveVPNContactFormViewController.swift */, 0A2FF33E24A4B75000F88F43 /* vpncheckmark.json */, + 0A918DC0252C785200496088 /* VPNRegion.swift */, ); path = BraveVPN; sourceTree = ""; @@ -5438,6 +5450,7 @@ 4422D56F21BFFB7F00BF1855 /* regexp.h in Headers */, 4422D43821BFD29E00BF1855 /* NSData+GZIP.h in Headers */, 4422D50921BFFB7600BF1855 /* port_example.h in Headers */, + 0A47389F25D2DD6F0048201A /* NSPredicate+Additions.h in Headers */, 4422D42B21BFCF8900BF1855 /* RecentlyUsedCache.h in Headers */, 4422D56021BFFB7F00BF1855 /* prefilter_tree.h in Headers */, 0AB6E25724C5EE290060B052 /* NSLogDisabler.h in Headers */, @@ -6893,6 +6906,7 @@ 44331DDC22561F34007E3E93 /* ToolbarUrlActionsDelegate.swift in Sources */, 27C6478F2551CD2B006D72FC /* RewardsInternalsDebugViewController.swift in Sources */, 4422D43721BFD29E00BF1855 /* NSData+GZIP.m in Sources */, + 0A47389E25D2DD6F0048201A /* NSPredicate+Additions.m in Sources */, 27EF6B8024BF48C7005E034F /* FeedSourceListViewController.swift in Sources */, D31CF65C1CC1959A001D0BD0 /* PrivilegedRequest.swift in Sources */, 0A764F31230EE5CA003A1D9B /* OnboardingState.swift in Sources */, @@ -6983,6 +6997,7 @@ 0ADA01F32450894B00B21475 /* IAPObserver.swift in Sources */, 39DD030D1CD53E1900BC09B3 /* HomePageHelper.swift in Sources */, C4F3B29A1CFCF93A00966259 /* ButtonToast.swift in Sources */, + 0A918DC1252C785200496088 /* VPNRegion.swift in Sources */, D31A0FC71A65D6D000DC8C7E /* SearchSuggestClient.swift in Sources */, 4422D4AE21BFFB7600BF1855 /* testutil.cc in Sources */, 0AE5C09922CAA01E00DFF3EE /* RewardsButton.swift in Sources */, @@ -7088,6 +7103,7 @@ 4422D56521BFFB7F00BF1855 /* filtered_re2.cc in Sources */, 277223722469B4FB0059A7EB /* FaviconFetcher.swift in Sources */, 274398F524E71D8700E79605 /* FailableDecodable.swift in Sources */, + 0A918DCD252C81FA00496088 /* BraveVPNRegionPickerViewController.swift in Sources */, 279C756B219DDE3B001CD1CB /* FingerprintingProtection.swift in Sources */, E650755F1E37F756006961AC /* Try.m in Sources */, 0A3C789D23055C4A0022F6D8 /* OnboardingSearchEnginesViewController.swift in Sources */, diff --git a/Client/Application/ClientPreferences.swift b/Client/Application/ClientPreferences.swift index bffc5ab1ff1..09100a37a24 100644 --- a/Client/Application/ClientPreferences.swift +++ b/Client/Application/ClientPreferences.swift @@ -178,6 +178,8 @@ extension Preferences { Option(key: "vpn.vpn-bg-notification-showed", default: false) static let vpnSettingHeaderWasDismissed = Option(key: "vpn.vpn-header-dismissed", default: false) + /// User can decide to choose their vpn region manually. If nil, automatic mode is used based on device timezone. + static let vpnRegionOverride = Option(key: "vpn.region-override", default: nil) } final class Chromium { diff --git a/Client/Frontend/BraveVPN/BraveVPN.swift b/Client/Frontend/BraveVPN/BraveVPN.swift index f82bc734d30..a2552cfccfb 100644 --- a/Client/Frontend/BraveVPN/BraveVPN.swift +++ b/Client/Frontend/BraveVPN/BraveVPN.swift @@ -329,7 +329,7 @@ class BraveVPN { if firstTimeUserConfigPending { return } firstTimeUserConfigPending = true - serverManager.selectGuardianHost { host, error in + serverManager.selectGuardianHost { host, location, error in guard let host = host, error == nil else { firstTimeUserConfigPending = false logAndStoreError("configureFirstTimeUser connection problems") @@ -419,6 +419,7 @@ class BraveVPN { /// Attempts to reconfigure the vpn by migrating to a new server. /// The new hostname is chosen randomly. + /// Depending on user preference this will connect to either manually selected server region or a region closest to the user. /// This method disconnects from the vpn before reconfiguration is happening /// and reconnects automatically after reconfiguration is done. static func reconfigureVPN(completion: ((Bool) -> Void)? = nil) { @@ -428,42 +429,66 @@ class BraveVPN { // Small delay to disconnect the vpn. // Otherwise we might end up with 'no internet connection' error. DispatchQueue.global().asyncAfter(deadline: .now() + 1, execute: { - serverManager.selectGuardianHost { host, error in - guard let host = host, error == nil else { - completion?(false) - logAndStoreError("reconfigureVPN host error") - return - } - - guard let credentialString = - GRDKeychain.getPasswordString(forAccount: kKeychainStr_SubscriberCredential) else { - logAndStoreError("reconfigureVPN failed to retrieve subscriber credentials") - completion?(false) - return + // Region selected manually by the user. + if let regionOverride = Preferences.VPN.vpnRegionOverride.value { + serverManager.findBestHost(inRegion: regionOverride) { host, _, error in + guard let host = host, error == nil else { + completion?(false) + logAndStoreError("reconfigureVPN findBestHost host error") + return + } + + reconfigure(with: host) { success in + completion?(success) + } } - - saveHostname(host) - - helper.createFreshUser(withSubscriberCredential: credentialString) { status, createError in - if status != .success { - logAndStoreError("reconfigureVPN createFreshUser failed") + } + // Default behavior, automatic mode, chooses location based on device's timezone. + else { + serverManager.selectGuardianHost { host, _, error in + guard let host = host, error == nil else { completion?(false) + logAndStoreError("reconfigureVPN selectGuardianHost host error") return } - connectOrMigrateToNewNode { status in - switch status { - case .error: - logAndStoreError("reconfigureVPN connectOrMigrateToNewNode failed") - completion?(false) - case .success: - completion?(true) - } + reconfigure(with: host) { success in + completion?(success) } } + } }) } + + private static func reconfigure(with host: String, completion: ((Bool) -> Void)? = nil) { + guard let credentialString = + GRDKeychain.getPasswordString(forAccount: kKeychainStr_SubscriberCredential) else { + logAndStoreError("reconfigureVPN failed to retrieve subscriber credentials") + completion?(false) + return + } + + saveHostname(host) + + helper.createFreshUser(withSubscriberCredential: credentialString) { status, createError in + if status != .success { + logAndStoreError("reconfigureVPN createFreshUser failed") + completion?(false) + return + } + + connectOrMigrateToNewNode { status in + switch status { + case .error: + logAndStoreError("reconfigureVPN connectOrMigrateToNewNode failed") + completion?(false) + case .success: + completion?(true) + } + } + } + } /// Clears current vpn configuration and removes it from preferences. /// This method does not clear keychain items and jwt token. @@ -538,4 +563,33 @@ class BraveVPN { GRDVPNHelper.saveAll(inOneBoxHostname: hostname) GRDGatewayAPI.shared().apiHostname = hostname } + + // MARK: - Server selection + + /// Returns a list of available vpn regions to switch to. + static func requestAllServerRegions(_ completion: @escaping ([VPNRegion]?) -> Void) { + housekeepingApi.requestAllServerRegions { regionList, success in + if !success { + logAndStoreError("requestAllServerRegions call failed") + completion(nil) + return + } + + guard let regionList = regionList, + let data = try? JSONSerialization.data(withJSONObject: regionList, + options: .fragmentsAllowed) else { + logAndStoreError("failed to deserialize server regions data") + completion(nil) + return + } + + + do { + completion(try JSONDecoder().decode([VPNRegion].self, from: data)) + } catch { + logAndStoreError("Failed to decode VPNRegion JSON: \(error.localizedDescription)") + completion(nil) + } + } + } } diff --git a/Client/Frontend/BraveVPN/BraveVPNRegionPickerViewController.swift b/Client/Frontend/BraveVPN/BraveVPNRegionPickerViewController.swift new file mode 100644 index 00000000000..88fe7f81cc3 --- /dev/null +++ b/Client/Frontend/BraveVPN/BraveVPNRegionPickerViewController.swift @@ -0,0 +1,235 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import UIKit +import Shared +import BraveShared +import BraveUI +import Lottie + +class BraveVPNRegionPickerViewController: UIViewController { + private let regionList: [VPNRegion] + + private var overlayView: UIView? + private let tableView: UITableView + + private enum Section: Int, CaseIterable { + case automatic = 0 + case regionList + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// This group monitors vpn connection status. + private var dispatchGroup: DispatchGroup? + + private var isLoading: Bool = false { + didSet { + overlayView?.removeFromSuperview() + + navigationItem.hidesBackButton = isLoading + + // Prevent dismissing the modal by swipe when the VPN is being configured + if #available(iOS 13.0, *) { + navigationController?.isModalInPresentation = isLoading + } + + if !isLoading { return } + + let overlay = UIView().then { + $0.backgroundColor = UIColor.black.withAlphaComponent(0.5) + let activityIndicator = UIActivityIndicatorView().then { indicator in + indicator.startAnimating() + indicator.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + + $0.addSubview(activityIndicator) + } + + view.addSubview(overlay) + overlay.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + overlayView = overlay + } + } + + init(serverList: [VPNRegion]) { + self.regionList = serverList + .sorted { $0.namePretty < $1.namePretty } + + if #available(iOS 14, *) { + tableView = UITableView(frame: .zero, style: .insetGrouped) + } else { + tableView = UITableView(frame: .zero, style: .grouped) + } + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + title = Strings.VPN.regionPickerTitle + + tableView.delegate = self + tableView.dataSource = self + tableView.register(VPNRegionCell.self) + + NotificationCenter.default.addObserver(self, selector: #selector(vpnConfigChanged(notification:)), + name: .NEVPNStatusDidChange, object: nil) + + view.addSubview(tableView) + tableView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + @objc private func vpnConfigChanged(notification: NSNotification) { + guard let connection = notification.object as? NEVPNConnection else { return } + + if connection.status == .connected { + dispatchGroup?.leave() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.reloadData() + } +} + +// MARK: - UITableView Data Source & Delegate +extension BraveVPNRegionPickerViewController: UITableViewDelegate, UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + Section.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == Section.automatic.rawValue ? 1 : regionList.count + } + + func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + if section == Section.automatic.rawValue { + return Strings.VPN.regionPickerAutomaticDescription + } + + return nil + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(for: indexPath) as VPNRegionCell + cell.accessoryType = .none + + switch indexPath.section { + case Section.automatic.rawValue: + cell.textLabel?.text = Strings.VPN.regionPickerAutomaticModeCellText + if Preferences.VPN.vpnRegionOverride.value == nil { + cell.accessoryType = .checkmark + } + case Section.regionList.rawValue: + guard let server = regionList[safe: indexPath.row] else { return cell } + cell.textLabel?.text = server.namePretty + if server.name == Preferences.VPN.vpnRegionOverride.value { + cell.accessoryType = .checkmark + } + default: + assertionFailure("Section count out of bounds") + } + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let region = regionList[safe: indexPath.row] else { return } + + // Tapped on the same cell, do nothing + if (region.name == Preferences.VPN.vpnRegionOverride.value) + || (indexPath.section == Section.automatic.rawValue && Preferences.VPN.vpnRegionOverride.value == nil) { + return + } + + tableView.reloadData() + + isLoading = true + + if indexPath.section == Section.automatic.rawValue { + Preferences.VPN.vpnRegionOverride.value = nil + } else { + Preferences.VPN.vpnRegionOverride.value = region.name + } + + self.dispatchGroup = DispatchGroup() + + BraveVPN.reconfigureVPN() { [weak self] success in + guard let self = self else { return } + + func _showError() { + DispatchQueue.main.async { + let alert = AlertController(title: Strings.VPN.regionPickerErrorTitle, + message: Strings.VPN.regionPickerErrorMessage, + preferredStyle: .alert) + let okAction = UIAlertAction(title: Strings.OKString, style: .default) { _ in + self.dismiss(animated: true) + } + alert.addAction(okAction) + + self.present(alert, animated: true) + } + } + + if !success { + _showError() + } + + // Changing vpn server settings takes lot of time, + // and nothing we can do about it as it relies on Apple apis. + // Here we observe vpn status and we show success alert if it connected, + // otherwise an error alert is show if it did not manage to connect in 30 seconds. + self.dispatchGroup?.enter() + + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { + self.dispatchGroup = nil + _showError() + } + + self.dispatchGroup?.notify(queue: .main) { + self.dismiss(animated: true) { + self.showSuccessAlert() + } + } + } + } + + private func showSuccessAlert() { + let animation = AnimationView(name: "vpncheckmark").then { + $0.bounds = CGRect(x: 0, y: 0, width: 300, height: 200) + $0.contentMode = .scaleAspectFill + $0.play() + } + + let popup = AlertPopupView(imageView: animation, + title: Strings.VPN.regionSwitchSuccessPopupText, message: "", + titleWeight: .semibold, titleSize: 18, + dismissHandler: { true }) + + popup.showWithType(showType: .flyUp, autoDismissTime: 1.5) + } +} + +private class VPNRegionCell: UITableViewCell, TableViewReusable { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .value1, reuseIdentifier: reuseIdentifier) + } + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError() + } +} diff --git a/Client/Frontend/BraveVPN/BraveVPNSettingsViewController.swift b/Client/Frontend/BraveVPN/BraveVPNSettingsViewController.swift index d4804a32461..237f6212468 100644 --- a/Client/Frontend/BraveVPN/BraveVPNSettingsViewController.swift +++ b/Client/Frontend/BraveVPN/BraveVPNSettingsViewController.swift @@ -69,6 +69,8 @@ class BraveVPNSettingsViewController: TableViewController { } } + private var serverList = [VPNRegion]() + override func viewDidLoad() { super.viewDidLoad() @@ -76,6 +78,8 @@ class BraveVPNSettingsViewController: TableViewController { NotificationCenter.default.addObserver(self, selector: #selector(vpnConfigChanged), name: .NEVPNStatusDidChange, object: nil) + fetchRegionList() + let switchView = SwitchAccessoryView(initialValue: BraveVPN.isConnected, valueChange: { vpnOn in if vpnOn { BraveVPN.reconnect() @@ -117,8 +121,15 @@ class BraveVPNSettingsViewController: TableViewController { rows: [Row(text: Strings.VPN.settingsServerHost, detailText: hostname, uuid: hostCellId), Row(text: Strings.VPN.settingsServerLocation, detailText: location, uuid: locationCellId), + Row(text: Strings.VPN.settingsChangeLocation, + selection: { [unowned self] in + self.selectServerTapped() + }, + cellClass: ButtonCell.self), Row(text: Strings.VPN.settingsResetConfiguration, - selection: resetConfigurationTapped, + selection: { [unowned self] in + self.selectServerTapped() + }, cellClass: ButtonCell.self, uuid: resetCellId)], uuid: serverSectionId) @@ -141,6 +152,20 @@ class BraveVPNSettingsViewController: TableViewController { deinit { NotificationCenter.default.removeObserver(self) + serverListRequest?.cancel() + } + + private var serverListRequest: URLSessionDataTask? + + private func fetchRegionList() { + BraveVPN.requestAllServerRegions() { [weak self] regionList in + guard let regionList = regionList else { + log.error("Failed to fetch vpn region list") + return + } + + self?.serverList = regionList + } } private var hostname: String { @@ -234,6 +259,20 @@ class BraveVPNSettingsViewController: TableViewController { present(alert, animated: true) } + private func selectServerTapped() { + if serverList.isEmpty { + let alert = UIAlertController(title: Strings.VPN.vpnConfigGenericErrorTitle, + message: Strings.VPN.settingsFailedToFetchServerList, + preferredStyle: .alert) + alert.addAction(.init(title: Strings.OKString, style: .default)) + present(alert, animated: true) + return + } + + let vc = BraveVPNRegionPickerViewController(serverList: serverList) + navigationController?.pushViewController(vc, animated: true) + } + private func showVPNResetErrorAlert() { let alert = UIAlertController(title: Strings.VPN.resetVPNErrorTitle, message: Strings.VPN.resetVPNErrorBody, preferredStyle: .alert) diff --git a/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.h b/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.h index 2d8f86a2d69..d5ec01ce1ca 100644 --- a/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.h +++ b/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.h @@ -71,6 +71,11 @@ typedef NS_ENUM(NSInteger, GRDHousekeepingValidationMethod) { /// @param completion completion block returning an array of all hostnames and indicating request success - (void)requestAllHostnamesWithCompletion:(void (^)(NSArray * _Nullable allServers, BOOL success))completion; +/// endpoint: /api/v1/servers/all-server-regions +/// Used to retrieve all available Server Regions from housekeeping to allow users to override the selected Server Region +/// @param completion completion block returning an array contain a dictionary for each server region and a BOOL indicating a successful API call +- (void)requestAllServerRegions:(void (^)(NSArray * _Nullable items, BOOL success))completion; + @end NS_ASSUME_NONNULL_END diff --git a/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.m b/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.m index 65f30f29ed4..bbc0f3a375d 100644 --- a/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.m +++ b/Client/Frontend/BraveVPN/GRDAPI/GRDHousekeepingAPI.m @@ -397,4 +397,38 @@ - (void)requestAllHostnamesWithCompletion:(void (^)(NSArray * _Nullable, BOOL))c [task resume]; } +- (void)requestAllServerRegions:(void (^)(NSArray * _Nullable items, BOOL success))completion { + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://housekeeping.sudosecuritygroup.com/api/v1/servers/all-server-regions"]]; + [request setCachePolicy:NSURLRequestReloadIgnoringCacheData]; + + NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + if (error != nil) { + NSLog(@"Failed to get all region items: %@", error); + if (completion) completion(nil, NO); + } + + NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode]; + if (statusCode == 500) { + NSLog(@"[requestAllServerRegions] Internal server error"); + if (completion) completion(nil, NO); + return; + + } else if (statusCode == 204) { + NSLog(@"[requestAllServerRegions] came back empty"); + if (completion) completion(@[], YES); + return; + + } else if (statusCode == 200) { + NSArray *returnItems = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (completion) completion(returnItems, YES); + return; + + } else { + NSLog(@"Unknown server response: %ld", statusCode); + if (completion) completion(nil, NO); + } + }]; + [task resume]; +} + @end diff --git a/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.h b/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.h index cc1bc348d15..e79bde51f50 100644 --- a/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.h +++ b/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.h @@ -12,9 +12,14 @@ NS_ASSUME_NONNULL_BEGIN @interface GRDServerManager : NSObject -- (void)selectGuardianHostWithCompletion:(void (^)(NSString * _Nullable guardianHost, NSString * _Nullable errorMessage))completion; +- (void)bindPushToken; +- (void)selectGuardianHostWithCompletion:(void (^)(NSString * _Nullable guardianHost, NSString * _Nullable guardianHostLocation, NSString * _Nullable errorMessage))completion; - (void)getGuardianHostsWithCompletion:(void (^)(NSArray * _Nullable servers, NSString * _Nullable errorMessage))completion; - ++ (NSDictionary *)localRegionFromTimezones:(NSArray *)timezones; +- (void)findBestHostInRegion:(NSString *)regionName + completion:(void(^_Nullable)(NSString * _Nullable host, + NSString *hostLocation, + NSString * _Nullable error))block; @end NS_ASSUME_NONNULL_END diff --git a/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.m b/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.m index 7d4c15ce6db..44b41bb7029 100644 --- a/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.m +++ b/Client/Frontend/BraveVPN/GRDAPI/GRDServerManager.m @@ -3,8 +3,10 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +@import UserNotifications; #import "GRDServerManager.h" #import "NSLogDisabler.h" +#import "NSPredicate+Additions.h" @interface GRDServerManager() { GRDNetworkHealthType networkHealth; @@ -21,61 +23,34 @@ - (instancetype)init { return self; } -- (void)selectGuardianHostWithCompletion:(void (^)(NSString * _Nullable, NSString * _Nullable errorMessage))completion { +- (void)selectGuardianHostWithCompletion:(void (^)(NSString * _Nullable, NSString * _Nullable, NSString * _Nullable errorMessage))completion { [self getGuardianHostsWithCompletion:^(NSArray * _Nullable servers, NSString * _Nullable errorMessage) { if (servers == nil) { - if (completion) completion(nil, errorMessage); + if (completion) completion(nil, nil, errorMessage); return; } - // Create two mutable arrays for later use - NSMutableArray *zeroServers = [[NSMutableArray alloc] init]; - NSMutableArray *oneServers = [[NSMutableArray alloc] init]; + // The server selection logic tries to prioritize low capacity servers which is defined as + // having few clients connected. Low is defined as a capacity score of 0 or 1 + // capcaity score != connected clients. It's a calculated value based on information from each VPN node + // this predicate will filter out anything above 1 as its capacity score + NSArray *availableServers = [servers filteredArrayUsingPredicate:[NSPredicate capacityPredicate]]; - for (int i = 0; i < [servers count]; i++) { - NSDictionary *serverObj = [servers objectAtIndex:i]; - - // Seperate the available servers into capacity score of 0 and 1 - // Capacity scores over 1 are ignored entirely - if ([[serverObj objectForKey:@"capacity-score"] integerValue] == 0) { - [zeroServers addObject:serverObj]; - - } else if ([[serverObj objectForKey:@"capacity-score"] integerValue] == 1) { - [oneServers addObject:serverObj]; - } - } - - NSArray *availableServers; - // Fallback in the case that there are no servers with capacity score of 0 or 1 - if ([zeroServers count] == 0 && [oneServers count] == 0) { - // Just take the servers returned by housekeeping and send it - availableServers = [NSArray arrayWithArray:servers]; - - } else { - // If there is only 1 or 0 servers available with a 0 capacity score - // add it to the oneServers array and use it - if ([zeroServers count] <= 1) { - for (NSDictionary *zeroServer in zeroServers) { - [oneServers addObject:zeroServer]; - } - availableServers = [NSArray arrayWithArray:oneServers]; - - // If there are at least two servers with a capcity score of 0 use that array - } else if ([zeroServers count] > 1) { - availableServers = [NSArray arrayWithArray:zeroServers]; - - // If there are only servers with a capacity score of 1 use the oneServers array - } else { - availableServers = [NSArray arrayWithArray:oneServers]; - } + // if at least 2 low capacity servers are not available, just use full list instead + // helps mitigate edge case: single server returned, but it is down yet not reported as such by Housekeeping + if ([availableServers count] < 2) { + // take full list of servers returned by housekeeping and use them + availableServers = servers; + NSLog(@"[selectGuardianHostWithCompletion] less than 2 low cap servers available, so not limiting list"); } // Get a random index based on the length of availableServers // Then use that random index to select a hostname and return it to the caller NSUInteger randomIndex = arc4random_uniform((unsigned int)[availableServers count]); NSString *host = [[availableServers objectAtIndex:randomIndex] objectForKey:@"hostname"]; + NSString *hostLocation = [[availableServers objectAtIndex:randomIndex] objectForKey:@"display-name"]; NSLog(@"Selected hostname: %@", host); - if (completion) completion(host, nil); + if (completion) completion(host, hostLocation, nil); }]; } @@ -120,42 +95,19 @@ - (void)getGuardianHostsWithCompletion:(void (^)(NSArray * _Nullable, NSString * NSTimeInterval nowUnix = [[NSDate date] timeIntervalSince1970]; [defaults setObject:[NSNumber numberWithInt:nowUnix] forKey:@"housekeepingTimezonesTimestamp"]; - NSString *regionName; - BOOL regionFound = NO; + NSDictionary *region = [GRDServerManager localRegionFromTimezones:timeZones]; + NSLog(@"[DEBUG] found region: %@", region[@"name"]); + NSString *regionName = region[@"name"]; NSTimeZone *local = [NSTimeZone localTimeZone]; + NSLog(@"[DEBUG] real local time zone: %@", local); - // Loop through all time zones to match it with the current local - // one the device is currently set to - for (NSDictionary *region in timeZones) { - NSString *localRegionName = [region objectForKey:@"name"]; - NSArray *regionTimezones = [region objectForKey:@"timezones"]; - - // Loop through all time zones for the current region - for (NSString *timezone in regionTimezones) { - if ([[local name] isEqualToString:timezone]) { - regionName = localRegionName; - regionFound = YES; - break; - } - - // There are time zones like "Cupertino" in iOS which is not a - // real time zone but a pseudo time zone. Simply set the region to us-west - if ([[local name] isEqualToString:@"US/Pacific"]) { - regionName = @"us-west"; - regionFound = YES; - break; - } - } - - // Exit the outter loop - if (regionFound == YES) { - break; - } - } - +// if ([defaults boolForKey:kGuardianUseFauxTimeZone]){ +// NSLog(@"[DEBUG] using faux timezone: %@", [defaults valueForKey:kGuardianFauxTimeZone]); +// regionName = [defaults valueForKey:kGuardianFauxTimeZone]; +// } // This is only meant as a fallback to have something // when absolutely everything seems to have fallen apart - if (regionFound == NO) { + if (regionName == nil) { NSLog(@"[getGuardianHostsWithCompletion] Failed to find time zone: %@", local); NSLog(@"[getGuardianHostsWithCompletion] Setting time zone to us-east"); regionName = @"us-east"; @@ -167,6 +119,7 @@ - (void)getGuardianHostsWithCompletion:(void (^)(NSArray * _Nullable, NSString * if (completion) completion(nil, @"Failed to request list of servers."); return; } else { + //NSLog(@"servers: %@", servers); [defaults setObject:servers forKey:@"kKnownGuardianHosts"]; if (completion) completion(servers, nil); } @@ -174,4 +127,48 @@ - (void)getGuardianHostsWithCompletion:(void (^)(NSArray * _Nullable, NSString * }]; } +- (void)findBestHostInRegion:(NSString *)regionName + completion:(void(^_Nullable)(NSString * _Nullable host, + NSString *hostLocation, + NSString * _Nullable error))block { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + GRDHousekeepingAPI *housekeeping = [[GRDHousekeepingAPI alloc] init]; + [housekeeping requestServersForRegion:regionName completion:^(NSArray * _Nonnull servers, BOOL success) { + NSLog(@"[DEBUG] servers: %@", servers); + if (servers.count < 1){ + if (block){ + dispatch_async(dispatch_get_main_queue(), ^{ + block(nil, nil, NSLocalizedString(@"No server found", nil)); + }); + } + } else { + NSArray *availableServers = [servers filteredArrayUsingPredicate:[NSPredicate capacityPredicate]]; + NSLog(@"[DEBUG] availableServers: %@", availableServers); + if (availableServers.count < 2){ + NSLog(@"[DEBUG] less than 2 low capacity servers: %@", availableServers); + availableServers = servers; + } + + NSUInteger randomIndex = arc4random_uniform((unsigned int)[availableServers count]); + NSString *guardianHost = [[availableServers objectAtIndex:randomIndex] objectForKey:@"hostname"]; + NSString *guardianHostLocation = [[availableServers objectAtIndex:randomIndex] objectForKey:@"display-name"]; + NSLog(@"[DEBUG] selecting host: %@ at random index: %lu", guardianHost, randomIndex); + if(block){ + dispatch_async(dispatch_get_main_queue(), ^{ + block(guardianHost, guardianHostLocation, nil); + }); + } + } + }]; + }); +} + + + ++ (NSDictionary *)localRegionFromTimezones:(NSArray *)timezones { + NSDictionary *found = [[timezones filteredArrayUsingPredicate:[NSPredicate timezonePredicate]] lastObject]; + return found; + +} + @end diff --git a/Client/Frontend/BraveVPN/GRDAPI/NSPredicate+Additions.h b/Client/Frontend/BraveVPN/GRDAPI/NSPredicate+Additions.h new file mode 100644 index 00000000000..01d3aad38d3 --- /dev/null +++ b/Client/Frontend/BraveVPN/GRDAPI/NSPredicate+Additions.h @@ -0,0 +1,20 @@ +// +// NSPredicate+Additions.h +// Guardian +// +// Created by Kevin Bradley on 8/19/20. +// Copyright © 2020 Sudo Security Group Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSPredicate (Additions) + ++ (NSPredicate *)timezonePredicate; ++ (NSPredicate *)capacityPredicate; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Client/Frontend/BraveVPN/GRDAPI/NSPredicate+Additions.m b/Client/Frontend/BraveVPN/GRDAPI/NSPredicate+Additions.m new file mode 100644 index 00000000000..5383ed899ee --- /dev/null +++ b/Client/Frontend/BraveVPN/GRDAPI/NSPredicate+Additions.m @@ -0,0 +1,41 @@ +// +// NSPredicate+Additions.m +// Guardian +// +// Created by Kevin Bradley on 8/19/20. +// Copyright © 2020 Sudo Security Group Inc. All rights reserved. +// + +#import "NSPredicate+Additions.h" + +@implementation NSPredicate (Additions) + ++ (NSPredicate *)timezonePredicate { + NSString *local = [[NSTimeZone localTimeZone] name]; + NSPredicate *pred = [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary * _Nullable bindings) { + + NSArray *timezones = [evaluatedObject valueForKey:@"timezones"]; + if ([timezones containsObject:local]){ + //NSLog(@"evaluatedObject: %@ has %@", evaluatedObject, local); + return TRUE; + } + return FALSE; + + }]; + if ([local isEqualToString:@"US/Pacific"]){ //Cupertino + pred = [NSPredicate predicateWithFormat:@"name == %@", @"us-west"]; + } + return pred; +} + ++ (NSPredicate *)capacityPredicate { + return [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary * _Nullable bindings) { + NSInteger cs = [evaluatedObject[@"capacity-score"] integerValue]; + if (cs <= 1){ + return TRUE; + } + return FALSE; + }]; +} + +@end diff --git a/Client/Frontend/BraveVPN/VPNRegion.swift b/Client/Frontend/BraveVPN/VPNRegion.swift new file mode 100644 index 00000000000..a20b3da7063 --- /dev/null +++ b/Client/Frontend/BraveVPN/VPNRegion.swift @@ -0,0 +1,18 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation + +struct VPNRegion: Codable { + let continent: String + let name: String + let namePretty: String + + private enum CodingKeys: String, CodingKey { + case continent + case name + case namePretty = "name-pretty" + } +}