summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2025-03-18 09:05:29 +0100
committerBug Magnet <marco.nikic@mullvad.net>2025-03-18 09:05:29 +0100
commite9af9e754c57fb9646cc5701c0f34b285ece100f (patch)
tree3afa9276af1aa1c7d2d9b0be302549f999059a8c
parentfa6258844450f1f09bf1e9d3b2dd9c100803a63a (diff)
parent37f23efa253850d8cf5551bb8f0aa0cba9338ed6 (diff)
downloadmullvadvpn-e9af9e754c57fb9646cc5701c0f34b285ece100f.tar.xz
mullvadvpn-e9af9e754c57fb9646cc5701c0f34b285ece100f.zip
Merge branch 'improve-filter-view-ios-1067'
-rw-r--r--ios/CHANGELOG.md3
-rw-r--r--ios/MullvadMockData/MullvadREST/MockRelayCache.swift28
-rw-r--r--ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift27
-rw-r--r--ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift (renamed from ios/MullvadVPNTests/MullvadREST/ApiHandlers/ServerRelaysResponse+Stubs.swift)24
-rw-r--r--ios/MullvadREST/Relay/RelayCandidates.swift19
-rw-r--r--ios/MullvadREST/Relay/RelaySelectorProtocol.swift5
-rw-r--r--ios/MullvadREST/Relay/RelaySelectorWrapper.swift46
-rw-r--r--ios/MullvadREST/Relay/RelayWithLocation.swift10
-rw-r--r--ios/MullvadTypes/RelayConstraint.swift7
-rw-r--r--ios/MullvadTypes/RelayFilter.swift4
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj62
-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.swift80
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift86
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift350
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift57
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift98
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift221
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift25
-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)64
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift25
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift102
-rw-r--r--ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift6
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Relay/ObfuscatorPortSelectorTests.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift11
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/RelayCacheTracker/RelayCacheTracker+Stubs.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/FilterDescriptorTests.swift169
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/RelayFilterViewModelTests.swift161
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift1
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift18
41 files changed, 1242 insertions, 690 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/MockRelayCache.swift b/ios/MullvadMockData/MullvadREST/MockRelayCache.swift
new file mode 100644
index 0000000000..f2b0123b73
--- /dev/null
+++ b/ios/MullvadMockData/MullvadREST/MockRelayCache.swift
@@ -0,0 +1,28 @@
+//
+// MockRelayCache.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2025-03-10.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+import Foundation
+@testable import MullvadREST
+
+public struct MockRelayCache: RelayCacheProtocol {
+ public init() {}
+
+ public func read() throws -> MullvadREST.StoredRelays {
+ try .init(
+ cachedRelays: CachedRelays(
+ relays: ServerRelaysResponseStubs.sampleRelays,
+ updatedAt: Date()
+ )
+ )
+ }
+
+ public func readPrebundledRelays() throws -> MullvadREST.StoredRelays {
+ try self.read()
+ }
+
+ public func write(record: MullvadREST.StoredRelays) throws {}
+}
diff --git a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift
index 8776b9cdcc..64dbf3a943 100644
--- a/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift
+++ b/ios/MullvadMockData/MullvadREST/RelaySelectorStub.swift
@@ -13,10 +13,19 @@ import WireGuardKitTypes
/// Relay selector stub that accepts a block that can be used to provide custom implementation.
public final class RelaySelectorStub: RelaySelectorProtocol {
+ public let relayCache: any RelayCacheProtocol
+
var selectedRelaysResult: (UInt) throws -> SelectedRelays
+ var candidatesResult: (() throws -> RelayCandidates)?
- init(selectedRelaysResult: @escaping (UInt) throws -> SelectedRelays) {
+ init(
+ relayCache: RelayCacheProtocol = MockRelayCache(),
+ selectedRelaysResult: @escaping (UInt) throws -> SelectedRelays,
+ candidatesResult: (() throws -> RelayCandidates)? = nil
+ ) {
+ self.relayCache = relayCache
self.selectedRelaysResult = selectedRelaysResult
+ self.candidatesResult = candidatesResult
}
public func selectRelays(
@@ -25,6 +34,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 +47,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 +71,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/MullvadVPNTests/MullvadREST/ApiHandlers/ServerRelaysResponse+Stubs.swift b/ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift
index 595adeb757..3cd20183d2 100644
--- a/ios/MullvadVPNTests/MullvadREST/ApiHandlers/ServerRelaysResponse+Stubs.swift
+++ b/ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift
@@ -10,11 +10,11 @@ import Foundation
@testable import MullvadREST
import WireGuardKitTypes
-enum ServerRelaysResponseStubs {
- static let wireguardPortRanges: [[UInt16]] = [[4000, 4001], [5000, 5001]]
- static let shadowsocksPortRanges: [[UInt16]] = [[51900, 51949]]
+public enum ServerRelaysResponseStubs {
+ public static let wireguardPortRanges: [[UInt16]] = [[4000, 4001], [5000, 5001]]
+ public static let shadowsocksPortRanges: [[UInt16]] = [[51900, 51949]]
- static let sampleRelays = REST.ServerRelaysResponse(
+ public static let sampleRelays = REST.ServerRelaysResponse(
locations: [
"es-mad": REST.ServerLocation(
country: "Spain",
@@ -79,9 +79,9 @@ enum ServerRelaysResponseStubs {
REST.ServerRelay(
hostname: "es1-wireguard",
active: true,
- owned: true,
+ owned: false,
location: "es-mad",
- provider: "",
+ provider: "100TB",
weight: 500,
ipv4AddrIn: .loopback,
ipv6AddrIn: .loopback,
@@ -95,7 +95,7 @@ enum ServerRelaysResponseStubs {
active: true,
owned: true,
location: "se-got",
- provider: "",
+ provider: "Blix",
weight: 1000,
ipv4AddrIn: .loopback,
ipv6AddrIn: .loopback,
@@ -109,7 +109,7 @@ enum ServerRelaysResponseStubs {
active: true,
owned: true,
location: "se-sto",
- provider: "",
+ provider: "DataPacket",
weight: 50,
ipv4AddrIn: .loopback,
ipv6AddrIn: .loopback,
@@ -137,7 +137,7 @@ enum ServerRelaysResponseStubs {
active: true,
owned: true,
location: "us-dal",
- provider: "",
+ provider: "M247",
weight: 100,
ipv4AddrIn: .loopback,
ipv6AddrIn: .loopback,
@@ -149,9 +149,9 @@ enum ServerRelaysResponseStubs {
REST.ServerRelay(
hostname: "us-nyc-wg-301",
active: true,
- owned: true,
+ owned: false,
location: "us-nyc",
- provider: "",
+ provider: "xtom",
weight: 100,
ipv4AddrIn: .loopback,
ipv6AddrIn: .loopback,
@@ -165,7 +165,7 @@ enum ServerRelaysResponseStubs {
active: false,
owned: true,
location: "us-nyc",
- provider: "",
+ provider: "Qnax",
weight: 100,
ipv4AddrIn: .loopback,
ipv6AddrIn: .loopback,
diff --git a/ios/MullvadREST/Relay/RelayCandidates.swift b/ios/MullvadREST/Relay/RelayCandidates.swift
new file mode 100644
index 0000000000..8a2c3a7f1b
--- /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: Equatable, Sendable {
+ 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/RelaySelectorProtocol.swift b/ios/MullvadREST/Relay/RelaySelectorProtocol.swift
index f4d1a8e474..8b84c3a6ba 100644
--- a/ios/MullvadREST/Relay/RelaySelectorProtocol.swift
+++ b/ios/MullvadREST/Relay/RelaySelectorProtocol.swift
@@ -12,10 +12,15 @@ import MullvadTypes
/// Protocol describing a type that can select a relay.
public protocol RelaySelectorProtocol {
+ var relayCache: RelayCacheProtocol { get }
func selectRelays(
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..cb3bf3b4d8 100644
--- a/ios/MullvadREST/Relay/RelayWithLocation.swift
+++ b/ios/MullvadREST/Relay/RelayWithLocation.swift
@@ -9,8 +9,8 @@
import Foundation
import MullvadTypes
-public struct RelayWithLocation<T: AnyRelay> {
- let relay: T
+public struct RelayWithLocation<T: AnyRelay & Sendable>: Sendable {
+ public let relay: T
public let serverLocation: Location
public func matches(location: RelayLocation) -> Bool {
@@ -60,8 +60,12 @@ public struct RelayWithLocation<T: AnyRelay> {
}
}
-extension RelayWithLocation: Equatable {
+extension RelayWithLocation: Hashable {
public static func == (lhs: RelayWithLocation<T>, rhs: RelayWithLocation<T>) -> Bool {
lhs.relay.hostname == rhs.relay.hostname
}
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(relay.hostname)
+ }
}
diff --git a/ios/MullvadTypes/RelayConstraint.swift b/ios/MullvadTypes/RelayConstraint.swift
index 9b01a513aa..96bf81f100 100644
--- a/ios/MullvadTypes/RelayConstraint.swift
+++ b/ios/MullvadTypes/RelayConstraint.swift
@@ -10,8 +10,8 @@ import Foundation
private let anyConstraint = "any"
-public enum RelayConstraint<T>: Codable, Equatable,
- CustomDebugStringConvertible where T: Codable & Equatable {
+public enum RelayConstraint<T>: Codable, Equatable, CustomDebugStringConvertible, Sendable
+ where T: Codable & Equatable & Sendable {
case any
case only(T)
@@ -34,7 +34,7 @@ public enum RelayConstraint<T>: Codable, Equatable,
return output
}
- private struct OnlyRepr: Codable {
+ private struct OnlyRepr: Codable, Sendable {
var only: T
}
@@ -46,7 +46,6 @@ public enum RelayConstraint<T>: Codable, Equatable,
self = .any
} else {
let onlyVariant = try container.decode(OnlyRepr.self)
-
self = .only(onlyVariant.only)
}
}
diff --git a/ios/MullvadTypes/RelayFilter.swift b/ios/MullvadTypes/RelayFilter.swift
index ae02fde5d3..59ed6be2fe 100644
--- a/ios/MullvadTypes/RelayFilter.swift
+++ b/ios/MullvadTypes/RelayFilter.swift
@@ -8,8 +8,8 @@
import Foundation
-public struct RelayFilter: Codable, Equatable {
- public enum Ownership: Codable {
+public struct RelayFilter: Codable, Equatable, Sendable {
+ public enum Ownership: Codable, Sendable {
case any
case owned
case rented
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 773f170f03..2e5bb220f8 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -928,6 +928,7 @@
F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */; };
F0164EC32B4C49D30020268D /* ShadowsocksLoaderStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */; };
F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */; };
+ F017F8E02D78AC020076EC01 /* RelayFilterDataSourceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */; };
F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01DAE322C2B032A00521E46 /* RelaySelection.swift */; };
F022EBA62CF0C6AE009484B9 /* ConsolidatedApplicationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */; };
F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */; };
@@ -976,6 +977,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 */; };
@@ -1006,7 +1008,6 @@
F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */; };
F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */; };
F0A086902C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A0868F2C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift */; };
- F0A1638A2C47B77300592300 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; };
F0A7EBB22CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A7EBB12CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift */; };
F0A7EBB62CF092CC005BB671 /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; };
F0ACE30D2BE4E478006D5333 /* MullvadMockData.h in Headers */ = {isa = PBXBuildFile; fileRef = F0ACE30A2BE4E478006D5333 /* MullvadMockData.h */; settings = {ATTRIBUTES = (Public, ); }; };
@@ -1022,8 +1023,6 @@
F0ACE32D2BE4E784006D5333 /* AccountMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449EB9FE2B95FF2500DFA4EB /* AccountMock.swift */; };
F0ACE32F2BE4EA8B006D5333 /* MockProxyFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */; };
F0ACE3332BE516F1006D5333 /* RESTRequestExecutor+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */; };
- F0ACE3362BE517D6006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; };
- F0ACE3372BE517F1006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */; };
F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */; };
F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */; };
F0ADF1CD2CFDFF3100299F09 /* StringConversionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */; };
@@ -1034,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 */; };
@@ -1077,6 +1077,14 @@
F0F316192BF3572B0078DBCF /* RelaySelectorResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */; };
F0F3161B2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */; };
F0F56B092C0E058A009D676B /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; };
+ F0FA16092D7F0425007E2546 /* FilterDescriptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FA16082D7F0413007E2546 /* FilterDescriptorTests.swift */; };
+ F0FA160A2D7F0E8B007E2546 /* FilterDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */; };
+ F0FA160C2D7F2BF2007E2546 /* ServerRelaysResponse+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FA160B2D7F2BF2007E2546 /* ServerRelaysResponse+Stubs.swift */; };
+ F0FA160E2D7F2C40007E2546 /* MockRelayCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FA160D2D7F2C3D007E2546 /* MockRelayCache.swift */; };
+ F0FA16102D7F2FC7007E2546 /* RelayFilterViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FA160F2D7F2FC0007E2546 /* RelayFilterViewModelTests.swift */; };
+ F0FA16112D7F2FE8007E2546 /* RelayFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */; };
+ F0FA16142D7F3ABF007E2546 /* RelayFilterDataSourceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */; };
+ F0FA16152D7F3E16007E2546 /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; };
F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
F0FADDEC2BE90AB0000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4002D3FF22E002FF3BB /* View+Modifier.swift */; };
@@ -2344,6 +2352,7 @@
F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = "<group>"; };
F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoaderStub.swift; sourceTree = "<group>"; };
F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIterator.swift; sourceTree = "<group>"; };
+ F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterDataSourceItem.swift; sourceTree = "<group>"; };
F01DAE322C2B032A00521E46 /* RelaySelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelaySelection.swift; sourceTree = "<group>"; };
F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = "<group>"; };
F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededViewController.swift; sourceTree = "<group>"; };
@@ -2386,6 +2395,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>"; };
@@ -2410,7 +2420,6 @@
F0ACE3082BE4E478006D5333 /* MullvadMockData.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MullvadMockData.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F0ACE30A2BE4E478006D5333 /* MullvadMockData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadMockData.h; sourceTree = "<group>"; };
F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProxyFactory.swift; sourceTree = "<group>"; };
- F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ServerRelaysResponse+Stubs.swift"; sourceTree = "<group>"; };
F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipCollectionView.swift; sourceTree = "<group>"; };
F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFlowLayout.swift; sourceTree = "<group>"; };
F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringConversionError.swift; sourceTree = "<group>"; };
@@ -2421,11 +2430,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>"; };
@@ -2461,6 +2471,10 @@
F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchArguments.swift; sourceTree = "<group>"; };
F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorResult.swift; sourceTree = "<group>"; };
F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoRelaysSatisfyingConstraintsError.swift; sourceTree = "<group>"; };
+ F0FA16082D7F0413007E2546 /* FilterDescriptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDescriptorTests.swift; sourceTree = "<group>"; };
+ F0FA160B2D7F2BF2007E2546 /* ServerRelaysResponse+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ServerRelaysResponse+Stubs.swift"; sourceTree = "<group>"; };
+ F0FA160D2D7F2C3D007E2546 /* MockRelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRelayCache.swift; sourceTree = "<group>"; };
+ F0FA160F2D7F2FC0007E2546 /* RelayFilterViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModelTests.swift; sourceTree = "<group>"; };
F0FBD98E2C4A60CC00EE5323 /* KeyExchangingResultStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyExchangingResultStub.swift; sourceTree = "<group>"; };
F910A4002D3FF22E002FF3BB /* View+Modifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Modifier.swift"; sourceTree = "<group>"; };
F910A4302D4A1B3B002FF3BB /* InAppPurchaseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseCoordinator.swift; sourceTree = "<group>"; };
@@ -2726,7 +2740,6 @@
isa = PBXGroup;
children = (
A9CF11FC2A0518E7001D9565 /* AddressCacheTests.swift */,
- F0ACE3342BE51745006D5333 /* ServerRelaysResponse+Stubs.swift */,
);
path = ApiHandlers;
sourceTree = "<group>";
@@ -2806,6 +2819,7 @@
440E9EFC2BDA982200B1FD11 /* View controllers */ = {
isa = PBXGroup;
children = (
+ F0FA16072D7F03F8007E2546 /* Filter */,
7A9BE5A02B8F881B00E2A7D0 /* SelectLocation */,
440E9EFD2BDA982A00B1FD11 /* Tunnel */,
);
@@ -3118,7 +3132,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,8 +4326,10 @@
F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */,
F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */,
7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */,
+ F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */,
7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */,
7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */,
+ F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */,
7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */,
7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */,
7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */,
@@ -4584,6 +4600,7 @@
F0ACE3172BE4E487006D5333 /* MullvadREST */ = {
isa = PBXGroup;
children = (
+ F0FA160D2D7F2C3D007E2546 /* MockRelayCache.swift */,
F0164EB92B4456D30020268D /* AccessMethodRepository+Stub.swift */,
A900E9BF2ACC661900C95F67 /* AccessTokenManager+Stubs.swift */,
A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */,
@@ -4593,6 +4610,7 @@
58FE25EF2AA77664003D1918 /* RelaySelectorStub.swift */,
A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */,
7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */,
+ F0FA160B2D7F2BF2007E2546 /* ServerRelaysResponse+Stubs.swift */,
);
path = MullvadREST;
sourceTree = "<group>";
@@ -4621,7 +4639,6 @@
F0DC779F2B2222D20087F09D /* Relay */ = {
isa = PBXGroup;
children = (
- 7AFBE38E2D09AB4E002335FC /* RelayPicking */,
7ADCB2D72B6A6EB300C88F89 /* AnyRelay.swift */,
585DA87626B024A600B8C587 /* CachedRelays.swift */,
F0DDE4272B220A15006B57A7 /* Haversine.swift */,
@@ -4633,6 +4650,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 */,
@@ -4755,6 +4774,15 @@
path = ChangeLog;
sourceTree = "<group>";
};
+ F0FA16072D7F03F8007E2546 /* Filter */ = {
+ isa = PBXGroup;
+ children = (
+ F0FA16082D7F0413007E2546 /* FilterDescriptorTests.swift */,
+ F0FA160F2D7F2FC0007E2546 /* RelayFilterViewModelTests.swift */,
+ );
+ path = Filter;
+ sourceTree = "<group>";
+ };
F910A4322D4A1BA1002FF3BB /* InAppPurchase */ = {
isa = PBXGroup;
children = (
@@ -5682,6 +5710,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 */,
@@ -5817,6 +5846,7 @@
A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */,
A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */,
F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */,
+ F0FA16152D7F3E16007E2546 /* Collection+Sorting.swift in Sources */,
A9A5F9F12ACB05160083449F /* String+Helpers.swift in Sources */,
A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */,
44E1F75B2D3FEC81003A60FF /* DestinationDescriber.swift in Sources */,
@@ -5836,6 +5866,7 @@
A9A5F9FB2ACB05160083449F /* InAppNotificationProvider.swift in Sources */,
A9A5F9FC2ACB05160083449F /* SystemNotificationProvider.swift in Sources */,
A9A5F9FD2ACB05160083449F /* NotificationResponse.swift in Sources */,
+ F0FA16142D7F3ABF007E2546 /* RelayFilterDataSourceItem.swift in Sources */,
A9A5F9FE2ACB05160083449F /* NotificationManager.swift in Sources */,
A9A5F9FF2ACB05160083449F /* NotificationManagerDelegate.swift in Sources */,
7A9BE5AD2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift in Sources */,
@@ -5855,6 +5886,7 @@
A9A5FA0A2ACB05160083449F /* StorePaymentEvent.swift in Sources */,
A902E7A62D3FB0D9007F844A /* LogFileOutputStreamTests.swift in Sources */,
A9A5FA0B2ACB05160083449F /* StorePaymentManager.swift in Sources */,
+ F0FA16092D7F0425007E2546 /* FilterDescriptorTests.swift in Sources */,
A9A5FA0C2ACB05160083449F /* StorePaymentManagerDelegate.swift in Sources */,
A9A5FA0D2ACB05160083449F /* StorePaymentManagerError.swift in Sources */,
A9A5FA0E2ACB05160083449F /* StorePaymentObserver.swift in Sources */,
@@ -5899,6 +5931,7 @@
7A9BE5A62B90762F00E2A7D0 /* CustomListsDataSource.swift in Sources */,
F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */,
7A9BE5A22B8F88C500E2A7D0 /* LocationNodeTests.swift in Sources */,
+ F0FA160A2D7F0E8B007E2546 /* FilterDescriptor.swift in Sources */,
A9A5FA232ACB05160083449F /* TunnelState.swift in Sources */,
A9A5FA242ACB05160083449F /* TunnelStore.swift in Sources */,
A9A5FA252ACB05160083449F /* UpdateAccountDataOperation.swift in Sources */,
@@ -5917,6 +5950,7 @@
A9A5FA2C2ACB05160083449F /* DeviceCheckOperationTests.swift in Sources */,
A9A5FA2D2ACB05160083449F /* DurationTests.swift in Sources */,
A9A5FA2E2ACB05160083449F /* FileCacheTests.swift in Sources */,
+ F0FA16112D7F2FE8007E2546 /* RelayFilterViewModel.swift in Sources */,
F0D5591F2D38051C0072B63F /* LatestChangesNotificationProvider.swift in Sources */,
7A9F28FC2CA69D0C005F2089 /* DAITASettingsTests.swift in Sources */,
A9A5FA2F2ACB05160083449F /* FixedWidthIntegerArithmeticsTests.swift in Sources */,
@@ -5925,7 +5959,7 @@
A9A5FA302ACB05160083449F /* InputTextFormatterTests.swift in Sources */,
44B02E3B2BC5732D008EDF34 /* LoggingTests.swift in Sources */,
F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */,
- F0ACE3362BE517D6006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */,
+ F0FA16102D7F2FC7007E2546 /* RelayFilterViewModelTests.swift in Sources */,
7ADCB2DA2B6A730400C88F89 /* IPOverrideRepositoryStub.swift in Sources */,
A9A5FA312ACB05160083449F /* MockFileCache.swift in Sources */,
44E1F75A2D3FDCCA003A60FF /* DestinationDescriberTests.swift in Sources */,
@@ -6077,7 +6111,6 @@
F08B6B7C2C528C6300D0A121 /* SingleHopEphemeralPeerExchanger.swift in Sources */,
F08B6B7D2C528C6300D0A121 /* EphemeralPeerExchangingPipeline.swift in Sources */,
F08B6B7E2C528C6300D0A121 /* MultiHopEphemeralPeerExchanger.swift in Sources */,
- F0ACE3372BE517F1006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */,
58F7753D2AB8473200425B47 /* BlockedStateErrorMapperStub.swift in Sources */,
58FE25D42AA729B5003D1918 /* PacketTunnelActorTests.swift in Sources */,
F07751572C50F149006E6A12 /* EphemeralPeerExchangingPipelineTests.swift in Sources */,
@@ -6204,7 +6237,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 */,
@@ -6378,6 +6411,7 @@
7A5869B92B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift in Sources */,
586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */,
58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */,
+ F017F8E02D78AC020076EC01 /* RelayFilterDataSourceItem.swift in Sources */,
586C0D832B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift in Sources */,
58B26E262943522400D5980C /* NotificationProvider.swift in Sources */,
58CE5E64224146200008646E /* AppDelegate.swift in Sources */,
@@ -6439,6 +6473,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 */,
@@ -6752,7 +6787,6 @@
A9D9A4CE2C36D54E004088DD /* TunnelObfuscationTests.swift in Sources */,
A9D9A4CC2C36D54E004088DD /* UnsafeListener.swift in Sources */,
A9D9A4CD2C36D54E004088DD /* UDPConnection.swift in Sources */,
- F0A1638A2C47B77300592300 /* ServerRelaysResponse+Stubs.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -6768,8 +6802,10 @@
F0ACE3222BE4E4F2006D5333 /* APIProxy+Stubs.swift in Sources */,
F0ACE3332BE516F1006D5333 /* RESTRequestExecutor+Stubs.swift in Sources */,
F0ACE32D2BE4E784006D5333 /* AccountMock.swift in Sources */,
+ F0FA160E2D7F2C40007E2546 /* MockRelayCache.swift in Sources */,
7A52F96A2C1735AE00B133B9 /* RelaySelectorStub.swift in Sources */,
7AF84F462D12C5B000C72690 /* SelectedRelaysStub+Stubs.swift in Sources */,
+ F0FA160C2D7F2BF2007E2546 /* ServerRelaysResponse+Stubs.swift in Sources */,
F03A69F72C2AD2D6000E2E7E /* TimeInterval+Timeout.swift in Sources */,
F0ACE32F2BE4EA8B006D5333 /* MockProxyFactory.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..666fd12c98
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift
@@ -0,0 +1,80 @@
+//
+// 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 {
+ // Check if multihop is enabled via settings
+ let isMultihopEnabled = settings.tunnelMultihopState.isEnabled
+ let isSmartRoutingEnabled = settings.daita.isAutomaticRouting
+
+ /// Closure to check if there are enough relays available for multihoping
+ let hasSufficientRelays: () -> Bool = {
+ (relayFilterResult.entryRelays ?? []).count >= 1 &&
+ relayFilterResult.exitRelays.count >= 1 &&
+ numberOfServers > 1
+ }
+
+ if isMultihopEnabled {
+ // Multihop mode requires at least one entry relay, one exit relay,
+ // and more than one unique server.
+ return hasSufficientRelays()
+ } else if isSmartRoutingEnabled {
+ // Smart Routing mode: Enabled only if there is NO daita server in the exit relays
+ let isSmartRoutingNeeded = !relayFilterResult.exitRelays.contains { $0.relay.daita == true }
+ return isSmartRoutingNeeded ? hasSufficientRelays() : true
+ } else {
+ // Single-hop mode: The filter is enabled if at least one available exit relay exists.
+ return !relayFilterResult.exitRelays.isEmpty
+ }
+ }
+
+ var title: String {
+ guard isEnabled else {
+ return NSLocalizedString(
+ "RELAY_FILTER_BUTTON_TITLE",
+ tableName: "RelayFilter",
+ value: "No matching servers",
+ comment: ""
+ )
+ }
+ return createTitleForAvailableServers()
+ }
+
+ var description: String {
+ guard settings.daita.daitaState.isEnabled else {
+ return ""
+ }
+ return NSLocalizedString(
+ "RELAY_FILTER_BUTTON_DESCRIPTION",
+ tableName: "RelayFilter",
+ value: "When using DAITA, one provider with DAITA-enabled servers is required.",
+ comment: ""
+ )
+ }
+
+ init(relayFilterResult: RelayCandidates, settings: LatestTunnelSettings) {
+ self.settings = settings
+ self.relayFilterResult = relayFilterResult
+ }
+
+ private var numberOfServers: Int {
+ Set(relayFilterResult.entryRelays ?? []).union(relayFilterResult.exitRelays).count
+ }
+
+ private func createTitleForAvailableServers() -> String {
+ let displayNumber: (Int) -> String = { number in
+ number >= 100 ? "99+" : "\(number)"
+ }
+ return String(format: "Show %@ servers", displayNumber(numberOfServers))
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
index f6cba86133..80eb58c550 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
@@ -12,79 +12,81 @@ import UIKit
struct RelayFilterCellFactory: @preconcurrency CellFactoryProtocol {
let tableView: UITableView
- func makeCell(for item: RelayFilterDataSource.Item, indexPath: IndexPath) -> UITableViewCell {
- let cell = tableView.dequeueReusableCell(withIdentifier: item.reuseIdentifier.rawValue, for: indexPath)
+ func makeCell(for item: RelayFilterDataSourceItem, indexPath: IndexPath) -> UITableViewCell {
+ let cell = tableView.dequeueReusableCell(
+ withIdentifier: RelayFilterDataSource.CellReuseIdentifiers.allCases[indexPath.section].rawValue,
+ for: indexPath
+ )
configureCell(cell, item: item, indexPath: indexPath)
return cell
}
- func configureCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item, indexPath: IndexPath) {
- switch item {
+ func configureCell(
+ _ cell: UITableViewCell,
+ item: RelayFilterDataSourceItem,
+ indexPath: IndexPath
+ ) {
+ switch item.type {
case .ownershipAny, .ownershipOwned, .ownershipRented:
- configureOwnershipCell(cell, item: item)
+ configureOwnershipCell(cell as? SelectableSettingsCell, item: item)
case .allProviders, .provider:
- configureProviderCell(cell, item: item)
+ configureProviderCell(cell as? CheckableSettingsCell, item: item)
}
}
- private func configureOwnershipCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item) {
- guard let cell = cell as? SelectableSettingsCell else { return }
-
- var title = ""
- switch item {
- case .ownershipAny:
- title = "Any"
- cell.setAccessibilityIdentifier(.ownershipAnyCell)
- case .ownershipOwned:
- title = "Mullvad owned only"
- cell.setAccessibilityIdentifier(.ownershipMullvadOwnedCell)
- case .ownershipRented:
- title = "Rented only"
- cell.setAccessibilityIdentifier(.ownershipRentedCell)
- default:
- assertionFailure("Item mismatch. Got: \(item)")
- }
+ private func configureOwnershipCell(_ cell: SelectableSettingsCell?, item: RelayFilterDataSourceItem) {
+ guard let cell = cell else { return }
cell.titleLabel.text = NSLocalizedString(
"RELAY_FILTER_CELL_LABEL",
tableName: "Relay filter ownership cell",
- value: title,
+ value: item.name,
comment: ""
)
+ let accessibilityIdentifier: AccessibilityIdentifier
+ switch item.type {
+ case .ownershipAny:
+ accessibilityIdentifier = .ownershipAnyCell
+ case .ownershipOwned:
+ accessibilityIdentifier = .ownershipMullvadOwnedCell
+ case .ownershipRented:
+ accessibilityIdentifier = .ownershipRentedCell
+ default:
+ assertionFailure("Unexpected ownership item: \(item)")
+ return
+ }
+
+ cell.setAccessibilityIdentifier(accessibilityIdentifier)
cell.applySubCellStyling()
}
- private func configureProviderCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item) {
- guard let cell = cell as? CheckableSettingsCell else { return }
-
- let title: String
-
- switch item {
- case .allProviders:
- title = "All providers"
- setFontWeight(.semibold, to: cell.titleLabel)
- case let .provider(name):
- title = name
- setFontWeight(.regular, to: cell.titleLabel)
- default:
- title = ""
- assertionFailure("Item mismatch. Got: \(item)")
- }
+ private func configureProviderCell(_ cell: CheckableSettingsCell?, item: RelayFilterDataSourceItem) {
+ guard let cell = cell else { return }
+ let alpha = item.isEnabled ? 1.0 : 0.2
cell.titleLabel.text = NSLocalizedString(
"RELAY_FILTER_CELL_LABEL",
tableName: "Relay filter provider cell",
- value: title,
+ value: item.name,
comment: ""
)
+ cell.detailTitleLabel.text = item.description
+
+ if item.type == .allProviders {
+ setFontWeight(.semibold, to: cell.titleLabel)
+ } else {
+ setFontWeight(.regular, to: cell.titleLabel)
+ }
cell.applySubCellStyling()
cell.setAccessibilityIdentifier(.relayFilterProviderCell)
+ cell.titleLabel.alpha = alpha
+ cell.detailTitleLabel.alpha = alpha
}
private func setFontWeight(_ weight: UIFont.Weight, to label: UILabel) {
- label.font = UIFont.systemFont(ofSize: label.font.pointSize, weight: .semibold)
+ label.font = UIFont.systemFont(ofSize: label.font.pointSize, weight: weight)
}
}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
index 5843fed968..a6aa13478c 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
@@ -10,31 +10,14 @@ import Combine
import MullvadREST
import MullvadTypes
import UIKit
-
final class RelayFilterDataSource: UITableViewDiffableDataSource<
RelayFilterDataSource.Section,
- RelayFilterDataSource.Item
+ RelayFilterDataSourceItem
> {
- private var tableView: UITableView?
+ private weak var tableView: UITableView?
private var viewModel: RelayFilterViewModel
- private var disposeBag = Set<Combine.AnyCancellable>()
private let relayFilterCellFactory: RelayFilterCellFactory
-
- var selectedOwnershipItem: Item {
- guard let selectedIndexPath = getSelectedIndexPaths(in: .ownership).first,
- let selectedItem = itemIdentifier(for: selectedIndexPath)
- else {
- return .ownershipAny
- }
-
- return selectedItem
- }
-
- var selectedProviderItems: [Item] {
- return getSelectedIndexPaths(in: .providers).compactMap { indexPath in
- itemIdentifier(for: indexPath)
- }
- }
+ private var disposeBag = Set<Combine.AnyCancellable>()
init(tableView: UITableView, viewModel: RelayFilterViewModel) {
self.tableView = tableView
@@ -47,145 +30,183 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource<
relayFilterCellFactory.makeCell(for: itemIdentifier, indexPath: indexPath)
}
- registerClasses()
+ registerCells()
createDataSnapshot()
-
tableView.delegate = self
-
- viewModel.$relays
- .combineLatest(viewModel.$relayFilter)
- .sink { [weak self] _, filter in
- self?.updateDataSnapshot(filter: filter)
- }
- .store(in: &disposeBag)
+ setupBindings()
}
- func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- switch getSection(for: indexPath) {
- case .ownership:
- if viewModel.ownership(for: itemIdentifier(for: indexPath)) == viewModel.relayFilter.ownership {
- cell.setSelected(true, animated: false)
- }
- case .providers:
- switch viewModel.relayFilter.providers {
- case .any:
- cell.setSelected(true, animated: false)
- case let .only(providers):
- switch itemIdentifier(for: indexPath) {
- case .allProviders:
- let allProvidersAreSelected = providers.count == viewModel.uniqueProviders.count
- if allProvidersAreSelected {
- cell.setSelected(true, animated: false)
- }
- case let .provider(name):
- if providers.contains(name) {
- cell.setSelected(true, animated: false)
- }
- default:
- break
- }
- }
- }
+ private func registerCells() {
+ CellReuseIdentifiers.allCases.forEach { tableView?.register(
+ $0.reusableViewClass,
+ forCellReuseIdentifier: $0.rawValue
+ ) }
+ HeaderFooterReuseIdentifiers.allCases.forEach { tableView?.register(
+ $0.reusableViewClass,
+ forHeaderFooterViewReuseIdentifier: $0.rawValue
+ ) }
}
- private func registerClasses() {
- CellReuseIdentifiers.allCases.forEach { cellIdentifier in
- tableView?.register(
- cellIdentifier.reusableViewClass,
- forCellReuseIdentifier: cellIdentifier.rawValue
- )
- }
-
- HeaderFooterReuseIdentifiers.allCases.forEach { reuseIdentifier in
- tableView?.register(
- reuseIdentifier.reusableViewClass,
- forHeaderFooterViewReuseIdentifier: reuseIdentifier.rawValue
- )
- }
+ private func setupBindings() {
+ viewModel
+ .$relayFilter
+ .dropFirst()
+ .removeDuplicates()
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] filter in
+ self?.updateDataSnapshot(filter: filter)
+ }
+ .store(in: &disposeBag)
}
private func createDataSnapshot() {
- var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
+ var snapshot = NSDiffableDataSourceSnapshot<Section, RelayFilterDataSourceItem>()
snapshot.appendSections(Section.allCases)
-
- applySnapshot(snapshot, animated: false)
+ apply(snapshot, animatingDifferences: false)
}
- private func updateDataSnapshot(filter: RelayFilter? = nil) {
+ private func updateDataSnapshot(filter: RelayFilter) {
let oldSnapshot = snapshot()
-
- var newSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()
+ var newSnapshot = NSDiffableDataSourceSnapshot<Section, RelayFilterDataSourceItem>()
newSnapshot.appendSections(Section.allCases)
Section.allCases.forEach { section in
switch section {
case .ownership:
if !oldSnapshot.itemIdentifiers(inSection: section).isEmpty {
- newSnapshot.appendItems(Item.ownerships, toSection: .ownership)
+ newSnapshot.appendItems(RelayFilterDataSourceItem.ownerships, toSection: .ownership)
}
case .providers:
if !oldSnapshot.itemIdentifiers(inSection: section).isEmpty {
- let ownership = (filter ?? viewModel.relayFilter).ownership
- let items = viewModel.availableProviders(for: ownership).map { Item.provider($0) }
-
- newSnapshot.appendItems([.allProviders], toSection: .providers)
- newSnapshot.appendItems(items, toSection: .providers)
+ newSnapshot.appendItems(
+ [RelayFilterDataSourceItem.allProviders] + viewModel.availableProviders(for: filter.ownership),
+ toSection: .providers
+ )
+ applySnapshot(newSnapshot, animated: false)
}
}
}
-
- applySnapshot(newSnapshot, animated: false)
}
private func applySnapshot(
- _ snapshot: NSDiffableDataSourceSnapshot<Section, Item>,
+ _ snapshot: NSDiffableDataSourceSnapshot<Section, RelayFilterDataSourceItem>,
animated: Bool,
completion: (() -> Void)? = nil
) {
apply(snapshot, animatingDifferences: animated) { [weak self] in
guard let self else { return }
-
updateSelection(from: viewModel.relayFilter)
completion?()
}
}
private func updateSelection(from filter: RelayFilter) {
- if let ownershipItem = viewModel.ownershipItem(for: filter.ownership) {
- selectRow(true, at: indexPath(for: ownershipItem))
+ tableView?.indexPathsForSelectedRows?.forEach { selectRow(false, at: $0) }
+
+ if let ownership = viewModel.ownershipItem(for: filter.ownership),
+ let ownershipIndexPath = indexPath(for: ownership) {
+ selectRow(true, at: ownershipIndexPath)
}
switch filter.providers {
case .any:
selectAllProviders(true)
case let .only(providers):
+ selectAllProviders(false)
providers.forEach { providerName in
- if let providerItem = viewModel.providerItem(for: providerName) {
- selectRow(true, at: indexPath(for: providerItem))
- }
+ selectRow(true, at: indexPath(for: viewModel.providerItem(for: providerName)))
}
-
updateAllProvidersSelection()
}
}
+ private func isItemSelected(_ item: RelayFilterDataSourceItem, for filter: RelayFilter) -> Bool {
+ switch item.type {
+ case .ownershipAny, .ownershipOwned, .ownershipRented:
+ return viewModel.ownership(for: item) == filter.ownership
+ case .allProviders:
+ return filter.providers == .any
+ case .provider:
+ return switch filter.providers {
+ case .any:
+ true
+ case let .only(providers):
+ providers.contains(item.name)
+ }
+ }
+ }
+
private func updateAllProvidersSelection() {
let selectedCount = getSelectedIndexPaths(in: .providers).count
let providerCount = viewModel.availableProviders(for: viewModel.relayFilter.ownership).count
+ selectRow(selectedCount == providerCount, at: indexPath(for: .allProviders))
+ }
- if selectedCount == providerCount {
- selectRow(true, at: indexPath(for: .allProviders))
+ private func handleCollapseOwnership(isExpanded: Bool) {
+ var newSnapshot = snapshot()
+ if isExpanded {
+ newSnapshot.deleteItems(RelayFilterDataSourceItem.ownerships)
+ } else {
+ newSnapshot.appendItems(RelayFilterDataSourceItem.ownerships, toSection: .ownership)
}
+ applySnapshot(newSnapshot, animated: !isExpanded)
+ }
+
+ private func handleCollapseProviders(isExpanded: Bool) {
+ let currentSnapshot = snapshot()
+ var newSnapshot = currentSnapshot
+
+ if isExpanded {
+ let items = newSnapshot.itemIdentifiers(inSection: .providers)
+ newSnapshot.deleteItems(items)
+ } else {
+ newSnapshot.appendItems(
+ [RelayFilterDataSourceItem.allProviders] + viewModel
+ .availableProviders(for: viewModel.relayFilter.ownership),
+ toSection: .providers
+ )
+ }
+ applySnapshot(newSnapshot, animated: !isExpanded)
+ }
+
+ private func selectRow(_ select: Bool, at indexPath: IndexPath?) {
+ guard let indexPath else { return }
+
+ if select {
+ tableView?.selectRow(at: indexPath, animated: false, scrollPosition: .none)
+ } else {
+ tableView?.deselectRow(at: indexPath, animated: false)
+ }
+ }
+
+ private func selectAllProviders(_ select: Bool) {
+ let providerItems = snapshot().itemIdentifiers(inSection: .providers)
+
+ providerItems.forEach { providerItem in
+ selectRow(select, at: indexPath(for: providerItem))
+ }
+ }
+
+ private func getSelectedIndexPaths(in section: Section) -> [IndexPath] {
+ let sectionIndex = snapshot().indexOfSection(section)
+
+ return tableView?.indexPathsForSelectedRows?.filter { indexPath in
+ indexPath.section == sectionIndex
+ } ?? []
+ }
+
+ private func getSection(for indexPath: IndexPath) -> Section {
+ return snapshot().sectionIdentifiers[indexPath.section]
}
}
+// MARK: - UITableViewDelegate
+
extension RelayFilterDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
switch getSection(for: indexPath) {
case .ownership:
- if let selectedIndexPath = self.indexPath(for: selectedOwnershipItem) {
- selectRow(false, at: selectedIndexPath)
- }
+ selectRow(false, at: getSelectedIndexPaths(in: .ownership).first)
case .providers:
break
}
@@ -204,36 +225,17 @@ extension RelayFilterDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let item = itemIdentifier(for: indexPath) else { return }
-
- switch getSection(for: indexPath) {
- case .ownership:
- break
- case .providers:
- if item == .allProviders {
- selectAllProviders(true)
- } else {
- updateAllProvidersSelection()
- }
- }
-
- viewModel.addItemToFilter(item)
+ viewModel.toggleItem(item)
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
guard let item = itemIdentifier(for: indexPath) else { return }
+ viewModel.toggleItem(item)
+ }
- switch getSection(for: indexPath) {
- case .ownership:
- break
- case .providers:
- if item == .allProviders {
- selectAllProviders(false)
- } else {
- selectRow(false, at: self.indexPath(for: .allProviders))
- }
- }
-
- viewModel.removeItemFromFilter(item)
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ guard let item = itemIdentifier(for: indexPath) else { return }
+ cell.setSelected(isItemSelected(item, for: viewModel.relayFilter), animated: false)
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
@@ -264,18 +266,14 @@ extension RelayFilterDataSource: UITableViewDelegate {
view.didCollapseHandler = { [weak self] headerView in
guard let self else { return }
-
- var snapshot = snapshot()
-
switch sectionId {
case .ownership:
- handleCollapseOwnership(snapshot: &snapshot, isExpanded: headerView.isExpanded)
+ handleCollapseOwnership(isExpanded: headerView.isExpanded)
case .providers:
- handleCollapseProviders(snapshot: &snapshot, isExpanded: headerView.isExpanded)
+ handleCollapseProviders(isExpanded: headerView.isExpanded)
}
headerView.isExpanded.toggle()
- applySnapshot(snapshot, animated: true)
}
return view
@@ -288,74 +286,20 @@ extension RelayFilterDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return UIMetrics.TableView.separatorHeight
}
-
- private func selectRow(_ select: Bool, at indexPath: IndexPath?) {
- guard let indexPath else { return }
-
- if select {
- tableView?.selectRow(at: indexPath, animated: false, scrollPosition: .none)
- } else {
- tableView?.deselectRow(at: indexPath, animated: false)
- }
- }
-
- private func getSelectedIndexPaths(in section: Section) -> [IndexPath] {
- let sectionIndex = snapshot().indexOfSection(section)
-
- return tableView?.indexPathsForSelectedRows?.filter { indexPath in
- indexPath.section == sectionIndex
- } ?? []
- }
-
- private func getSection(for indexPath: IndexPath) -> Section {
- return snapshot().sectionIdentifiers[indexPath.section]
- }
-
- private func selectAllProviders(_ select: Bool) {
- let providerItems = snapshot().itemIdentifiers(inSection: .providers)
-
- providerItems.forEach { providerItem in
- selectRow(select, at: indexPath(for: providerItem))
- }
- }
-
- private func handleCollapseOwnership(
- snapshot: inout NSDiffableDataSourceSnapshot<RelayFilterDataSource.Section, RelayFilterDataSource.Item>,
- isExpanded: Bool
- ) {
- if isExpanded {
- snapshot.deleteItems(Item.ownerships)
- } else {
- snapshot.appendItems(Item.ownerships, toSection: .ownership)
- }
- }
-
- private func handleCollapseProviders(
- snapshot: inout NSDiffableDataSourceSnapshot<RelayFilterDataSource.Section, RelayFilterDataSource.Item>,
- isExpanded: Bool
- ) {
- if isExpanded {
- let items = snapshot.itemIdentifiers(inSection: .providers)
- snapshot.deleteItems(items)
- } else {
- let items = viewModel.availableProviders(for: viewModel.relayFilter.ownership).map { Item.provider($0) }
- snapshot.appendItems([.allProviders], toSection: .providers)
- snapshot.appendItems(items, toSection: .providers)
- }
- }
}
+// MARK: - Cell Identifiers
+
extension RelayFilterDataSource {
+ enum Section: CaseIterable { case ownership, providers }
+
enum CellReuseIdentifiers: String, CaseIterable {
- case ownershipCell
- case providerCell
+ case ownershipCell, providerCell
var reusableViewClass: AnyClass {
switch self {
- case .ownershipCell:
- return SelectableSettingsCell.self
- case .providerCell:
- return CheckableSettingsCell.self
+ case .ownershipCell: return SelectableSettingsCell.self
+ case .providerCell: return CheckableSettingsCell.self
}
}
}
@@ -363,34 +307,6 @@ extension RelayFilterDataSource {
enum HeaderFooterReuseIdentifiers: String, CaseIterable {
case section
- var reusableViewClass: AnyClass {
- return SettingsHeaderView.self
- }
- }
-
- enum Section: Hashable, CaseIterable {
- case ownership
- case providers
- }
-
- enum Item: Hashable {
- case ownershipAny
- case ownershipOwned
- case ownershipRented
- case allProviders
- case provider(_ name: String)
-
- static var ownerships: [Item] {
- return [.ownershipAny, .ownershipOwned, .ownershipRented]
- }
-
- var reuseIdentifier: CellReuseIdentifiers {
- switch self {
- case .ownershipAny, .ownershipOwned, .ownershipRented:
- return .ownershipCell
- case .allProviders, .provider:
- return .providerCell
- }
- }
+ var reusableViewClass: AnyClass { SettingsHeaderView.self }
}
}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift
new file mode 100644
index 0000000000..6a74626b5e
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift
@@ -0,0 +1,57 @@
+//
+// RelayFilterDataSourceItem.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2025-03-05.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+struct RelayFilterDataSourceItem: Hashable, Comparable {
+ let name: String
+ var description = ""
+ let type: ItemType
+ let isEnabled: Bool
+
+ enum ItemType: Hashable {
+ case ownershipAny, ownershipOwned, ownershipRented, allProviders, provider
+ }
+
+ static let anyOwnershipItem = RelayFilterDataSourceItem(name: NSLocalizedString(
+ "RELAY_FILTER_ANY_LABEL",
+ tableName: "RelayFilter",
+ value: "Any",
+ comment: ""
+ ), type: .ownershipAny, isEnabled: true)
+
+ static let ownedOwnershipItem = RelayFilterDataSourceItem(name: NSLocalizedString(
+ "RELAY_FILTER_OWNED_LABEL",
+ tableName: "RelayFilter",
+ value: "Owned",
+ comment: ""
+ ), type: .ownershipOwned, isEnabled: true)
+
+ static let rentedOwnershipItem = RelayFilterDataSourceItem(name: NSLocalizedString(
+ "RELAY_FILTER_RENTED_LABEL",
+ tableName: "RelayFilter",
+ value: "Rented",
+ comment: ""
+ ), type: .ownershipRented, isEnabled: true)
+
+ static let ownerships: [RelayFilterDataSourceItem] = [anyOwnershipItem, ownedOwnershipItem, rentedOwnershipItem]
+
+ static var allProviders: RelayFilterDataSourceItem {
+ RelayFilterDataSourceItem(name: NSLocalizedString(
+ "RELAY_FILTER_ALL_PROVIDERS_LABEL",
+ tableName: "RelayFilter",
+ value: "All Providers",
+ comment: ""
+ ), type: .allProviders, isEnabled: true)
+ }
+
+ static func < (lhs: RelayFilterDataSourceItem, rhs: RelayFilterDataSourceItem) -> Bool {
+ let nameComparison = lhs.name.caseInsensitiveCompare(rhs.name)
+ return nameComparison == .orderedAscending
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
index 253a71c94d..5ff7700741 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()
@@ -62,61 +85,40 @@ class RelayFilterViewController: UIViewController {
tableView.estimatedRowHeight = 60
tableView.estimatedSectionHeaderHeight = tableView.estimatedRowHeight
tableView.allowsMultipleSelection = true
+ tableView.isMultipleTouchEnabled = false
+
+ view.addSubview(tableView)
+ buttonContainerView.addArrangedSubview(descriptionLabel)
+ buttonContainerView.addArrangedSubview(applyButton)
- view.addConstrainedSubviews([tableView, 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
+ .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
}
.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..3fd833bfc4 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift
@@ -8,120 +8,183 @@
import Combine
import MullvadREST
+import MullvadSettings
import MullvadTypes
-class RelayFilterViewModel {
- @Published var relays: [REST.ServerRelay]
+final class RelayFilterViewModel {
@Published var relayFilter: RelayFilter
+ private var settings: LatestTunnelSettings
+ private let relaySelectorWrapper: RelaySelectorProtocol
+ private let relaysWithLocation: LocationRelays
+ private var relayCandidatesForAny: RelayCandidates
+
+ init(settings: LatestTunnelSettings, relaySelectorWrapper: RelaySelectorProtocol) {
+ self.settings = settings
+ self.relaySelectorWrapper = relaySelectorWrapper
+ self.relayFilter = settings.relayConstraints.filter.value ?? RelayFilter()
+
+ // Retrieve all available relays that satisfy the `any` constraint.
+ // This constraint ensures that the selected relays are associated with the current tunnel settings
+ // and serve as the primary source of truth for subsequent filtering operations.
+ // Further filtering will be applied based on specific criteria such as `ownership` or `provider`.
+ var copy = settings
+ copy.relayConstraints.filter = .any
+ if let relayCandidatesForAny = try? relaySelectorWrapper.findCandidates(tunnelSettings: copy) {
+ self.relayCandidatesForAny = relayCandidatesForAny
+ } else {
+ self.relayCandidatesForAny = RelayCandidates(entryRelays: nil, exitRelays: [])
+ }
+
+ // Directly setting relaysWithLocation in constructor
+ if let cachedResponse = try? relaySelectorWrapper.relayCache.read().relays {
+ self.relaysWithLocation = LocationRelays(
+ relays: cachedResponse.wireguard.relays,
+ locations: cachedResponse.locations
+ )
+ } else {
+ self.relaysWithLocation = LocationRelays(relays: [], locations: [:])
+ }
+ }
+
+ private var relays: [REST.ServerRelay] { relaysWithLocation.relays }
+
var uniqueProviders: [String] {
- Set(relays.map { $0.provider }).caseInsensitiveSorted()
+ extractProviders(from: relays)
}
var ownedProviders: [String] {
- Set(relays.filter { $0.owned == true }.map { $0.provider }).caseInsensitiveSorted()
+ extractProviders(from: relays.filter { $0.owned == true })
}
var rentedProviders: [String] {
- Set(relays.filter { $0.owned == false }.map { $0.provider }).caseInsensitiveSorted()
+ extractProviders(from: relays.filter { $0.owned == false })
}
- init(relays: [REST.ServerRelay], relayFilter: RelayFilter) {
- self.relays = relays
- self.relayFilter = relayFilter
- }
+ // MARK: - public Methods
- func addItemToFilter(_ item: RelayFilterDataSource.Item) {
- switch item {
+ func toggleItem(_ item: RelayFilterDataSourceItem) {
+ switch item.type {
case .ownershipAny, .ownershipOwned, .ownershipRented:
relayFilter.ownership = ownership(for: item) ?? .any
case .allProviders:
- relayFilter.providers = .any
- case let .provider(name):
- switch relayFilter.providers {
- case .any:
- relayFilter.providers = .only([name])
- case var .only(providers):
- if !providers.contains(name) {
- providers.append(name)
- providers.caseInsensitiveSort()
-
- if providers == availableProviders(for: relayFilter.ownership) {
- relayFilter.providers = .any
- } else {
- relayFilter.providers = .only(providers)
- }
- }
- }
+ relayFilter.providers = relayFilter.providers == .any ? .only([]) : .any
+ case .provider:
+ toggleProvider(item.name)
}
}
- func removeItemFromFilter(_ item: RelayFilterDataSource.Item) {
- switch item {
- case .ownershipAny, .ownershipOwned, .ownershipRented:
- break
- case .allProviders:
- relayFilter.providers = .only([])
- case let .provider(name):
- switch relayFilter.providers {
- case .any:
- var providers = availableProviders(for: relayFilter.ownership)
- providers.removeAll { $0 == name }
- relayFilter.providers = .only(providers)
- case var .only(providers):
- providers.removeAll { $0 == name }
- relayFilter.providers = .only(providers)
- }
- }
+ func availableProviders(for ownership: RelayFilter.Ownership) -> [RelayFilterDataSourceItem] {
+ providers(for: ownership)
+ .map {
+ providerItem(for: $0)
+ }.sorted()
}
- func ownership(for item: RelayFilterDataSource.Item?) -> RelayFilter.Ownership? {
- switch item {
- case .ownershipAny:
- return .any
- case .ownershipOwned:
- return .owned
- case .ownershipRented:
- return .rented
- default:
- return nil
- }
+ func ownership(for item: RelayFilterDataSourceItem) -> RelayFilter.Ownership? {
+ let ownershipMapping: [RelayFilterDataSourceItem.ItemType: RelayFilter.Ownership] = [
+ .ownershipAny: .any,
+ .ownershipOwned: .owned,
+ .ownershipRented: .rented,
+ ]
+
+ return ownershipMapping[item.type]
}
- func ownershipItem(for ownership: RelayFilter.Ownership?) -> RelayFilterDataSource.Item? {
+ func ownershipItem(for ownership: RelayFilter.Ownership) -> RelayFilterDataSourceItem? {
+ let ownershipMapping: [RelayFilter.Ownership: RelayFilterDataSourceItem.ItemType] = [
+ .any: .ownershipAny,
+ .owned: .ownershipOwned,
+ .rented: .ownershipRented,
+ ]
+
+ return RelayFilterDataSourceItem.ownerships
+ .first { $0.type == ownershipMapping[ownership] }
+ }
+
+ func providerItem(for providerName: String) -> RelayFilterDataSourceItem {
+ let isDaitaEnabled = settings.daita.daitaState.isEnabled
+ let isProviderEnabled = isProviderEnabled(for: providerName)
+ let isFilterable = getFilteredRelays(relayFilter).isEnabled
+ return RelayFilterDataSourceItem(
+ name: providerName,
+ description: isDaitaEnabled && isProviderEnabled
+ ? NSLocalizedString(
+ "RELAY_FILTER_PROVIDER_DESCRIPTION_FORMAT_LABEL",
+ tableName: "RelayFilter",
+ value: "DAITA-enabled",
+ comment: "Format for DAITA provider description"
+ )
+ : "",
+ type: .provider,
+ // If the current filter is valid, return true immediately.
+ // Otherwise, check if the provider is enabled when filtering specifically by the given provider name.
+ isEnabled: isFilterable || isProviderEnabled
+ )
+ }
+
+ func getFilteredRelays(_ relayFilter: RelayFilter) -> FilterDescriptor {
+ return FilterDescriptor(
+ relayFilterResult: RelayCandidates(
+ entryRelays: relayCandidatesForAny.entryRelays?.filter {
+ RelaySelector.relayMatchesFilter($0.relay, filter: relayFilter)
+ },
+ exitRelays: relayCandidatesForAny.exitRelays.filter {
+ RelaySelector.relayMatchesFilter($0.relay, filter: relayFilter)
+ }
+ ),
+ settings: settings
+ )
+ }
+
+ // MARK: - private Methods
+
+ private func providers(for ownership: RelayFilter.Ownership) -> [String] {
switch ownership {
case .any:
- return .ownershipAny
+ uniqueProviders
case .owned:
- return .ownershipOwned
+ ownedProviders
case .rented:
- return .ownershipRented
- default:
- return nil
+ rentedProviders
}
}
- func providerName(for item: RelayFilterDataSource.Item?) -> String? {
- switch item {
- case let .provider(name):
- return name
- default:
- return nil
+ private func toggleProvider(_ name: String) {
+ switch relayFilter.providers {
+ case .any:
+ // If currently "any", switch to only the selected provider
+ var providers = providers(for: relayFilter.ownership)
+ providers.removeAll { $0 == name }
+ relayFilter.providers = .only(providers.map { $0 })
+ case var .only(selectedProviders):
+ if selectedProviders.contains(name) {
+ // If provider exists, remove it
+ selectedProviders.removeAll { $0 == name }
+ } else {
+ // Otherwise, add it
+ selectedProviders.append(name)
+ }
+
+ // If all available providers are selected, switch back to "any"
+ relayFilter.providers = selectedProviders.isEmpty
+ ? .only([])
+ : (
+ selectedProviders.count == providers(for: relayFilter.ownership).count
+ ? .any
+ : .only(selectedProviders)
+ )
}
}
- func providerItem(for providerName: String?) -> RelayFilterDataSource.Item? {
- return .provider(providerName ?? "")
+ private func extractProviders(from relays: [REST.ServerRelay]) -> [String] {
+ Set(relays.map { $0.provider }).caseInsensitiveSorted()
}
- func availableProviders(for ownership: RelayFilter.Ownership) -> [String] {
- switch ownership {
- case .any:
- return uniqueProviders
- case .owned:
- return ownedProviders
- case .rented:
- return rentedProviders
- }
+ private func isProviderEnabled(for providerName: String) -> Bool {
+ // Check if the provider is enabled when filtering specifically by the given provider name.
+ return getFilteredRelays(
+ RelayFilter(ownership: relayFilter.ownership, providers: .only([providerName]))
+ ).isEnabled
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
index 7eb23e40fd..bbb4e1125c 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,22 @@ 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,
+ directionalEdgeInsets: NSDirectionalEdgeInsets(top: 24, leading: 16, bottom: 0, trailing: 16)
+ ))
+ case .customLists:
+ return nil
+ }
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
switch sections[section] {
case .allLocations:
- return .zero
+ return dataSources[section].nodes.isEmpty ? 80 : .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..0b6b443b5e 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,19 @@ class LocationSectionHeaderView: UIView, UIContentView {
}
private var actualConfiguration: Configuration
+
+ private let containerView: UIStackView = {
+ let containerView = UIStackView()
+ containerView.axis = .horizontal
+ containerView.spacing = 8
+ containerView.isLayoutMarginsRelativeArrangement = true
+ 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
@@ -40,9 +50,8 @@ class LocationSectionHeaderView: UIView, UIContentView {
init(configuration: Configuration) {
self.actualConfiguration = configuration
super.init(frame: .zero)
- applyAppearance()
- addSubviews()
apply(configuration: configuration)
+ addSubviews()
}
required init?(coder: NSCoder) {
@@ -50,44 +59,61 @@ class LocationSectionHeaderView: UIView, UIContentView {
}
private func addSubviews() {
- addConstrainedSubviews([nameLabel, actionButton]) {
- nameLabel.pinEdgesToSuperviewMargins(.all().excluding(.trailing))
-
- actionButton.pinEdgesToSuperview(PinnableEdges([.trailing(8)]))
- actionButton.heightAnchor.constraint(equalTo: heightAnchor)
+ containerView.addArrangedSubview(nameLabel)
+ containerView.addArrangedSubview(actionButton)
+ addConstrainedSubviews([containerView]) {
+ containerView.pinEdgesToSuperviewMargins()
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
actionButton.setAccessibilityIdentifier(.openCustomListsMenuButton)
actionButton.addAction(action, for: .touchUpInside)
}
+ directionalLayoutMargins = actualConfiguration.directionalEdgeInsets
}
+}
+
+extension LocationSectionHeaderFooterView {
+ struct Style: Equatable {
+ let font: UIFont
+ let textColor: UIColor
+ let textAlignment: NSTextAlignment
+ let backgroundColor: UIColor
- private func applyAppearance() {
- backgroundColor = .primaryColor
+ static let header = Style(
+ font: .preferredFont(forTextStyle: .body, weight: .semibold),
+ textColor: .primaryTextColor,
+ textAlignment: .natural,
+ backgroundColor: .primaryColor
+ )
- let leadingInset = UIMetrics.locationCellLayoutMargins.leading + 6
- directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: leadingInset, bottom: 8, trailing: 24)
+ static let footer = Style(
+ font: .preferredFont(forTextStyle: .body, weight: .regular),
+ textColor: .secondaryTextColor,
+ textAlignment: .center,
+ backgroundColor: .clear
+ )
}
-}
-extension LocationSectionHeaderView {
struct Configuration: UIContentConfiguration, Equatable {
let name: String
-
+ let style: Style
+ var directionalEdgeInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
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..5fa22383bf 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
@@ -48,49 +48,54 @@ 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 {
+ settings.relayConstraints.filter.value ?? 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
-
- 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
+ self.multihopContext = startContext
- 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 +121,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: 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 +147,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 +171,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 +216,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 +259,7 @@ final class LocationViewControllerWrapper: UIViewController {
(
RelaySelection(
selected: selectedExit,
- excluded: multihopEnabled ? selectedEntry : nil,
+ excluded: settings.tunnelMultihopState.isEnabled ? selectedEntry : nil,
excludedTitle: MultihopContext.entry.description
),
entryLocationViewController,
diff --git a/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
index 9eb5dfe1ad..64812ff6c5 100644
--- a/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
@@ -11,6 +11,12 @@ import UIKit
class CheckableSettingsCell: SettingsCell {
let checkboxView = CheckboxView()
+ var isEnabled = true {
+ didSet {
+ titleLabel.isEnabled = isEnabled
+ }
+ }
+
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift
index 5b44407fe0..8a5988ca83 100644
--- a/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift
+++ b/ios/MullvadVPNTests/MullvadREST/Relay/MultihopDecisionFlowTests.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadSettings
@testable import MullvadTypes
diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/ObfuscatorPortSelectorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/ObfuscatorPortSelectorTests.swift
index bf180bfca4..bafc9cc1b6 100644
--- a/ios/MullvadVPNTests/MullvadREST/Relay/ObfuscatorPortSelectorTests.swift
+++ b/ios/MullvadVPNTests/MullvadREST/Relay/ObfuscatorPortSelectorTests.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadSettings
@testable import MullvadTypes
diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift
index 17e80ef8c3..ed7ae0ccdf 100644
--- a/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift
+++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelayPickingTests.swift
@@ -8,6 +8,7 @@
import Foundation
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadSettings
@testable import MullvadTypes
diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift
index 868d41e4c9..e383aa1b25 100644
--- a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift
+++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorTests.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadSettings
import MullvadTypes
@@ -214,14 +215,12 @@ class RelaySelectorTests: XCTestCase {
let filter = RelayFilter(ownership: .rented, providers: .any)
let constraints = RelayConstraints(
- exitLocations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])),
+ exitLocations: .only(UserSelectedRelays(locations: [.hostname("es", "mad", "es1-wireguard")])),
filter: .only(filter)
)
- XCTAssertThrowsError(try pickRelay(by: constraints, in: sampleRelays, failedAttemptCount: 0)) { error in
- let error = error as? NoRelaysSatisfyingConstraintsError
- XCTAssertEqual(error?.reason, .filterConstraintNotMatching)
- }
+ let result = try pickRelay(by: constraints, in: sampleRelays, failedAttemptCount: 0)
+ XCTAssertNotEqual(result.relay.owned, true)
}
func testRelayFilterConstraintWithCorrectProvider() throws {
@@ -238,7 +237,7 @@ class RelaySelectorTests: XCTestCase {
}
func testRelayFilterConstraintWithIncorrectProvider() throws {
- let provider = "DataPacket"
+ let provider = ""
let filter = RelayFilter(ownership: .any, providers: .only([provider]))
let constraints = RelayConstraints(
diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift
index 57c506404d..b84cb528b8 100644
--- a/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift
+++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelaySelectorWrapperTests.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadSettings
@testable import MullvadTypes
diff --git a/ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift b/ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift
index 971604d24c..d3b7967cf4 100644
--- a/ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift
+++ b/ios/MullvadVPNTests/MullvadREST/Shadowsocks/ShadowsocksLoaderTests.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadSettings
@testable import MullvadTypes
diff --git a/ios/MullvadVPNTests/MullvadVPN/RelayCacheTracker/RelayCacheTracker+Stubs.swift b/ios/MullvadVPNTests/MullvadVPN/RelayCacheTracker/RelayCacheTracker+Stubs.swift
index 006cb10824..5e98252c96 100644
--- a/ios/MullvadVPNTests/MullvadVPN/RelayCacheTracker/RelayCacheTracker+Stubs.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/RelayCacheTracker/RelayCacheTracker+Stubs.swift
@@ -7,6 +7,7 @@
//
import Foundation
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadTypes
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/FilterDescriptorTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/FilterDescriptorTests.swift
new file mode 100644
index 0000000000..66103b5a8c
--- /dev/null
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/FilterDescriptorTests.swift
@@ -0,0 +1,169 @@
+//
+// FilterDescriptorTests.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2025-03-10.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+import MullvadMockData
+@testable import MullvadREST
+@testable import MullvadSettings
+@testable import MullvadTypes
+import Testing
+
+@Suite("FilterDescriptorTests")
+struct FilterDescriptorTests {
+ @Test(
+ "Returns correct filter descriptor based on settings and relays",
+ arguments: [
+ (
+ LatestTunnelSettings(tunnelMultihopState: .on),
+ RelayCandidates(entryRelays: [], exitRelays: createRelayWithLocation()),
+ false,
+ false
+ ),
+ (
+ LatestTunnelSettings(tunnelMultihopState: .on),
+ RelayCandidates(entryRelays: createRelayWithLocation(), exitRelays: createRelayWithLocation()),
+ true,
+ false
+ ),
+ (
+ LatestTunnelSettings(daita: DAITASettings(daitaState: .on, directOnlyState: .off)),
+ RelayCandidates(entryRelays: [], exitRelays: [esMad1]),
+ true,
+ true
+ ),
+ (
+ LatestTunnelSettings(daita: DAITASettings(daitaState: .on, directOnlyState: .off)),
+ RelayCandidates(entryRelays: [esMad1], exitRelays: [seSto6]),
+ true,
+ true
+ ),
+ (
+ LatestTunnelSettings(daita: DAITASettings(daitaState: .on, directOnlyState: .on)),
+ RelayCandidates(entryRelays: nil, exitRelays: [esMad1]),
+ true,
+ true
+ ),
+ (
+ LatestTunnelSettings(daita: DAITASettings(daitaState: .off, directOnlyState: .off)),
+ RelayCandidates(entryRelays: nil, exitRelays: [esMad1, seSto6]),
+ true,
+ false
+ ),
+ (
+ LatestTunnelSettings(
+ tunnelMultihopState: .on,
+ daita: DAITASettings(daitaState: .off, directOnlyState: .on)
+ ),
+ RelayCandidates(entryRelays: nil, exitRelays: []),
+ false,
+ false
+ ),
+ (
+ LatestTunnelSettings(
+ tunnelMultihopState: .on,
+ daita: DAITASettings(daitaState: .off, directOnlyState: .off)
+ ),
+ RelayCandidates(entryRelays: nil, exitRelays: []),
+ false,
+ false
+ ),
+ (
+ LatestTunnelSettings(
+ tunnelMultihopState: .on,
+ daita: DAITASettings(daitaState: .on, directOnlyState: .on)
+ ),
+ RelayCandidates(entryRelays: createRelayWithLocation(), exitRelays: createRelayWithLocation()),
+ true,
+ true
+ ),
+ (
+ LatestTunnelSettings(
+ tunnelMultihopState: .on,
+ daita: DAITASettings(daitaState: .on, directOnlyState: .off)
+ ),
+ RelayCandidates(entryRelays: createRelayWithLocation(), exitRelays: createRelayWithLocation()),
+ true,
+ true
+ ),
+ ]
+ )
+ func testFilterDescriptor(
+ _ settings: LatestTunnelSettings,
+ _ relayCandidates: RelayCandidates,
+ _ expectedEnabledState: Bool,
+ _ expectedDescription: Bool
+ ) {
+ let filterDescriptor = FilterDescriptor(
+ relayFilterResult: relayCandidates,
+ settings: settings
+ )
+
+ #expect(
+ filterDescriptor.isEnabled == expectedEnabledState,
+ "Expected filter descriptor to be \(expectedEnabledState ? "enabled" : "disabled")"
+ )
+ #expect(
+ (filterDescriptor.title.rangeOfCharacter(from: .decimalDigits) != nil) == expectedEnabledState,
+ "Title should contain numbers only when enabled"
+ )
+ #expect(
+ filterDescriptor.description.isEmpty != expectedDescription,
+ "Description should \(expectedDescription ? "not be empty" : "be empty")"
+ )
+ }
+
+ // Helper function to generate relay locations
+ private static func createRelayWithLocation() -> [RelayWithLocation<REST.ServerRelay>] {
+ let sampleRelays = ServerRelaysResponseStubs.sampleRelays
+ return sampleRelays.wireguard.relays.map { relay in
+ let location = sampleRelays.locations[relay.location.rawValue]!
+
+ return RelayWithLocation(
+ relay: relay,
+ serverLocation: Location(
+ country: location.country,
+ countryCode: String(relay.location.country),
+ city: location.city,
+ cityCode: String(relay.location.city),
+ latitude: location.latitude,
+ longitude: location.longitude
+ )
+ )
+ }
+ }
+
+ private static var seSto6: RelayWithLocation<REST.ServerRelay> {
+ let sampleRelays = ServerRelaysResponseStubs.sampleRelays
+ let relay = sampleRelays.wireguard.relays.first { $0.hostname == "se6-wireguard" }!
+ let serverLocation = sampleRelays.locations["se-sto"]!
+ let location = Location(
+ country: serverLocation.country,
+ countryCode: serverLocation.country,
+ city: serverLocation.city,
+ cityCode: "se-sto",
+ latitude: serverLocation.latitude,
+ longitude: serverLocation.longitude
+ )
+
+ return RelayWithLocation(relay: relay, serverLocation: location)
+ }
+
+ private static var esMad1: RelayWithLocation<REST.ServerRelay> {
+ let sampleRelays = ServerRelaysResponseStubs.sampleRelays
+ let relay = sampleRelays.wireguard.relays.first { $0.hostname == "es1-wireguard" }!
+ let serverLocation = sampleRelays.locations["es-mad"]!
+ let location = Location(
+ country: serverLocation.country,
+ countryCode: serverLocation.country,
+ city: serverLocation.city,
+ cityCode: "es-mad",
+ latitude: serverLocation.latitude,
+ longitude: serverLocation.longitude
+ )
+
+ return RelayWithLocation(relay: relay, serverLocation: location)
+ }
+}
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/RelayFilterViewModelTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/RelayFilterViewModelTests.swift
new file mode 100644
index 0000000000..f011a15af8
--- /dev/null
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/Filter/RelayFilterViewModelTests.swift
@@ -0,0 +1,161 @@
+//
+// RelayFilterViewModelTests.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2025-03-10.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+import MullvadMockData
+@testable import MullvadREST
+@testable import MullvadSettings
+@testable import MullvadTypes
+import Testing
+
+struct RelayFilterViewModelTests {
+ @Test(
+ "Filters relays based on settings",
+ arguments: [
+ LatestTunnelSettings(),
+ LatestTunnelSettings(daita: DAITASettings(daitaState: .on, directOnlyState: .on)),
+ LatestTunnelSettings(daita: DAITASettings(daitaState: .on, directOnlyState: .off)),
+ LatestTunnelSettings(
+ tunnelMultihopState: .on,
+ daita: DAITASettings(daitaState: .on, directOnlyState: .on)
+ ),
+ LatestTunnelSettings(
+ tunnelMultihopState: .on,
+ daita: DAITASettings(daitaState: .on, directOnlyState: .off)
+ ),
+ ]
+ )
+ func testRelayFiltering(_ settings: LatestTunnelSettings) {
+ let viewModel = RelayFilterViewModel(
+ settings: settings,
+ relaySelectorWrapper: RelaySelectorWrapper(relayCache: MockRelayCache())
+ )
+ let filteredRelays = viewModel.getFilteredRelays(RelayFilter())
+
+ #expect(filteredRelays.isEnabled, "Filtered relays should be enabled")
+ }
+
+ @Test(
+ "Returns correct providers based on ownership type",
+ arguments: [
+ RelayFilter.Ownership.any,
+ RelayFilter.Ownership.owned,
+ RelayFilter.Ownership.rented
+ ]
+ )
+ func testAvailableProvidersByOwnership(_ ownership: RelayFilter.Ownership) {
+ let viewModel = RelayFilterViewModel(
+ settings: LatestTunnelSettings(),
+ relaySelectorWrapper: RelaySelectorWrapper(relayCache: MockRelayCache())
+ )
+ let providers = viewModel.availableProviders(for: ownership)
+
+ #expect(!providers.isEmpty, "Providers list should not be empty for \(ownership)")
+ }
+
+ @Test(
+ "Toggles relay providers filter items correctly",
+ arguments: [
+ RelayFilterDataSourceItem(name: "DataPacket", type: .provider, isEnabled: true),
+ RelayFilterDataSourceItem(name: "All Providers", type: .allProviders, isEnabled: true),
+ RelayFilterDataSourceItem(name: "Blix", type: .provider, isEnabled: true)
+ ]
+ )
+ func testToggleFilterItem(_ item: RelayFilterDataSourceItem) {
+ let viewModel = RelayFilterViewModel(
+ settings: LatestTunnelSettings(),
+ relaySelectorWrapper: RelaySelectorWrapper(relayCache: MockRelayCache())
+ )
+
+ let initialFilter = viewModel.relayFilter
+ viewModel.toggleItem(item)
+ let updatedFilter = viewModel.relayFilter
+
+ #expect(initialFilter != updatedFilter, "Toggling \(item.name) should change the filter state")
+
+ viewModel.toggleItem(item)
+
+ #expect(viewModel.relayFilter == initialFilter, "Toggling twice should restore the initial state")
+ }
+
+ @Test(
+ "Toggles relay provider filter items correctly",
+ arguments: [
+ RelayFilterDataSourceItem.ownedOwnershipItem,
+ RelayFilterDataSourceItem.rentedOwnershipItem
+ ]
+ )
+ func testToggleRelayProviderFilterItem(_ item: RelayFilterDataSourceItem) {
+ let viewModel = RelayFilterViewModel(
+ settings: LatestTunnelSettings(),
+ relaySelectorWrapper: RelaySelectorWrapper(relayCache: MockRelayCache())
+ )
+
+ let initialFilter = viewModel.relayFilter
+ viewModel.toggleItem(item)
+ let updatedFilter = viewModel.relayFilter
+
+ #expect(initialFilter != updatedFilter, "Toggling \(item.name) should update the filter state")
+ #expect(
+ viewModel.relayFilter.ownership == viewModel.ownership(for: item),
+ "Filter's ownership should match the toggled item's ownership"
+ )
+ }
+
+ @Test(
+ "Maps ownership filter to the correct ownership item",
+ arguments: [
+ (RelayFilter.Ownership.any, RelayFilterDataSourceItem.anyOwnershipItem),
+ (RelayFilter.Ownership.owned, RelayFilterDataSourceItem.ownedOwnershipItem),
+ (RelayFilter.Ownership.rented, RelayFilterDataSourceItem.rentedOwnershipItem)
+ ]
+ )
+ func testOwnershipItemForFilter(
+ _ ownership: RelayFilter.Ownership,
+ expectedItem: RelayFilterDataSourceItem
+ ) throws {
+ let viewModel = RelayFilterViewModel(
+ settings: LatestTunnelSettings(),
+ relaySelectorWrapper: RelaySelectorWrapper(relayCache: MockRelayCache())
+ )
+ guard let ownershipItem = viewModel.ownershipItem(for: ownership) else {
+ throw TestError.nilOwnershipItem("ownershipItem(for: \(ownership)) returned nil")
+ }
+
+ #expect(
+ ownershipItem == expectedItem,
+ "Expected \(expectedItem.name) for ownership type \(ownership), but got \(ownershipItem.name)"
+ )
+ }
+
+ @Test(
+ "Maps ownership item to the correct ownership filter",
+ arguments: [
+ (RelayFilterDataSourceItem.anyOwnershipItem, RelayFilter.Ownership.any),
+ (RelayFilterDataSourceItem.ownedOwnershipItem, RelayFilter.Ownership.owned),
+ (RelayFilterDataSourceItem.rentedOwnershipItem, RelayFilter.Ownership.rented),
+ ]
+ )
+ func testFilterOwnershipForItem(
+ _ ownershipItem: RelayFilterDataSourceItem,
+ expectedOwnership: RelayFilter.Ownership
+ ) {
+ let viewModel = RelayFilterViewModel(
+ settings: LatestTunnelSettings(),
+ relaySelectorWrapper: RelaySelectorWrapper(relayCache: MockRelayCache())
+ )
+ let ownership = viewModel.ownership(for: ownershipItem)
+
+ #expect(
+ ownership == expectedOwnership,
+ "Expected ownership \(expectedOwnership) for item \(ownershipItem.name), but got \(ownership)"
+ )
+ }
+}
+
+private enum TestError: Error {
+ case nilOwnershipItem(String)
+}
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
index 02e269a003..4a33ad01f8 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+import MullvadMockData
@testable import MullvadSettings
import XCTest
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
index 2ce088de3a..46fa7e03e8 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
@@ -6,6 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+import MullvadMockData
@testable import MullvadSettings
@testable import MullvadTypes
import XCTest
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift
index a33a153662..1ad55a8e71 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift
@@ -7,28 +7,12 @@
//
import Foundation
+import MullvadMockData
@testable import MullvadREST
@testable import MullvadSettings
import Network
import XCTest
-struct MockRelayCache: RelayCacheProtocol {
- func read() throws -> MullvadREST.StoredRelays {
- try .init(
- cachedRelays: CachedRelays(
- relays: ServerRelaysResponseStubs.sampleRelays,
- updatedAt: Date()
- )
- )
- }
-
- func readPrebundledRelays() throws -> MullvadREST.StoredRelays {
- try self.read()
- }
-
- func write(record: MullvadREST.StoredRelays) throws {}
-}
-
final class DestinationDescriberTests: XCTestCase {
static let store = InMemorySettingsStore<SettingNotFound>()
override static func setUp() {