diff --git a/ios/MullvadTypes/RelayConstraints.swift b/ios/MullvadTypes/RelayConstraints.swift index 125d81b1c162..3dbb6bc16077 100644 --- a/ios/MullvadTypes/RelayConstraints.swift +++ b/ios/MullvadTypes/RelayConstraints.swift @@ -20,7 +20,7 @@ public class RelayConstraintsUpdater: ConstraintsPropagation { } } -public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible { +public struct RelayConstraints: Codable, Equatable { @available(*, deprecated, renamed: "locations") private var location: RelayConstraint = .only(.country("se")) 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.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 929180afa433..000000000000 --- a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,22 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "173f567a2dfec11d74588eea82cecea555bdc0bc", - "version" : "1.4.0" - } - }, - { - "identity" : "wireguard-apple", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mullvad/wireguard-apple.git", - "state" : { - "revision" : "15242e1698fc45261285d7417ed2cd5130d7332e" - } - } - ], - "version" : 2 -} diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift index ff69c6288788..f6f83cf89146 100644 --- a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift @@ -60,9 +60,31 @@ 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 + RelaySelection.MultihopContext.allCases.forEach { context in + switch context { + case .entry: + relayConstraints.entryLocations = self.updateRelayConstraint( + relayConstraints.entryLocations, + for: action, + in: list + ) + case .exit: + 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 +97,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..6f38e5dbc07d 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -54,10 +54,13 @@ 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 + selectedRelays: RelaySelection( + entry: tunnelManager.settings.relayConstraints.entryLocations.value, + exit: tunnelManager.settings.relayConstraints.exitLocations.value, + currentContext: .exit + ) ) locationViewControllerWrapper.delegate = self @@ -155,15 +158,19 @@ extension LocationCoordinator: RelayCacheTrackerObserver { } extension LocationCoordinator: LocationViewControllerWrapperDelegate { - func didSelectRelays(relays: UserSelectedRelays) { + func didSelectRelays(relays: (relays: UserSelectedRelays, context: RelaySelection.MultihopContext)) { var relayConstraints = tunnelManager.settings.relayConstraints - relayConstraints.exitLocations = .only(relays) + + switch relays.context { + case .entry: + relayConstraints.entryLocations = .only(relays.relays) + case .exit: + relayConstraints.exitLocations = .only(relays.relays) + } tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { self.tunnelManager.startTunnel() } - - didFinish?(self) } 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..51d6ee542544 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift @@ -69,6 +69,7 @@ class LocationCell: UITableViewCell { private var behavior: LocationCellBehavior = .select private let chevronDown = UIImage(resource: .iconChevronDown) private let chevronUp = UIImage(resource: .iconChevronUp) + private var isMultipHopSelection = false var isDisabled = false { didSet { @@ -245,7 +246,7 @@ class LocationCell: UITableViewCell { } private func statusIndicatorColor() -> UIColor { - if isDisabled { + if isDisabled && !isMultipHopSelection { return UIColor.RelayStatusIndicator.inactiveColor } else if isHighlighted { return UIColor.RelayStatusIndicator.highlightColor @@ -339,9 +340,18 @@ extension LocationCell { } } + setMultihopSelection(item.multihopContext) setBehavior(behavior) } + func setMultihopSelection(_ context: RelaySelection.MultihopContext?) { + if let context { + isMultipHopSelection = true + isDisabled = true + locationLabel.text! += " (\(context.description))" + } + } + 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..1512117a748b 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 multihopContext: RelaySelection.MultihopContext? 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..950fafdbf1cb 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift @@ -17,11 +17,15 @@ final class LocationDataSource: LocationDiffableDataSourceProtocol { private var currentSearchString = "" private var dataSources: [LocationDataSourceProtocol] = [] - private var selectedItem: LocationCellViewModel? + // The selected cell. + private var selection: LocationCellViewModel? + // When multihop is enabled, this is the "inverted" selected cell, ie. entry + // if in exit mode and exit if in entry mode. + private var multihopSelection: LocationCellViewModel? let tableView: UITableView let sections: [LocationSection] - var didSelectRelayLocations: ((UserSelectedRelays) -> Void)? + var didSelectRelayLocations: (((UserSelectedRelays, RelaySelection.MultihopContext)) -> Void)? var didTapEditCustomLists: (() -> Void)? init( @@ -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.selection, 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,21 @@ final class LocationDataSource: ], reloadExisting: true) } + func setSelectedRelays(_ selectedRelays: RelaySelection) { + let contextScopedRelays = selectedRelays.relaysFromCurrentContext + + selection = mapSelection( + from: contextScopedRelays.current, + context: selectedRelays.currentContext + ) + multihopSelection = mapMultihopSelection( + from: contextScopedRelays.inverted, + context: selectedRelays.invertedContext + ) + + tableView.reloadData() + } + func scrollToSelectedRelay() { indexPathForSelectedRelay().flatMap { tableView.scrollToRow(at: $0, at: .middle, animated: false) @@ -146,14 +167,17 @@ final class LocationDataSource: // Called from `LocationDiffableDataSourceProtocol`. func nodeShouldBeSelected(_ node: LocationNode) -> Bool { - false + false // N/A } private func indexPathForSelectedRelay() -> IndexPath? { - selectedItem.flatMap { indexPath(for: $0) } + selection.flatMap { indexPath(for: $0) } } - private func mapSelectedItem(from selectedRelays: UserSelectedRelays?) { + private func mapSelection( + from selectedRelays: UserSelectedRelays?, + context: RelaySelection.MultihopContext + ) -> LocationCellViewModel? { let allLocationsDataSource = dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource @@ -165,55 +189,76 @@ 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 + indentationLevel: selectedNode.hierarchyLevel, + multihopContext: context ) // 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 + indentationLevel: selectedNode.hierarchyLevel, + multihopContext: context ) } } + + return nil } - private func setSelectedItem(_ item: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil) { - selectedItem = item - guard let selectedItem else { return } + private func mapMultihopSelection( + from selectedRelays: UserSelectedRelays?, + context: RelaySelection.MultihopContext + ) -> 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, context: context) + } - let rootNode = selectedItem.node.root + private func updateSelection(_ item: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil) { + // Reapply multihop context. Context only exists for selected items. + let context = selection?.multihopContext + selection = item + selection?.multihopContext = context + + guard let selection else { return } + + let rootNode = selection.node.root // Exit early if no changes to the node tree should be made. - guard selectedItem.node != rootNode else { + guard selection.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: selection.section, node: rootNode )) else { return } // Walk tree backwards to determine which nodes should be expanded. - selectedItem.node.forEachAncestor { node in + selection.node.forEachAncestor { node in node.showsChildren = true } // Construct node tree. let nodesToAdd = recursivelyCreateCellViewModelTree( for: rootNode, - in: selectedItem.section, + in: selection.section, indentationLevel: 1 ) // Insert the new node tree below the selected item. - var snapshotItems = snapshot().itemIdentifiers(inSection: selectedItem.section) + var snapshotItems = snapshot().itemIdentifiers(inSection: selection.section) snapshotItems.insert(contentsOf: nodesToAdd, at: indexPath.row + 1) let list = sections.enumerated().map { index, section in @@ -233,6 +278,11 @@ 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 + + if itemIdentifier(for: indexPath)?.node == multihopSelection?.node { + cell.setMultihopSelection(multihopSelection?.multihopContext) + } + cell.delegate = self return cell } @@ -271,7 +321,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 != multihopSelection?.node) && item.node.isActive } func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { @@ -279,15 +330,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 == selection { + 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 +347,11 @@ extension LocationDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = itemIdentifier(for: indexPath) else { return } - selectedItem = item + + // Reapply multihop context. Context only exists for selected items. + let context = selection?.multihopContext + selection = item + selection?.multihopContext = context var customListSelection: UserSelectedRelays.CustomListSelection? if let topmostNode = item.node.root as? CustomListLocationNode { @@ -310,7 +366,9 @@ extension LocationDataSource: UITableViewDelegate { customListSelection: customListSelection ) - didSelectRelayLocations?(relayLocations) + if let context { + didSelectRelayLocations?((relayLocations, context)) + } } private func scrollToTop(animated: Bool) { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index 38188122ffa6..210a281a155a 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift @@ -13,7 +13,7 @@ import UIKit protocol LocationViewControllerDelegate: AnyObject { func navigateToCustomLists(nodes: [LocationNode]) - func didSelectRelays(relays: UserSelectedRelays) + func didSelectRelays(relays: (relays: UserSelectedRelays, context: RelaySelection.MultihopContext)) func didUpdateFilter(filter: RelayFilter) } @@ -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..96a8f87b058a 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift @@ -14,25 +14,41 @@ import UIKit protocol LocationViewControllerWrapperDelegate: AnyObject { func navigateToCustomLists(nodes: [LocationNode]) func navigateToFilter() - func didSelectRelays(relays: UserSelectedRelays) + func didSelectRelays(relays: (relays: UserSelectedRelays, context: RelaySelection.MultihopContext)) func didUpdateFilter(filter: RelayFilter) } final class LocationViewControllerWrapper: UIViewController { - private let locationViewController: LocationViewController + enum SegmentedControlOption: Int { + case entry, exit + } + + private let entryLocationViewController: LocationViewController? + private let exitLocationViewController: LocationViewController private let segmentedControl = UISegmentedControl() + private let locationViewContainer = UIStackView() + private var selectedRelays: RelaySelection weak var delegate: LocationViewControllerWrapperDelegate? - init(customListRepository: CustomListRepositoryProtocol, selectedRelays: UserSelectedRelays?) { - locationViewController = LocationViewController( + init(customListRepository: CustomListRepositoryProtocol, selectedRelays: RelaySelection) { + entryLocationViewController = LocationViewController( customListRepository: customListRepository, selectedRelays: selectedRelays ) + exitLocationViewController = LocationViewController( + customListRepository: customListRepository, + selectedRelays: selectedRelays + ) + + self.selectedRelays = selectedRelays + super.init(nibName: nil, bundle: nil) - locationViewController.delegate = self + updateViewControllers { + $0.delegate = self + } } var didFinish: (() -> Void)? @@ -50,14 +66,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() { @@ -105,39 +132,70 @@ final class LocationViewControllerWrapper: UIViewController { tableName: "SelectLocation", value: "Entry", comment: "" - ), at: 0, animated: false) + ), at: SegmentedControlOption.entry.rawValue, animated: false) segmentedControl.insertSegment(withTitle: NSLocalizedString( "MULTIHOP_TAB_EXIT", tableName: "SelectLocation", value: "Exit", comment: "" - ), at: 1, animated: false) + ), at: SegmentedControlOption.exit.rawValue, animated: false) - segmentedControl.selectedSegmentIndex = 0 + segmentedControl.selectedSegmentIndex = selectedRelays.currentContext.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 SegmentedControlOption.entry.rawValue: + selectedRelays.currentContext = .entry + case SegmentedControlOption.exit.rawValue: + selectedRelays.currentContext = .exit + default: + break + } + + swapViewController() + } + + func swapViewController() { + locationViewContainer.arrangedSubviews.forEach { view in + view.removeFromSuperview() + } + + var currentViewController: LocationViewController? + + switch selectedRelays.currentContext { + case .entry: + exitLocationViewController.removeFromParent() + currentViewController = entryLocationViewController + case .exit: + entryLocationViewController?.removeFromParent() + currentViewController = exitLocationViewController + } + + guard let currentViewController else { return } + + currentViewController.setSelectedRelays(selectedRelays) + addChild(currentViewController) + currentViewController.didMove(toParent: self) + + locationViewContainer.addArrangedSubview(currentViewController.view) } } @@ -146,8 +204,19 @@ extension LocationViewControllerWrapper: LocationViewControllerDelegate { delegate?.navigateToCustomLists(nodes: nodes) } - func didSelectRelays(relays: UserSelectedRelays) { - delegate?.didSelectRelays(relays: relays) + func didSelectRelays(relays: (relays: UserSelectedRelays, context: RelaySelection.MultihopContext)) { + switch relays.context { + case .entry: + selectedRelays.entry = relays.relays + delegate?.didSelectRelays(relays: relays) + + // Trigger change in segmented control, which in turn triggers view controller swap. + segmentedControl.selectedSegmentIndex = SegmentedControlOption.exit.rawValue + segmentedControl.sendActions(for: .valueChanged) + case .exit: + delegate?.didSelectRelays(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..ba2537c5b779 --- /dev/null +++ b/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift @@ -0,0 +1,56 @@ +// +// RelaySelection.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-04-29. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes + +struct RelaySelection { + enum MultihopContext: Int, CaseIterable, CustomStringConvertible { + case entry, exit + + var description: String { + switch self { + case .entry: + NSLocalizedString( + "MULTIHOP_ENTRY", + tableName: "SelectLocation", + value: "Entry", + comment: "" + ) + case .exit: + NSLocalizedString( + "MULTIHOP_EXIT", + tableName: "SelectLocation", + value: "Exit", + comment: "" + ) + } + } + } + + var entry: UserSelectedRelays? + var exit: UserSelectedRelays? + var currentContext: MultihopContext + + var invertedContext: MultihopContext { + switch currentContext { + case .entry: + .exit + case .exit: + .entry + } + } + + var relaysFromCurrentContext: (current: UserSelectedRelays?, inverted: UserSelectedRelays?) { + switch currentContext { + case .entry: + (current: entry, inverted: exit) + case .exit: + (current: exit, inverted: entry) + } + } +} diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift index 08e9c129fe92..9a430aaa92c9 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift @@ -177,6 +177,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { multihopState: multihopState ) + let multihopState = try? settingsReader.read().multihopState + + multihopStateListener.onNewMultihop?(multihopState ?? .off) + + let shadowsocksRelaySelector = ShadowsocksRelaySelector( + relayCache: ipOverrideWrapper, + multihopUpdater: multihopUpdater + ) + let transportStrategy = TransportStrategy( datasource: AccessMethodRepository(), shadowsocksLoader: ShadowsocksLoader(