diff --git a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift index 8776b9cdcc1f..94dd9e1aafbc 100644 --- a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift +++ b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift @@ -14,9 +14,14 @@ import WireGuardKitTypes /// Relay selector stub that accepts a block that can be used to provide custom implementation. public final class RelaySelectorStub: RelaySelectorProtocol { var selectedRelaysResult: (UInt) throws -> SelectedRelays + var candidatesResult: (() throws -> RelaysCandidates)? - init(selectedRelaysResult: @escaping (UInt) throws -> SelectedRelays) { + init( + selectedRelaysResult: @escaping (UInt) throws -> SelectedRelays, + candidatesResult: (() throws -> RelaysCandidates)? = nil + ) { self.selectedRelaysResult = selectedRelaysResult + self.candidatesResult = candidatesResult } public func selectRelays( @@ -25,6 +30,12 @@ public final class RelaySelectorStub: RelaySelectorProtocol { ) throws -> SelectedRelays { return try selectedRelaysResult(connectionAttemptCount) } + + public func findCandidates( + tunnelSettings: LatestTunnelSettings + ) throws -> RelaysCandidates { + return try candidatesResult?() ?? RelaysCandidates(entryRelays: [], exitRelays: []) + } } extension RelaySelectorStub { @@ -32,7 +43,7 @@ extension RelaySelectorStub { public static func nonFallible() -> RelaySelectorStub { let publicKey = PrivateKey().publicKey.rawValue - return RelaySelectorStub { _ in + return RelaySelectorStub(selectedRelaysResult: { _ in let cityRelay = SelectedRelay( endpoint: MullvadEndpoint( ipv4Relay: IPv4Endpoint(ip: .loopback, port: 1300), @@ -56,13 +67,15 @@ extension RelaySelectorStub { exit: cityRelay, retryAttempt: 0 ) - } + }, candidatesResult: nil) } /// Returns a relay selector that cannot satisfy constraints . public static func unsatisfied() -> RelaySelectorStub { - return RelaySelectorStub { _ in + return RelaySelectorStub(selectedRelaysResult: { _ in + throw NoRelaysSatisfyingConstraintsError(.relayConstraintNotMatching) + }, candidatesResult: { throw NoRelaysSatisfyingConstraintsError(.relayConstraintNotMatching) - } + }) } } diff --git a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift index 2edd67a7ea9b..7026d6e2e746 100644 --- a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift +++ b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift @@ -9,6 +9,17 @@ import MullvadSettings import MullvadTypes import Network +public struct RelaysCandidates { + public let entryRelays: [RelayWithLocation]? + public let exitRelays: [RelayWithLocation] + public init( + entryRelays: [RelayWithLocation]?, + exitRelays: [RelayWithLocation] + ) { + self.entryRelays = entryRelays + self.exitRelays = exitRelays + } +} protocol RelayPicking { var obfuscation: ObfuscatorPortSelection { get } diff --git a/ios/MullvadREST/Relay/RelaySelector.swift b/ios/MullvadREST/Relay/RelaySelector.swift index 14742aef09e4..620daba28dc7 100644 --- a/ios/MullvadREST/Relay/RelaySelector.swift +++ b/ios/MullvadREST/Relay/RelaySelector.swift @@ -15,7 +15,7 @@ public enum RelaySelector { // MARK: - public /// Determines whether a `REST.ServerRelay` satisfies the given relay filter. - public static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool { + static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool { if case let .only(providers) = filter.providers, providers.contains(relay.provider) == false { return false } diff --git a/ios/MullvadREST/Relay/RelaySelectorProtocol.swift b/ios/MullvadREST/Relay/RelaySelectorProtocol.swift index f4d1a8e474cf..b046818e0253 100644 --- a/ios/MullvadREST/Relay/RelaySelectorProtocol.swift +++ b/ios/MullvadREST/Relay/RelaySelectorProtocol.swift @@ -16,6 +16,10 @@ public protocol RelaySelectorProtocol { tunnelSettings: LatestTunnelSettings, connectionAttemptCount: UInt ) throws -> SelectedRelays + + func findCandidates( + tunnelSettings: LatestTunnelSettings + ) throws -> RelaysCandidates } /// Struct describing the selected relay. diff --git a/ios/MullvadREST/Relay/RelaySelectorWrapper.swift b/ios/MullvadREST/Relay/RelaySelectorWrapper.swift index b2fe2d19fc6d..cf738005ed44 100644 --- a/ios/MullvadREST/Relay/RelaySelectorWrapper.swift +++ b/ios/MullvadREST/Relay/RelaySelectorWrapper.swift @@ -10,7 +10,7 @@ import MullvadSettings import MullvadTypes public final class RelaySelectorWrapper: RelaySelectorProtocol, Sendable { - let relayCache: RelayCacheProtocol + public let relayCache: RelayCacheProtocol public init(relayCache: RelayCacheProtocol) { self.relayCache = relayCache @@ -20,12 +20,7 @@ public final class RelaySelectorWrapper: RelaySelectorProtocol, Sendable { tunnelSettings: LatestTunnelSettings, connectionAttemptCount: UInt ) throws -> SelectedRelays { - let obfuscation = try ObfuscatorPortSelector( - relays: try relayCache.read().relays - ).obfuscate( - tunnelSettings: tunnelSettings, - connectionAttemptCount: connectionAttemptCount - ) + let obfuscation = try prepareObfuscation(for: tunnelSettings, connectionAttemptCount: connectionAttemptCount) return switch tunnelSettings.tunnelMultihopState { case .off: @@ -44,4 +39,41 @@ public final class RelaySelectorWrapper: RelaySelectorProtocol, Sendable { ).pick() } } + + public func findCandidates(tunnelSettings: LatestTunnelSettings) throws -> RelaysCandidates { + let obfuscation = try prepareObfuscation(for: tunnelSettings, connectionAttemptCount: 0) + + let findCandidates: (REST.ServerRelaysResponse, Bool) throws + -> [RelayWithLocation] = { relays, daitaEnabled in + try RelaySelector.WireGuard.findCandidates( + by: .any, + in: relays, + filterConstraint: tunnelSettings.relayConstraints.filter, + daitaEnabled: daitaEnabled + ) + } + + if tunnelSettings.daita.isAutomaticRouting { + let entryCandidates = try findCandidates( + obfuscation.entryRelays, + tunnelSettings.daita.daitaState.isEnabled + ) + let exitCandidates = try findCandidates(obfuscation.exitRelays, false) + return RelaysCandidates(entryRelays: entryCandidates, exitRelays: exitCandidates) + } else { + let exitCandidates = try findCandidates(obfuscation.exitRelays, tunnelSettings.daita.daitaState.isEnabled) + return RelaysCandidates(entryRelays: nil, exitRelays: exitCandidates) + } + } + + private func prepareObfuscation( + for tunnelSettings: LatestTunnelSettings, + connectionAttemptCount: UInt + ) throws -> ObfuscatorPortSelection { + let relays = try relayCache.read().relays + return try ObfuscatorPortSelector(relays: relays).obfuscate( + tunnelSettings: tunnelSettings, + connectionAttemptCount: connectionAttemptCount + ) + } } diff --git a/ios/MullvadREST/Relay/RelayWithLocation.swift b/ios/MullvadREST/Relay/RelayWithLocation.swift index dad5bf153d82..c4d82c07f82d 100644 --- a/ios/MullvadREST/Relay/RelayWithLocation.swift +++ b/ios/MullvadREST/Relay/RelayWithLocation.swift @@ -10,7 +10,7 @@ import Foundation import MullvadTypes public struct RelayWithLocation { - let relay: T + public let relay: T public let serverLocation: Location public func matches(location: RelayLocation) -> Bool { diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 2b1036fadf5e..11cc935cc522 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -1033,11 +1033,12 @@ F0B495762D02025200CFEC2A /* ChipContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495752D02025200CFEC2A /* ChipContainerView.swift */; }; F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */; }; F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495792D02F41F00CFEC2A /* ChipFeature.swift */; }; + F0B583D42D6DCE12007F5AE4 /* FilterDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */; }; F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */; }; F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F02BF751E300817A42 /* RelayWithDistance.swift */; }; F0B894F32BF7526700817A42 /* RelaySelector+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */; }; F0B894F52BF7528700817A42 /* RelaySelector+Shadowsocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */; }; - F0BE65372B9F136A005CC385 /* LocationSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */; }; + F0BE65372B9F136A005CC385 /* LocationSectionHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0BE65362B9F136A005CC385 /* LocationSectionHeaderFooterView.swift */; }; F0C13FE42C64F7CB00BD087D /* DAITASettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C13FE32C64F7CB00BD087D /* DAITASettings.swift */; }; F0C13FE62C64FB3400BD087D /* TunnelSettingsV6.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C13FE52C64FB3400BD087D /* TunnelSettingsV6.swift */; }; F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */; }; @@ -2420,11 +2421,12 @@ F0B495752D02025200CFEC2A /* ChipContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipContainerView.swift; sourceTree = ""; }; F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewModelProtocol.swift; sourceTree = ""; }; F0B495792D02F41F00CFEC2A /* ChipFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFeature.swift; sourceTree = ""; }; + F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDescriptor.swift; sourceTree = ""; }; F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = ""; }; F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = ""; }; F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Wireguard.swift"; sourceTree = ""; }; F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Shadowsocks.swift"; sourceTree = ""; }; - F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationSectionHeaderView.swift; sourceTree = ""; }; + F0BE65362B9F136A005CC385 /* LocationSectionHeaderFooterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationSectionHeaderFooterView.swift; sourceTree = ""; }; F0C13FE32C64F7CB00BD087D /* DAITASettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITASettings.swift; sourceTree = ""; }; F0C13FE52C64FB3400BD087D /* TunnelSettingsV6.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV6.swift; sourceTree = ""; }; F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = ""; }; @@ -3115,7 +3117,7 @@ 7A6389F72B864CDF008E77E1 /* LocationNode.swift */, 7A5468AB2C6A55B100590086 /* LocationRelays.swift */, F050AE512B70DFC0003F4EDB /* LocationSection.swift */, - F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */, + F0BE65362B9F136A005CC385 /* LocationSectionHeaderFooterView.swift */, 5888AD86227B17950051EB06 /* LocationViewController.swift */, 7AB3BEB42BD7A6CB00E34384 /* LocationViewControllerWrapper.swift */, F01DAE322C2B032A00521E46 /* RelaySelection.swift */, @@ -4299,6 +4301,7 @@ F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */, F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */, 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */, + F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */, 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */, 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */, 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */, @@ -6183,7 +6186,7 @@ 7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */, 58C774BE29A7A249003A1A56 /* CustomNavigationController.swift in Sources */, E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */, - F0BE65372B9F136A005CC385 /* LocationSectionHeaderView.swift in Sources */, + F0BE65372B9F136A005CC385 /* LocationSectionHeaderFooterView.swift in Sources */, 7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */, 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */, 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, @@ -6418,6 +6421,7 @@ 584592612639B4A200EF967F /* TermsOfServiceContentView.swift in Sources */, 5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */, 586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */, + F0B583D42D6DCE12007F5AE4 /* FilterDescriptor.swift in Sources */, 7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */, F050AE522B70DFC0003F4EDB /* LocationSection.swift in Sources */, 063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */, diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 5e30f2fae96c..30bf4b0fc9ae 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -50,6 +50,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private(set) var shadowsocksLoader: ShadowsocksLoaderProtocol! private(set) var configuredTransportProvider: ProxyConfigurationTransportProvider! private(set) var ipOverrideRepository = IPOverrideRepository() + private(set) var relaySelector: RelaySelectorWrapper! private var launchArguments = LaunchArguments() private var encryptedDNSTransport: EncryptedDNSTransport! @@ -104,7 +105,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD tunnelStore = TunnelStore(application: backgroundTaskProvider) - let relaySelector = RelaySelectorWrapper( + relaySelector = RelaySelectorWrapper( relayCache: ipOverrideWrapper ) tunnelManager = createTunnelManager( diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index f8b6e7a555c5..be0edcb183dd 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -54,6 +54,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo private var accessMethodRepository: AccessMethodRepositoryProtocol private let configuredTransportProvider: ProxyConfigurationTransportProvider private let ipOverrideRepository: IPOverrideRepository + private let relaySelectorWrapper: RelaySelectorWrapper private var outOfTimeTimer: Timer? @@ -72,7 +73,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo appPreferences: AppPreferencesDataSource, accessMethodRepository: AccessMethodRepositoryProtocol, transportProvider: ProxyConfigurationTransportProvider, - ipOverrideRepository: IPOverrideRepository + ipOverrideRepository: IPOverrideRepository, + relaySelectorWrapper: RelaySelectorWrapper ) { self.tunnelManager = tunnelManager @@ -86,6 +88,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo self.accessMethodRepository = accessMethodRepository self.configuredTransportProvider = transportProvider self.ipOverrideRepository = ipOverrideRepository + self.relaySelectorWrapper = relaySelectorWrapper super.init() @@ -509,7 +512,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo let locationCoordinator = LocationCoordinator( navigationController: navigationController, tunnelManager: tunnelManager, - relayCacheTracker: relayCacheTracker, + relaySelectorWrapper: relaySelectorWrapper, customListRepository: CustomListRepository() ) diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift index 683a2bf3240f..f1405271475c 100644 --- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift @@ -15,9 +15,8 @@ import UIKit class LocationCoordinator: Coordinator, Presentable, Presenting { private let tunnelManager: TunnelManager private var tunnelObserver: TunnelObserver? - private let relayCacheTracker: RelayCacheTracker + private let relaySelectorWrapper: RelaySelectorWrapper private let customListRepository: CustomListRepositoryProtocol - private var locationRelays: LocationRelays? let navigationController: UINavigationController @@ -31,45 +30,52 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { } as? LocationViewControllerWrapper } - var relayFilter: RelayFilter { - switch tunnelManager.settings.relayConstraints.filter { - case .any: - return RelayFilter() - case let .only(filter): - return filter - } - } - var didFinish: ((LocationCoordinator) -> Void)? init( navigationController: UINavigationController, tunnelManager: TunnelManager, - relayCacheTracker: RelayCacheTracker, + relaySelectorWrapper: RelaySelectorWrapper, customListRepository: CustomListRepositoryProtocol ) { self.navigationController = navigationController self.tunnelManager = tunnelManager - self.relayCacheTracker = relayCacheTracker + self.relaySelectorWrapper = relaySelectorWrapper self.customListRepository = customListRepository } func start() { // If multihop is enabled, we should check if there's a DAITA related error when opening the location // view. If there is, help the user by showing the entry instead of the exit view. - var startContext: LocationViewControllerWrapper.MultihopContext = .exit - if tunnelManager.settings.tunnelMultihopState.isEnabled { - startContext = if case .noRelaysSatisfyingDaitaConstraints = tunnelManager.tunnelStatus.observedState - .blockedState?.reason { .entry } else { .exit } - } +// var startContext: LocationViewControllerWrapper.MultihopContext = .exit +// let relays = +// if tunnelManager.settings.tunnelMultihopState.isEnabled { +// startContext = if case .noRelaysSatisfyingDaitaConstraints = tunnelManager.tunnelStatus.observedState +// .blockedState?.reason { .entry } else { .exit } +// } + +// let locationRelays = if let cachedRelays = try? relayCacheTracker.getCachedRelays() { +// LocationRelays( +// relays: cachedRelays.relays.wireguard.relays, +// locations: cachedRelays.relays.locations +// ) +// } else { +// LocationRelays(relays: [], locations: [:]) +// } let locationViewControllerWrapper = LocationViewControllerWrapper( - customListRepository: customListRepository, - constraints: tunnelManager.settings.relayConstraints, - multihopEnabled: tunnelManager.settings.tunnelMultihopState.isEnabled, - daitaSettings: tunnelManager.settings.daita, - startContext: startContext + settings: tunnelManager.settings, + relaySelectorWrapper: relaySelectorWrapper, + customListRepository: customListRepository ) + +// let locationViewControllerWrapper = LocationViewControllerWrapper( +// customListRepository: customListRepository, +// constraints: tunnelManager.settings.relayConstraints, +// multihopEnabled: tunnelManager.settings.tunnelMultihopState.isEnabled, +// daitaSettings: tunnelManager.settings.daita, +// startContext: startContext +// ) locationViewControllerWrapper.delegate = self locationViewControllerWrapper.didFinish = { [weak self] in @@ -82,15 +88,6 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { } addTunnelObserver() - relayCacheTracker.addObserver(self) - - if let cachedRelays = try? relayCacheTracker.getCachedRelays() { - updateRelaysWithLocationFrom( - cachedRelays: cachedRelays, - filter: relayFilter, - controllerWrapper: locationViewControllerWrapper - ) - } navigationController.pushViewController(locationViewControllerWrapper, animated: false) } @@ -99,12 +96,8 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { let tunnelObserver = TunnelBlockObserver( didUpdateTunnelSettings: { [weak self] _, settings in - guard let self, let locationRelays else { return } - locationViewControllerWrapper?.onDaitaSettingsUpdate( - settings.daita, - relaysWithLocation: locationRelays, - filter: relayFilter - ) + guard let self else { return } + locationViewControllerWrapper?.onNewSettings?(settings) } ) @@ -112,52 +105,6 @@ class LocationCoordinator: Coordinator, Presentable, Presenting { self.tunnelObserver = tunnelObserver } - private func updateRelaysWithLocationFrom( - cachedRelays: CachedRelays, - filter: RelayFilter, - controllerWrapper: LocationViewControllerWrapper - ) { - var relaysWithLocation = LocationRelays( - relays: cachedRelays.relays.wireguard.relays, - locations: cachedRelays.relays.locations - ) - relaysWithLocation.relays = relaysWithLocation.relays.filter { relay in - RelaySelector.relayMatchesFilter(relay, filter: filter) - } - - locationRelays = relaysWithLocation - - controllerWrapper.setRelaysWithLocation(relaysWithLocation, filter: filter) - } - - private func makeRelayFilterCoordinator(forModalPresentation isModalPresentation: Bool) - -> RelayFilterCoordinator { - let navigationController = CustomNavigationController() - - let relayFilterCoordinator = RelayFilterCoordinator( - navigationController: navigationController, - tunnelManager: tunnelManager, - relayCacheTracker: relayCacheTracker - ) - - relayFilterCoordinator.didFinish = { [weak self] coordinator, filter in - guard let self else { return } - - if let cachedRelays = try? relayCacheTracker.getCachedRelays(), let locationViewControllerWrapper, - let filter { - updateRelaysWithLocationFrom( - cachedRelays: cachedRelays, - filter: filter, - controllerWrapper: locationViewControllerWrapper - ) - } - - coordinator.dismiss(animated: true) - } - - return relayFilterCoordinator - } - private func showAddCustomList(nodes: [LocationNode]) { let coordinator = AddCustomListCoordinator( navigationController: CustomNavigationController(), @@ -204,22 +151,22 @@ extension LocationCoordinator: UIAdaptivePresentationControllerDelegate { } } -extension LocationCoordinator: @preconcurrency RelayCacheTrackerObserver { - func relayCacheTracker( - _ tracker: RelayCacheTracker, - didUpdateCachedRelays cachedRelays: CachedRelays - ) { - let locationRelays = LocationRelays( - relays: cachedRelays.relays.wireguard.relays, - locations: cachedRelays.relays.locations +extension LocationCoordinator: @preconcurrency LocationViewControllerWrapperDelegate { + func navigateToFilter() { + let relayFilterCoordinator = RelayFilterCoordinator( + navigationController: CustomNavigationController(), + tunnelManager: tunnelManager, + relaySelectorWrapper: relaySelectorWrapper ) - self.locationRelays = locationRelays - locationViewControllerWrapper?.setRelaysWithLocation(locationRelays, filter: relayFilter) + relayFilterCoordinator.didFinish = { coordinator, _ in + coordinator.dismiss(animated: true) + } + relayFilterCoordinator.start() + + presentChild(relayFilterCoordinator, animated: true) } -} -extension LocationCoordinator: @preconcurrency LocationViewControllerWrapperDelegate { func didSelectEntryRelays(_ relays: UserSelectedRelays) { var relayConstraints = tunnelManager.settings.relayConstraints relayConstraints.entryLocations = .only(relays) @@ -245,23 +192,7 @@ extension LocationCoordinator: @preconcurrency LocationViewControllerWrapperDele func didUpdateFilter(_ filter: RelayFilter) { var relayConstraints = tunnelManager.settings.relayConstraints relayConstraints.filter = .only(filter) - tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) - - if let cachedRelays = try? relayCacheTracker.getCachedRelays(), let locationViewControllerWrapper { - updateRelaysWithLocationFrom( - cachedRelays: cachedRelays, - filter: filter, - controllerWrapper: locationViewControllerWrapper - ) - } - } - - func navigateToFilter() { - let coordinator = makeRelayFilterCoordinator(forModalPresentation: true) - coordinator.start() - - presentChild(coordinator, animated: true) } func navigateToCustomLists(nodes: [LocationNode]) { diff --git a/ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift b/ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift index eeb7c3e5ec02..33596d2960de 100644 --- a/ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift @@ -11,10 +11,10 @@ import MullvadTypes import Routing import UIKit -class RelayFilterCoordinator: Coordinator, Presentable, @preconcurrency RelayCacheTrackerObserver { +class RelayFilterCoordinator: Coordinator, Presentable { private let tunnelManager: TunnelManager - private let relayCacheTracker: RelayCacheTracker - private var cachedRelays: CachedRelays? + private let relaySelectorWrapper: RelaySelectorWrapper + private var tunnelObserver: TunnelObserver? let navigationController: UINavigationController @@ -28,29 +28,23 @@ class RelayFilterCoordinator: Coordinator, Presentable, @preconcurrency RelayCac } as? RelayFilterViewController } - var relayFilter: RelayFilter { - switch tunnelManager.settings.relayConstraints.filter { - case .any: - return RelayFilter() - case let .only(filter): - return filter - } - } - var didFinish: ((RelayFilterCoordinator, RelayFilter?) -> Void)? init( navigationController: UINavigationController, tunnelManager: TunnelManager, - relayCacheTracker: RelayCacheTracker + relaySelectorWrapper: RelaySelectorWrapper ) { self.navigationController = navigationController self.tunnelManager = tunnelManager - self.relayCacheTracker = relayCacheTracker + self.relaySelectorWrapper = relaySelectorWrapper } func start() { - let relayFilterViewController = RelayFilterViewController() + let relayFilterViewController = RelayFilterViewController( + settings: tunnelManager.settings, + relaySelectorWrapper: relaySelectorWrapper + ) relayFilterViewController.onApplyFilter = { [weak self] filter in guard let self else { return } @@ -65,25 +59,8 @@ class RelayFilterCoordinator: Coordinator, Presentable, @preconcurrency RelayCac relayFilterViewController.didFinish = { [weak self] in guard let self else { return } - didFinish?(self, nil) } - - relayCacheTracker.addObserver(self) - - if let cachedRelays = try? relayCacheTracker.getCachedRelays() { - self.cachedRelays = cachedRelays - relayFilterViewController.setCachedRelays(cachedRelays, filter: relayFilter) - } - navigationController.pushViewController(relayFilterViewController, animated: false) } - - func relayCacheTracker( - _ tracker: RelayCacheTracker, - didUpdateCachedRelays cachedRelays: CachedRelays - ) { - self.cachedRelays = cachedRelays - relayFilterViewController?.setCachedRelays(cachedRelays, filter: relayFilter) - } } diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index 68335ebccd98..feec71868740 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -81,7 +81,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, @preconcurrency Setting appPreferences: appDelegate.appPreferences, accessMethodRepository: accessMethodRepository, transportProvider: appDelegate.configuredTransportProvider, - ipOverrideRepository: appDelegate.ipOverrideRepository + ipOverrideRepository: appDelegate.ipOverrideRepository, + relaySelectorWrapper: appDelegate.relaySelector ) appCoordinator?.onShowSettings = { [weak self] in diff --git a/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift new file mode 100644 index 000000000000..1b95b94787e0 --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift @@ -0,0 +1,77 @@ +// +// FilterDescriptor.swift +// MullvadVPN +// +// Created by Mojgan on 2025-02-25. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// +import MullvadREST +import MullvadSettings +struct FilterDescriptor { + let relayFilterResult: RelaysCandidates + let settings: LatestTunnelSettings + + var isEnabled: Bool { + let exitCount = relayFilterResult.exitRelays.count + let entryCount = relayFilterResult.entryRelays?.count ?? 0 + let totalcount = exitCount + entryCount + let isMultihopEnabled = settings.tunnelMultihopState.isEnabled + return (isMultihopEnabled && totalcount > 1) || (!isMultihopEnabled && totalcount > 0) + } + + var title: String { + let exitCount = relayFilterResult.exitRelays.count + let entryCount = relayFilterResult.entryRelays?.count ?? 0 + guard isEnabled else { + return NSLocalizedString( + "RELAY_FILTER_BUTTON_TITLE", + tableName: "RelayFilter", + value: "No matching servers", + comment: "" + ) + } + return createTitleForAvailableServers( + entryCount: entryCount, + exitCount: exitCount, + isMultihopEnabled: settings.tunnelMultihopState.isEnabled, + isDirectOnly: settings.daita.isDirectOnly + ) + } + + var description: String { + guard settings.daita.isDirectOnly else { + return "" + } + return NSLocalizedString( + "RELAY_FILTER_BUTTON_DESCRIPTION", + tableName: "RelayFilter", + value: "Direct only DAITA is enabled, affecting your filters.", + comment: "" + ) + } + + init(relayFilterResult: RelaysCandidates, settings: LatestTunnelSettings) { + self.settings = settings + self.relayFilterResult = relayFilterResult + } + + private func createTitleForAvailableServers( + entryCount: Int, + exitCount: Int, + isMultihopEnabled: Bool, + isDirectOnly: Bool + ) -> String { + let displayNumber: (Int) -> String = { number in + number > 100 ? "99+" : "\(number)" + } + + if isMultihopEnabled && isDirectOnly { + return String( + format: "Show %@ entry & %@ exit servers", + displayNumber(entryCount), + displayNumber(exitCount) + ) + } + return String(format: "Show %@ servers", displayNumber(exitCount)) + } +} diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift index 5843fed96813..4c1905cf5318 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift @@ -52,9 +52,8 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource< tableView.delegate = self - viewModel.$relays - .combineLatest(viewModel.$relayFilter) - .sink { [weak self] _, filter in + viewModel.$relayFilter + .sink { [weak self] filter in self?.updateDataSnapshot(filter: filter) } .store(in: &disposeBag) @@ -111,7 +110,7 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource< applySnapshot(snapshot, animated: false) } - private func updateDataSnapshot(filter: RelayFilter? = nil) { + func updateDataSnapshot(filter: RelayFilter? = nil) { let oldSnapshot = snapshot() var newSnapshot = NSDiffableDataSourceSnapshot() diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift index 253a71c94dd1..b14b36f67610 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift @@ -8,32 +8,54 @@ import Combine import MullvadREST +import MullvadSettings import MullvadTypes import UIKit class RelayFilterViewController: UIViewController { private let tableView = UITableView(frame: .zero, style: .grouped) - private var viewModel: RelayFilterViewModel? + private var viewModel: RelayFilterViewModel private var dataSource: RelayFilterDataSource? - private var cachedRelays: CachedRelays? - private var filter = RelayFilter() private var disposeBag = Set() + private let buttonContainerView: UIStackView = { + let containerView = UIStackView() + containerView.axis = .vertical + containerView.spacing = 8 + containerView.isLayoutMarginsRelativeArrangement = true + return containerView + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.font = .preferredFont(forTextStyle: .body) + label.textColor = .secondaryTextColor + return label + }() + private let applyButton: AppButton = { let button = AppButton(style: .success) button.setAccessibilityIdentifier(.applyButton) - button.setTitle(NSLocalizedString( - "RELAY_FILTER_BUTTON_TITLE", - tableName: "RelayFilter", - value: "Apply", - comment: "" - ), for: .normal) return button }() var onApplyFilter: ((RelayFilter) -> Void)? var didFinish: (() -> Void)? + init( + settings: LatestTunnelSettings, + relaySelectorWrapper: RelaySelectorWrapper + ) { + self.viewModel = RelayFilterViewModel(settings: settings, relaySelectorWrapper: relaySelectorWrapper) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { super.viewDidLoad() @@ -63,60 +85,36 @@ class RelayFilterViewController: UIViewController { tableView.estimatedSectionHeaderHeight = tableView.estimatedRowHeight tableView.allowsMultipleSelection = true - view.addConstrainedSubviews([tableView, applyButton]) { + view.addSubview(tableView) + buttonContainerView.addArrangedSubview(descriptionLabel) + buttonContainerView.addArrangedSubview(applyButton) + + view.addConstrainedSubviews([tableView, buttonContainerView]) { tableView.pinEdgesToSuperview(.all().excluding(.bottom)) - applyButton.pinEdgesToSuperviewMargins(.all().excluding(.top)) - applyButton.topAnchor.constraint( + buttonContainerView.pinEdgesToSuperviewMargins(.all().excluding(.top)) + buttonContainerView.topAnchor.constraint( equalTo: tableView.bottomAnchor, constant: UIMetrics.contentLayoutMargins.top ) } - setUpDataSource() - } - - func setCachedRelays(_ cachedRelays: CachedRelays, filter: RelayFilter) { - self.cachedRelays = cachedRelays - self.filter = filter - - viewModel?.relays = cachedRelays.relays.wireguard.relays - viewModel?.relayFilter = filter + setupDataSource() } - private func setUpDataSource() { - let viewModel = RelayFilterViewModel( - relays: cachedRelays?.relays.wireguard.relays ?? [], - relayFilter: filter - ) - self.viewModel = viewModel - + private func setupDataSource() { viewModel.$relayFilter .sink { [weak self] filter in - switch filter.providers { - case .any: - self?.applyButton.isEnabled = true - case let .only(providers): - switch filter.ownership { - case .any: - self?.applyButton.isEnabled = !providers.isEmpty - case .owned: - let filterHasAtLeastOneOwnedProvider = viewModel.ownedProviders - .first(where: { providers.contains($0) }) != nil - self?.applyButton.isEnabled = filterHasAtLeastOneOwnedProvider - case .rented: - let filterHasAtLeastOneRentedProvider = viewModel.rentedProviders - .first(where: { providers.contains($0) }) != nil - self?.applyButton.isEnabled = filterHasAtLeastOneRentedProvider - } - } + guard let self else { return } + let filterDescriptor = viewModel.getFilteredRelays(filter) + applyButton.isEnabled = filterDescriptor.isEnabled + applyButton.setTitle(filterDescriptor.title, for: .normal) + descriptionLabel.text = filterDescriptor.description } .store(in: &disposeBag) - dataSource = RelayFilterDataSource(tableView: tableView, viewModel: viewModel) } @objc private func applyFilter() { - guard let viewModel = viewModel else { return } var relayFilter = viewModel.relayFilter switch viewModel.relayFilter.ownership { diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift index 585de3d02f1a..92668fb06eb2 100644 --- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift @@ -8,12 +8,35 @@ import Combine import MullvadREST +import MullvadSettings import MullvadTypes class RelayFilterViewModel { - @Published var relays: [REST.ServerRelay] + private var settings: LatestTunnelSettings + private let relaysWithLocation: LocationRelays + private let relaySelectorWrapper: RelaySelectorWrapper @Published var relayFilter: RelayFilter + init(settings: LatestTunnelSettings, relaySelectorWrapper: RelaySelectorWrapper) { + self.settings = settings + self.relaySelectorWrapper = relaySelectorWrapper + relaysWithLocation = if let cachedResponse = try? relaySelectorWrapper.relayCache.read().relays { + LocationRelays(relays: cachedResponse.wireguard.relays, locations: cachedResponse.locations) + } else { + LocationRelays(relays: [], locations: [:]) + } + + self.relayFilter = if case let .only(filter) = settings.relayConstraints.filter { + filter + } else { + RelayFilter() + } + } + + private var relays: [REST.ServerRelay] { + relaysWithLocation.relays + } + var uniqueProviders: [String] { Set(relays.map { $0.provider }).caseInsensitiveSorted() } @@ -26,11 +49,6 @@ class RelayFilterViewModel { Set(relays.filter { $0.owned == false }.map { $0.provider }).caseInsensitiveSorted() } - init(relays: [REST.ServerRelay], relayFilter: RelayFilter) { - self.relays = relays - self.relayFilter = relayFilter - } - func addItemToFilter(_ item: RelayFilterDataSource.Item) { switch item { case .ownershipAny, .ownershipOwned, .ownershipRented: @@ -75,6 +93,21 @@ class RelayFilterViewModel { } } + func providerItem(for providerName: String?) -> RelayFilterDataSource.Item? { + return .provider(providerName ?? "") + } + + func availableProviders(for ownership: RelayFilter.Ownership) -> [String] { + switch ownership { + case .any: + return uniqueProviders + case .owned: + return ownedProviders + case .rented: + return rentedProviders + } + } + func ownership(for item: RelayFilterDataSource.Item?) -> RelayFilter.Ownership? { switch item { case .ownershipAny: @@ -101,27 +134,16 @@ class RelayFilterViewModel { } } - func providerName(for item: RelayFilterDataSource.Item?) -> String? { - switch item { - case let .provider(name): - return name - default: - return nil - } - } - - func providerItem(for providerName: String?) -> RelayFilterDataSource.Item? { - return .provider(providerName ?? "") - } - - func availableProviders(for ownership: RelayFilter.Ownership) -> [String] { - switch ownership { - case .any: - return uniqueProviders - case .owned: - return ownedProviders - case .rented: - return rentedProviders + func getFilteredRelays(_ relayFilter: RelayFilter) -> FilterDescriptor { + settings.relayConstraints.filter = .only(relayFilter) + do { + let result = try relaySelectorWrapper.findCandidates(tunnelSettings: settings) + return FilterDescriptor(relayFilterResult: result, settings: settings) + } catch { + return FilterDescriptor( + relayFilterResult: RelaysCandidates(entryRelays: [], exitRelays: []), + settings: settings + ) } } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift index 7eb23e40fd88..7e672379a9d2 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift @@ -253,12 +253,26 @@ extension LocationDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { switch sections[section] { case .allLocations: - return LocationSectionHeaderView( - configuration: LocationSectionHeaderView.Configuration(name: LocationSection.allLocations.description) + return LocationSectionHeaderFooterView( + configuration: LocationSectionHeaderFooterView.Configuration( + name: LocationSection.allLocations.header, + style: LocationSectionHeaderFooterView.Style( + font: .preferredFont(forTextStyle: .body, weight: .semibold), + textColor: .primaryTextColor, + textAlignment: .natural, + backgroundColor: .primaryColor + ) + ) ) case .customLists: - return LocationSectionHeaderView(configuration: LocationSectionHeaderView.Configuration( - name: LocationSection.customLists.description, + return LocationSectionHeaderFooterView(configuration: LocationSectionHeaderFooterView.Configuration( + name: LocationSection.customLists.header, + style: LocationSectionHeaderFooterView.Style( + font: .preferredFont(forTextStyle: .body, weight: .semibold), + textColor: .primaryTextColor, + textAlignment: .natural, + backgroundColor: .primaryColor + ), primaryAction: UIAction( handler: { [weak self] _ in self?.didTapEditCustomLists?() @@ -269,13 +283,26 @@ extension LocationDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - nil + switch sections[section] { + case .allLocations: + return LocationSectionHeaderFooterView(configuration: LocationSectionHeaderFooterView.Configuration( + name: LocationSection.allLocations.footer, + style: LocationSectionHeaderFooterView.Style( + font: .preferredFont(forTextStyle: .body, weight: .regular), + textColor: .secondaryTextColor, + textAlignment: .center, + backgroundColor: .clear + ) + )) + case .customLists: + return nil + } } func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { switch sections[section] { case .allLocations: - return .zero + return dataSources[section].nodes.isEmpty ? 60.0 : .zero case .customLists: return 24 } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift index c73a0435aae9..804cf0ab950e 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift @@ -12,3 +12,19 @@ struct LocationRelays: Sendable { var relays: [REST.ServerRelay] var locations: [String: REST.ServerLocation] } + +extension Array where Element == RelayWithLocation { + func toLocationRelays() -> LocationRelays { + return LocationRelays( + relays: map { $0.relay }, + locations: reduce(into: [String: REST.ServerLocation]()) { result, entry in + result[entry.relay.location.rawValue] = REST.ServerLocation( + country: entry.serverLocation.country, + city: entry.serverLocation.city, + latitude: entry.serverLocation.latitude, + longitude: entry.serverLocation.longitude + ) + } + ) + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift index 1f8c320decd3..2992dbf1e95d 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift @@ -7,27 +7,40 @@ // import Foundation -enum LocationSection: String, Hashable, CustomStringConvertible, CaseIterable, CellIdentifierProtocol, Sendable { +enum LocationSection: String, Hashable, CaseIterable, CellIdentifierProtocol, Sendable { case customLists case allLocations - var description: String { + var header: String { switch self { case .customLists: return NSLocalizedString( - "SELECT_LOCATION_ADD_CUSTOM_LISTS", + "HEADER_SELECT_LOCATION_ADD_CUSTOM_LISTS", value: "Custom lists", comment: "" ) case .allLocations: return NSLocalizedString( - "SELECT_LOCATION_ALL_LOCATIONS", + "HEADER_SELECT_LOCATION_ALL_LOCATIONS", value: "All locations", comment: "" ) } } + var footer: String { + switch self { + case .customLists: + return "" + case .allLocations: + return NSLocalizedString( + "FOOTER_SELECT_LOCATION_ALL_LOCATIONS", + value: "No matching relays found, check your filter settings.", + comment: "" + ) + } + } + var cellClass: AnyClass { LocationCell.self } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift similarity index 63% rename from ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift rename to ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift index bfa2d1c1642c..0860e2e48ea4 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift @@ -9,7 +9,7 @@ import Foundation import UIKit -class LocationSectionHeaderView: UIView, UIContentView { +class LocationSectionHeaderFooterView: UIView, UIContentView { var configuration: UIContentConfiguration { get { actualConfiguration @@ -22,9 +22,20 @@ class LocationSectionHeaderView: UIView, UIContentView { } private var actualConfiguration: Configuration + + private let containerView: UIStackView = { + let containerView = UIStackView() + containerView.axis = .horizontal + containerView.spacing = 8 + containerView.isLayoutMarginsRelativeArrangement = true + containerView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0) + return containerView + }() + private let nameLabel: UILabel = { let label = UILabel() - label.numberOfLines = 1 + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping label.textColor = .primaryTextColor label.font = .systemFont(ofSize: 16, weight: .semibold) return label @@ -50,20 +61,27 @@ class LocationSectionHeaderView: UIView, UIContentView { } private func addSubviews() { - addConstrainedSubviews([nameLabel, actionButton]) { - nameLabel.pinEdgesToSuperviewMargins(.all().excluding(.trailing)) - - actionButton.pinEdgesToSuperview(PinnableEdges([.trailing(8)])) + containerView.addArrangedSubview(nameLabel) + containerView.addArrangedSubview(actionButton) + addConstrainedSubviews([containerView]) { + containerView.pinEdgesToSuperview() +// nameLabel.pinEdgesToSuperviewMargins(.all().excluding(.trailing)) +// +// actionButton.pinEdgesToSuperview(PinnableEdges([.trailing(8)])) actionButton.heightAnchor.constraint(equalTo: heightAnchor) actionButton.widthAnchor.constraint(equalTo: actionButton.heightAnchor) - - actionButton.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 16) +// +// actionButton.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 16) } } private func apply(configuration: Configuration) { let isActionHidden = configuration.primaryAction == nil + backgroundColor = configuration.style.backgroundColor + nameLabel.textColor = configuration.style.textColor nameLabel.text = configuration.name + nameLabel.font = configuration.style.font + nameLabel.textAlignment = configuration.style.textAlignment actionButton.isHidden = isActionHidden actionButton.accessibilityIdentifier = nil actualConfiguration.primaryAction.flatMap { action in @@ -73,21 +91,26 @@ class LocationSectionHeaderView: UIView, UIContentView { } private func applyAppearance() { - backgroundColor = .primaryColor - let leadingInset = UIMetrics.locationCellLayoutMargins.leading + 6 directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: leadingInset, bottom: 8, trailing: 24) } } -extension LocationSectionHeaderView { +extension LocationSectionHeaderFooterView { + struct Style: Equatable { + let font: UIFont + let textColor: UIColor + let textAlignment: NSTextAlignment + let backgroundColor: UIColor + } + struct Configuration: UIContentConfiguration, Equatable { let name: String - + let style: Style var primaryAction: UIAction? func makeContentView() -> UIView & UIContentView { - LocationSectionHeaderView(configuration: self) + LocationSectionHeaderFooterView(configuration: self) } func updated(for state: UIConfigurationState) -> Configuration { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift index a23f1c176e73..d3998ce206b6 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift @@ -86,9 +86,9 @@ final class LocationViewController: UIViewController { dataSource?.setRelays(relaysWithLocation, selectedRelays: selectedRelays) } - func setShouldFilterDaita(_ shouldFilterDaita: Bool) { - self.shouldFilterDaita = shouldFilterDaita - filterView.setDaita(shouldFilterDaita) + func setDaitaChip(_ isEnabled: Bool) { + self.shouldFilterDaita = isEnabled + filterView.setDaita(isEnabled) } func refreshCustomLists() { @@ -100,7 +100,16 @@ final class LocationViewController: UIViewController { dataSource?.setSelectedRelays(selectedRelays) } - func enableDaitaAutomaticRouting() { + func toggleDaitaAutomaticRouting(isEnabled: Bool) { + guard isEnabled else { + daitaInfoView?.removeFromSuperview() + daitaInfoView = nil + + searchBar.searchTextField.isEnabled = true + UITextField.SearchTextFieldAppearance.inactive.apply(to: searchBar) + return + } + guard daitaInfoView == nil else { return } let daitaInfoView = DAITAInfoView() @@ -118,14 +127,6 @@ final class LocationViewController: UIViewController { searchBar.searchTextField.isEnabled = false } - func disableDaitaAutomaticRouting() { - daitaInfoView?.removeFromSuperview() - daitaInfoView = nil - - searchBar.searchTextField.isEnabled = true - UITextField.SearchTextFieldAppearance.inactive.apply(to: searchBar) - } - // MARK: - Private private func setUpDataSource() { diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift index 71c291555ef3..b5e45ac8e44f 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift @@ -48,49 +48,55 @@ final class LocationViewControllerWrapper: UIViewController { private let exitLocationViewController: LocationViewController private let segmentedControl = UISegmentedControl() private let locationViewContainer = UIView() + private var settings: LatestTunnelSettings + private var relaySelectorWrapper: RelaySelectorWrapper + private var multihopContext: MultihopContext = .exit private var selectedEntry: UserSelectedRelays? private var selectedExit: UserSelectedRelays? - private let multihopEnabled: Bool - private var multihopContext: MultihopContext = .exit - private var daitaSettings: DAITASettings weak var delegate: LocationViewControllerWrapperDelegate? + var onNewSettings: ((LatestTunnelSettings) -> Void)? + + private var relayFilter: RelayFilter { + if case let .only(filter) = settings.relayConstraints.filter { + return filter + } + return RelayFilter() + } + init( - customListRepository: CustomListRepositoryProtocol, - constraints: RelayConstraints, - multihopEnabled: Bool, - daitaSettings: DAITASettings, - startContext: MultihopContext + settings: LatestTunnelSettings, + relaySelectorWrapper: RelaySelectorWrapper, + customListRepository: CustomListRepositoryProtocol ) { - self.multihopEnabled = multihopEnabled - self.daitaSettings = daitaSettings - multihopContext = startContext - - selectedEntry = constraints.entryLocations.value - selectedExit = constraints.exitLocations.value - - if multihopEnabled { - entryLocationViewController = LocationViewController( - customListRepository: customListRepository, - selectedRelays: RelaySelection(), - shouldFilterDaita: daitaSettings.isDirectOnly - ) + self.selectedEntry = settings.relayConstraints.entryLocations.value + self.selectedExit = settings.relayConstraints.exitLocations.value + self.settings = settings + self.relaySelectorWrapper = relaySelectorWrapper - if daitaSettings.isAutomaticRouting { - entryLocationViewController?.enableDaitaAutomaticRouting() - } - } + entryLocationViewController = LocationViewController( + customListRepository: customListRepository, + selectedRelays: RelaySelection(), + shouldFilterDaita: settings.daita.isDirectOnly + ) exitLocationViewController = LocationViewController( customListRepository: customListRepository, selectedRelays: RelaySelection(), - shouldFilterDaita: daitaSettings.isDirectOnly && !multihopEnabled + shouldFilterDaita: settings.daita.isDirectOnly && !settings.daita.isAutomaticRouting ) super.init(nibName: nil, bundle: nil) + self.onNewSettings = { [weak self] newSettings in + self?.settings = newSettings + self?.setRelaysWithLocation() + } + + setRelaysWithLocation() + updateViewControllers { $0.delegate = self } @@ -116,20 +122,18 @@ final class LocationViewControllerWrapper: UIViewController { swapViewController() } - func setRelaysWithLocation(_ relaysWithLocation: LocationRelays, filter: RelayFilter) { - var daitaFilteredRelays = relaysWithLocation - if daitaSettings.daitaState.isEnabled && daitaSettings.directOnlyState.isEnabled { - daitaFilteredRelays.relays = relaysWithLocation.relays.filter { relay in - relay.daita == true - } - } - - if multihopEnabled { - entryLocationViewController?.setRelaysWithLocation(daitaFilteredRelays, filter: filter) - exitLocationViewController.setRelaysWithLocation(relaysWithLocation, filter: filter) - } else { - exitLocationViewController.setRelaysWithLocation(daitaFilteredRelays, filter: filter) + private func setRelaysWithLocation() { + let emptyResult = LocationRelays(relays: [], locations: [:]) + let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: self.settings) + entryLocationViewController?.setDaitaChip(settings.daita.isDirectOnly) + entryLocationViewController?.toggleDaitaAutomaticRouting(isEnabled: settings.daita.isAutomaticRouting) + if let entryRelays = relaysCandidates?.entryRelays { + entryLocationViewController?.setRelaysWithLocation(entryRelays.toLocationRelays(), filter: relayFilter) } + exitLocationViewController.setRelaysWithLocation( + relaysCandidates?.exitRelays.toLocationRelays() ?? emptyResult, + filter: relayFilter + ) } func refreshCustomLists() { @@ -138,20 +142,6 @@ final class LocationViewControllerWrapper: UIViewController { } } - func onDaitaSettingsUpdate(_ settings: DAITASettings, relaysWithLocation: LocationRelays, filter: RelayFilter) { - daitaSettings = settings - guard multihopEnabled else { return } - - setRelaysWithLocation(relaysWithLocation, filter: filter) - entryLocationViewController?.setShouldFilterDaita(settings.isDirectOnly) - - if daitaSettings.isAutomaticRouting { - entryLocationViewController?.enableDaitaAutomaticRouting() - } else { - entryLocationViewController?.disableDaitaAutomaticRouting() - } - } - private func updateViewControllers(callback: (LocationViewController) -> Void) { [entryLocationViewController, exitLocationViewController] .compactMap { $0 } @@ -176,7 +166,8 @@ final class LocationViewControllerWrapper: UIViewController { comment: "" ), primaryAction: UIAction(handler: { [weak self] _ in - self?.delegate?.navigateToFilter() + guard let self = self else { return } + delegate?.navigateToFilter() }) ) navigationItem.leftBarButtonItem?.setAccessibilityIdentifier(.selectLocationFilterButton) @@ -220,7 +211,7 @@ final class LocationViewControllerWrapper: UIViewController { locationViewContainer.pinEdgesToSuperview(.all().excluding(.top)) - if multihopEnabled { + if settings.tunnelMultihopState.isEnabled { locationViewContainer.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 4) } else { locationViewContainer.pinEdgeToSuperviewMargin(.top(0)) @@ -263,7 +254,7 @@ final class LocationViewControllerWrapper: UIViewController { ( RelaySelection( selected: selectedExit, - excluded: multihopEnabled ? selectedEntry : nil, + excluded: settings.tunnelMultihopState.isEnabled ? selectedEntry : nil, excludedTitle: MultihopContext.entry.description ), entryLocationViewController,