// // LocationViewControllerWrapper.swift // MullvadVPN // // Created by Jon Petersson on 2024-04-23. // Copyright © 2025 Mullvad VPN AB. All rights reserved. // import MullvadREST import MullvadSettings import MullvadTypes import SwiftUI import UIKit protocol LocationViewControllerWrapperDelegate: AnyObject { func navigateToCustomLists(nodes: [LocationNode]) func navigateToFilter() func navigateToDaitaSettings() func didSelectEntryRelays(_ relays: UserSelectedRelays) func didSelectExitRelays(_ relays: UserSelectedRelays) func didUpdateFilter(_ filter: RelayFilter) } final class LocationViewControllerWrapper: UIViewController { enum MultihopContext: Int, CaseIterable, CustomStringConvertible { case entry, exit var description: String { switch self { case .entry: NSLocalizedString("Entry", comment: "") case .exit: NSLocalizedString("Exit", comment: "") } } } private var entryLocationViewController: LocationViewController? private let exitLocationViewController: LocationViewController private var segmentedControlView: UIView! private let locationViewContainer = UIView() private var segmentedViewModel = SegmentedControlViewModel() private var settings: LatestTunnelSettings private var relaySelectorWrapper: RelaySelectorWrapper private var multihopContext: MultihopContext = .exit private var selectedEntry: UserSelectedRelays? private var selectedExit: UserSelectedRelays? weak var delegate: LocationViewControllerWrapperDelegate? var onNewSettings: ((LatestTunnelSettings) -> Void)? private var relayFilter: RelayFilter { settings.relayConstraints.filter.value ?? RelayFilter() } init( settings: LatestTunnelSettings, relaySelectorWrapper: RelaySelectorWrapper, customListRepository: CustomListRepositoryProtocol, startContext: MultihopContext ) { self.selectedEntry = settings.relayConstraints.entryLocations.value self.selectedExit = settings.relayConstraints.exitLocations.value self.settings = settings self.relaySelectorWrapper = relaySelectorWrapper self.multihopContext = startContext entryLocationViewController = LocationViewController( customListRepository: customListRepository, selectedRelays: RelaySelection(), shouldFilterDaita: false, shouldFilterObfuscation: false ) exitLocationViewController = LocationViewController( customListRepository: customListRepository, selectedRelays: RelaySelection(), shouldFilterDaita: false, shouldFilterObfuscation: false ) super.init(nibName: nil, bundle: nil) self.onNewSettings = { [weak self] newSettings in self?.settings = newSettings self?.setRelaysWithLocation() } setRelaysWithLocation() updateViewControllers { $0.delegate = self } } var didFinish: (() -> Void)? required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.setAccessibilityIdentifier(.selectLocationViewWrapper) view.backgroundColor = .secondaryColor setUpNavigation() setUpSegmentedControl() addSubviews() add(entryLocationViewController) add(exitLocationViewController) swapViewController() } private func setRelaysWithLocation() { let emptyResult = LocationRelays(relays: [], locations: [:]) let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: settings) let isMultihop = settings.tunnelMultihopState.isEnabled let isDirectOnly = settings.daita.isDirectOnly let isAutomaticRouting = settings.daita.isAutomaticRouting let isObfuscation = settings.wireGuardObfuscation.state.affectsRelaySelection if isMultihop { entryLocationViewController?.setObfuscationChip(isObfuscation && !isAutomaticRouting) entryLocationViewController?.setDaitaChip(isDirectOnly) entryLocationViewController?.toggleDaitaAutomaticRouting(isEnabled: isAutomaticRouting) } else { segmentedControlView?.isHidden = true exitLocationViewController.setObfuscationChip(isObfuscation) exitLocationViewController.setDaitaChip(isDirectOnly) } if let entryRelays = relaysCandidates?.entryRelays { entryLocationViewController?.setRelaysWithLocation(entryRelays.toLocationRelays(), filter: relayFilter) } else { entryLocationViewController?.setRelaysWithLocation( emptyResult, filter: relayFilter ) } exitLocationViewController.setRelaysWithLocation( relaysCandidates?.exitRelays.toLocationRelays() ?? emptyResult, filter: relayFilter ) } func refreshCustomLists() { updateViewControllers { $0.refreshCustomLists() } } private func updateViewControllers(callback: (LocationViewController) -> Void) { [entryLocationViewController, exitLocationViewController] .compactMap { $0 } .forEach { callback($0) } } private func setUpNavigation() { navigationItem.largeTitleDisplayMode = .never navigationItem.title = NSLocalizedString("Select location", comment: "") navigationItem.leftBarButtonItem = UIBarButtonItem( title: NSLocalizedString("Filter", comment: ""), primaryAction: UIAction(handler: { [weak self] _ in guard let self = self else { return } delegate?.navigateToFilter() }) ) navigationItem.leftBarButtonItem?.setAccessibilityIdentifier(.selectLocationFilterButton) navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: .done, primaryAction: UIAction(handler: { [weak self] _ in self?.didFinish?() }) ) navigationItem.rightBarButtonItem?.setAccessibilityIdentifier(.closeSelectLocationButton) } private func setUpSegmentedControl() { let swiftUISegmentedControl = SegmentedControl( segments: MultihopContext.allCases.map { $0.description }, viewModel: segmentedViewModel, onSelectedSegment: segmentedControlDidChange ) let host = UIHostingController(rootView: swiftUISegmentedControl) addChild(host) host.didMove(toParent: self) segmentedControlView = host.view! segmentedViewModel.selectedSegmentIndex = multihopContext.rawValue host.view.backgroundColor = .clear } private func addSubviews() { view.addConstrainedSubviews([segmentedControlView, locationViewContainer]) { segmentedControlView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) segmentedControlView.pinEdgesToSuperviewMargins(PinnableEdges([.top(0), .leading(8), .trailing(8)])) locationViewContainer.pinEdgesToSuperview(.all().excluding(.top)) if settings.tunnelMultihopState.isEnabled { locationViewContainer.topAnchor.constraint(equalTo: segmentedControlView.bottomAnchor, constant: 4) } else { locationViewContainer.pinEdgeToSuperviewMargin(.top(0)) } } } private func add(_ locationViewController: LocationViewController?) { guard let locationViewController else { return } addChild(locationViewController) locationViewController.didMove(toParent: self) locationViewContainer.addConstrainedSubviews([locationViewController.view]) { locationViewController.view.pinEdgesToSuperview() } } private func segmentedControlDidChange(selectedIndex: Int) { multihopContext = .allCases[selectedIndex] swapViewController() } private func swapViewController() { var selectedRelays: RelaySelection var oldViewController: LocationViewController? var newViewController: LocationViewController? (selectedRelays, oldViewController, newViewController) = switch multihopContext { case .entry: ( RelaySelection( selected: selectedEntry, excluded: selectedExit, excludedTitle: MultihopContext.exit.description ), exitLocationViewController, entryLocationViewController ) case .exit: ( RelaySelection( selected: selectedExit, excluded: settings.tunnelMultihopState.isEnabled ? selectedEntry : nil, excludedTitle: MultihopContext.entry.description ), entryLocationViewController, exitLocationViewController ) } newViewController?.setSelectedRelays(selectedRelays) oldViewController?.view.isUserInteractionEnabled = false newViewController?.view.isUserInteractionEnabled = true UIView.animate(withDuration: 0.0) { oldViewController?.view.alpha = 0 newViewController?.view.alpha = 1 } } } extension LocationViewControllerWrapper: @preconcurrency LocationViewControllerDelegate { func navigateToCustomLists(nodes: [LocationNode]) { delegate?.navigateToCustomLists(nodes: nodes) } func navigateToDaitaSettings() { delegate?.navigateToDaitaSettings() } func didSelectRelays(relays: UserSelectedRelays) { switch multihopContext { case .entry: selectedEntry = relays delegate?.didSelectEntryRelays(relays) segmentedViewModel.selectedSegmentIndex = MultihopContext.exit.rawValue segmentedControlDidChange(selectedIndex: MultihopContext.exit.rawValue) case .exit: delegate?.didSelectExitRelays(relays) didFinish?() } } func didUpdateFilter(filter: RelayFilter) { delegate?.didUpdateFilter(filter) } } private extension WireGuardObfuscationState { /// This flag affects whether the "Setting: Obfuscation" pill is shown when selecting a location var affectsRelaySelection: Bool { switch self { case .shadowsocks, .quic: true default: false } } }