diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2025-03-18 09:05:29 +0100 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-03-18 09:05:29 +0100 |
| commit | e9af9e754c57fb9646cc5701c0f34b285ece100f (patch) | |
| tree | 3afa9276af1aa1c7d2d9b0be302549f999059a8c | |
| parent | fa6258844450f1f09bf1e9d3b2dd9c100803a63a (diff) | |
| parent | 37f23efa253850d8cf5551bb8f0aa0cba9338ed6 (diff) | |
| download | mullvadvpn-e9af9e754c57fb9646cc5701c0f34b285ece100f.tar.xz mullvadvpn-e9af9e754c57fb9646cc5701c0f34b285ece100f.zip | |
Merge branch 'improve-filter-view-ios-1067'
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() { |
