diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 9de33c960a09..20838b07a0c0 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -491,6 +491,7 @@ 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353902AAA014400F0A71C /* SimulatorVPNConnection.swift */; }; 7A3353932AAA089000F0A71C /* SimulatorTunnelInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353922AAA089000F0A71C /* SimulatorTunnelInfo.swift */; }; 7A3353972AAA0F8600F0A71C /* OperationBlockObserverSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */; }; + 7A3EFAAB2BDFDAE800318736 /* RelaySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3EFAAA2BDFDAE800318736 /* RelaySelection.swift */; }; 7A3FD1B52AD4465A0042BEA6 /* AppMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */; }; 7A3FD1B72AD54ABD0042BEA6 /* AnyTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB982A98F4ED00F578F2 /* AnyTransport.swift */; }; 7A3FD1B82AD54AE60042BEA6 /* TimeServerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */; }; @@ -1841,6 +1842,7 @@ 7A3353902AAA014400F0A71C /* SimulatorVPNConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorVPNConnection.swift; sourceTree = ""; }; 7A3353922AAA089000F0A71C /* SimulatorTunnelInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorTunnelInfo.swift; sourceTree = ""; }; 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationBlockObserverSupport.swift; sourceTree = ""; }; + 7A3EFAAA2BDFDAE800318736 /* RelaySelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelection.swift; sourceTree = ""; }; 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandlerTests.swift; sourceTree = ""; }; 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = ""; }; 7A45CFC22C05FF2F00D80B21 /* ScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotTests.swift; sourceTree = ""; }; @@ -2768,6 +2770,7 @@ F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */, 5888AD86227B17950051EB06 /* LocationViewController.swift */, 7AB3BEB42BD7A6CB00E34384 /* LocationViewControllerWrapper.swift */, + 7A3EFAAA2BDFDAE800318736 /* RelaySelection.swift */, ); path = SelectLocation; sourceTree = ""; @@ -5774,6 +5777,7 @@ 5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */, 7A28826D2BAAC9DE00FD9F20 /* IPOverrideHeaderView.swift in Sources */, A98502032B627B120061901E /* LocalNetworkProbe.swift in Sources */, + 7A3EFAAB2BDFDAE800318736 /* RelaySelection.swift in Sources */, 583FE01029C0F532006E85F9 /* CustomSplitViewController.swift in Sources */, 7A6F2FA92AFD0842006D0856 /* CustomDNSDataSource.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift index ff69c6288788..1bd748f75e56 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift @@ -60,9 +60,25 @@ class ListCustomListCoordinator: Coordinator, Presentable, Presenting { coordinator.didFinish = { [weak self] editCustomListCoordinator, action, list in guard let self else { return } + popToList() editCustomListCoordinator.removeFromParent() - self.updateRelayConstraints(for: action, in: list) + + var relayConstraints = tunnelManager.settings.relayConstraints + relayConstraints.entryLocations = self.updateRelayConstraint( + relayConstraints.entryLocations, + for: action, + in: list + ) + relayConstraints.exitLocations = self.updateRelayConstraint( + relayConstraints.exitLocations, + for: action, + in: list + ) + + tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { [weak self] in + self?.tunnelManager.reconnectTunnel(selectNewRelay: true) + } } coordinator.didCancel = { [weak self] editCustomListCoordinator in @@ -75,38 +91,39 @@ class ListCustomListCoordinator: Coordinator, Presentable, Presenting { addChild(coordinator) } - private func updateRelayConstraints(for action: EditCustomListCoordinator.FinishAction, in list: CustomList) { - var relayConstraints = tunnelManager.settings.relayConstraints + private func updateRelayConstraint( + _ relayConstraint: RelayConstraint, + for action: EditCustomListCoordinator.FinishAction, + in list: CustomList + ) -> RelayConstraint { + var relayConstraint = relayConstraint - guard let customListSelection = relayConstraints.exitLocations.value?.customListSelection, + guard let customListSelection = relayConstraint.value?.customListSelection, customListSelection.listId == list.id - else { return } + else { return relayConstraint } switch action { case .save: - // TODO: - Add entry locations if customListSelection.isList { let selectedRelays = UserSelectedRelays( locations: list.locations, customListSelection: UserSelectedRelays.CustomListSelection(listId: list.id, isList: true) ) - relayConstraints.exitLocations = .only(selectedRelays) + relayConstraint = .only(selectedRelays) } else { let selectedConstraintIsRemovedFromList = list.locations.filter { - relayConstraints.exitLocations.value?.locations.contains($0) ?? false + relayConstraint.value?.locations.contains($0) ?? false }.isEmpty if selectedConstraintIsRemovedFromList { - relayConstraints.exitLocations = .only(UserSelectedRelays(locations: [])) + relayConstraint = .only(UserSelectedRelays(locations: [])) } } case .delete: - relayConstraints.exitLocations = .only(UserSelectedRelays(locations: [])) + relayConstraint = .only(UserSelectedRelays(locations: [])) } - tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { [weak self] in - self?.tunnelManager.reconnectTunnel(selectNewRelay: true) - } + return relayConstraint } private func popToList() { diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift index 38b9ced8cd22..2fbd848e98a5 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -54,10 +54,9 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { } func start() { - // TODO: - the location should be defined whether it's Entry or Exit location let locationViewControllerWrapper = LocationViewControllerWrapper( customListRepository: customListRepository, - selectedRelays: tunnelManager.settings.relayConstraints.exitLocations.value + constraints: tunnelManager.settings.relayConstraints ) locationViewControllerWrapper.delegate = self @@ -155,15 +154,22 @@ extension LocationCoordinator: RelayCacheTrackerObserver { } extension LocationCoordinator: LocationViewControllerWrapperDelegate { - func didSelectRelays(relays: UserSelectedRelays) { + func didSelectEntryRelays(relays: UserSelectedRelays) { var relayConstraints = tunnelManager.settings.relayConstraints - relayConstraints.exitLocations = .only(relays) + relayConstraints.entryLocations = .only(relays) tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { self.tunnelManager.startTunnel() } + } + + func didSelectExitRelays(relays: UserSelectedRelays) { + var relayConstraints = tunnelManager.settings.relayConstraints + relayConstraints.exitLocations = .only(relays) - didFinish?(self) + tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { + self.tunnelManager.startTunnel() + } } func didUpdateFilter(filter: RelayFilter) { diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift index b702e2303cdf..3310fd69dcad 100644 --- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift +++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift @@ -137,7 +137,7 @@ extension UIColor { } enum SegmentedControl { - static let backgroundColor = UIColor(red: 0.18, green: 0.33, blue: 0.49, alpha: 1.0) + static let backgroundColor = UIColor(red: 0.14, green: 0.25, blue: 0.38, alpha: 1.0) static let selectedColor = successColor } diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift index 5a952b146b43..60a752032206 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift @@ -94,7 +94,7 @@ class RelayFilterView: UIView { contentContainer.spacing = UIMetrics.FilterView.labelSpacing addConstrainedSubviews([contentContainer]) { - contentContainer.pinEdges(.init([.top(4), .bottom(0)]), to: self) + contentContainer.pinEdges(.init([.top(7), .bottom(0)]), to: self) contentContainer.pinEdges(.init([.leading(4), .trailing(4)]), to: layoutMarginsGuide) } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift index 4f2f5ab62ce8..549ebbec1735 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift @@ -72,7 +72,7 @@ class LocationCell: UITableViewCell { var isDisabled = false { didSet { - updateDisabled() + updateDisabled(isDisabled) updateBackgroundColor() updateStatusIndicatorColor() } @@ -150,7 +150,7 @@ class LocationCell: UITableViewCell { updateCollapseImage() updateAccessibilityCustomActions() - updateDisabled() + updateDisabled(isDisabled) updateBackgroundColor() setLayoutMargins() @@ -207,7 +207,7 @@ class LocationCell: UITableViewCell { statusIndicator.backgroundColor = statusIndicatorColor() } - private func updateDisabled() { + private func updateDisabled(_ isDisabled: Bool) { locationLabel.alpha = isDisabled ? 0.2 : 1 collapseButton.alpha = isDisabled ? 0.2 : 1 @@ -339,9 +339,17 @@ extension LocationCell { } } + setExcludedRelayTitle(item.excludedRelayTitle) setBehavior(behavior) } + func setExcludedRelayTitle(_ title: String?) { + if let title { + locationLabel.text! += " (\(title))" + updateDisabled(true) + } + } + private func setBehavior(_ newBehavior: LocationCellBehavior) { self.behavior = newBehavior updateLeadingImage() diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift index 14b7745efd27..cb9febdfc36d 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift @@ -13,21 +13,20 @@ struct LocationCellViewModel: Hashable { let node: LocationNode var indentationLevel = 0 var isSelected = false + var excludedRelayTitle: String? func hash(into hasher: inout Hasher) { hasher.combine(node) hasher.combine(node.children.count) hasher.combine(section) hasher.combine(isSelected) - hasher.combine(indentationLevel) } static func == (lhs: Self, rhs: Self) -> Bool { lhs.node == rhs.node && lhs.node.children.count == rhs.node.children.count && lhs.section == rhs.section && - lhs.isSelected == rhs.isSelected && - lhs.indentationLevel == rhs.indentationLevel + lhs.isSelected == rhs.isSelected } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift index 3aa69604e494..dd311b71c0df 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift @@ -17,7 +17,11 @@ final class LocationDataSource: LocationDiffableDataSourceProtocol { private var currentSearchString = "" private var dataSources: [LocationDataSourceProtocol] = [] - private var selectedItem: LocationCellViewModel? + // The selected location. + private var selectedLocation: LocationCellViewModel? + // When multihop is enabled, this is the "inverted" selected location, ie. entry + // if in exit mode and exit if in entry mode. + private var excludedLocation: LocationCellViewModel? let tableView: UITableView let sections: [LocationSection] @@ -50,7 +54,7 @@ final class LocationDataSource: defaultRowAnimation = .fade } - func setRelays(_ response: REST.ServerRelaysResponse, selectedRelays: UserSelectedRelays?, filter: RelayFilter) { + func setRelays(_ response: REST.ServerRelaysResponse, selectedRelays: RelaySelection, filter: RelayFilter) { let allLocationsDataSource = dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource @@ -64,7 +68,7 @@ final class LocationDataSource: allLocationsDataSource?.reload(response, relays: relays) customListsDataSource?.reload(allLocationNodes: allLocationsDataSource?.nodes ?? []) - mapSelectedItem(from: selectedRelays) + setSelectedRelays(selectedRelays) filterRelays(by: currentSearchString) } @@ -81,8 +85,10 @@ final class LocationDataSource: } updateDataSnapshot(with: list, reloadExisting: !searchString.isEmpty) { + self.tableView.reloadData() + if searchString.isEmpty { - self.setSelectedItem(self.selectedItem, animated: false, completion: { + self.updateSelection(self.selectedLocation, animated: false, completion: { self.scrollToSelectedRelay() }) } else { @@ -92,7 +98,7 @@ final class LocationDataSource: } /// Refreshes the custom list section and keeps all modifications intact (selection and expanded states). - func refreshCustomLists(selectedRelays: UserSelectedRelays?) { + func refreshCustomLists(selectedRelays: RelaySelection) { guard let allLocationsDataSource = dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource, let customListsDataSource = @@ -110,7 +116,7 @@ final class LocationDataSource: customListsDataSource.reload(allLocationNodes: allLocationsDataSource.nodes) // Reapply current selection. - mapSelectedItem(from: selectedRelays) + setSelectedRelays(selectedRelays) // Reapply current search filter. let searchResultNodes = dataSources[0].search(by: currentSearchString) @@ -133,6 +139,14 @@ final class LocationDataSource: ], reloadExisting: true) } + func setSelectedRelays(_ selectedRelays: RelaySelection) { + selectedLocation = mapSelection(from: selectedRelays.selected) + excludedLocation = mapMultihopSelection(from: selectedRelays.excluded) + excludedLocation?.excludedRelayTitle = selectedRelays.excludedTitle + + tableView.reloadData() + } + func scrollToSelectedRelay() { indexPathForSelectedRelay().flatMap { tableView.scrollToRow(at: $0, at: .middle, animated: false) @@ -146,14 +160,14 @@ final class LocationDataSource: // Called from `LocationDiffableDataSourceProtocol`. func nodeShouldBeSelected(_ node: LocationNode) -> Bool { - false + false // N/A } private func indexPathForSelectedRelay() -> IndexPath? { - selectedItem.flatMap { indexPath(for: $0) } + selectedLocation.flatMap { indexPath(for: $0) } } - private func mapSelectedItem(from selectedRelays: UserSelectedRelays?) { + private func mapSelection(from selectedRelays: UserSelectedRelays?) -> LocationCellViewModel? { let allLocationsDataSource = dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource @@ -165,7 +179,7 @@ final class LocationDataSource: if let customListSelection = selectedRelays.customListSelection, let customList = customListsDataSource?.customList(by: customListSelection.listId), let selectedNode = customListsDataSource?.node(by: selectedRelays, for: customList) { - selectedItem = LocationCellViewModel( + return LocationCellViewModel( section: .customLists, node: selectedNode, indentationLevel: selectedNode.hierarchyLevel @@ -173,47 +187,59 @@ final class LocationDataSource: // Look for a matching all locations node. } else if let location = selectedRelays.locations.first, let selectedNode = allLocationsDataSource?.node(by: location) { - selectedItem = LocationCellViewModel( + return LocationCellViewModel( section: .allLocations, node: selectedNode, indentationLevel: selectedNode.hierarchyLevel ) } } + + return nil + } + + private func mapMultihopSelection(from selectedRelays: UserSelectedRelays?) -> LocationCellViewModel? { + // Multihop selection doesn't apply to anything but single hosts. + guard + selectedRelays?.locations.count == 1, + case .hostname = selectedRelays?.locations.first + else { return nil } + + return mapSelection(from: selectedRelays) } - private func setSelectedItem(_ item: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil) { - selectedItem = item - guard let selectedItem else { return } + private func updateSelection(_ item: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil) { + selectedLocation = item + guard let selectedLocation else { return } - let rootNode = selectedItem.node.root + let rootNode = selectedLocation.node.root // Exit early if no changes to the node tree should be made. - guard selectedItem.node != rootNode else { + guard selectedLocation.node != rootNode else { completion?() return } // Make sure we have an index path for the selected item. guard let indexPath = indexPath(for: LocationCellViewModel( - section: selectedItem.section, + section: selectedLocation.section, node: rootNode )) else { return } // Walk tree backwards to determine which nodes should be expanded. - selectedItem.node.forEachAncestor { node in + selectedLocation.node.forEachAncestor { node in node.showsChildren = true } // Construct node tree. let nodesToAdd = recursivelyCreateCellViewModelTree( for: rootNode, - in: selectedItem.section, + in: selectedLocation.section, indentationLevel: 1 ) // Insert the new node tree below the selected item. - var snapshotItems = snapshot().itemIdentifiers(inSection: selectedItem.section) + var snapshotItems = snapshot().itemIdentifiers(inSection: selectedLocation.section) snapshotItems.insert(contentsOf: nodesToAdd, at: indexPath.row + 1) let list = sections.enumerated().map { index, section in @@ -233,6 +259,13 @@ final class LocationDataSource: override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // swiftlint:disable:next force_cast let cell = super.tableView(tableView, cellForRowAt: indexPath) as! LocationCell + + // Compare nodes on name rather than whole node in order to match relays in both .customLists + // and .allLocations. + if itemIdentifier(for: indexPath)?.node.name == excludedLocation?.node.name { + cell.setExcludedRelayTitle(excludedLocation?.excludedRelayTitle) + } + cell.delegate = self return cell } @@ -271,7 +304,8 @@ extension LocationDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - itemIdentifier(for: indexPath)?.node.isActive ?? false + guard let item = itemIdentifier(for: indexPath) else { return false } + return (item.node != excludedLocation?.node) && item.node.isActive } func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { @@ -279,15 +313,16 @@ extension LocationDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let item = itemIdentifier(for: indexPath), item == selectedItem { - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + if let item = itemIdentifier(for: indexPath), item == selectedLocation { + cell.setSelected(true, animated: false) } } func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if let indexPath = indexPathForSelectedRelay() { - tableView.deselectRow(at: indexPath, animated: false) - selectedItem = nil + if let cell = tableView.cellForRow(at: indexPath) { + cell.setSelected(false, animated: false) + } } return indexPath @@ -295,7 +330,7 @@ extension LocationDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = itemIdentifier(for: indexPath) else { return } - selectedItem = item + selectedLocation = item var customListSelection: UserSelectedRelays.CustomListSelection? if let topmostNode = item.node.root as? CustomListLocationNode { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index 38188122ffa6..4cdfc7144696 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift @@ -25,7 +25,7 @@ final class LocationViewController: UIViewController { private var dataSource: LocationDataSource? private var cachedRelays: CachedRelays? private var filter = RelayFilter() - private var selectedRelays: UserSelectedRelays? + private var selectedRelays: RelaySelection weak var delegate: LocationViewControllerDelegate? var customListRepository: CustomListRepositoryProtocol @@ -37,9 +37,10 @@ final class LocationViewController: UIViewController { return (filter.ownership == .any) && (filter.providers == .any) } - init(customListRepository: CustomListRepositoryProtocol, selectedRelays: UserSelectedRelays?) { + init(customListRepository: CustomListRepositoryProtocol, selectedRelays: RelaySelection) { self.customListRepository = customListRepository self.selectedRelays = selectedRelays + super.init(nibName: nil, bundle: nil) } @@ -55,7 +56,7 @@ final class LocationViewController: UIViewController { view.accessibilityIdentifier = .selectLocationView view.backgroundColor = .secondaryColor - setUpDataSources() + setUpDataSource() setUpTableView() setUpTopContent() @@ -97,17 +98,22 @@ final class LocationViewController: UIViewController { dataSource?.refreshCustomLists(selectedRelays: selectedRelays) } + func setSelectedRelays(_ selectedRelays: RelaySelection) { + self.selectedRelays = selectedRelays + dataSource?.setSelectedRelays(selectedRelays) + } + // MARK: - Private - private func setUpDataSources() { + private func setUpDataSource() { dataSource = LocationDataSource( tableView: tableView, allLocations: AllLocationDataSource(), customLists: CustomListsDataSource(repository: customListRepository) ) - dataSource?.didSelectRelayLocations = { [weak self] locations in - self?.delegate?.didSelectRelays(relays: locations) + dataSource?.didSelectRelayLocations = { [weak self] relays in + self?.delegate?.didSelectRelays(relays: relays) } dataSource?.didTapEditCustomLists = { [weak self] in diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift index 333d965f8734..9cc2fdcde33f 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift @@ -14,25 +14,60 @@ import UIKit protocol LocationViewControllerWrapperDelegate: AnyObject { func navigateToCustomLists(nodes: [LocationNode]) func navigateToFilter() - func didSelectRelays(relays: UserSelectedRelays) + func didSelectEntryRelays(relays: UserSelectedRelays) + func didSelectExitRelays(relays: UserSelectedRelays) func didUpdateFilter(filter: RelayFilter) } final class LocationViewControllerWrapper: UIViewController { - private let locationViewController: LocationViewController + enum MultihopContext: Int { + case entry, exit + } + + private let entryLocationViewController: LocationViewController? + private let exitLocationViewController: LocationViewController private let segmentedControl = UISegmentedControl() + private let locationViewContainer = UIStackView() + + private var selectedEntry: UserSelectedRelays? + private var selectedExit: UserSelectedRelays? + private var multihopContext: MultihopContext = .exit + + private let entryTitle = NSLocalizedString( + "MULTIHOP_ENTRY", + tableName: "SelectLocation", + value: "Entry", + comment: "" + ) + + private let exitTitle = NSLocalizedString( + "MULTIHOP_EXIT", + tableName: "SelectLocation", + value: "Exit", + comment: "" + ) weak var delegate: LocationViewControllerWrapperDelegate? - init(customListRepository: CustomListRepositoryProtocol, selectedRelays: UserSelectedRelays?) { - locationViewController = LocationViewController( + init(customListRepository: CustomListRepositoryProtocol, constraints: RelayConstraints) { + selectedEntry = constraints.entryLocations.value + selectedExit = constraints.exitLocations.value + + entryLocationViewController = LocationViewController( + customListRepository: customListRepository, + selectedRelays: RelaySelection(selected: selectedEntry, excluded: selectedExit, excludedTitle: exitTitle) + ) + + exitLocationViewController = LocationViewController( customListRepository: customListRepository, - selectedRelays: selectedRelays + selectedRelays: RelaySelection(selected: selectedExit, excluded: selectedEntry, excludedTitle: entryTitle) ) super.init(nibName: nil, bundle: nil) - locationViewController.delegate = self + updateViewControllers { + $0.delegate = self + } } var didFinish: (() -> Void)? @@ -50,14 +85,25 @@ final class LocationViewControllerWrapper: UIViewController { setUpNavigation() setUpSegmentedControl() addSubviews() + swapViewController() } func setCachedRelays(_ cachedRelays: CachedRelays, filter: RelayFilter) { - locationViewController.setCachedRelays(cachedRelays, filter: filter) + updateViewControllers { + $0.setCachedRelays(cachedRelays, filter: filter) + } } func refreshCustomLists() { - locationViewController.refreshCustomLists() + updateViewControllers { + $0.refreshCustomLists() + } + } + + private func updateViewControllers(callback: (LocationViewController) -> Void) { + [entryLocationViewController, exitLocationViewController] + .compactMap { $0 } + .forEach { callback($0) } } private func setUpNavigation() { @@ -100,44 +146,70 @@ final class LocationViewControllerWrapper: UIViewController { .font: UIFont.systemFont(ofSize: 17, weight: .medium), ], for: .normal) - segmentedControl.insertSegment(withTitle: NSLocalizedString( - "MULTIHOP_TAB_ENTRY", - tableName: "SelectLocation", - value: "Entry", - comment: "" - ), at: 0, animated: false) - segmentedControl.insertSegment(withTitle: NSLocalizedString( - "MULTIHOP_TAB_EXIT", - tableName: "SelectLocation", - value: "Exit", - comment: "" - ), at: 1, animated: false) + segmentedControl.insertSegment(withTitle: entryTitle, at: MultihopContext.entry.rawValue, animated: false) + segmentedControl.insertSegment(withTitle: exitTitle, at: MultihopContext.exit.rawValue, animated: false) - segmentedControl.selectedSegmentIndex = 0 + segmentedControl.selectedSegmentIndex = multihopContext.rawValue segmentedControl.addTarget(self, action: #selector(segmentedControlDidChange), for: .valueChanged) } private func addSubviews() { - addChild(locationViewController) - locationViewController.didMove(toParent: self) - - view.addConstrainedSubviews([segmentedControl, locationViewController.view]) { + view.addConstrainedSubviews([segmentedControl, locationViewContainer]) { segmentedControl.heightAnchor.constraint(equalToConstant: 44) segmentedControl.pinEdgesToSuperviewMargins(PinnableEdges([.top(0), .leading(8), .trailing(8)])) - locationViewController.view.pinEdgesToSuperview(.all().excluding(.top)) + locationViewContainer.pinEdgesToSuperview(.all().excluding(.top)) #if DEBUG - locationViewController.view.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 4) + locationViewContainer.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 4) #else - locationViewController.view.pinEdgeToSuperviewMargin(.top(0)) + locationViewContainer.pinEdgeToSuperviewMargin(.top(0)) #endif } } @objc private func segmentedControlDidChange(sender: UISegmentedControl) { - refreshCustomLists() + switch segmentedControl.selectedSegmentIndex { + case MultihopContext.entry.rawValue: + multihopContext = .entry + case MultihopContext.exit.rawValue: + multihopContext = .exit + default: + break + } + + swapViewController() + } + + func swapViewController() { + locationViewContainer.arrangedSubviews.forEach { view in + view.removeFromSuperview() + } + + var currentViewController: LocationViewController? + var selectedRelays: RelaySelection + + switch multihopContext { + case .entry: + selectedRelays = RelaySelection(selected: selectedEntry, excluded: selectedExit, excludedTitle: exitTitle) + + exitLocationViewController.removeFromParent() + currentViewController = entryLocationViewController + case .exit: + selectedRelays = RelaySelection(selected: selectedExit, excluded: selectedEntry, excludedTitle: entryTitle) + + entryLocationViewController?.removeFromParent() + currentViewController = exitLocationViewController + } + + guard let currentViewController else { return } + + currentViewController.setSelectedRelays(selectedRelays) + addChild(currentViewController) + currentViewController.didMove(toParent: self) + + locationViewContainer.addArrangedSubview(currentViewController.view) } } @@ -147,7 +219,18 @@ extension LocationViewControllerWrapper: LocationViewControllerDelegate { } func didSelectRelays(relays: UserSelectedRelays) { - delegate?.didSelectRelays(relays: relays) + switch multihopContext { + case .entry: + selectedEntry = relays + delegate?.didSelectEntryRelays(relays: relays) + + // Trigger change in segmented control, which in turn triggers view controller swap. + segmentedControl.selectedSegmentIndex = MultihopContext.exit.rawValue + segmentedControl.sendActions(for: .valueChanged) + case .exit: + delegate?.didSelectExitRelays(relays: relays) + didFinish?() + } } func didUpdateFilter(filter: RelayFilter) { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift b/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift new file mode 100644 index 000000000000..85b185a574c2 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift @@ -0,0 +1,15 @@ +// +// RelaySelection.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-04-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes + +struct RelaySelection { + var selected: UserSelectedRelays? + var excluded: UserSelectedRelays? + var excludedTitle: String? +}