summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
authormojganii <mojgan.jelodar@codic.se>2025-02-28 11:40:03 +0100
committerBug Magnet <marco.nikic@mullvad.net>2025-03-18 09:05:07 +0100
commit4d92aff083ed802a53459bd3ca6ecf9254439d07 (patch)
tree0fa8f6907cb8f89fab333389d716fdc23fc7c968 /ios
parentfa6258844450f1f09bf1e9d3b2dd9c100803a63a (diff)
downloadmullvadvpn-4d92aff083ed802a53459bd3ca6ecf9254439d07.tar.xz
mullvadvpn-4d92aff083ed802a53459bd3ca6ecf9254439d07.zip
Show number of available relays in filter view
Diffstat (limited to 'ios')
-rw-r--r--ios/CHANGELOG.md3
-rw-r--r--ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift23
-rw-r--r--ios/MullvadREST/Relay/RelayCandidates.swift19
-rw-r--r--ios/MullvadREST/Relay/RelaySelector.swift2
-rw-r--r--ios/MullvadREST/Relay/RelaySelectorProtocol.swift4
-rw-r--r--ios/MullvadREST/Relay/RelaySelectorWrapper.swift46
-rw-r--r--ios/MullvadREST/Relay/RelayWithLocation.swift2
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj18
-rw-r--r--ios/MullvadVPN/AppDelegate.swift3
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift7
-rw-r--r--ios/MullvadVPN/Coordinators/LocationCoordinator.swift125
-rw-r--r--ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift41
-rw-r--r--ios/MullvadVPN/SceneDelegate.swift3
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift85
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift7
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift99
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift76
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift24
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift16
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift21
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderView.swift)58
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift25
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift105
23 files changed, 485 insertions, 327 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 86f85e699e..39573cc968 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -25,6 +25,9 @@ Line wrap the file at 100 chars. Th
### Added
- Make account number copyable on welcome screen.
+### Changed
+- Improve the filter view to display the number of available servers based on selected criteria.
+
## 2025.2 - 2025-02-08
### Added
- Add different themes for app icons
diff --git a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift
index 8776b9cdcc..37ee50a252 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 -> RelayCandidates)?
- init(selectedRelaysResult: @escaping (UInt) throws -> SelectedRelays) {
+ init(
+ selectedRelaysResult: @escaping (UInt) throws -> SelectedRelays,
+ candidatesResult: (() throws -> RelayCandidates)? = 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 -> RelayCandidates {
+ return try candidatesResult?() ?? RelayCandidates(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/RelayCandidates.swift b/ios/MullvadREST/Relay/RelayCandidates.swift
new file mode 100644
index 0000000000..42567690a4
--- /dev/null
+++ b/ios/MullvadREST/Relay/RelayCandidates.swift
@@ -0,0 +1,19 @@
+//
+// RelayCandidates.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2025-03-03.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+public struct RelayCandidates {
+ public let entryRelays: [RelayWithLocation<REST.ServerRelay>]?
+ public let exitRelays: [RelayWithLocation<REST.ServerRelay>]
+ public init(
+ entryRelays: [RelayWithLocation<REST.ServerRelay>]?,
+ exitRelays: [RelayWithLocation<REST.ServerRelay>]
+ ) {
+ self.entryRelays = entryRelays
+ self.exitRelays = exitRelays
+ }
+}
diff --git a/ios/MullvadREST/Relay/RelaySelector.swift b/ios/MullvadREST/Relay/RelaySelector.swift
index 14742aef09..620daba28d 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 f4d1a8e474..a0cab79ce5 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 -> RelayCandidates
}
/// Struct describing the selected relay.
diff --git a/ios/MullvadREST/Relay/RelaySelectorWrapper.swift b/ios/MullvadREST/Relay/RelaySelectorWrapper.swift
index b2fe2d19fc..c52a0a2faf 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 -> RelayCandidates {
+ let obfuscation = try prepareObfuscation(for: tunnelSettings, connectionAttemptCount: 0)
+
+ let findCandidates: (REST.ServerRelaysResponse, Bool) throws
+ -> [RelayWithLocation<REST.ServerRelay>] = { relays, daitaEnabled in
+ try RelaySelector.WireGuard.findCandidates(
+ by: .any,
+ in: relays,
+ filterConstraint: tunnelSettings.relayConstraints.filter,
+ daitaEnabled: daitaEnabled
+ )
+ }
+
+ if tunnelSettings.daita.isAutomaticRouting || tunnelSettings.tunnelMultihopState.isEnabled {
+ let entryCandidates = try findCandidates(
+ tunnelSettings.tunnelMultihopState.isEnabled ? obfuscation.entryRelays : obfuscation.exitRelays,
+ tunnelSettings.daita.daitaState.isEnabled
+ )
+ let exitCandidates = try findCandidates(obfuscation.exitRelays, false)
+ return RelayCandidates(entryRelays: entryCandidates, exitRelays: exitCandidates)
+ } else {
+ let exitCandidates = try findCandidates(obfuscation.exitRelays, tunnelSettings.daita.daitaState.isEnabled)
+ return RelayCandidates(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 dad5bf153d..c4d82c07f8 100644
--- a/ios/MullvadREST/Relay/RelayWithLocation.swift
+++ b/ios/MullvadREST/Relay/RelayWithLocation.swift
@@ -10,7 +10,7 @@ import Foundation
import MullvadTypes
public struct RelayWithLocation<T: AnyRelay> {
- 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 773f170f03..ada2b8e01d 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -976,6 +976,7 @@
F07751572C50F149006E6A12 /* EphemeralPeerExchangingPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F053F4B92C4A94D300FBD937 /* EphemeralPeerExchangingPipelineTests.swift */; };
F07751582C50F149006E6A12 /* MultiHopEphemeralPeerExchangerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C4C9BD2C49477B00A79006 /* MultiHopEphemeralPeerExchangerTests.swift */; };
F07751592C50F149006E6A12 /* SingleHopEphemeralPeerExchangerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A163882C47B46300592300 /* SingleHopEphemeralPeerExchangerTests.swift */; };
+ F0791F1B2D76377500449F6D /* RelayCandidates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0791F1A2D76377400449F6D /* RelayCandidates.swift */; };
F07B53572C53B5270024F547 /* LocalNetworkIPs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07B53562C53B5270024F547 /* LocalNetworkIPs.swift */; };
F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */; };
F07C9D952B220C77006F1C5E /* libmullvad_ios.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 01F1FF1D29F0627D007083C3 /* libmullvad_ios.a */; };
@@ -1034,11 +1035,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 */; };
@@ -2386,6 +2388,7 @@
F072D3CE2C07122400906F64 /* SettingsUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUpdaterTests.swift; sourceTree = "<group>"; };
F072D3D12C071AD100906F64 /* ShadowsocksLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoaderTests.swift; sourceTree = "<group>"; };
F073FCB22C6617D70062EA1D /* TunnelStore+Stubs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TunnelStore+Stubs.swift"; sourceTree = "<group>"; };
+ F0791F1A2D76377400449F6D /* RelayCandidates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCandidates.swift; sourceTree = "<group>"; };
F07B53562C53B5270024F547 /* LocalNetworkIPs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNetworkIPs.swift; sourceTree = "<group>"; };
F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputTextFormatterTests.swift; sourceTree = "<group>"; };
F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherOperation.swift; sourceTree = "<group>"; };
@@ -2421,11 +2424,12 @@
F0B495752D02025200CFEC2A /* ChipContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipContainerView.swift; sourceTree = "<group>"; };
F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewModelProtocol.swift; sourceTree = "<group>"; };
F0B495792D02F41F00CFEC2A /* ChipFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFeature.swift; sourceTree = "<group>"; };
+ F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDescriptor.swift; sourceTree = "<group>"; };
F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = "<group>"; };
F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = "<group>"; };
F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Wireguard.swift"; sourceTree = "<group>"; };
F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Shadowsocks.swift"; sourceTree = "<group>"; };
- F0BE65362B9F136A005CC385 /* LocationSectionHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationSectionHeaderView.swift; sourceTree = "<group>"; };
+ F0BE65362B9F136A005CC385 /* LocationSectionHeaderFooterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationSectionHeaderFooterView.swift; sourceTree = "<group>"; };
F0C13FE32C64F7CB00BD087D /* DAITASettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITASettings.swift; sourceTree = "<group>"; };
F0C13FE52C64FB3400BD087D /* TunnelSettingsV6.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV6.swift; sourceTree = "<group>"; };
F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = "<group>"; };
@@ -3118,7 +3122,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 */,
@@ -4312,6 +4316,7 @@
F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */,
F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */,
7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */,
+ F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */,
7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */,
7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */,
7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */,
@@ -4621,7 +4626,6 @@
F0DC779F2B2222D20087F09D /* Relay */ = {
isa = PBXGroup;
children = (
- 7AFBE38E2D09AB4E002335FC /* RelayPicking */,
7ADCB2D72B6A6EB300C88F89 /* AnyRelay.swift */,
585DA87626B024A600B8C587 /* CachedRelays.swift */,
F0DDE4272B220A15006B57A7 /* Haversine.swift */,
@@ -4633,6 +4637,8 @@
7AD63A3A2CD5278900445268 /* ObfuscationMethodSelector.swift */,
7AD63A382CD520FD00445268 /* ObfuscatorPortSelector.swift */,
5820675A26E6576800655B05 /* RelayCache.swift */,
+ 7AFBE38E2D09AB4E002335FC /* RelayPicking */,
+ F0791F1A2D76377400449F6D /* RelayCandidates.swift */,
F0DDE4282B220A15006B57A7 /* RelaySelector.swift */,
F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */,
F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */,
@@ -5682,6 +5688,7 @@
06799AEC28F98E4800ACD94E /* RESTTaskIdentifier.swift in Sources */,
7A2E7B712D6C9FE0009EF2C3 /* APIError.swift in Sources */,
58E7BA192A975DF70068EC3A /* RESTTransportProvider.swift in Sources */,
+ F0791F1B2D76377500449F6D /* RelayCandidates.swift in Sources */,
F0B894F52BF7528700817A42 /* RelaySelector+Shadowsocks.swift in Sources */,
06799ADE28F98E4800ACD94E /* RESTRequestHandler.swift in Sources */,
F0DDE4162B220458006B57A7 /* TransportProvider.swift in Sources */,
@@ -6204,7 +6211,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 */,
@@ -6439,6 +6446,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 c88f37ba4b..16d40ce140 100644
--- a/ios/MullvadVPN/AppDelegate.swift
+++ b/ios/MullvadVPN/AppDelegate.swift
@@ -51,6 +51,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!
@@ -105,7 +106,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 1cc3240046..58dac8181d 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()
@@ -510,7 +513,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 683a2bf324..e482940672 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,26 +30,17 @@ 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
}
@@ -64,12 +54,12 @@ class LocationCoordinator: Coordinator, Presentable, Presenting {
}
let locationViewControllerWrapper = LocationViewControllerWrapper(
+ settings: tunnelManager.settings,
+ relaySelectorWrapper: relaySelectorWrapper,
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 +72,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 +80,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 +89,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 +135,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 +176,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 eeb7c3e5ec..33596d2960 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 68335ebccd..feec718687 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 0000000000..51eb06d698
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift
@@ -0,0 +1,85 @@
+//
+// 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: RelayCandidates
+ 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 settings.daita.daitaState.isEnabled
+ ? NSLocalizedString(
+ "RELAY_FILTER_BUTTON_DESCRIPTION",
+ tableName: "RelayFilter",
+ value: "DAITA is enabled, affecting your filters.",
+ comment: ""
+ )
+ : ""
+ }
+ return NSLocalizedString(
+ "RELAY_FILTER_BUTTON_DESCRIPTION",
+ tableName: "RelayFilter",
+ value: "Direct only DAITA is enabled, affecting your filters.",
+ comment: ""
+ )
+ }
+
+ init(relayFilterResult: RelayCandidates, 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 5843fed968..4c1905cf53 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<Section, Item>()
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
index 253a71c94d..9873aa8dfa 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
@@ -8,32 +8,55 @@
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<Combine.AnyCancellable>()
+ 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
+ label.textAlignment = .center
+ 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 +86,40 @@ 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
-
- viewModel.$relayFilter
+ private func setupDataSource() {
+ viewModel
+ .$relayFilter
+ .receive(on: DispatchQueue.main)
+ .removeDuplicates()
.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
+ descriptionLabel.isEnabled = filterDescriptor.isEnabled
}
.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 585de3d02f..267242cc8d 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: RelayCandidates(entryRelays: [], exitRelays: []),
+ settings: settings
+ )
}
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
index 7eb23e40fd..f7b08ff63c 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
@@ -253,12 +253,16 @@ 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: .header
+ )
)
case .customLists:
- return LocationSectionHeaderView(configuration: LocationSectionHeaderView.Configuration(
- name: LocationSection.customLists.description,
+ return LocationSectionHeaderFooterView(configuration: LocationSectionHeaderFooterView.Configuration(
+ name: LocationSection.customLists.header,
+ style: .header,
primaryAction: UIAction(
handler: { [weak self] _ in
self?.didTapEditCustomLists?()
@@ -269,13 +273,21 @@ 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: .footer
+ ))
+ 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 c73a0435aa..804cf0ab95 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<REST.ServerRelay> {
+ 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 1f8c320dec..2992dbf1e9 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
index bfa2d1c164..9cb5f0bec4 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: 8)
+ 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,22 @@ 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()
actionButton.heightAnchor.constraint(equalTo: heightAnchor)
actionButton.widthAnchor.constraint(equalTo: actionButton.heightAnchor)
-
- 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 +86,40 @@ 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
+
+ static let header = Style(
+ font: .preferredFont(forTextStyle: .body, weight: .semibold),
+ textColor: .primaryTextColor,
+ textAlignment: .natural,
+ backgroundColor: .primaryColor
+ )
+
+ static let footer = Style(
+ font: .preferredFont(forTextStyle: .body, weight: .regular),
+ textColor: .secondaryTextColor,
+ textAlignment: .center,
+ backgroundColor: .clear
+ )
+ }
+
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 a23f1c176e..d3998ce206 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 71c291555e..31cbea093e 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
@@ -48,49 +48,57 @@ 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(
+ settings: LatestTunnelSettings,
+ relaySelectorWrapper: RelaySelectorWrapper,
customListRepository: CustomListRepositoryProtocol,
- constraints: RelayConstraints,
- multihopEnabled: Bool,
- daitaSettings: DAITASettings,
startContext: MultihopContext
) {
- self.multihopEnabled = multihopEnabled
- self.daitaSettings = daitaSettings
- multihopContext = startContext
-
- selectedEntry = constraints.entryLocations.value
- selectedExit = constraints.exitLocations.value
+ self.selectedEntry = settings.relayConstraints.entryLocations.value
+ self.selectedExit = settings.relayConstraints.exitLocations.value
+ self.settings = settings
+ self.relaySelectorWrapper = relaySelectorWrapper
+ self.multihopContext = startContext
- if multihopEnabled {
- entryLocationViewController = LocationViewController(
- customListRepository: customListRepository,
- selectedRelays: RelaySelection(),
- shouldFilterDaita: daitaSettings.isDirectOnly
- )
-
- 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 +124,24 @@ 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)
+ private func setRelaysWithLocation() {
+ let emptyResult = LocationRelays(relays: [], locations: [:])
+ let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: self.settings)
+ entryLocationViewController?.setDaitaChip(settings.daita.isDirectOnly)
+ exitLocationViewController.setDaitaChip(settings.daita.isDirectOnly && !settings.tunnelMultihopState.isEnabled)
+ entryLocationViewController?.toggleDaitaAutomaticRouting(isEnabled: settings.daita.isAutomaticRouting)
+ if let entryRelays = relaysCandidates?.entryRelays {
+ entryLocationViewController?.setRelaysWithLocation(entryRelays.toLocationRelays(), filter: relayFilter)
} else {
- exitLocationViewController.setRelaysWithLocation(daitaFilteredRelays, filter: filter)
+ entryLocationViewController?.setRelaysWithLocation(
+ emptyResult,
+ filter: relayFilter
+ )
}
+ exitLocationViewController.setRelaysWithLocation(
+ relaysCandidates?.exitRelays.toLocationRelays() ?? emptyResult,
+ filter: relayFilter
+ )
}
func refreshCustomLists() {
@@ -138,20 +150,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 +174,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 +219,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 +262,7 @@ final class LocationViewControllerWrapper: UIViewController {
(
RelaySelection(
selected: selectedExit,
- excluded: multihopEnabled ? selectedEntry : nil,
+ excluded: settings.tunnelMultihopState.isEnabled ? selectedEntry : nil,
excludedTitle: MultihopContext.entry.description
),
entryLocationViewController,