summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@kvadrat.se>2023-06-07 13:08:40 +0200
committerBug Magnet <marco.nikic@mullvad.net>2023-10-17 14:18:42 +0200
commita08809bb3b3900d8cfaae1a4c01cbb20b081fe39 (patch)
tree1a1a851c8c30e1ba3f2ed24dcd24c078e79847a0 /ios
parent88463387e9d83e3366fb8c087605dc4c052f4981 (diff)
downloadmullvadvpn-a08809bb3b3900d8cfaae1a4c01cbb20b081fe39.tar.xz
mullvadvpn-a08809bb3b3900d8cfaae1a4c01cbb20b081fe39.zip
Add filtering to location selection
Diffstat (limited to 'ios')
-rw-r--r--ios/CHANGELOG.md2
-rw-r--r--ios/MullvadTypes/RelayConstraints.swift6
-rw-r--r--ios/MullvadTypes/RelayFilter.swift25
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj96
-rw-r--r--ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift89
-rw-r--r--ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift79
-rw-r--r--ios/MullvadVPN/Extensions/Collection+Sorting.swift21
-rw-r--r--ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift2
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift64
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountContentView.swift2
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift2
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift2
-rw-r--r--ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift4
-rw-r--r--ios/MullvadVPN/View controllers/Preferences/PreferencesCellFactory.swift1
-rw-r--r--ios/MullvadVPN/View controllers/Preferences/PreferencesDataSource.swift5
-rw-r--r--ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift2
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/AddCreditSucceededViewController.swift2
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift87
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift55
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift397
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift122
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift111
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift127
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift9
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift2
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift75
-rw-r--r--ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift42
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift4
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsCell.swift22
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsDNSInfoCell.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift7
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsInputCell.swift2
-rw-r--r--ios/MullvadVPN/Views/CheckboxView.swift47
-rw-r--r--ios/MullvadVPNTests/RelaySelectorTests.swift68
-rw-r--r--ios/MullvadVPNTests/ServerRelaysResponse+Stubs.swift2
-rw-r--r--ios/RelaySelector/RelaySelector.swift31
37 files changed, 1511 insertions, 107 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 91871e7b47..9b948ec015 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -44,6 +44,8 @@ Line wrap the file at 100 chars. Th
- Rotate public key from within packet tunnel when it detects that the key stored on backend does
not match the one stored on device.
- Add WireGuard port selection to settings.
+- Add redeeming voucher code on account view.
+- Add filtering on ownership and provider to location selection view.
## [2023.2 - 2023-04-03]
### Changed
diff --git a/ios/MullvadTypes/RelayConstraints.swift b/ios/MullvadTypes/RelayConstraints.swift
index f0bc09bf25..602e17b088 100644
--- a/ios/MullvadTypes/RelayConstraints.swift
+++ b/ios/MullvadTypes/RelayConstraints.swift
@@ -25,6 +25,7 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible
// Added in 2023.3
public var port: RelayConstraint<UInt16>
+ public var filter: RelayConstraint<RelayFilter>
public var debugDescription: String {
"RelayConstraints { location: \(location), port: \(port) }"
@@ -32,10 +33,12 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible
public init(
location: RelayConstraint<RelayLocation> = .only(.country("se")),
- port: RelayConstraint<UInt16> = .any
+ port: RelayConstraint<UInt16> = .any,
+ filter: RelayConstraint<RelayFilter> = .any
) {
self.location = location
self.port = port
+ self.filter = filter
}
public init(from decoder: Decoder) throws {
@@ -44,5 +47,6 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible
// Added in 2023.3
port = try container.decodeIfPresent(RelayConstraint<UInt16>.self, forKey: .port) ?? .any
+ filter = try container.decodeIfPresent(RelayConstraint<RelayFilter>.self, forKey: .filter) ?? .any
}
}
diff --git a/ios/MullvadTypes/RelayFilter.swift b/ios/MullvadTypes/RelayFilter.swift
new file mode 100644
index 0000000000..48b5c0a326
--- /dev/null
+++ b/ios/MullvadTypes/RelayFilter.swift
@@ -0,0 +1,25 @@
+//
+// RelayFilter.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-08.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public struct RelayFilter: Codable, Equatable {
+ public enum Ownership: Codable {
+ case any
+ case owned
+ case rented
+ }
+
+ public var ownership: Ownership
+ public var providers: RelayConstraint<[String]>
+
+ public init(ownership: Ownership = .any, providers: RelayConstraint<[String]> = .any) {
+ self.ownership = ownership
+ self.providers = providers
+ }
+}
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 3f6ab488ff..6484120dbc 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -418,6 +418,9 @@
7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */; };
7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBC42A8E44AC00E5CE4C /* AppRoutes.swift */; };
7A1A26432A2612AE00B978AA /* PaymentAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */; };
+ 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */; };
+ 7A1A26472A29CF0800B978AA /* RelayFilterDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */; };
+ 7A1A26492A29D48A00B978AA /* RelayFilterCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */; };
7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */; };
7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */; };
7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */; };
@@ -432,10 +435,8 @@
7A3FD1B72AD54ABD0042BEA6 /* AnyTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB982A98F4ED00F578F2 /* AnyTransport.swift */; };
7A3FD1B82AD54AE60042BEA6 /* TimeServerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */; };
7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */; };
- 7A42DECD2A09064C00B209BE /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */; };
7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */; };
7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; };
- 7A7AD28F29DEDB1C00480EF1 /* SettingsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28E29DEDB1C00480EF1 /* SettingsHeaderView.swift */; };
7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */; };
7A83C3FF2A55B72E00DFB83A /* MullvadVPNApp.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */; };
7A83C4022A57FAA800DFB83A /* SettingsDNSInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A83C4012A57FAA800DFB83A /* SettingsDNSInfoCell.swift */; };
@@ -449,7 +450,6 @@
7A88DCF52A93471F00D2FF0E /* ApplicationRouterTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBCA2A8E45DC00E5CE4C /* ApplicationRouterTypes.swift */; };
7A88DCF62A93471F00D2FF0E /* AppRouteProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBC62A8E457A00E5CE4C /* AppRouteProtocol.swift */; };
7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA12A96302700DD6A34 /* WelcomeCoordinator.swift */; };
- 7A9CCCB42A96302800DD6A34 /* TermsOfServiceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */; };
7A9CCCB52A96302800DD6A34 /* AddCreditSucceededCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA32A96302700DD6A34 /* AddCreditSucceededCoordinator.swift */; };
7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */; };
7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */; };
@@ -466,18 +466,29 @@
7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */; };
7A9CCCC32A96302800DD6A34 /* ApplicationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCB12A96302800DD6A34 /* ApplicationCoordinator.swift */; };
7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */; };
+ 7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1412A2E3306000B728D /* CheckboxView.swift */; };
+ 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */; };
7ABCA5B32A9349F20044A708 /* Routing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; };
7ABCA5B42A9349F20044A708 /* Routing.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
7ABCA5B72A9353C60044A708 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CAF9F72983D36800BE19F7 /* Coordinator.swift */; };
7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */; };
+ 7AC8A3AE2ABC6FBB00DC4939 /* SettingsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */; };
+ 7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */; };
7AD0AA1C2AD6A63F00119E10 /* PacketTunnelActorStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AD0AA1B2AD6A63F00119E10 /* PacketTunnelActorStub.swift */; };
7AD0AA1D2AD6A86700119E10 /* PacketTunnelActorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AD0AA192AD69B6E00119E10 /* PacketTunnelActorProtocol.swift */; };
7AD0AA1F2AD6C8B900119E10 /* URLRequestProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AD0AA1E2AD6C8B900119E10 /* URLRequestProxyProtocol.swift */; };
7AD0AA212AD6CB0000119E10 /* URLRequestProxyStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AD0AA202AD6CB0000119E10 /* URLRequestProxyStub.swift */; };
7AE044BB2A935726003915D8 /* Routing.h in Headers */ = {isa = PBXBuildFile; fileRef = 7A88DCD02A8FABBE00D2FF0E /* Routing.h */; settings = {ATTRIBUTES = (Public, ); }; };
- 7AE47E522A17972A000418DA /* AlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE47E512A17972A000418DA /* AlertViewController.swift */; };
7AEF7F1A2AD00F52006FE45D /* AppMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEF7F192AD00F52006FE45D /* AppMessageHandler.swift */; };
+ 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF10EB12ADE859200C090B9 /* AlertViewController.swift */; };
+ 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */; };
7AF6E5F02A95051E00F2679D /* RouterBlockDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */; };
+ 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */; };
+ 7AF9BE8C2A321D1F00DBFEDB /* RelayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */; };
+ 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */; };
+ 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; };
+ 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */; };
+ 7AF9BE972A41C71F00DBFEDB /* RelayFilterChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */; };
7AF9BE992A4E0FE900DBFEDB /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */; };
A900E9B82ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */; };
A900E9BA2ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */; };
@@ -1494,6 +1505,11 @@
7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FuzzyMatch.swift"; sourceTree = "<group>"; };
7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Coordinator+Router.swift"; sourceTree = "<group>"; };
7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentAlertPresenter.swift; sourceTree = "<group>"; };
+ 7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewController.swift; sourceTree = "<group>"; };
+ 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterDataSource.swift; sourceTree = "<group>"; };
+ 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterCellFactory.swift; sourceTree = "<group>"; };
+ 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = "<group>"; };
+ 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = "<group>"; };
7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITextField+Appearance.swift"; sourceTree = "<group>"; };
7A2960F52A963F7500389B82 /* AlertCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCoordinator.swift; sourceTree = "<group>"; };
7A2960FC2A964BB700389B82 /* AlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresentation.swift; sourceTree = "<group>"; };
@@ -1508,7 +1524,6 @@
7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = "<group>"; };
7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = "<group>"; };
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = "<group>"; };
- 7A7AD28E29DEDB1C00480EF1 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = "<group>"; };
7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = "<group>"; };
7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNApp.xctestplan; sourceTree = "<group>"; };
7A83C4002A55B81A00DFB83A /* MullvadVPNCI.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MullvadVPNCI.xctestplan; sourceTree = "<group>"; };
@@ -1535,14 +1550,24 @@
7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SafariCoordinator.swift; sourceTree = "<group>"; };
7A9CCCB12A96302800DD6A34 /* ApplicationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationCoordinator.swift; sourceTree = "<group>"; };
7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = "<group>"; };
+ 7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = "<group>"; };
+ 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = "<group>"; };
7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = "<group>"; };
+ 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = "<group>"; };
7AD0AA192AD69B6E00119E10 /* PacketTunnelActorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorProtocol.swift; sourceTree = "<group>"; };
7AD0AA1B2AD6A63F00119E10 /* PacketTunnelActorStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorStub.swift; sourceTree = "<group>"; };
7AD0AA1E2AD6C8B900119E10 /* URLRequestProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestProxyProtocol.swift; sourceTree = "<group>"; };
7AD0AA202AD6CB0000119E10 /* URLRequestProxyStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestProxyStub.swift; sourceTree = "<group>"; };
- 7AE47E512A17972A000418DA /* AlertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertViewController.swift; sourceTree = "<group>"; };
7AEF7F192AD00F52006FE45D /* AppMessageHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandler.swift; sourceTree = "<group>"; };
+ 7AF10EB12ADE859200C090B9 /* AlertViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertViewController.swift; sourceTree = "<group>"; };
+ 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelayFilterCoordinator.swift; sourceTree = "<group>"; };
7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterBlockDelegate.swift; sourceTree = "<group>"; };
+ 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilter.swift; sourceTree = "<group>"; };
+ 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = "<group>"; };
+ 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = "<group>"; };
+ 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterCoordinator.swift; sourceTree = "<group>"; };
+ 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; };
+ 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterChipView.swift; sourceTree = "<group>"; };
7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownStylingOptions.swift; sourceTree = "<group>"; };
A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = "<group>"; };
A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTRequestExecutor+Stubs.swift"; sourceTree = "<group>"; };
@@ -1910,6 +1935,7 @@
58CAFA01298530DC00BE19F7 /* Promise.swift */,
5898D2B12902A6DE00EB5EBA /* RelayConstraint.swift */,
58781CC822AE7CA8009B9D8E /* RelayConstraints.swift */,
+ 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */,
5898D2AF2902A67C00EB5EBA /* RelayLocation.swift */,
581DA2722A1E227D0046ED47 /* RESTTypes.swift */,
58F1311427E0B2AB007AC5BC /* Result+Extensions.swift */,
@@ -1983,6 +2009,7 @@
583FE01A29C19777006E85F9 /* Preferences */,
583FE01929C19760006E85F9 /* ProblemReport */,
F028A5472A336E1900C0CAA3 /* RedeemVoucher */,
+ 7AF9BE912A39F47D00DBFEDB /* RelayFilter */,
583FE01C29C19793006E85F9 /* RevokedDevice */,
583FE01729C196F3006E85F9 /* SelectLocation */,
583FE01829C19709006E85F9 /* Settings */,
@@ -2006,7 +2033,8 @@
583FE01829C19709006E85F9 /* Settings */ = {
isa = PBXGroup;
children = (
- 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */,
+ 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */,
+ 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */,
5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */,
582BB1AE229566420055B6EF /* SettingsCell.swift */,
5864AF0029C7879B005B0CD9 /* SettingsCellFactory.swift */,
@@ -2014,12 +2042,12 @@
58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */,
7A83C4012A57FAA800DFB83A /* SettingsDNSInfoCell.swift */,
584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */,
- 7A7AD28E29DEDB1C00480EF1 /* SettingsHeaderView.swift */,
+ 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */,
+ 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */,
58677711290976FB006F721F /* SettingsInteractor.swift */,
5867770F290975E8006F721F /* SettingsInteractorFactory.swift */,
58ACF64A26553C3F00ACE4B7 /* SettingsSwitchCell.swift */,
58CCA01122424D11004F3011 /* SettingsViewController.swift */,
- 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */,
);
path = Settings;
sourceTree = "<group>";
@@ -2041,8 +2069,8 @@
5864AF0229C7879B005B0CD9 /* PreferencesCellFactory.swift */,
584D26C3270C855A004EA533 /* PreferencesDataSource.swift */,
587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */,
- 58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */,
5871167E2910035700D41AAC /* PreferencesInteractor.swift */,
+ 58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */,
587EB671271451E300123C75 /* PreferencesViewModel.swift */,
);
path = Preferences;
@@ -2097,6 +2125,7 @@
isa = PBXGroup;
children = (
5868585424054096000B8131 /* AppButton.swift */,
+ 7A9FA1412A2E3306000B728D /* CheckboxView.swift */,
58ACF64C26567A4F00ACE4B7 /* CustomSwitch.swift */,
58ACF64E26567A7100ACE4B7 /* CustomSwitchContainer.swift */,
58293FB025124117005D0BB5 /* CustomTextField.swift */,
@@ -2155,6 +2184,7 @@
587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */,
58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */,
7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */,
+ 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */,
5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */,
584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */,
587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */,
@@ -2596,6 +2626,7 @@
7A9CCCAB2A96302800DD6A34 /* LoginCoordinator.swift */,
7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */,
7A9CCCAE2A96302800DD6A34 /* ProfileVoucherCoordinator.swift */,
+ 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */,
7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */,
7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */,
7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */,
@@ -2638,6 +2669,7 @@
7A88DCDD2A8FABBE00D2FF0E /* RoutingTests */,
58CE5E61224146200008646E /* Products */,
584F991F2902CBDD001F858D /* Frameworks */,
+ 7AC8A3A82ABC6F4800DC4939 /* Recovered References */,
);
sourceTree = "<group>";
};
@@ -2871,7 +2903,7 @@
children = (
7A2960FC2A964BB700389B82 /* AlertPresentation.swift */,
58B9EB122488ED2100095626 /* AlertPresenter.swift */,
- 7AE47E512A17972A000418DA /* AlertViewController.swift */,
+ 7AF10EB12ADE859200C090B9 /* AlertViewController.swift */,
);
path = Alert;
sourceTree = "<group>";
@@ -2907,6 +2939,29 @@
path = RoutingTests;
sourceTree = "<group>";
};
+ 7AC8A3A82ABC6F4800DC4939 /* Recovered References */ = {
+ isa = PBXGroup;
+ children = (
+ 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.swift */,
+ 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */,
+ 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */,
+ );
+ name = "Recovered References";
+ sourceTree = "<group>";
+ };
+ 7AF9BE912A39F47D00DBFEDB /* RelayFilter */ = {
+ isa = PBXGroup;
+ children = (
+ 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */,
+ 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */,
+ 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */,
+ 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */,
+ 7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */,
+ 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */,
+ );
+ path = RelayFilter;
+ sourceTree = "<group>";
+ };
A97F1F422A1F4E1A00ECEFDE /* MullvadTransport */ = {
isa = PBXGroup;
children = (
@@ -4214,6 +4269,7 @@
7A3353932AAA089000F0A71C /* SimulatorTunnelInfo.swift in Sources */,
5867771429097BCD006F721F /* PaymentState.swift in Sources */,
F0EF50D32A8FA47E0031E8DF /* ChangeLogInteractor.swift in Sources */,
+ 7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */,
F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */,
7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */,
587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */,
@@ -4225,6 +4281,7 @@
5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */,
587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */,
586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */,
+ 7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */,
58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */,
584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */,
58F2E148276A307400A79513 /* MapConnectionStatusOperation.swift in Sources */,
@@ -4243,6 +4300,7 @@
F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */,
58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */,
5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */,
+ 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */,
58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */,
58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */,
5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */,
@@ -4250,7 +4308,9 @@
58435AC229CB2A350099C71B /* LocationCellFactory.swift in Sources */,
58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */,
E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */,
+ 7AC8A3AE2ABC6FBB00DC4939 /* SettingsHeaderView.swift in Sources */,
582BB1B1229569620055B6EF /* UINavigationBar+Appearance.swift in Sources */,
+ 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */,
58ACF6492655365700ACE4B7 /* PreferencesViewController.swift in Sources */,
7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */,
58C774BE29A7A249003A1A56 /* CustomNavigationController.swift in Sources */,
@@ -4258,6 +4318,7 @@
7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */,
0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */,
58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */,
+ 7A1A26472A29CF0800B978AA /* RelayFilterDataSource.swift in Sources */,
5864AF0929C78850005B0CD9 /* PreferencesCellFactory.swift in Sources */,
587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */,
5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */,
@@ -4293,6 +4354,7 @@
5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */,
58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */,
586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */,
+ 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */,
58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */,
5864859929A0D028006C5743 /* FormsheetPresentationController.swift in Sources */,
7A9CCCB52A96302800DD6A34 /* AddCreditSucceededCoordinator.swift in Sources */,
@@ -4316,31 +4378,33 @@
5878A27729093A4F0096FC88 /* StorePaymentBlockObserver.swift in Sources */,
5868585524054096000B8131 /* AppButton.swift in Sources */,
58E25F812837BBBB002CFB2C /* SceneDelegate.swift in Sources */,
- 7A9CCCB42A96302800DD6A34 /* TermsOfServiceCoordinator.swift in Sources */,
+ 7A1A26492A29D48A00B978AA /* RelayFilterCellFactory.swift in Sources */,
5867771629097C5B006F721F /* ProductState.swift in Sources */,
58C76A082A33850E00100D75 /* ApplicationTarget.swift in Sources */,
F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */,
585E820327F3285E00939F0E /* SendStoreReceiptOperation.swift in Sources */,
5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */,
585B4B8726D9098900555C4C /* TunnelStatusNotificationProvider.swift in Sources */,
+ 7AF9BE972A41C71F00DBFEDB /* RelayFilterChipView.swift in Sources */,
063F026628FFE11C001FA09F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */,
58DF28A52417CB4B00E836B0 /* StorePaymentManager.swift in Sources */,
583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */,
587EB6742714520600123C75 /* PreferencesDataSourceDelegate.swift in Sources */,
582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */,
+ 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */,
58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */,
5864AF0829C78849005B0CD9 /* CellFactoryProtocol.swift in Sources */,
F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */,
F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */,
587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */,
5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */,
+ 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */,
5862805422428EF100F5A6E1 /* TranslucentButtonBlurView.swift in Sources */,
587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */,
5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */,
5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */,
5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */,
7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */,
- 7AE47E522A17972A000418DA /* AlertViewController.swift in Sources */,
F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */,
58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */,
58CAFA002983FF0200BE19F7 /* LoginInteractor.swift in Sources */,
@@ -4353,13 +4417,11 @@
5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */,
583FE01029C0F532006E85F9 /* CustomSplitViewController.swift in Sources */,
58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */,
- 7A42DECD2A09064C00B209BE /* SelectableSettingsCell.swift in Sources */,
5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */,
580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */,
5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */,
58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */,
58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */,
- 7A7AD28F29DEDB1C00480EF1 /* SettingsHeaderView.swift in Sources */,
58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */,
58B26E262943522400D5980C /* NotificationProvider.swift in Sources */,
58CE5E64224146200008646E /* AppDelegate.swift in Sources */,
@@ -4383,8 +4445,10 @@
581DA2752A1E283E0046ED47 /* WgKeyRotation.swift in Sources */,
7A83C4022A57FAA800DFB83A /* SettingsDNSInfoCell.swift in Sources */,
F0C6A8432AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift in Sources */,
+ 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */,
58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */,
58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */,
+ 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */,
587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */,
F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */,
58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */,
@@ -4416,6 +4480,7 @@
58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */,
F09A297C2A9F8A9B00EA3B6F /* VoucherTextField.swift in Sources */,
58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */,
+ 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */,
7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */,
58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */,
58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */,
@@ -4514,6 +4579,7 @@
58D22411294C90210029F5F8 /* MullvadEndpoint.swift in Sources */,
58D22412294C90210029F5F8 /* RelayConstraint.swift in Sources */,
58D22413294C90210029F5F8 /* RelayConstraints.swift in Sources */,
+ 7AF9BE8C2A321D1F00DBFEDB /* RelayFilter.swift in Sources */,
58D22414294C90210029F5F8 /* RelayLocation.swift in Sources */,
581DA2732A1E227D0046ED47 /* RESTTypes.swift in Sources */,
58D22417294C90210029F5F8 /* FixedWidthInteger+Arithmetics.swift in Sources */,
diff --git a/ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift b/ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift
new file mode 100644
index 0000000000..742e82bc05
--- /dev/null
+++ b/ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift
@@ -0,0 +1,89 @@
+//
+// RelayFilterCoordinator.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-14.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadTypes
+import RelayCache
+import Routing
+import UIKit
+
+class RelayFilterCoordinator: Coordinator, Presentable, RelayCacheTrackerObserver {
+ private let tunnelManager: TunnelManager
+ private let relayCacheTracker: RelayCacheTracker
+ private var cachedRelays: CachedRelays?
+
+ let navigationController: UINavigationController
+
+ var presentedViewController: UIViewController {
+ return navigationController
+ }
+
+ var relayFilterViewController: RelayFilterViewController? {
+ return navigationController.viewControllers.first {
+ $0 is RelayFilterViewController
+ } 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
+ ) {
+ self.navigationController = navigationController
+ self.tunnelManager = tunnelManager
+ self.relayCacheTracker = relayCacheTracker
+ }
+
+ func start() {
+ let relayFilterViewController = RelayFilterViewController()
+
+ relayFilterViewController.onApplyFilter = { [weak self] filter in
+ guard let self else { return }
+
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ relayConstraints.filter = .only(filter)
+
+ tunnelManager.setRelayConstraints(relayConstraints)
+
+ didFinish?(self, filter)
+ }
+
+ 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/Coordinators/SelectLocationCoordinator.swift b/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift
index b813d70bc9..92a4dbb150 100644
--- a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift
@@ -11,15 +11,31 @@ import RelayCache
import Routing
import UIKit
-class SelectLocationCoordinator: Coordinator, Presentable, RelayCacheTrackerObserver {
+class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrackerObserver {
+ private let tunnelManager: TunnelManager
+ private let relayCacheTracker: RelayCacheTracker
+ private var cachedRelays: CachedRelays?
+
let navigationController: UINavigationController
var presentedViewController: UIViewController {
navigationController
}
- private let tunnelManager: TunnelManager
- private let relayCacheTracker: RelayCacheTracker
+ var selectLocationViewController: SelectLocationViewController? {
+ return navigationController.viewControllers.first {
+ $0 is SelectLocationViewController
+ } as? SelectLocationViewController
+ }
+
+ var relayFilter: RelayFilter {
+ switch tunnelManager.settings.relayConstraints.filter {
+ case .any:
+ return RelayFilter()
+ case let .only(filter):
+ return filter
+ }
+ }
var didFinish: ((SelectLocationCoordinator, RelayLocation?) -> Void)?
@@ -34,9 +50,9 @@ class SelectLocationCoordinator: Coordinator, Presentable, RelayCacheTrackerObse
}
func start() {
- let controller = SelectLocationViewController()
+ let selectLocationViewController = SelectLocationViewController()
- controller.didSelectRelay = { [weak self] relay in
+ selectLocationViewController.didSelectRelay = { [weak self] relay in
guard let self else { return }
var relayConstraints = tunnelManager.settings.relayConstraints
@@ -49,7 +65,25 @@ class SelectLocationCoordinator: Coordinator, Presentable, RelayCacheTrackerObse
didFinish?(self, relay)
}
- controller.didFinish = { [weak self] in
+ selectLocationViewController.navigateToFilter = { [weak self] in
+ guard let self else { return }
+
+ let coordinator = makeRelayFilterCoordinator(forModalPresentation: true)
+ coordinator.start()
+
+ presentChild(coordinator, animated: true)
+ }
+
+ selectLocationViewController.didUpdateFilter = { [weak self] filter in
+ guard let self else { return }
+
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ relayConstraints.filter = .only(filter)
+
+ tunnelManager.setRelayConstraints(relayConstraints)
+ }
+
+ selectLocationViewController.didFinish = { [weak self] in
guard let self else { return }
didFinish?(self, nil)
@@ -58,21 +92,42 @@ class SelectLocationCoordinator: Coordinator, Presentable, RelayCacheTrackerObse
relayCacheTracker.addObserver(self)
if let cachedRelays = try? relayCacheTracker.getCachedRelays() {
- controller.setCachedRelays(cachedRelays)
+ self.cachedRelays = cachedRelays
+ selectLocationViewController.setCachedRelays(cachedRelays, filter: relayFilter)
}
- controller.relayLocation = tunnelManager.settings.relayConstraints.location.value
+ selectLocationViewController.relayLocation = tunnelManager.settings.relayConstraints.location.value
+
+ navigationController.pushViewController(selectLocationViewController, animated: false)
+ }
+
+ 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
+ if let cachedRelays = self?.cachedRelays, let filter {
+ self?.selectLocationViewController?.setCachedRelays(cachedRelays, filter: filter)
+ }
+
+ coordinator.dismiss(animated: true)
+ }
- navigationController.pushViewController(controller, animated: false)
+ return relayFilterCoordinator
}
func relayCacheTracker(
_ tracker: RelayCacheTracker,
didUpdateCachedRelays cachedRelays: CachedRelays
) {
- guard let controller = navigationController.viewControllers
- .first as? SelectLocationViewController else { return }
+ self.cachedRelays = cachedRelays
- controller.setCachedRelays(cachedRelays)
+ selectLocationViewController?.setCachedRelays(cachedRelays, filter: relayFilter)
}
}
diff --git a/ios/MullvadVPN/Extensions/Collection+Sorting.swift b/ios/MullvadVPN/Extensions/Collection+Sorting.swift
new file mode 100644
index 0000000000..36c7898b1c
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/Collection+Sorting.swift
@@ -0,0 +1,21 @@
+//
+// Collection+Sorting.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-14.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+extension Collection where Element: StringProtocol {
+ public func caseInsensitiveSorted() -> [Element] {
+ sorted { $0.caseInsensitiveCompare($1) == .orderedAscending }
+ }
+}
+
+extension MutableCollection where Element: StringProtocol, Self: RandomAccessCollection {
+ public mutating func caseInsensitiveSort() {
+ sort { $0.caseInsensitiveCompare($1) == .orderedAscending }
+ }
+}
diff --git a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
index 5bee1b2156..56d07610c2 100644
--- a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
+++ b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
@@ -181,7 +181,7 @@ class FormSheetPresentationController: UIPresentationController {
let containerView,
!isInFullScreenPresentation else { return }
let frame = view.frame
- let bottomMarginFromKeyboard = adjustment > 0 ? UIMetrics.sectionSpacing : 0
+ let bottomMarginFromKeyboard = adjustment > 0 ? UIMetrics.TableView.sectionSpacing : 0
view.frame = CGRect(
origin: CGPoint(
x: frame.origin.x,
diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift
index 9538f14a86..4d5c420000 100644
--- a/ios/MullvadVPN/UI appearance/UIMetrics.swift
+++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift
@@ -10,6 +10,18 @@ import MullvadTypes
import UIKit
enum UIMetrics {
+ enum TableView {
+ /// Height for separators between cells and/or sections.
+ static let separatorHeight: CGFloat = 0.33
+ /// Spacing used between distinct sections of views
+ static let sectionSpacing: CGFloat = 24
+ /// Common layout margins for row views presentation
+ /// Similar to `SettingsCell.layoutMargins` however maintains equal horizontal spacing
+ static let rowViewLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)
+ /// Common cell indentation width
+ static let cellIndentationWidth: CGFloat = 16
+ }
+
enum CustomAlert {
/// Layout margins for container (main view) in `CustomAlertViewController`
static let containerMargins = NSDirectionalEdgeInsets(
@@ -53,9 +65,12 @@ enum UIMetrics {
enum SettingsCell {
static let textFieldContentInsets = UIEdgeInsets(top: 8, left: 24, bottom: 8, right: 24)
static let textFieldNonEditingContentInsetLeft: CGFloat = 40
+ static let layoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 12)
+ static let inputCellTextFieldLayoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
+ static let selectableSettingsCellLeftViewSpacing: CGFloat = 12
+ static let checkableSettingsCellLeftViewSpacing: CGFloat = 20
}
- /// Group of constants related to in-app notifications banner.
enum InAppBannerNotification {
/// Layout margins for contents presented within the banner.
static let layoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)
@@ -68,42 +83,20 @@ enum UIMetrics {
static let secondaryButtonPhone = CGSize(width: 42, height: 42)
static let secondaryButtonPad = CGSize(width: 52, height: 52)
}
+
+ enum FilterView {
+ static let labelSpacing: CGFloat = 5
+ static let interChipViewSpacing: CGFloat = 8
+ static let chipViewCornerRadius: CGFloat = 8
+ static let chipViewLayoutMargins = UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8)
+ static let chipViewLabelSpacing: CGFloat = 7
+ }
}
extension UIMetrics {
- /// Common layout margins for content presentation
- static let contentLayoutMargins = NSDirectionalEdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24)
-
- /// Common content margins for content presentation
- static let contentInsets = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
-
- /// Common layout margins for row views presentation
- /// Similar to `settingsCellLayoutMargins` however maintains equal horizontal spacing
- static let rowViewLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)
-
- /// Common layout margins for settings cell presentation
- static let settingsCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 12)
-
- /// Common layout margins for text field in settings input cell presentation
- static let settingsInputCellTextFieldLayoutMargins = UIEdgeInsets(
- top: 0,
- left: 8,
- bottom: 0,
- right: 8
- )
-
- /// Common layout margins for location cell presentation
- static let selectLocationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 12)
-
- /// Common cell indentation width
- static let cellIndentationWidth: CGFloat = 16
-
/// Spacing used in stack views of buttons
static let interButtonSpacing: CGFloat = 16
- /// Spacing used between distinct sections of views
- static let sectionSpacing: CGFloat = 24
-
/// Text field margins
static let textFieldMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
@@ -140,4 +133,13 @@ extension UIMetrics {
/// Preferred content size for controllers presented using formsheet modal presentation style.
static let preferredFormSheetContentSize = CGSize(width: 480, height: 640)
+
+ /// Common layout margins for content presentation
+ static let contentLayoutMargins = NSDirectionalEdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24)
+
+ /// Common content margins for content presentation
+ static let contentInsets = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
+
+ /// Common layout margins for location cell presentation
+ static let selectLocationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 12)
}
diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
index 707784e21e..a3ffd21d56 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
@@ -123,7 +123,7 @@ class AccountContentView: UIView {
contentStackView.pinEdgesToSuperviewMargins(.all().excluding(.bottom))
buttonStackView.topAnchor.constraint(
greaterThanOrEqualTo: contentStackView.bottomAnchor,
- constant: UIMetrics.sectionSpacing
+ constant: UIMetrics.TableView.sectionSpacing
)
buttonStackView.pinEdgesToSuperviewMargins(.all().excluding(.top))
}
diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift
index 0df7733b80..9aab160be5 100644
--- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift
+++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift
@@ -170,7 +170,7 @@ class DeviceManagementContentView: UIView {
deviceStackView.topAnchor.constraint(
equalTo: messageLabel.bottomAnchor,
- constant: UIMetrics.sectionSpacing
+ constant: UIMetrics.TableView.sectionSpacing
),
deviceStackView.leadingAnchor.constraint(equalTo: scrollContentView.leadingAnchor),
deviceStackView.trailingAnchor.constraint(equalTo: scrollContentView.trailingAnchor),
diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift
index c044ebd4e2..2cb3dcb731 100644
--- a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift
+++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift
@@ -71,7 +71,7 @@ class DeviceRowView: UIView {
super.init(frame: .zero)
backgroundColor = .primaryColor
- directionalLayoutMargins = UIMetrics.rowViewLayoutMargins
+ directionalLayoutMargins = UIMetrics.TableView.rowViewLayoutMargins
for subview in [textLabel, removeButton, activityIndicator, creationDateLabel] {
addSubview(subview)
diff --git a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift
index 5f4113660d..edd3f50105 100644
--- a/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift
+++ b/ios/MullvadVPN/View controllers/OutOfTime/OutOfTimeContentView.swift
@@ -79,7 +79,7 @@ class OutOfTimeContentView: UIView {
let stackView = UIStackView(arrangedSubviews: [statusActivityView, titleLabel, bodyLabel])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
- stackView.spacing = UIMetrics.sectionSpacing
+ stackView.spacing = UIMetrics.TableView.sectionSpacing
return stackView
}()
@@ -89,7 +89,7 @@ class OutOfTimeContentView: UIView {
)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
- stackView.spacing = UIMetrics.sectionSpacing
+ stackView.spacing = UIMetrics.TableView.sectionSpacing
return stackView
}()
diff --git a/ios/MullvadVPN/View controllers/Preferences/PreferencesCellFactory.swift b/ios/MullvadVPN/View controllers/Preferences/PreferencesCellFactory.swift
index 63788dc99b..fb07ac569f 100644
--- a/ios/MullvadVPN/View controllers/Preferences/PreferencesCellFactory.swift
+++ b/ios/MullvadVPN/View controllers/Preferences/PreferencesCellFactory.swift
@@ -103,7 +103,6 @@ final class PreferencesCellFactory: CellFactoryProtocol {
title: localizedString,
for: .blockMalware
)
- cell.setInfoButtonIsVisible(true)
cell.infoButtonHandler = { [weak self] in
self?.delegate?.showInfo(for: .blockMalware)
}
diff --git a/ios/MullvadVPN/View controllers/Preferences/PreferencesDataSource.swift b/ios/MullvadVPN/View controllers/Preferences/PreferencesDataSource.swift
index 354c8370ca..62a3e8d8f2 100644
--- a/ios/MullvadVPN/View controllers/Preferences/PreferencesDataSource.swift
+++ b/ios/MullvadVPN/View controllers/Preferences/PreferencesDataSource.swift
@@ -450,7 +450,6 @@ final class PreferencesDataSource: UITableViewDiffableDataSource<
case .contentBlockers:
configureContentBlockersHeader(view)
return view
-
case .wireGuardPorts:
configureWireguardPortsHeader(view)
return view
@@ -499,10 +498,10 @@ final class PreferencesDataSource: UITableViewDiffableDataSource<
switch sectionIdentifier {
#if DEBUG
case .wireGuardObfuscationPort:
- return UIMetrics.sectionSpacing
+ return UIMetrics.TableView.sectionSpacing
#else
case .wireGuardPorts:
- return UIMetrics.sectionSpacing
+ return UIMetrics.TableView.sectionSpacing
#endif
default:
diff --git a/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift b/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift
index 2a267142ae..e634ac9896 100644
--- a/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift
+++ b/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift
@@ -61,7 +61,7 @@ class PreferencesViewController: UITableViewController, PreferencesDataSourceDel
}
tableView.tableHeaderView =
- UIView(frame: .init(origin: .zero, size: .init(width: 0, height: UIMetrics.sectionSpacing)))
+ UIView(frame: .init(origin: .zero, size: .init(width: 0, height: UIMetrics.TableView.sectionSpacing)))
}
override func setEditing(_ editing: Bool, animated: Bool) {
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/AddCreditSucceededViewController.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/AddCreditSucceededViewController.swift
index 1176075b93..46c38f4c04 100644
--- a/ios/MullvadVPN/View controllers/RedeemVoucher/AddCreditSucceededViewController.swift
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/AddCreditSucceededViewController.swift
@@ -115,7 +115,7 @@ class AddCreditSucceededViewController: UIViewController, RootContainment {
titleLabel.pinEdgesToSuperviewMargins(PinnableEdges([.leading(0), .trailing(0)]))
titleLabel.topAnchor.constraint(
equalTo: statusImageView.bottomAnchor,
- constant: UIMetrics.sectionSpacing
+ constant: UIMetrics.TableView.sectionSpacing
)
messageLabel.topAnchor.constraint(
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
new file mode 100644
index 0000000000..29aec029a3
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
@@ -0,0 +1,87 @@
+//
+// RelayFilterCellFactory.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-02.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+struct RelayFilterCellFactory: CellFactoryProtocol {
+ let tableView: UITableView
+
+ func makeCell(for item: RelayFilterDataSource.Item, indexPath: IndexPath) -> UITableViewCell {
+ let cell = tableView.dequeueReusableCell(withIdentifier: item.reuseIdentifier.rawValue, for: indexPath)
+ configureCell(cell, item: item, indexPath: indexPath)
+
+ return cell
+ }
+
+ func configureCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item, indexPath: IndexPath) {
+ switch item {
+ case .ownershipAny, .ownershipOwned, .ownershipRented:
+ configureOwnershipCell(cell, item: item)
+ case .allProviders, .provider:
+ configureProviderCell(cell, 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"
+ case .ownershipOwned:
+ title = "Mullvad owned only"
+ case .ownershipRented:
+ title = "Rented only"
+ default:
+ assertionFailure("Item mismatch. Got: \(item)")
+ }
+
+ cell.titleLabel.text = NSLocalizedString(
+ "RELAY_FILTER_CELL_LABEL",
+ tableName: "Relay filter ownership cell",
+ value: title,
+ comment: ""
+ )
+
+ cell.applySubCellStyling()
+ cell.accessibilityIdentifier = "RelayFilterOwnershipCell"
+ }
+
+ 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)")
+ }
+
+ cell.titleLabel.text = NSLocalizedString(
+ "RELAY_FILTER_CELL_LABEL",
+ tableName: "Relay filter provider cell",
+ value: title,
+ comment: ""
+ )
+
+ cell.applySubCellStyling()
+ cell.accessibilityIdentifier = "RelayFilterProviderCell"
+ }
+
+ private func setFontWeight(_ weight: UIFont.Weight, to label: UILabel) {
+ label.font = UIFont.systemFont(ofSize: label.font.pointSize, weight: .semibold)
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift
new file mode 100644
index 0000000000..986281c9d6
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift
@@ -0,0 +1,55 @@
+//
+// RelayFilterChipView.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-20.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class RelayFilterChipView: UIView {
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFont.preferredFont(forTextStyle: .caption1)
+ label.adjustsFontForContentSizeCategory = true
+ label.textColor = .white
+ return label
+ }()
+
+ var didTapButton: (() -> Void)?
+
+ init() {
+ super.init(frame: .zero)
+
+ let closeButton = IncreasedHitButton()
+ closeButton.setImage(
+ UIImage(named: "IconCloseSml")?.withTintColor(.white.withAlphaComponent(0.6)),
+ for: .normal
+ )
+ closeButton.addTarget(self, action: #selector(didTapButton(_:)), for: .touchUpInside)
+
+ let container = UIStackView(arrangedSubviews: [titleLabel, closeButton])
+ container.spacing = UIMetrics.FilterView.chipViewLabelSpacing
+ container.backgroundColor = .primaryColor
+ container.layer.cornerRadius = UIMetrics.FilterView.chipViewCornerRadius
+ container.layoutMargins = UIMetrics.FilterView.chipViewLayoutMargins
+ container.isLayoutMarginsRelativeArrangement = true
+
+ addConstrainedSubviews([container]) {
+ container.pinEdgesToSuperview()
+ }
+ }
+
+ func setTitle(_ text: String) {
+ titleLabel.text = text
+ }
+
+ @objc private func didTapButton(_ sender: UIButton) {
+ didTapButton?()
+ }
+
+ required init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
new file mode 100644
index 0000000000..8c74b0559c
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
@@ -0,0 +1,397 @@
+//
+// RelayFilterDataSource.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-02.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Combine
+import MullvadREST
+import MullvadTypes
+import RelayCache
+import UIKit
+
+final class RelayFilterDataSource: UITableViewDiffableDataSource<
+ RelayFilterDataSource.Section,
+ RelayFilterDataSource.Item
+> {
+ private 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)
+ }
+ }
+
+ init(tableView: UITableView, viewModel: RelayFilterViewModel) {
+ self.tableView = tableView
+ self.viewModel = viewModel
+
+ let relayFilterCellFactory = RelayFilterCellFactory(tableView: tableView)
+ self.relayFilterCellFactory = relayFilterCellFactory
+
+ super.init(tableView: tableView) { _, indexPath, itemIdentifier in
+ relayFilterCellFactory.makeCell(for: itemIdentifier, indexPath: indexPath)
+ }
+
+ registerClasses()
+ createDataSnapshot()
+
+ tableView.delegate = self
+
+ viewModel.$relays
+ .combineLatest(viewModel.$relayFilter)
+ .sink { [weak self] _, filter in
+ self?.updateDataSnapshot(filter: filter)
+ }
+ .store(in: &disposeBag)
+ }
+
+ 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 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 createDataSnapshot() {
+ var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
+ snapshot.appendSections(Section.allCases)
+
+ applySnapshot(snapshot, animated: false)
+ }
+
+ private func updateDataSnapshot(filter: RelayFilter? = nil) {
+ let oldSnapshot = snapshot()
+
+ var newSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()
+ 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)
+ }
+ 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)
+ }
+ }
+ }
+
+ applySnapshot(newSnapshot, animated: false)
+ }
+
+ private func applySnapshot(
+ _ snapshot: NSDiffableDataSourceSnapshot<Section, Item>,
+ 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))
+ }
+
+ switch filter.providers {
+ case .any:
+ selectAllProviders(true)
+ case let .only(providers):
+ providers.forEach { providerName in
+ if let providerItem = viewModel.providerItem(for: providerName) {
+ selectRow(true, at: indexPath(for: providerItem))
+ }
+ }
+
+ updateAllProvidersSelection()
+ }
+ }
+
+ private func updateAllProvidersSelection() {
+ let selectedCount = getSelectedIndexPaths(in: .providers).count
+ let providerCount = viewModel.availableProviders(for: viewModel.relayFilter.ownership).count
+
+ if selectedCount == providerCount {
+ selectRow(true, at: indexPath(for: .allProviders))
+ }
+ }
+}
+
+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)
+ }
+ case .providers:
+ break
+ }
+
+ return indexPath
+ }
+
+ func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? {
+ switch getSection(for: indexPath) {
+ case .ownership:
+ return nil
+ case .providers:
+ return indexPath
+ }
+ }
+
+ 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)
+ }
+
+ func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
+ guard let item = itemIdentifier(for: indexPath) else { return }
+
+ 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, viewForHeaderInSection section: Int) -> UIView? {
+ guard let view = tableView.dequeueReusableHeaderFooterView(
+ withIdentifier: HeaderFooterReuseIdentifiers.section.rawValue
+ ) as? SettingsHeaderView else { return nil }
+
+ let sectionId = snapshot().sectionIdentifiers[section]
+ let title: String
+
+ switch sectionId {
+ case .ownership:
+ title = "Ownership"
+ case .providers:
+ title = "Providers"
+ }
+
+ view.titleLabel.text = NSLocalizedString(
+ "RELAY_FILTER_HEADER_LABEL",
+ tableName: "Relay filter header",
+ value: title,
+ comment: ""
+ )
+
+ view.didCollapseHandler = { [weak self] headerView in
+ guard let self else { return }
+
+ var snapshot = snapshot()
+
+ switch sectionId {
+ case .ownership:
+ handleCollapseOwnership(snapshot: &snapshot, isExpanded: headerView.isExpanded)
+ case .providers:
+ handleCollapseProviders(snapshot: &snapshot, isExpanded: headerView.isExpanded)
+ }
+
+ headerView.isExpanded.toggle()
+ applySnapshot(snapshot, animated: true)
+ }
+
+ return view
+ }
+
+ func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
+ return nil
+ }
+
+ func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
+ return UITableView.automaticDimension
+ }
+
+ 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)
+ }
+ }
+}
+
+extension RelayFilterDataSource {
+ enum CellReuseIdentifiers: String, CaseIterable {
+ case ownershipCell
+ case providerCell
+
+ var reusableViewClass: AnyClass {
+ switch self {
+ case .ownershipCell:
+ return SelectableSettingsCell.self
+ case .providerCell:
+ return CheckableSettingsCell.self
+ }
+ }
+ }
+
+ 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
+ }
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift
new file mode 100644
index 0000000000..ab66f9b3ff
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift
@@ -0,0 +1,122 @@
+//
+// RelayFilterAppliedView.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-19.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadTypes
+import UIKit
+
+class RelayFilterView: UIView {
+ enum Filter {
+ case ownership
+ case providers
+ }
+
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+
+ label.text = NSLocalizedString(
+ "RELAY_FILTER_APPLIED_TITLE",
+ tableName: "RelayFilter",
+ value: "Filtered:",
+ comment: ""
+ )
+
+ label.font = UIFont.preferredFont(forTextStyle: .caption1)
+ label.adjustsFontForContentSizeCategory = true
+ label.textColor = .white
+
+ return label
+ }()
+
+ private let ownershipView = RelayFilterChipView()
+ private let providersView = RelayFilterChipView()
+ private var filter: RelayFilter?
+
+ var didUpdateFilter: ((RelayFilter) -> Void)?
+
+ init() {
+ super.init(frame: .zero)
+
+ setUpViews()
+ }
+
+ required init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func setFilter(_ filter: RelayFilter) {
+ self.filter = filter
+
+ ownershipView.isHidden = filter.ownership == .any
+ providersView.isHidden = filter.providers == .any
+
+ switch filter.ownership {
+ case .any:
+ break
+ case .owned:
+ ownershipView.setTitle(localizedOwnershipText(for: "Owned"))
+ case .rented:
+ ownershipView.setTitle(localizedOwnershipText(for: "Rented"))
+ }
+
+ switch filter.providers {
+ case .any:
+ providersView.isHidden = true
+ case let .only(providers):
+ providersView.setTitle(localizedProvidersText(for: providers.count))
+ }
+ }
+
+ private func setUpViews() {
+ ownershipView.didTapButton = { [weak self] in
+ guard var filter = self?.filter else { return }
+
+ filter.ownership = .any
+ self?.didUpdateFilter?(filter)
+ }
+
+ providersView.didTapButton = { [weak self] in
+ guard var filter = self?.filter else { return }
+
+ filter.providers = .any
+ self?.didUpdateFilter?(filter)
+ }
+
+ // Add a dummy view at the end to push content to the left.
+ let filterContainer = UIStackView(arrangedSubviews: [ownershipView, providersView, UIView()])
+ filterContainer.spacing = UIMetrics.FilterView.interChipViewSpacing
+
+ let contentContainer = UIStackView(arrangedSubviews: [titleLabel, filterContainer])
+ contentContainer.spacing = UIMetrics.FilterView.labelSpacing
+
+ addConstrainedSubviews([contentContainer]) {
+ contentContainer.pinEdges(.init([.top(0), .bottom(0)]), to: self)
+ contentContainer.pinEdges(.init([.leading(0), .trailing(0)]), to: layoutMarginsGuide)
+ }
+ }
+
+ private func localizedOwnershipText(for string: String) -> String {
+ return NSLocalizedString(
+ "RELAY_FILTER_APPLIED_OWNERSHIP",
+ tableName: "RelayFilter",
+ value: string,
+ comment: ""
+ )
+ }
+
+ private func localizedProvidersText(for count: Int) -> String {
+ return String(
+ format: NSLocalizedString(
+ "RELAY_FILTER_APPLIED_PROVIDERS",
+ tableName: "RelayFilter",
+ value: "Providers: %d",
+ comment: ""
+ ),
+ count
+ )
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
new file mode 100644
index 0000000000..f9c19c96a7
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
@@ -0,0 +1,111 @@
+//
+// RelayFilterViewController.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-02.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Combine
+import MullvadTypes
+import RelayCache
+import UIKit
+
+class RelayFilterViewController: UIViewController {
+ private let tableView = UITableView(frame: .zero, style: .grouped)
+ private var viewModel: RelayFilterViewModel?
+ private var dataSource: RelayFilterDataSource?
+ private var cachedRelays: CachedRelays?
+ private var filter = RelayFilter()
+ private var disposeBag = Set<Combine.AnyCancellable>()
+
+ private let applyButton: AppButton = {
+ let button = AppButton(style: .success)
+ button.accessibilityIdentifier = "ApplyButton"
+ button.setTitle(NSLocalizedString(
+ "RELAY_FILTER_BUTTON_TITLE",
+ tableName: "RelayFilter",
+ value: "Apply",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
+ var onApplyFilter: ((RelayFilter) -> Void)?
+ var didFinish: (() -> Void)?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.directionalLayoutMargins = UIMetrics.contentLayoutMargins
+ view.backgroundColor = .secondaryColor
+
+ navigationItem.title = NSLocalizedString(
+ "RELAY_FILTER_NAVIGATION_TITLE",
+ tableName: "RelayFilter",
+ value: "Filter",
+ comment: ""
+ )
+
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ systemItem: .cancel,
+ primaryAction: UIAction(handler: { [weak self] _ in
+ self?.didFinish?()
+ })
+ )
+
+ applyButton.addTarget(self, action: #selector(applyFilter), for: .touchUpInside)
+
+ tableView.backgroundColor = view.backgroundColor
+ tableView.separatorColor = view.backgroundColor
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.estimatedRowHeight = 60
+ tableView.estimatedSectionHeaderHeight = tableView.estimatedRowHeight
+ tableView.allowsMultipleSelection = true
+
+ view.addConstrainedSubviews([applyButton, tableView]) {
+ tableView.pinEdgesToSuperview(.all().excluding(.bottom))
+ applyButton.pinEdgesToSuperviewMargins(.init([.leading(0), .trailing(0), .bottom(0)]))
+ applyButton.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
+ }
+
+ private func setUpDataSource() {
+ let viewModel = RelayFilterViewModel(
+ relays: cachedRelays?.relays.wireguard.relays ?? [],
+ relayFilter: filter
+ )
+ self.viewModel = viewModel
+
+ viewModel.$relayFilter
+ .sink { [weak self] filter in
+ switch filter.providers {
+ case .any:
+ self?.applyButton.isEnabled = true
+ case let .only(providers):
+ self?.applyButton.isEnabled = !providers.isEmpty
+ }
+ }
+ .store(in: &disposeBag)
+
+ dataSource = RelayFilterDataSource(tableView: tableView, viewModel: viewModel)
+ }
+
+ @objc private func applyFilter() {
+ guard let filter = viewModel?.relayFilter else { return }
+ onApplyFilter?(filter)
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift
new file mode 100644
index 0000000000..5cd147f1d2
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift
@@ -0,0 +1,127 @@
+//
+// RelayFilterViewModel.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-09.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Combine
+import MullvadREST
+import MullvadTypes
+
+class RelayFilterViewModel {
+ @Published var relays: [REST.ServerRelay]
+ @Published var relayFilter: RelayFilter
+
+ var uniqueProviders: [String] {
+ Set(relays.map { $0.provider }).caseInsensitiveSorted()
+ }
+
+ var ownedProviders: [String] {
+ Set(relays.filter { $0.owned == true }.map { $0.provider }).caseInsensitiveSorted()
+ }
+
+ var rentedProviders: [String] {
+ Set(relays.filter { $0.owned == false }.map { $0.provider }).caseInsensitiveSorted()
+ }
+
+ init(relays: [REST.ServerRelay], relayFilter: RelayFilter) {
+ self.relays = relays
+ self.relayFilter = relayFilter
+ }
+
+ func addItemToFilter(_ item: RelayFilterDataSource.Item) {
+ switch item {
+ case .ownershipAny, .ownershipOwned, .ownershipRented:
+ 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)
+ }
+ }
+ }
+ }
+ }
+
+ 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 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 ownershipItem(for ownership: RelayFilter.Ownership?) -> RelayFilterDataSource.Item? {
+ switch ownership {
+ case .any:
+ return .ownershipAny
+ case .owned:
+ return .ownershipOwned
+ case .rented:
+ return .ownershipRented
+ default:
+ return nil
+ }
+ }
+
+ func providerName(for item: RelayFilterDataSource.Item?) -> String? {
+ switch item {
+ case let .provider(name):
+ return name
+ default:
+ return nil
+ }
+ }
+
+ func providerItem(for providerName: String?) -> RelayFilterDataSource.Item? {
+ return .provider(providerName ?? "")
+ }
+
+ func availableProviders(for ownership: RelayFilter.Ownership) -> [String] {
+ switch ownership {
+ case .any:
+ return uniqueProviders
+ case .owned:
+ return ownedProviders
+ case .rented:
+ return rentedProviders
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
index ee5efa3d24..a5032402f2 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
@@ -8,6 +8,7 @@
import MullvadREST
import MullvadTypes
+import RelaySelector
import UIKit
protocol LocationDataSourceItemProtocol {
@@ -73,11 +74,15 @@ final class LocationDataSource: UITableViewDiffableDataSource<Int, RelayLocation
registerClasses()
}
- func setRelays(_ response: REST.ServerRelaysResponse) {
+ func setRelays(_ response: REST.ServerRelaysResponse, filter: RelayFilter) {
+ let relays = response.wireguard.relays.filter { relay in
+ return RelaySelector.relayMatchesFilter(relay, filter: filter)
+ }
+
let rootNode = Self.makeRootNode()
nodeByLocation.removeAll()
- for relay in response.wireguard.relays {
+ for relay in relays {
guard case let .city(countryCode, cityCode) = RelayLocation(dashSeparatedString: relay.location),
let serverLocation = response.locations[relay.location] else { continue }
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift
index cb63f1428d..80edfcceb7 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift
@@ -92,7 +92,7 @@ class SelectLocationCell: UITableViewCell {
}
private func setupCell() {
- indentationWidth = UIMetrics.cellIndentationWidth
+ indentationWidth = UIMetrics.TableView.cellIndentationWidth
backgroundColor = .clear
contentView.backgroundColor = .clear
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift
index c1e6a7f923..f1bb9dce4e 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift
@@ -14,15 +14,24 @@ import UIKit
final class SelectLocationViewController: UIViewController {
private let searchBar = UISearchBar()
private let tableView = UITableView()
+ private let topContentView = UIStackView()
+ private let filterView = RelayFilterView()
private var dataSource: LocationDataSource?
private var cachedRelays: CachedRelays?
+ private var filter = RelayFilter()
var relayLocation: RelayLocation?
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
+ var filterViewShouldBeHidden: Bool {
+ return (filter.ownership == .any) && (filter.providers == .any)
+ }
+
+ var navigateToFilter: (() -> Void)?
var didSelectRelay: ((RelayLocation) -> Void)?
+ var didUpdateFilter: ((RelayFilter) -> Void)?
var didFinish: (() -> Void)?
// MARK: - View lifecycle
@@ -38,6 +47,19 @@ final class SelectLocationViewController: UIViewController {
value: "Select location",
comment: ""
)
+
+ navigationItem.leftBarButtonItem = UIBarButtonItem(
+ title: NSLocalizedString(
+ "NAVIGATION_TITLE",
+ tableName: "SelectLocation",
+ value: "Filter",
+ comment: ""
+ ),
+ primaryAction: UIAction(handler: { [weak self] _ in
+ self?.navigateToFilter?()
+ })
+ )
+
navigationItem.rightBarButtonItem = UIBarButtonItem(
systemItem: .done,
primaryAction: UIAction(handler: { [weak self] _ in
@@ -45,15 +67,15 @@ final class SelectLocationViewController: UIViewController {
})
)
- setupDataSource()
- setupTableView()
- setupSearchBar()
+ setUpDataSource()
+ setUpTableView()
+ setUpTopContent()
- view.addConstrainedSubviews([searchBar, tableView]) {
- searchBar.pinEdgesToSuperviewMargins(.all().excluding(.bottom))
+ view.addConstrainedSubviews([topContentView, tableView]) {
+ topContentView.pinEdgesToSuperviewMargins(.all().excluding(.bottom))
tableView.pinEdgesToSuperview(.all().excluding(.top))
- tableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor)
+ tableView.topAnchor.constraint(equalTo: topContentView.bottomAnchor)
}
}
@@ -75,15 +97,23 @@ final class SelectLocationViewController: UIViewController {
// MARK: - Public
- func setCachedRelays(_ cachedRelays: CachedRelays) {
+ func setCachedRelays(_ cachedRelays: CachedRelays, filter: RelayFilter) {
self.cachedRelays = cachedRelays
+ self.filter = filter
+
+ if filterViewShouldBeHidden {
+ filterView.isHidden = true
+ } else {
+ filterView.isHidden = false
+ filterView.setFilter(filter)
+ }
- dataSource?.setRelays(cachedRelays.relays)
+ dataSource?.setRelays(cachedRelays.relays, filter: filter)
}
// MARK: - Private
- private func setupDataSource() {
+ private func setUpDataSource() {
dataSource = LocationDataSource(tableView: tableView)
dataSource?.didSelectRelayLocation = { [weak self] location in
self?.didSelectRelay?(location)
@@ -92,11 +122,11 @@ final class SelectLocationViewController: UIViewController {
dataSource?.selectedRelayLocation = relayLocation
if let cachedRelays {
- dataSource?.setRelays(cachedRelays.relays)
+ dataSource?.setRelays(cachedRelays.relays, filter: filter)
}
}
- private func setupTableView() {
+ private func setUpTableView() {
tableView.backgroundColor = view.backgroundColor
tableView.separatorColor = .secondaryColor
tableView.separatorInset = .zero
@@ -105,7 +135,28 @@ final class SelectLocationViewController: UIViewController {
tableView.keyboardDismissMode = .onDrag
}
- private func setupSearchBar() {
+ private func setUpTopContent() {
+ topContentView.axis = .vertical
+ topContentView.addArrangedSubview(filterView)
+ topContentView.addArrangedSubview(searchBar)
+
+ filterView.isHidden = filterViewShouldBeHidden
+
+ filterView.didUpdateFilter = { [weak self] in
+ guard let self else { return }
+
+ filter = $0
+ didUpdateFilter?($0)
+
+ if let cachedRelays {
+ setCachedRelays(cachedRelays, filter: filter)
+ }
+ }
+
+ setUpSearchBar()
+ }
+
+ private func setUpSearchBar() {
searchBar.delegate = self
searchBar.searchBarStyle = .minimal
searchBar.layer.cornerRadius = 8
diff --git a/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
new file mode 100644
index 0000000000..a732234473
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
@@ -0,0 +1,42 @@
+//
+// CheckableSettingsCell.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-05.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class CheckableSettingsCell: SettingsCell {
+ let checkboxView = CheckboxView()
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+
+ setLeftView(checkboxView, spacing: UIMetrics.SettingsCell.checkableSettingsCellLeftViewSpacing)
+ selectedBackgroundView?.backgroundColor = .clear
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+
+ setLeftView(checkboxView, spacing: UIMetrics.SettingsCell.checkableSettingsCellLeftViewSpacing)
+ }
+
+ override func setSelected(_ selected: Bool, animated: Bool) {
+ super.setSelected(selected, animated: animated)
+
+ checkboxView.isChecked = selected
+ }
+
+ override func applySubCellStyling() {
+ super.applySubCellStyling()
+
+ contentView.layoutMargins.left = 0
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift
index 1f6f4c865a..1afa622d00 100644
--- a/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift
@@ -19,7 +19,7 @@ class SelectableSettingsCell: SettingsCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
- setLeftView(tickImageView)
+ setLeftView(tickImageView, spacing: UIMetrics.SettingsCell.selectableSettingsCellLeftViewSpacing)
selectedBackgroundView?.backgroundColor = UIColor.Cell.selectedBackgroundColor
}
@@ -30,7 +30,7 @@ class SelectableSettingsCell: SettingsCell {
override func prepareForReuse() {
super.prepareForReuse()
- setLeftView(tickImageView)
+ setLeftView(tickImageView, spacing: UIMetrics.SettingsCell.selectableSettingsCellLeftViewSpacing)
}
override func setSelected(_ selected: Bool, animated: Bool) {
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift
index 7cd88c8e81..789462cb9a 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift
@@ -36,7 +36,9 @@ class SettingsCell: UITableViewCell {
let detailTitleLabel = UILabel()
let disclosureImageView = UIImageView(image: nil)
let contentContainer = UIStackView()
- var infoButtonHandler: InfoButtonHandler?
+ var infoButtonHandler: InfoButtonHandler? { didSet {
+ infoButton.isHidden = infoButtonHandler == nil
+ }}
var disclosureType: SettingsDisclosureType = .none {
didSet {
@@ -63,6 +65,7 @@ class SettingsCell: UITableViewCell {
button.accessibilityIdentifier = "InfoButton"
button.tintColor = .white
button.setImage(UIImage(named: "IconInfo"), for: .normal)
+ button.isHidden = true
return button
}()
@@ -127,7 +130,6 @@ class SettingsCell: UITableViewCell {
}
contentContainer.addArrangedSubview(content)
- contentContainer.spacing = 12
contentView.addConstrainedSubviews([contentContainer]) {
contentContainer.pinEdgesToSuperviewMargins()
@@ -141,26 +143,24 @@ class SettingsCell: UITableViewCell {
override func prepareForReuse() {
super.prepareForReuse()
+ infoButton.isHidden = true
removeLeftView()
- setInfoButtonIsVisible(false)
setLayoutMargins()
}
func applySubCellStyling() {
- contentView.layoutMargins.left += UIMetrics.cellIndentationWidth
+ contentView.layoutMargins.left += UIMetrics.TableView.cellIndentationWidth
backgroundView?.backgroundColor = UIColor.SubCell.backgroundColor
}
- func setInfoButtonIsVisible(_ visible: Bool) {
- infoButton.isHidden = !visible
- }
-
- func setLeftView(_ view: UIView) {
+ func setLeftView(_ view: UIView, spacing: CGFloat) {
removeLeftView()
if contentContainer.arrangedSubviews.count <= 1 {
contentContainer.insertArrangedSubview(view, at: 0)
}
+
+ contentContainer.spacing = spacing
}
func removeLeftView() {
@@ -175,9 +175,9 @@ class SettingsCell: UITableViewCell {
private func setLayoutMargins() {
// Set layout margins for standard acceessories added into the cell (reorder control, etc..)
- directionalLayoutMargins = UIMetrics.settingsCellLayoutMargins
+ directionalLayoutMargins = UIMetrics.SettingsCell.layoutMargins
// Set layout margins for cell content
- contentView.directionalLayoutMargins = UIMetrics.settingsCellLayoutMargins
+ contentView.directionalLayoutMargins = UIMetrics.SettingsCell.layoutMargins
}
}
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDNSInfoCell.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDNSInfoCell.swift
index 455396acd8..02594d5a29 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsDNSInfoCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsDNSInfoCell.swift
@@ -15,7 +15,7 @@ class SettingsDNSInfoCell: UITableViewCell {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .secondaryColor
- contentView.directionalLayoutMargins = UIMetrics.settingsCellLayoutMargins
+ contentView.directionalLayoutMargins = UIMetrics.SettingsCell.layoutMargins
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.textColor = UIColor.Cell.titleTextColor
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift
index 1837b85898..f4aebdd8e8 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift
@@ -101,7 +101,7 @@ final class SettingsDataSource: UITableViewDiffableDataSource<
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
- UIMetrics.sectionSpacing
+ return UIMetrics.TableView.sectionSpacing
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift b/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift
index c934042d62..dbe1e62d11 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift
@@ -51,7 +51,9 @@ class SettingsHeaderView: UITableViewHeaderFooterView {
}
var didCollapseHandler: CollapseHandler?
- var infoButtonHandler: InfoButtonHandler?
+ var infoButtonHandler: InfoButtonHandler? { didSet {
+ infoButton.isHidden = infoButtonHandler == nil
+ }}
private let chevronDown = UIImage(named: "IconChevronDown")
private let chevronUp = UIImage(named: "IconChevronUp")
@@ -60,6 +62,7 @@ class SettingsHeaderView: UITableViewHeaderFooterView {
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
+ infoButton.isHidden = true
infoButton.addTarget(
self,
action: #selector(handleInfoButton(_:)),
@@ -72,7 +75,7 @@ class SettingsHeaderView: UITableViewHeaderFooterView {
for: .touchUpInside
)
- contentView.directionalLayoutMargins = UIMetrics.settingsCellLayoutMargins
+ contentView.directionalLayoutMargins = UIMetrics.SettingsCell.layoutMargins
contentView.backgroundColor = UIColor.Cell.backgroundColor
let buttonAreaWidth = UIMetrics.contentLayoutMargins.leading + UIMetrics
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsInputCell.swift b/ios/MullvadVPN/View controllers/Settings/SettingsInputCell.swift
index 8e494c1e11..f9b2b3b57a 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsInputCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsInputCell.swift
@@ -83,7 +83,7 @@ class SettingsInputCell: SelectableSettingsCell {
textField.delegate = self
textField.keyboardType = .numberPad
textField.returnKeyType = .done
- textField.textMargins = UIMetrics.settingsInputCellTextFieldLayoutMargins
+ textField.textMargins = UIMetrics.SettingsCell.inputCellTextFieldLayoutMargins
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
UITextField.SearchTextFieldAppearance.inactive.apply(to: textField)
diff --git a/ios/MullvadVPN/Views/CheckboxView.swift b/ios/MullvadVPN/Views/CheckboxView.swift
new file mode 100644
index 0000000000..0ffb354a28
--- /dev/null
+++ b/ios/MullvadVPN/Views/CheckboxView.swift
@@ -0,0 +1,47 @@
+//
+// CheckboxView.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-06-05.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class CheckboxView: UIView {
+ private let backgroundView: UIView = {
+ let view = UIView()
+ view.backgroundColor = .white
+ view.layer.cornerRadius = 4
+ return view
+ }()
+
+ private let checkmarkView: UIImageView = {
+ let imageView = UIImageView(image: UIImage(named: "IconTick"))
+ imageView.tintColor = .successColor
+ imageView.contentMode = .scaleAspectFit
+ imageView.alpha = 0
+ return imageView
+ }()
+
+ var isChecked = false {
+ didSet {
+ checkmarkView.alpha = isChecked ? 1 : 0
+ }
+ }
+
+ init() {
+ super.init(frame: .zero)
+
+ directionalLayoutMargins = .init(top: 4, leading: 4, bottom: 4, trailing: 4)
+
+ addConstrainedSubviews([backgroundView, checkmarkView]) {
+ backgroundView.pinEdgesToSuperview()
+ checkmarkView.pinEdgesToSuperviewMargins()
+ }
+ }
+
+ required init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/ios/MullvadVPNTests/RelaySelectorTests.swift b/ios/MullvadVPNTests/RelaySelectorTests.swift
index b390592b36..e3ec34e1f4 100644
--- a/ios/MullvadVPNTests/RelaySelectorTests.swift
+++ b/ios/MullvadVPNTests/RelaySelectorTests.swift
@@ -107,4 +107,72 @@ class RelaySelectorTests: XCTestCase {
XCTAssertTrue(sampleRelays.bridge.relays.contains(selectedRelay))
}
+
+ func testRelayFilterConstraintWithOwnedOwnership() throws {
+ let filter = RelayFilter(ownership: .owned, providers: .any)
+ let constraints = RelayConstraints(
+ location: .only(.hostname("se", "sto", "se6-wireguard")),
+ filter: .only(filter)
+ )
+
+ let result = try RelaySelector.evaluate(
+ relays: sampleRelays,
+ constraints: constraints,
+ numberOfFailedAttempts: 0
+ )
+
+ XCTAssertTrue(result.relay.owned)
+ }
+
+ func testRelayFilterConstraintWithRentedOwnership() throws {
+ let filter = RelayFilter(ownership: .rented, providers: .any)
+ let constraints = RelayConstraints(
+ location: .only(.hostname("se", "sto", "se6-wireguard")),
+ filter: .only(filter)
+ )
+
+ let result = try? RelaySelector.evaluate(
+ relays: sampleRelays,
+ constraints: constraints,
+ numberOfFailedAttempts: 0
+ )
+
+ XCTAssertNil(result)
+ }
+
+ func testRelayFilterConstraintWithCorrectProvider() throws {
+ let provider = "31173"
+
+ let filter = RelayFilter(ownership: .any, providers: .only([provider]))
+ let constraints = RelayConstraints(
+ location: .only(.hostname("se", "sto", "se6-wireguard")),
+ filter: .only(filter)
+ )
+
+ let result = try RelaySelector.evaluate(
+ relays: sampleRelays,
+ constraints: constraints,
+ numberOfFailedAttempts: 0
+ )
+
+ XCTAssertEqual(result.relay.provider, provider)
+ }
+
+ func testRelayFilterConstraintWithIncorrectProvider() throws {
+ let provider = "DataPacket"
+
+ let filter = RelayFilter(ownership: .any, providers: .only([provider]))
+ let constraints = RelayConstraints(
+ location: .only(.hostname("se", "sto", "se6-wireguard")),
+ filter: .only(filter)
+ )
+
+ let result = try? RelaySelector.evaluate(
+ relays: sampleRelays,
+ constraints: constraints,
+ numberOfFailedAttempts: 0
+ )
+
+ XCTAssertNil(result)
+ }
}
diff --git a/ios/MullvadVPNTests/ServerRelaysResponse+Stubs.swift b/ios/MullvadVPNTests/ServerRelaysResponse+Stubs.swift
index 8bca08f4f8..14a614184c 100644
--- a/ios/MullvadVPNTests/ServerRelaysResponse+Stubs.swift
+++ b/ios/MullvadVPNTests/ServerRelaysResponse+Stubs.swift
@@ -110,7 +110,7 @@ enum ServerRelaysResponseStubs {
active: true,
owned: true,
location: "se-sto",
- provider: "",
+ provider: "31173",
weight: 100,
ipv4AddrIn: .loopback,
ipv6AddrIn: .loopback,
diff --git a/ios/RelaySelector/RelaySelector.swift b/ios/RelaySelector/RelaySelector.swift
index bb25c1c960..20a8496d0d 100644
--- a/ios/RelaySelector/RelaySelector.swift
+++ b/ios/RelaySelector/RelaySelector.swift
@@ -120,12 +120,37 @@ public enum RelaySelector {
)
}
+ /// Determines whether a `REST.ServerRelay` satisfies the given relay filter.
+ public static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool {
+ if case let .only(providers) = filter.providers, providers.contains(relay.provider) == false {
+ return false
+ }
+
+ switch filter.ownership {
+ case .any:
+ return true
+ case .owned:
+ return relay.owned
+ case .rented:
+ return !relay.owned
+ }
+ }
+
/// Produce a list of `RelayWithLocation` items satisfying the given constraints
private static func applyConstraints<T: AnyRelay>(
_ constraints: RelayConstraints,
relays: [RelayWithLocation<T>]
) -> [RelayWithLocation<T>] {
- relays.filter { relayWithLocation -> Bool in
+ return relays.filter { relayWithLocation -> Bool in
+ switch constraints.filter {
+ case .any:
+ break
+ case let .only(filter):
+ if !relayMatchesFilter(relayWithLocation.relay, filter: filter) {
+ return false
+ }
+ }
+
switch constraints.location {
case .any:
return true
@@ -282,9 +307,11 @@ public struct RelaySelectorResult: Codable, Equatable {
public var location: Location
}
-protocol AnyRelay {
+public protocol AnyRelay {
var hostname: String { get }
+ var owned: Bool { get }
var location: String { get }
+ var provider: String { get }
var weight: UInt64 { get }
var active: Bool { get }
var includeInCountry: Bool { get }