From a08809bb3b3900d8cfaae1a4c01cbb20b081fe39 Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Wed, 7 Jun 2023 13:08:40 +0200 Subject: [PATCH] Add filtering to location selection --- ios/CHANGELOG.md | 2 + ios/MullvadTypes/RelayConstraints.swift | 6 +- ios/MullvadTypes/RelayFilter.swift | 25 ++ ios/MullvadVPN.xcodeproj/project.pbxproj | 96 ++++- .../Coordinators/RelayFilterCoordinator.swift | 89 ++++ .../SelectLocationCoordinator.swift | 79 +++- .../Extensions/Collection+Sorting.swift | 21 + .../FormsheetPresentationController.swift | 2 +- ios/MullvadVPN/UI appearance/UIMetrics.swift | 64 +-- .../Account/AccountContentView.swift | 2 +- .../DeviceManagementContentView.swift | 2 +- .../DeviceList/DeviceRowView.swift | 2 +- .../OutOfTime/OutOfTimeContentView.swift | 4 +- .../Preferences/PreferencesCellFactory.swift | 1 - .../Preferences/PreferencesDataSource.swift | 5 +- .../PreferencesViewController.swift | 2 +- .../AddCreditSucceededViewController.swift | 2 +- .../RelayFilter/RelayFilterCellFactory.swift | 87 ++++ .../RelayFilter/RelayFilterChipView.swift | 55 +++ .../RelayFilter/RelayFilterDataSource.swift | 397 ++++++++++++++++++ .../RelayFilter/RelayFilterView.swift | 122 ++++++ .../RelayFilterViewController.swift | 111 +++++ .../RelayFilter/RelayFilterViewModel.swift | 127 ++++++ .../SelectLocation/LocationDataSource.swift | 9 +- .../SelectLocation/SelectLocationCell.swift | 2 +- .../SelectLocationViewController.swift | 75 +++- .../Settings/CheckableSettingsCell.swift | 42 ++ .../Settings/SelectableSettingsCell.swift | 4 +- .../Settings/SettingsCell.swift | 22 +- .../Settings/SettingsDNSInfoCell.swift | 2 +- .../Settings/SettingsDataSource.swift | 2 +- .../Settings/SettingsHeaderView.swift | 7 +- .../Settings/SettingsInputCell.swift | 2 +- ios/MullvadVPN/Views/CheckboxView.swift | 47 +++ ios/MullvadVPNTests/RelaySelectorTests.swift | 68 +++ .../ServerRelaysResponse+Stubs.swift | 2 +- ios/RelaySelector/RelaySelector.swift | 31 +- 37 files changed, 1511 insertions(+), 107 deletions(-) create mode 100644 ios/MullvadTypes/RelayFilter.swift create mode 100644 ios/MullvadVPN/Coordinators/RelayFilterCoordinator.swift create mode 100644 ios/MullvadVPN/Extensions/Collection+Sorting.swift create mode 100644 ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift create mode 100644 ios/MullvadVPN/View controllers/RelayFilter/RelayFilterChipView.swift create mode 100644 ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift create mode 100644 ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift create mode 100644 ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift create mode 100644 ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift create mode 100644 ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift create mode 100644 ios/MullvadVPN/Views/CheckboxView.swift diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 91871e7b479e..9b948ec015e1 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 f0bc09bf25f8..602e17b0886c 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 + public var filter: RelayConstraint public var debugDescription: String { "RelayConstraints { location: \(location), port: \(port) }" @@ -32,10 +33,12 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible public init( location: RelayConstraint = .only(.country("se")), - port: RelayConstraint = .any + port: RelayConstraint = .any, + filter: RelayConstraint = .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.self, forKey: .port) ?? .any + filter = try container.decodeIfPresent(RelayConstraint.self, forKey: .filter) ?? .any } } diff --git a/ios/MullvadTypes/RelayFilter.swift b/ios/MullvadTypes/RelayFilter.swift new file mode 100644 index 000000000000..48b5c0a326e9 --- /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 3f6ab488ff17..6484120dbc86 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 = ""; }; 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Coordinator+Router.swift"; sourceTree = ""; }; 7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentAlertPresenter.swift; sourceTree = ""; }; + 7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewController.swift; sourceTree = ""; }; + 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterDataSource.swift; sourceTree = ""; }; + 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterCellFactory.swift; sourceTree = ""; }; + 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = ""; }; + 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITextField+Appearance.swift"; sourceTree = ""; }; 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCoordinator.swift; sourceTree = ""; }; 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresentation.swift; sourceTree = ""; }; @@ -1508,7 +1524,6 @@ 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = ""; }; 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = ""; }; 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = ""; }; - 7A7AD28E29DEDB1C00480EF1 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = ""; }; 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNApp.xctestplan; sourceTree = ""; }; 7A83C4002A55B81A00DFB83A /* MullvadVPNCI.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MullvadVPNCI.xctestplan; sourceTree = ""; }; @@ -1535,14 +1550,24 @@ 7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SafariCoordinator.swift; sourceTree = ""; }; 7A9CCCB12A96302800DD6A34 /* ApplicationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationCoordinator.swift; sourceTree = ""; }; 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = ""; }; + 7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = ""; }; + 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = ""; }; 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = ""; }; + 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 7AD0AA192AD69B6E00119E10 /* PacketTunnelActorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorProtocol.swift; sourceTree = ""; }; 7AD0AA1B2AD6A63F00119E10 /* PacketTunnelActorStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorStub.swift; sourceTree = ""; }; 7AD0AA1E2AD6C8B900119E10 /* URLRequestProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestProxyProtocol.swift; sourceTree = ""; }; 7AD0AA202AD6CB0000119E10 /* URLRequestProxyStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestProxyStub.swift; sourceTree = ""; }; - 7AE47E512A17972A000418DA /* AlertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertViewController.swift; sourceTree = ""; }; 7AEF7F192AD00F52006FE45D /* AppMessageHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandler.swift; sourceTree = ""; }; + 7AF10EB12ADE859200C090B9 /* AlertViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertViewController.swift; sourceTree = ""; }; + 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelayFilterCoordinator.swift; sourceTree = ""; }; 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterBlockDelegate.swift; sourceTree = ""; }; + 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilter.swift; sourceTree = ""; }; + 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = ""; }; + 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = ""; }; + 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterCoordinator.swift; sourceTree = ""; }; + 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; + 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterChipView.swift; sourceTree = ""; }; 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownStylingOptions.swift; sourceTree = ""; }; A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = ""; }; A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTRequestExecutor+Stubs.swift"; sourceTree = ""; }; @@ -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 = ""; @@ -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 = ""; }; @@ -2871,7 +2903,7 @@ children = ( 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */, 58B9EB122488ED2100095626 /* AlertPresenter.swift */, - 7AE47E512A17972A000418DA /* AlertViewController.swift */, + 7AF10EB12ADE859200C090B9 /* AlertViewController.swift */, ); path = Alert; sourceTree = ""; @@ -2907,6 +2939,29 @@ path = RoutingTests; sourceTree = ""; }; + 7AC8A3A82ABC6F4800DC4939 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.swift */, + 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */, + 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + 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 = ""; + }; 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 000000000000..742e82bc050a --- /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 b813d70bc963..92a4dbb15053 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 000000000000..36c7898b1c64 --- /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 5bee1b2156b3..56d07610c279 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 9538f14a8669..4d5c42000094 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 707784e21e28..a3ffd21d563c 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 0df7733b8087..9aab160be520 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 c044ebd4e2a7..2cb3dcb731d8 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 5f4113660ddc..edd3f501058b 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 63788dc99bc6..fb07ac569f0f 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 354c8370cade..62a3e8d8f205 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 2a267142aeaf..e634ac9896a7 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 1176075b93b4..46c38f4c0480 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 000000000000..29aec029a3b8 --- /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 000000000000..986281c9d617 --- /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 000000000000..8c74b0559c6c --- /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() + 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() + snapshot.appendSections(Section.allCases) + + applySnapshot(snapshot, animated: false) + } + + private func updateDataSnapshot(filter: RelayFilter? = nil) { + let oldSnapshot = snapshot() + + var newSnapshot = NSDiffableDataSourceSnapshot() + 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, + 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, + isExpanded: Bool + ) { + if isExpanded { + snapshot.deleteItems(Item.ownerships) + } else { + snapshot.appendItems(Item.ownerships, toSection: .ownership) + } + } + + private func handleCollapseProviders( + snapshot: inout NSDiffableDataSourceSnapshot, + 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 000000000000..ab66f9b3ff00 --- /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 000000000000..f9c19c96a7bd --- /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() + + 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 000000000000..5cd147f1d27d --- /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 ee5efa3d2497..a5032402f2b5 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 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 000000000000..a732234473f5 --- /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 1f6f4c865ac8..1afa622d00be 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 7cd88c8e81fc..789462cb9a4d 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 455396acd847..02594d5a2969 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 1837b85898d9..f4aebdd8e8ee 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 c934042d6242..dbe1e62d113f 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 8e494c1e1136..f9b2b3b57a48 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 000000000000..0ffb354a28f9 --- /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 b390592b365a..e3ec34e1f4ff 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 8bca08f4f8eb..14a614184c80 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 bb25c1c960c9..20a8496d0dbc 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( _ constraints: RelayConstraints, relays: [RelayWithLocation] ) -> [RelayWithLocation] { - 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 }