diff --git a/ios/MullvadTypes/RelayConstraints.swift b/ios/MullvadTypes/RelayConstraints.swift index 9b32fdf16ebb..95b13e9cf6b0 100644 --- a/ios/MullvadTypes/RelayConstraints.swift +++ b/ios/MullvadTypes/RelayConstraints.swift @@ -13,6 +13,7 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible // Added in 2023.3 public var port: RelayConstraint + public var filter: RelayConstraint public var debugDescription: String { return "RelayConstraints { location: \(location), port: \(port) }" @@ -20,10 +21,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 { @@ -32,5 +35,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 1b9482ee7b33..4e9c80ede2d7 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -379,15 +379,27 @@ 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */; }; 58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF2C02281BDE02009EF542 /* SettingsManager.swift */; }; 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+FuzzyMatch.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 */; }; 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */; }; - 7A42DECD2A09064C00B209BE /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */; }; + 7A42DECD2A09064C00B209BE /* (null) in Sources */ = {isa = PBXBuildFile; }; 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 */; }; + 7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1412A2E3306000B728D /* CheckboxView.swift */; }; + 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */; }; 7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */; }; 7AE47E522A17972A000418DA /* CustomAlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE47E512A17972A000418DA /* CustomAlertViewController.swift */; }; 7AF0419E29E957EB00D492DD /* AccountCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF0419D29E957EB00D492DD /* AccountCoordinator.swift */; }; + 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */; }; + 7AF9BE892A30C62A00DBFEDB /* SettingsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.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 */; }; + 7AF9BE932A39F49E00DBFEDB /* RelayFilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */; }; + 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */; }; + 7AF9BE972A41C71F00DBFEDB /* RelayFilterChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */; }; A917351F29FAA9C400D5DCFD /* RESTTransportStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A917351E29FAA9C400D5DCFD /* RESTTransportStrategy.swift */; }; A917352129FAAA5200D5DCFD /* TransportStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A917352029FAAA5200D5DCFD /* TransportStrategyTests.swift */; }; A93D13782A1F60A6001EB0B1 /* shadowsocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 586F2BE129F6916F009E6924 /* shadowsocks.h */; settings = {ATTRIBUTES = (Private, ); }; }; @@ -1114,15 +1126,26 @@ 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyboardResponder.swift; sourceTree = ""; }; 58FF2C02281BDE02009EF542 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FuzzyMatch.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 = ""; }; 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = ""; }; - 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.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 = ""; }; + 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 = ""; }; 7AE47E512A17972A000418DA /* CustomAlertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertViewController.swift; sourceTree = ""; }; 7AF0419D29E957EB00D492DD /* AccountCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCoordinator.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 = ""; }; A917351E29FAA9C400D5DCFD /* RESTTransportStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTransportStrategy.swift; sourceTree = ""; }; A917352029FAAA5200D5DCFD /* TransportStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportStrategyTests.swift; sourceTree = ""; }; A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCacheTests.swift; sourceTree = ""; }; @@ -1404,6 +1427,7 @@ 58CAFA01298530DC00BE19F7 /* Promise.swift */, 5898D2B12902A6DE00EB5EBA /* RelayConstraint.swift */, 58781CC822AE7CA8009B9D8E /* RelayConstraints.swift */, + 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */, 5898D2AF2902A67C00EB5EBA /* RelayLocation.swift */, 581DA2722A1E227D0046ED47 /* RESTTypes.swift */, 58F1311427E0B2AB007AC5BC /* Result+Extensions.swift */, @@ -1470,6 +1494,7 @@ 583FE01A29C19777006E85F9 /* Preferences */, 583FE01929C19760006E85F9 /* ProblemReport */, F028A5472A336E1900C0CAA3 /* RedeemVoucher */, + 7AF9BE912A39F47D00DBFEDB /* RelayFilter */, 583FE01C29C19793006E85F9 /* RevokedDevice */, 583FE01729C196F3006E85F9 /* SelectLocation */, 583FE01829C19709006E85F9 /* Settings */, @@ -1493,20 +1518,21 @@ 583FE01829C19709006E85F9 /* Settings */ = { isa = PBXGroup; children = ( + 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */, + 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */, 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */, 582BB1AE229566420055B6EF /* SettingsCell.swift */, 5864AF0029C7879B005B0CD9 /* SettingsCellFactory.swift */, - 7A7AD28E29DEDB1C00480EF1 /* SettingsHeaderView.swift */, 58EE2E38272FF814003BFF93 /* SettingsDataSource.swift */, 58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */, 584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */, + 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.swift */, + 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */, 58677711290976FB006F721F /* SettingsInteractor.swift */, 5867770F290975E8006F721F /* SettingsInteractorFactory.swift */, 584D26C1270C8542004EA533 /* SettingsStaticTextFooterView.swift */, 58ACF64A26553C3F00ACE4B7 /* SettingsSwitchCell.swift */, 58CCA01122424D11004F3011 /* SettingsViewController.swift */, - 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */, - 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */, ); path = Settings; sourceTree = ""; @@ -1528,8 +1554,8 @@ 5864AF0229C7879B005B0CD9 /* PreferencesCellFactory.swift */, 584D26C3270C855A004EA533 /* PreferencesDataSource.swift */, 587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */, - 58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */, 5871167E2910035700D41AAC /* PreferencesInteractor.swift */, + 58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */, 587EB671271451E300123C75 /* PreferencesViewModel.swift */, ); path = Preferences; @@ -1584,6 +1610,7 @@ isa = PBXGroup; children = ( 5868585424054096000B8131 /* AppButton.swift */, + 7A9FA1412A2E3306000B728D /* CheckboxView.swift */, 58ACF64C26567A4F00ACE4B7 /* CustomSwitch.swift */, 58ACF64E26567A7100ACE4B7 /* CustomSwitchContainer.swift */, 58293FB025124117005D0BB5 /* CustomTextField.swift */, @@ -1637,6 +1664,7 @@ 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */, 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */, 58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */, + 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */, 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */, @@ -1827,6 +1855,7 @@ 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */, 58CAF9F92983E0C600BE19F7 /* LoginCoordinator.swift */, 583FE00D29C0D586006E85F9 /* OutOfTimeCoordinator.swift */, + 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */, 5847D58C29B7740F008C3808 /* RevokedCoordinator.swift */, 586891CC29D452E4002A8278 /* SafariCoordinator.swift */, 587C92FD2986E28100FB9664 /* SelectLocationCoordinator.swift */, @@ -2159,6 +2188,19 @@ path = MullvadRESTTests; 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 = ( @@ -2943,6 +2985,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 */, @@ -2964,6 +3007,7 @@ F028A56E2A34DCC600C0CAA3 /* RedeemVoucherInteractor.swift in Sources */, 5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */, 5878F4FC29CDA2E4003D4BE2 /* ChangeLogViewController.swift in Sources */, + 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */, 068CE5742927B7A400A068BB /* Migration.swift in Sources */, 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */, 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, @@ -2972,6 +3016,7 @@ 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */, E158B360285381C60002F069 /* String+AccountFormatting.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 */, @@ -2979,12 +3024,14 @@ 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */, 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, 584D26C2270C8542004EA533 /* SettingsStaticTextFooterView.swift in Sources */, + 7A1A26472A29CF0800B978AA /* RelayFilterDataSource.swift in Sources */, 587C93002986E2B600FB9664 /* TermsOfServiceCoordinator.swift in Sources */, 5864AF0929C78850005B0CD9 /* PreferencesCellFactory.swift in Sources */, 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, A97FF54B2A0B7AD000900996 /* SimulatorTunnelTransportProvider.swift in Sources */, 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */, 58FB865A26EA214400F188BC /* RelayCacheTrackerObserver.swift in Sources */, + 7AF9BE892A30C62A00DBFEDB /* SettingsHeaderView.swift in Sources */, 58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */, 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, @@ -3011,6 +3058,7 @@ 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */, 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */, 5893C6F929C1B480009090D1 /* DNSSettings.swift in Sources */, + 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 5864859929A0D028006C5743 /* FormsheetPresentationController.swift in Sources */, 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */, @@ -3026,23 +3074,27 @@ 5868585524054096000B8131 /* AppButton.swift in Sources */, 5893C6FC29C311E9009090D1 /* ApplicationRouter.swift in Sources */, 58E25F812837BBBB002CFB2C /* SceneDelegate.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 */, F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, 58CAF9F82983D36800BE19F7 /* Coordinator.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 */, @@ -3061,7 +3113,7 @@ 5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */, 583FE01029C0F532006E85F9 /* CustomSplitViewController.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, - 7A42DECD2A09064C00B209BE /* SelectableSettingsCell.swift in Sources */, + 7A42DECD2A09064C00B209BE /* (null) in Sources */, 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, @@ -3069,7 +3121,6 @@ 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, 06410E07292D108E00AFC18C /* SettingsStore.swift in Sources */, 586A950D290125F0007BAF2B /* PresentAlertOperation.swift in Sources */, - 7A7AD28F29DEDB1C00480EF1 /* SettingsHeaderView.swift in Sources */, 5878F50229CDB989003D4BE2 /* ChangeLogCoordinator.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, @@ -3106,6 +3157,7 @@ 5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */, 063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, + 7AF9BE932A39F49E00DBFEDB /* RelayFilterCoordinator.swift in Sources */, 5896AE84246D5889005B36CB /* CustomDateComponentsFormatting.swift in Sources */, 5871167F2910035700D41AAC /* PreferencesInteractor.swift in Sources */, 587AD7C623421D7000E93A53 /* TunnelSettingsV1.swift in Sources */, @@ -3118,6 +3170,7 @@ 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */, 583FE00E29C0D586006E85F9 /* OutOfTimeCoordinator.swift in Sources */, + 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */, 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */, 58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */, 580F8B8328197881002E0998 /* TunnelSettingsV2.swift in Sources */, @@ -3222,6 +3275,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 */, 58D22415294C90210029F5F8 /* PacketTunnelStatus.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/App/RelayFilterCoordinator.swift b/ios/MullvadVPN/Coordinators/App/RelayFilterCoordinator.swift new file mode 100644 index 000000000000..46c9dab2fe76 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/App/RelayFilterCoordinator.swift @@ -0,0 +1,88 @@ +// +// RelayFilterCoordinator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2023-06-14. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes +import RelayCache +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.didApplyFilter = { [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/App/SelectLocationCoordinator.swift b/ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift index aab311c9245c..bdd3e1c0d8b4 100644 --- a/ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/App/SelectLocationCoordinator.swift @@ -10,15 +10,35 @@ import MullvadTypes import RelayCache 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 { return navigationController } - private let tunnelManager: TunnelManager - private let relayCacheTracker: RelayCacheTracker + var presentationContext: UIViewController { + return navigationController + } + + 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)? @@ -33,9 +53,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 @@ -48,7 +68,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) @@ -57,21 +95,43 @@ 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/Extensions/UIBarButtonItem+Blocks.swift b/ios/MullvadVPN/Extensions/UIBarButtonItem+Blocks.swift index 5fbc9d2f284d..c0b83aff6ab4 100644 --- a/ios/MullvadVPN/Extensions/UIBarButtonItem+Blocks.swift +++ b/ios/MullvadVPN/Extensions/UIBarButtonItem+Blocks.swift @@ -37,6 +37,15 @@ extension UIBarButtonItem { self.actionHandler = actionHandler } + /** + Initialize bar button item with title and block handler. + */ + convenience init(title: String, actionHandler: @escaping ActionHandler) { + self.init(title: title, style: .plain, target: nil, action: nil) + + self.actionHandler = actionHandler + } + @objc private func handleAction() { actionHandler?() } diff --git a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift index d88202231dff..3518c0634562 100644 --- a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift +++ b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift @@ -164,7 +164,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/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 49f1bc18e82e..270cdde6a35b 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -71,6 +71,7 @@ final class TunnelManager: StorePaymentObserver { private var _tunnelSettings = TunnelSettingsV2() private var _tunnel: Tunnel? + private var _tunnelStatus = TunnelStatus() /// Last processed device check. diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index c8f1704973d4..5de79ba3089f 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -41,32 +41,20 @@ enum UIMetrics { } 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) + enum TableView { + /// Height for separators between cells and/or sections. + static let separatorHeight: CGFloat = 0.33 - /// Common layout margins for settings cell presentation - static let settingsCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 12) + /// Spacing used between distinct sections of views + static let sectionSpacing: CGFloat = 24 - /// 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 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 + /// Common cell indentation width + static let cellIndentationWidth: CGFloat = 16 + } /// Group of constants related to in-app notifications banner. enum InAppBannerNotification { @@ -77,12 +65,49 @@ extension UIMetrics { static let indicatorSize = CGSize(width: 12, height: 12) } + enum SettingsCell { + /// Common layout margins for settings cell presentation. + static let layoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 12) + + /// Common layout margins for text field. + static let inputCellTextFieldLayoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + + /// Spacing between left view and content view. + static let selectableSettingsCellLeftViewSpacing: CGFloat = 12 + + /// Spacing between left view and content view. + static let checkableSettingsCellLeftViewSpacing: CGFloat = 20 + } + + enum FilterView { + // Spacing between chips and label. + static let labelSpacing: CGFloat = 5 + + // Spacing between chip views. + static let interChipViewSpacing: CGFloat = 8 + + // Chip view corner radius. + static let chipViewCornerRadius: CGFloat = 8 + + // Chip view layout margins. + static let chipViewLayoutMargins = UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8) + + // Spacing between chip view label and button. + static let chipViewLabelSpacing: CGFloat = 7 + } + + /// 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) + /// 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) diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift index 127926dd5d2b..8666d68d8f22 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift @@ -81,7 +81,7 @@ class AccountContentView: UIView { ]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical - stackView.spacing = UIMetrics.sectionSpacing + stackView.spacing = UIMetrics.TableView.sectionSpacing return stackView }() @@ -110,7 +110,7 @@ class AccountContentView: UIView { buttonStackView.topAnchor.constraint( greaterThanOrEqualTo: contentStackView.bottomAnchor, - constant: UIMetrics.sectionSpacing + constant: UIMetrics.TableView.sectionSpacing ), buttonStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), buttonStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift index 1305685ad812..9c80a5641078 100644 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift @@ -173,7 +173,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 b55cfaf386a6..51dfabf2a12c 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 6455a183e118..1d866f5ba864 100644 --- a/ios/MullvadVPN/View controllers/Preferences/PreferencesCellFactory.swift +++ b/ios/MullvadVPN/View controllers/Preferences/PreferencesCellFactory.swift @@ -87,7 +87,6 @@ final class PreferencesCellFactory: CellFactoryProtocol { ) cell.accessibilityHint = nil cell.applySubCellStyling() - cell.setInfoButtonIsVisible(true) cell.setOn(viewModel.blockMalware, animated: false) 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 2be26b07a2f7..1db6f665f170 100644 --- a/ios/MullvadVPN/View controllers/Preferences/PreferencesDataSource.swift +++ b/ios/MullvadVPN/View controllers/Preferences/PreferencesDataSource.swift @@ -373,7 +373,7 @@ final class PreferencesDataSource: UITableViewDiffableDataSource< case .wireGuardPorts: guard let view = tableView .dequeueReusableHeaderFooterView( - withIdentifier: HeaderFooterReuseIdentifiers.contentBlockerHeader.rawValue + withIdentifier: HeaderFooterReuseIdentifiers.wireGuardPortHeader.rawValue ) as? SettingsHeaderView else { return nil } configureWireguardPortsHeader(view) return view @@ -433,7 +433,7 @@ final class PreferencesDataSource: UITableViewDiffableDataSource< } case .wireGuardPorts: - return UIMetrics.sectionSpacing + return UIMetrics.TableView.sectionSpacing } } diff --git a/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift b/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift index d4f4e7691b48..6be9ae06061a 100644 --- a/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift +++ b/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift @@ -58,7 +58,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/RedeemVoucherSucceededViewController.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherSucceededViewController.swift index 9ec894734e98..604a6af2bc26 100644 --- a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherSucceededViewController.swift +++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherSucceededViewController.swift @@ -105,7 +105,7 @@ class RedeemVoucherSucceededViewController: UIViewController { 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..b4248339e60b --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift @@ -0,0 +1,89 @@ +// +// 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 + + init(tableView: UITableView) { + self.tableView = tableView + } + + 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 } + + var title = "" + switch item { + case .allProviders: + title = "All providers" + setFontWeight(.semibold, to: cell.titleLabel) + case let .provider(name): + title = name + setFontWeight(.regular, to: cell.titleLabel) + default: + 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..ea439d723894 --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift @@ -0,0 +1,396 @@ +// +// 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) + } + } + + var cancellable: Combine.AnyCancellable? + + init(tableView: UITableView, viewModel: RelayFilterViewModel) { + self.tableView = tableView + self.viewModel = viewModel + + let relayFilterCellFactory = RelayFilterCellFactory(tableView: tableView) + self.relayFilterCellFactory = relayFilterCellFactory + + super.init(tableView: tableView) { tableView, indexPath, itemIdentifier in + relayFilterCellFactory.makeCell(for: itemIdentifier, indexPath: indexPath) + } + + tableView.delegate = self + + registerClasses() + createDataSnapshot() + + viewModel.$relays + .combineLatest(viewModel.$relayFilter) + .sink { [weak self] relays in + self?.updateDataSnapshot() + } + .store(in: &disposeBag) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + switch getSection(for: indexPath) { + case .ownership: + if viewModel.getOwnership(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() { + 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 items = viewModel.uniqueProviders.map { Item.provider($0) } + + newSnapshot.appendItems([.allProviders]) + 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.getOwnershipItem(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.getProviderItem(for: providerName) { + selectRow(true, at: indexPath(for: providerItem)) + } + } + + updateAllProvidersSelection() + } + } + + private func updateAllProvidersSelection() { + if viewModel.uniqueProviders.count == getSelectedIndexPaths(in: .providers).count { + 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.uniqueProviders.map { Item.provider($0) } + + snapshot.appendItems([.allProviders]) + 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..2abd8710f287 --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift @@ -0,0 +1,121 @@ +// +// 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 + + 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: [providersView, ownershipView, 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..881026649443 --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift @@ -0,0 +1,108 @@ +// +// 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 didApplyFilter: ((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, actionHandler: { [weak self] in + self?.didFinish?() + }) + + applyButton.addTarget(self, action: #selector(didTapApplyButton), 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 didTapApplyButton() { + guard let filter = viewModel?.relayFilter else { return } + didApplyFilter?(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..2c18a59dabbc --- /dev/null +++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift @@ -0,0 +1,103 @@ +// +// 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] { + return Array(Set(relays.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 = getOwnership(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() + 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 = uniqueProviders + providers.removeAll { $0 == name } + relayFilter.providers = .only(providers) + case var .only(providers): + providers.removeAll { $0 == name } + relayFilter.providers = .only(providers) + } + } + } + + func getOwnership(for item: RelayFilterDataSource.Item?) -> RelayFilter.Ownership? { + switch item { + case .ownershipAny: + return .any + case .ownershipOwned: + return .owned + case .ownershipRented: + return .rented + default: + return nil + } + } + + func getOwnershipItem(for ownership: RelayFilter.Ownership?) -> RelayFilterDataSource.Item? { + switch ownership { + case .any: + return .ownershipAny + case .owned: + return .ownershipOwned + case .rented: + return .ownershipRented + default: + return nil + } + } + + func getProviderName(for item: RelayFilterDataSource.Item?) -> String? { + switch item { + case let .provider(name): + return name + default: + return nil + } + } + + func getProviderItem(for providerName: String?) -> RelayFilterDataSource.Item? { + return .provider(providerName ?? "") + } +} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift index 2693e75f644c..1585e12af477 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,16 +74,18 @@ final class LocationDataSource: UITableViewDiffableDataSource Void)? var didSelectRelay: ((RelayLocation) -> Void)? + var didUpdateFilter: ((RelayFilter) -> Void)? var didFinish: (() -> Void)? // MARK: - View lifecycle @@ -38,19 +47,32 @@ final class SelectLocationViewController: UIViewController { value: "Select location", comment: "" ) + + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: NSLocalizedString( + "NAVIGATION_TITLE", + tableName: "SelectLocation", + value: "Filter", + comment: "" + ), + actionHandler: { [weak self] in + self?.navigateToFilter?() + } + ) + navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done, actionHandler: { [weak self] in self?.didFinish?() }) - 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) } } @@ -72,15 +94,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) @@ -89,11 +119,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 @@ -102,7 +132,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 a6333462ee22..17ca8131b651 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 }() @@ -79,7 +82,6 @@ class SettingsCell: UITableViewCell { backgroundColor = .clear contentView.backgroundColor = .clear - infoButton.isHidden = true infoButton.addTarget( self, action: #selector(handleInfoButton(_:)), @@ -131,7 +133,6 @@ class SettingsCell: UITableViewCell { } contentContainer.addArrangedSubview(content) - contentContainer.spacing = 12 contentView.addConstrainedSubviews([contentContainer]) { contentContainer.pinEdgesToSuperviewMargins() @@ -145,26 +146,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() { @@ -179,9 +178,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/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift index 954361c46324..c86ec221e5f6 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift @@ -100,7 +100,7 @@ final class SettingsDataSource: UITableViewDiffableDataSource< } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 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 51bb8e274244..28255abb4fac 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift @@ -45,7 +45,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") @@ -54,6 +56,7 @@ class SettingsHeaderView: UITableViewHeaderFooterView { override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) + infoButton.isHidden = true infoButton.addTarget( self, action: #selector(handleInfoButton(_:)), @@ -66,7 +69,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/View controllers/Settings/SettingsStaticTextFooterView.swift b/ios/MullvadVPN/View controllers/Settings/SettingsStaticTextFooterView.swift index e99932dbebbf..d0efb1f83376 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsStaticTextFooterView.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsStaticTextFooterView.swift @@ -21,7 +21,7 @@ class SettingsStaticTextFooterView: UITableViewHeaderFooterView { override init(reuseIdentifier: String?) { super.init(reuseIdentifier: reuseIdentifier) - contentView.directionalLayoutMargins = UIMetrics.settingsCellLayoutMargins + contentView.directionalLayoutMargins = UIMetrics.SettingsCell.layoutMargins contentView.addSubview(titleLabel) contentView.addConstraints([ diff --git a/ios/MullvadVPN/Views/CheckboxView.swift b/ios/MullvadVPN/Views/CheckboxView.swift new file mode 100644 index 000000000000..e03d63cfdff5 --- /dev/null +++ b/ios/MullvadVPN/Views/CheckboxView.swift @@ -0,0 +1,46 @@ +// +// 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: Bool { didSet { + checkmarkView.alpha = isChecked ? 1 : 0 + }} + + init() { + isChecked = false + 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/RelaySelector/RelaySelector.swift b/ios/RelaySelector/RelaySelector.swift index 956461751b8d..961a1a752f93 100644 --- a/ios/RelaySelector/RelaySelector.swift +++ b/ios/RelaySelector/RelaySelector.swift @@ -65,12 +65,45 @@ public enum RelaySelector { ) } + /// Determines whether a `REST.ServerRelay` satisfies the given relay filter. + public static func relayMatchesFilter(_ relay: REST.ServerRelay, filter: RelayFilter) -> Bool { + switch filter.ownership { + case .any: + break + case .owned: + if !relay.owned { + return false + } + case .rented: + if relay.owned { + return false + } + } + + if case let .only(providers) = filter.providers { + if !providers.contains(relay.provider) { + return false + } + } + + return true + } + /// Produce a list of `RelayWithLocation` items satisfying the given constraints private static func applyConstraints( _ constraints: RelayConstraints, relays: [RelayWithLocation] ) -> [RelayWithLocation] { 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