diff --git a/ios/MullvadSettings/MultihopSettings.swift b/ios/MullvadSettings/MultihopSettings.swift index 1d3dc2306bda..a9a17f2b4358 100644 --- a/ios/MullvadSettings/MultihopSettings.swift +++ b/ios/MullvadSettings/MultihopSettings.swift @@ -9,7 +9,7 @@ import Foundation import MullvadTypes -/// Whether Multihop is enabled +/// Whether multihop is enabled. public enum MultihopState: Codable { case on case off diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 34b89e95a077..fec76131aece 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -567,11 +567,13 @@ 7A8A190E2CEB77C1000BCB5B /* SettingsRowViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A190D2CEB77B7000BCB5B /* SettingsRowViewFooter.swift */; }; 7A8A19102CEE391B000BCB5B /* RowSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A190F2CEE3918000BCB5B /* RowSeparator.swift */; }; 7A8A19122CEF1E68000BCB5B /* SettingsInfoContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19112CEF1E58000BCB5B /* SettingsInfoContainerView.swift */; }; - 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsObserver.swift */; }; - 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsObserver.swift */; }; - 7A8A19182CEF27AB000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19172CEF279C000BCB5B /* DAITATunnelSettingsViewModel.swift */; }; + 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */; }; + 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */; }; 7A8A191A2CEF41AF000BCB5B /* GroupedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19192CEF41AC000BCB5B /* GroupedRowView.swift */; }; - 7A8A191C2CEF55E3000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A191B2CEF55DA000BCB5B /* MultihopTunnelSettingsViewModel.swift */; }; + 7A8A191E2CEF5CF2000BCB5B /* TunnelSettingsObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A191D2CEF5CDF000BCB5B /* TunnelSettingsObservable.swift */; }; + 7A8A19242CF4C9BF000BCB5B /* MultihopPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */; }; + 7A8A19262CF4D37B000BCB5B /* DAITAPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19252CF4D373000BCB5B /* DAITAPage.swift */; }; + 7A8A19282CF603EB000BCB5B /* SettingsViewControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19272CF603E3000BCB5B /* SettingsViewControllerFactory.swift */; }; 7A9BE5A22B8F88C500E2A7D0 /* LocationNodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */; }; 7A9BE5A32B8F89B900E2A7D0 /* LocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389F72B864CDF008E77E1 /* LocationNode.swift */; }; 7A9BE5A52B90760C00E2A7D0 /* CustomListsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */; }; @@ -1921,11 +1923,13 @@ 7A8A190D2CEB77B7000BCB5B /* SettingsRowViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRowViewFooter.swift; sourceTree = ""; }; 7A8A190F2CEE3918000BCB5B /* RowSeparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowSeparator.swift; sourceTree = ""; }; 7A8A19112CEF1E58000BCB5B /* SettingsInfoContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInfoContainerView.swift; sourceTree = ""; }; - 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITATunnelSettingsObserver.swift; sourceTree = ""; }; - 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopTunnelSettingsObserver.swift; sourceTree = ""; }; - 7A8A19172CEF279C000BCB5B /* DAITATunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITATunnelSettingsViewModel.swift; sourceTree = ""; }; + 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITATunnelSettingsViewModel.swift; sourceTree = ""; }; + 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopTunnelSettingsViewModel.swift; sourceTree = ""; }; 7A8A19192CEF41AC000BCB5B /* GroupedRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedRowView.swift; sourceTree = ""; }; - 7A8A191B2CEF55DA000BCB5B /* MultihopTunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopTunnelSettingsViewModel.swift; sourceTree = ""; }; + 7A8A191D2CEF5CDF000BCB5B /* TunnelSettingsObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsObservable.swift; sourceTree = ""; }; + 7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopPage.swift; sourceTree = ""; }; + 7A8A19252CF4D373000BCB5B /* DAITAPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITAPage.swift; sourceTree = ""; }; + 7A8A19272CF603E3000BCB5B /* SettingsViewControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewControllerFactory.swift; sourceTree = ""; }; 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNodeTests.swift; sourceTree = ""; }; 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsDataSourceTests.swift; sourceTree = ""; }; 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsRepositoryStub.swift; sourceTree = ""; }; @@ -2885,7 +2889,6 @@ 4424CDD12CDBD457009D8C9F /* SwiftUI components */, 4422C06F2CCFF6520001A385 /* Obfuscation */, 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */, - F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */, 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */, 7A27E3CE2CBD4A830088BCFF /* SelectableSettingsDetailsCell.swift */, 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */, @@ -3823,6 +3826,8 @@ 7A9CCCAD2A96302800DD6A34 /* SettingsCoordinator.swift */, 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */, 7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */, + 7A8A19272CF603E3000BCB5B /* SettingsViewControllerFactory.swift */, + 7A8A191D2CEF5CDF000BCB5B /* TunnelSettingsObservable.swift */, ); path = Settings; sourceTree = ""; @@ -3972,8 +3977,7 @@ 7A8A18F72CE34E8F000BCB5B /* Multihop */ = { isa = PBXGroup; children = ( - 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsObserver.swift */, - 7A8A191B2CEF55DA000BCB5B /* MultihopTunnelSettingsViewModel.swift */, + 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */, 7A8A18F82CE34E9F000BCB5B /* SettingsMultihopView.swift */, ); path = Multihop; @@ -3994,8 +3998,8 @@ 7A8A19082CE5FFD7000BCB5B /* DAITA */ = { isa = PBXGroup; children = ( - 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsObserver.swift */, - 7A8A19172CEF279C000BCB5B /* DAITATunnelSettingsViewModel.swift */, + F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */, + 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */, 7A8A19092CE5FFDF000BCB5B /* SettingsDAITAView.swift */, ); path = DAITA; @@ -4103,6 +4107,7 @@ 85021CAD2BDBC4290098B400 /* AppLogsPage.swift */, 8587A05C2B84D43100152938 /* ChangeLogAlert.swift */, A9BFB0002BD00B7F00F2BCA1 /* CustomListPage.swift */, + 7A8A19252CF4D373000BCB5B /* DAITAPage.swift */, F09084672C6E88ED001CD36E /* DaitaPromptAlert.swift */, 85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */, 852A26452BA9C9CB006EB9C8 /* DNSSettingsPage.swift */, @@ -4111,6 +4116,7 @@ 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */, A998DA802BD147AD001D61A2 /* ListCustomListsPage.swift */, 852969342B4E9270007EAD4C /* LoginPage.swift */, + 7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */, 85139B2C2B84B4A700734217 /* OutOfTimePage.swift */, 852969322B4E9232007EAD4C /* Page.swift */, 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */, @@ -5771,7 +5777,7 @@ 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */, 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */, 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */, - 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsObserver.swift in Sources */, + 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */, 7A8A18F92CE34EA8000BCB5B /* SettingsMultihopView.swift in Sources */, 44BB5F972BE527F4002520EB /* TunnelState+UI.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, @@ -5802,6 +5808,7 @@ 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, F062000C2CB7EB5D002E6DB9 /* UIImage+Helpers.swift in Sources */, 7A6389EB2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift in Sources */, + 7A8A19282CF603EB000BCB5B /* SettingsViewControllerFactory.swift in Sources */, 58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */, 7A8A19072CE4E9D3000BCB5B /* SettingsInfoView.swift in Sources */, 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, @@ -5923,7 +5930,6 @@ F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */, F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */, 7A6000F62B60092F001CF0D9 /* AccessMethodViewModelEditing.swift in Sources */, - 7A8A191C2CEF55E3000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */, 5878A27729093A4F0096FC88 /* StorePaymentBlockObserver.swift in Sources */, 58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */, 5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */, @@ -5951,6 +5957,7 @@ 7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */, 582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */, 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */, + 7A8A191E2CEF5CF2000BCB5B /* TunnelSettingsObservable.swift in Sources */, 58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */, 5864AF0829C78849005B0CD9 /* CellFactoryProtocol.swift in Sources */, 7A6389E22B7E3BD6008E77E1 /* CustomListInteractor.swift in Sources */, @@ -5959,7 +5966,6 @@ 58CEB2F32AFD0BA100E6E088 /* TextCellContentView.swift in Sources */, 7A6389E72B7E42BE008E77E1 /* CustomListViewController.swift in Sources */, 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */, - 7A8A19182CEF27AB000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */, 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */, @@ -5994,7 +6000,7 @@ 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, 58FF9FF02B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift in Sources */, - 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsObserver.swift in Sources */, + 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */, 58CEB2FD2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift in Sources */, F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, @@ -6309,6 +6315,7 @@ 85557B202B5FBBD700795FE1 /* AccountPage.swift in Sources */, 852969352B4E9270007EAD4C /* LoginPage.swift in Sources */, A998DA832BD2B055001D61A2 /* EditCustomListLocationsPage.swift in Sources */, + 7A8A19242CF4C9BF000BCB5B /* MultihopPage.swift in Sources */, 7ACD79392C0DAADD00DBEE14 /* AddCustomListLocationsPage.swift in Sources */, 8556EB562B9B0AC500D26DD4 /* RevokedDevicePage.swift in Sources */, A9BFAFFF2BD004ED00F2BCA1 /* CustomListsTests.swift in Sources */, @@ -6318,6 +6325,7 @@ 8587A05D2B84D43100152938 /* ChangeLogAlert.swift in Sources */, 85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */, 7A45CFC72C071DD400D80B21 /* SnapshotHelper.swift in Sources */, + 7A8A19262CF4D37B000BCB5B /* DAITAPage.swift in Sources */, 856952DC2BD2922A008C1F84 /* PartnerAPIClient.swift in Sources */, 85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */, 855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 620761cd1570..a277b851bd41 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -146,6 +146,8 @@ public enum AccessibilityIdentifier: String { case editCustomListEditLocationsTableView case relayFilterChipView case dnsSettingsTableView + case multihopView + case daitaView // Other UI elements case accessMethodEnableSwitch diff --git a/ios/MullvadVPN/View controllers/Settings/DAITASettingsPromptItem.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITASettingsPromptItem.swift similarity index 72% rename from ios/MullvadVPN/View controllers/Settings/DAITASettingsPromptItem.swift rename to ios/MullvadVPN/Coordinators/Settings/DAITA/DAITASettingsPromptItem.swift index c67578027c73..74a86d93a59a 100644 --- a/ios/MullvadVPN/View controllers/Settings/DAITASettingsPromptItem.swift +++ b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITASettingsPromptItem.swift @@ -32,13 +32,13 @@ enum DAITASettingsPromptItem: CustomStringConvertible { switch self { case .daitaSettingIncompatibleWithSinglehop: """ - Not all our servers are DAITA-enabled. In order to use the internet, you might have to \ - select a new location after enabling. + DAITA isn’t available at the currently selected location. After enabling, please go to \ + the “Select location” view and select a location that supports DAITA. """ case .daitaSettingIncompatibleWithMultihop: """ - Not all our servers are DAITA-enabled. In order to use the internet, you might have to \ - select a new entry location after enabling. + DAITA isn’t available on the current entry server. After enabling, please go to the \ + “Select location” view and select an entry location that supports DAITA. """ } } diff --git a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsObserver.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsObserver.swift deleted file mode 100644 index 028f60fb3b64..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsObserver.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// DAITATunnelSettingsObserver.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-11-21. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadSettings - -class DAITATunnelSettingsObserver: ObservableObject { - private let tunnelManager: TunnelManager - private var tunnelObserver: TunnelObserver? - - var value: DAITASettings { - willSet(newValue) { - guard newValue != self.value else { return } - - objectWillChange.send() - tunnelManager.updateSettings([.daita(newValue)]) - } - } - - init(tunnelManager: TunnelManager) { - self.tunnelManager = tunnelManager - value = tunnelManager.settings.daita - - tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in - self?.value = newSettings.daita - }) - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift index bd0d13029cd8..d1f3e7945108 100644 --- a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift +++ b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift @@ -1,24 +1,107 @@ // -// DAITATunnelSettingsObservable.swift +// DAITATunnelSettingsViewModel.swift // MullvadVPN // // Created by Jon Petersson on 2024-11-21. // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Foundation +import MullvadREST import MullvadSettings -protocol DAITATunnelSettingsObservable: ObservableObject { - var value: DAITASettings { get set } +class DAITATunnelSettingsViewModel: TunnelSettingsObserver, ObservableObject { + typealias TunnelSetting = DAITASettings + + let tunnelManager: TunnelManager + var tunnelObserver: TunnelObserver? + + var didFailDAITAValidation: (((item: DAITASettingsPromptItem, setting: DAITASettings)) -> Void)? + + var value: DAITASettings { + willSet { + guard newValue != value else { return } + + objectWillChange.send() + tunnelManager.updateSettings([.daita(newValue)]) + } + } + + required init(tunnelManager: TunnelManager) { + self.tunnelManager = tunnelManager + value = tunnelManager.settings.daita + + tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in + self?.value = newSettings.daita + }) + } + + func evaluate(setting: DAITASettings) { + if let error = evaluateDaitaSettingsCompatibility(setting) { + let promptItem = promptItem(from: error, setting: setting) + + didFailDAITAValidation?((item: promptItem, setting: setting)) + return + } + + value = setting + } +} + +extension DAITATunnelSettingsViewModel { + private func promptItem( + from error: DAITASettingsCompatibilityError, + setting: DAITASettings + ) -> DAITASettingsPromptItem { + let promptItemSetting: DAITASettingsPromptItem.Setting = if setting.daitaState != value.daitaState { + .daita + } else { + .directOnly + } + + var promptItem: DAITASettingsPromptItem + + switch error { + case .singlehop: + promptItem = .daitaSettingIncompatibleWithSinglehop(promptItemSetting) + case .multihop: + promptItem = .daitaSettingIncompatibleWithMultihop(promptItemSetting) + } + + return promptItem + } + + private func evaluateDaitaSettingsCompatibility(_ settings: DAITASettings) -> DAITASettingsCompatibilityError? { + guard settings.daitaState.isEnabled else { return nil } + + var tunnelSettings = tunnelManager.settings + tunnelSettings.daita = settings + + var compatibilityError: DAITASettingsCompatibilityError? + + do { + _ = try tunnelManager.selectRelays(tunnelSettings: tunnelSettings) + } catch let error as NoRelaysSatisfyingConstraintsError where error.reason == .noDaitaRelaysFound { + // Return error if no relays could be selected due to DAITA constraints. + compatibilityError = tunnelSettings.tunnelMultihopState.isEnabled ? .multihop : .singlehop + } catch _ as NoRelaysSatisfyingConstraintsError { + // Even if the constraints error is not DAITA specific, if both DAITA and Direct only are enabled, + // we should return a DAITA related error since the current settings would have resulted in the + // relay selector not being able to select a DAITA relay anyway. + if settings.isDirectOnly { + compatibilityError = tunnelSettings.tunnelMultihopState.isEnabled ? .multihop : .singlehop + } + } catch {} + + return compatibilityError + } } -class MockDAITATunnelSettingsViewModel: DAITATunnelSettingsObservable { +class MockDAITATunnelSettingsViewModel: TunnelSettingsObservable { @Published var value: DAITASettings init(daitaSettings: DAITASettings = DAITASettings()) { value = daitaSettings } -} -class DAITATunnelSettingsViewModel: DAITATunnelSettingsObserver, DAITATunnelSettingsObservable {} + func evaluate(setting: MullvadSettings.DAITASettings) {} +} diff --git a/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift index 44265b80155d..1f4da085330e 100644 --- a/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift @@ -6,52 +6,11 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import MullvadSettings import SwiftUI -struct SettingsDAITAView: View where VM: DAITATunnelSettingsObservable { - @StateObject var tunnelViewModel: VM - - private let dataViewModel = SettingsInfoViewModel( - pages: [ - SettingsInfoViewModelPage( - body: NSLocalizedString( - "SETTINGS_INFO_DAITA_PAGE_1", - tableName: "Settings", - value: """ - DAITA (Defense against AI-guided Traffic Analysis) hides patterns in \ - your encrypted VPN traffic. - - By using sophisticated AI it’s possible to analyze the traffic of data \ - packets going in and out of your device (even if the traffic is encrypted). - - If an observer monitors these data packets, DAITA makes it significantly \ - harder for them to identify which websites you are visiting or with whom \ - you are communicating. - """, - comment: "" - ), - image: .daitaOffIllustration - ), - SettingsInfoViewModelPage( - body: NSLocalizedString( - "SETTINGS_INFO_DAITA_PAGE_2", - tableName: "Settings", - value: """ - DAITA does this by carefully adding network noise and making all network \ - packets the same size. - - Not all our servers are DAITA-enabled. Therefore, we use multihop \ - automatically to enable DAITA with any server. - - Attention: Be cautious if you have a limited data plan as this feature \ - will increase your network traffic. - """, - comment: "" - ), - image: .daitaOnIllustration - ) - ] - ) +struct SettingsDAITAView: View where ViewModel: TunnelSettingsObservable { + @StateObject var tunnelViewModel: ViewModel var body: some View { SettingsInfoContainerView { @@ -61,23 +20,26 @@ struct SettingsDAITAView: View where VM: DAITATunnelSettingsObservable { VStack { GroupedRowView { SwitchRowView( - enabled: $tunnelViewModel.value.daitaState.isEnabled, + isOn: daitaIsEnabled, text: NSLocalizedString( "SETTINGS_SWITCH_DAITA_ENABLE", tableName: "Settings", value: "Enable", comment: "" - ) + ), + accessibilityId: .daitaSwitch ) RowSeparator() SwitchRowView( - enabled: $tunnelViewModel.value.directOnlyState.isEnabled, + isOn: directOnlyIsEnabled, + disabled: !daitaIsEnabled.wrappedValue, text: NSLocalizedString( "SETTINGS_SWITCH_DAITA_DIRECT_ONLY", tableName: "Settings", value: "Direct only", comment: "" - ) + ), + accessibilityId: .daitaDirectOnlySwitch ) } @@ -101,6 +63,80 @@ struct SettingsDAITAView: View where VM: DAITATunnelSettingsObservable { } } +extension SettingsDAITAView { + var daitaIsEnabled: Binding { + Binding( + get: { + tunnelViewModel.value.daitaState.isEnabled + }, set: { enabled in + var settings = tunnelViewModel.value + settings.daitaState.isEnabled = enabled + + tunnelViewModel.evaluate(setting: settings) + } + ) + } + + var directOnlyIsEnabled: Binding { + Binding( + get: { + tunnelViewModel.value.directOnlyState.isEnabled + }, set: { enabled in + var settings = tunnelViewModel.value + settings.directOnlyState.isEnabled = enabled + + tunnelViewModel.evaluate(setting: settings) + } + ) + } +} + #Preview { SettingsDAITAView(tunnelViewModel: MockDAITATunnelSettingsViewModel()) } + +extension SettingsDAITAView { + private var dataViewModel: SettingsInfoViewModel { + SettingsInfoViewModel( + pages: [ + SettingsInfoViewModelPage( + body: NSLocalizedString( + "SETTINGS_INFO_DAITA_PAGE_1", + tableName: "Settings", + value: """ + DAITA (Defense against AI-guided Traffic Analysis) hides patterns in \ + your encrypted VPN traffic. + + By using sophisticated AI it’s possible to analyze the traffic of data \ + packets going in and out of your device (even if the traffic is encrypted). + + If an observer monitors these data packets, DAITA makes it significantly \ + harder for them to identify which websites you are visiting or with whom \ + you are communicating. + """, + comment: "" + ), + image: .daitaOffIllustration + ), + SettingsInfoViewModelPage( + body: NSLocalizedString( + "SETTINGS_INFO_DAITA_PAGE_2", + tableName: "Settings", + value: """ + DAITA does this by carefully adding network noise and making all network \ + packets the same size. + + Not all our servers are DAITA-enabled. Therefore, we use multihop \ + automatically to enable DAITA with any server. + + Attention: Be cautious if you have a limited data plan as this feature \ + will increase your network traffic. + """, + comment: "" + ), + image: .daitaOnIllustration + ), + ] + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsObserver.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsObserver.swift deleted file mode 100644 index 4f55ff145399..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsObserver.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// MultihopTunnelSettingsObserver.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-11-21. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadSettings - -class MultihopTunnelSettingsObserver: ObservableObject { - private let tunnelManager: TunnelManager - private var tunnelObserver: TunnelObserver? - - var value: MultihopState { - willSet(newValue) { - guard newValue != value else { return } - - objectWillChange.send() - tunnelManager.updateSettings([.multihop(newValue)]) - } - } - - init(tunnelManager: TunnelManager) { - self.tunnelManager = tunnelManager - value = tunnelManager.settings.tunnelMultihopState - - tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in - self?.value = newSettings.tunnelMultihopState - }) - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift index e4e84e7ede0c..45ae1b9a12a4 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift @@ -6,19 +6,43 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Foundation import MullvadSettings -protocol MultihopTunnelSettingsObservable: ObservableObject { - var value: MultihopState { get set } +class MultihopTunnelSettingsViewModel: TunnelSettingsObserver, ObservableObject { + typealias TunnelSetting = MultihopState + + let tunnelManager: TunnelManager + var tunnelObserver: TunnelObserver? + + var value: MultihopState { + willSet(newValue) { + guard newValue != value else { return } + + objectWillChange.send() + tunnelManager.updateSettings([.multihop(newValue)]) + } + } + + required init(tunnelManager: TunnelManager) { + self.tunnelManager = tunnelManager + value = tunnelManager.settings.tunnelMultihopState + + tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in + self?.value = newSettings.tunnelMultihopState + }) + } + + func evaluate(setting: MultihopState) { + // No op. + } } -class MockMultihopTunnelSettingsViewModel: MultihopTunnelSettingsObservable { +class MockMultihopTunnelSettingsViewModel: TunnelSettingsObservable { @Published var value: MultihopState init(multihopState: MultihopState = .off) { value = multihopState } -} -class MultihopTunnelSettingsViewModel: MultihopTunnelSettingsObserver, MultihopTunnelSettingsObservable {} + func evaluate(setting: MullvadSettings.MultihopState) {} +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift index d7717f16495d..2547f54e763a 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift @@ -6,42 +6,26 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Combine +import MullvadSettings import SwiftUI -struct SettingsMultihopView: View where VM: MultihopTunnelSettingsObservable { - @StateObject var tunnelViewModel: VM - - private let viewModel = SettingsInfoViewModel( - pages: [ - SettingsInfoViewModelPage( - body: NSLocalizedString( - "SETTINGS_INFO_MULTIHOP", - tableName: "Settings", - value: """ - Multihop routes your traffic into one WireGuard server and out another, making it \ - harder to trace. This results in increased latency but increases anonymity online. - """, - comment: "" - ), - image: .multihopIllustration - ) - ] - ) +struct SettingsMultihopView: View where ViewModel: TunnelSettingsObservable { + @StateObject var tunnelViewModel: ViewModel var body: some View { SettingsInfoContainerView { VStack(alignment: .leading, spacing: 8) { - SettingsInfoView(viewModel: viewModel) + SettingsInfoView(viewModel: dataViewModel) SwitchRowView( - enabled: $tunnelViewModel.value.isEnabled, + isOn: $tunnelViewModel.value.isEnabled, text: NSLocalizedString( "SETTINGS_SWITCH_MULTIHOP", tableName: "Settings", value: "Enable", comment: "" - ) + ), + accessibilityId: .multihopSwitch ) .padding(.leading, UIMetrics.contentInsets.left) .padding(.trailing, UIMetrics.contentInsets.right) @@ -53,3 +37,24 @@ struct SettingsMultihopView: View where VM: MultihopTunnelSettingsObservable #Preview { SettingsMultihopView(tunnelViewModel: MockMultihopTunnelSettingsViewModel()) } + +extension SettingsMultihopView { + private var dataViewModel: SettingsInfoViewModel { + SettingsInfoViewModel( + pages: [ + SettingsInfoViewModelPage( + body: NSLocalizedString( + "SETTINGS_INFO_MULTIHOP", + tableName: "Settings", + value: """ + Multihop routes your traffic into one WireGuard server and out another, making it \ + harder to trace. This results in increased latency but increases anonymity online. + """, + comment: "" + ), + image: .multihopIllustration + ), + ] + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift index 33075ec61f80..f8c501ff11f6 100644 --- a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift @@ -42,12 +42,10 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV UINavigationControllerDelegate { private let logger = Logger(label: "SettingsNavigationCoordinator") - private let interactorFactory: SettingsInteractorFactory private var currentRoute: SettingsNavigationRoute? private var modalRoute: SettingsNavigationRoute? - private let accessMethodRepository: AccessMethodRepositoryProtocol - private let proxyConfigurationTester: ProxyConfigurationTesterProtocol - private let ipOverrideRepository: IPOverrideRepository + private let interactorFactory: SettingsInteractorFactory + private var viewControllerFactory: SettingsViewControllerFactory? let navigationController: UINavigationController @@ -78,9 +76,17 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV ) { self.navigationController = navigationController self.interactorFactory = interactorFactory - self.accessMethodRepository = accessMethodRepository - self.proxyConfigurationTester = proxyConfigurationTester - self.ipOverrideRepository = ipOverrideRepository + + super.init() + + viewControllerFactory = SettingsViewControllerFactory( + interactorFactory: interactorFactory, + accessMethodRepository: accessMethodRepository, + proxyConfigurationTester: proxyConfigurationTester, + ipOverrideRepository: ipOverrideRepository, + navigationController: navigationController, + alertPresenter: AlertPresenter(context: self) + ) } /// Start the coordinator fllow. @@ -200,7 +206,7 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV /// - Parameters: /// - result: the result of creating a child representing a route. /// - animated: whether to animate the transition. - private func push(from result: MakeChildResult, animated: Bool) { + private func push(from result: SettingsViewControllerFactory.MakeChildResult, animated: Bool) { switch result { case let .viewController(vc): navigationController.pushViewController(vc, animated: animated) @@ -225,83 +231,19 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV // MARK: - Route mapping - /// The result of creating a child representing a route. - private enum MakeChildResult { - /// View controller that should be pushed into navigation stack. - case viewController(UIViewController) - - /// Child coordinator that should be added to the children hierarchy. - /// The child is responsile for presenting itself. - case childCoordinator(SettingsChildCoordinator) - - /// Failure to produce a child. - case failed - } - /// Produce a view controller or a child coordinator representing the route. /// - Parameter route: the route for which to request the new view controller or child coordinator. /// - Returns: a result of creating a child for the route. - private func makeChild(for route: SettingsNavigationRoute) -> MakeChildResult { - switch route { - case .root: + private func makeChild(for route: SettingsNavigationRoute) -> SettingsViewControllerFactory.MakeChildResult { + if route == .root { let controller = SettingsViewController( interactor: interactorFactory.makeSettingsInteractor(), alertPresenter: AlertPresenter(context: self) ) controller.delegate = self return .viewController(controller) - - case .vpnSettings: - return .childCoordinator(VPNSettingsCoordinator( - navigationController: navigationController, - interactorFactory: interactorFactory, - ipOverrideRepository: ipOverrideRepository - )) - - case .problemReport: - return .viewController(ProblemReportViewController( - interactor: interactorFactory.makeProblemReportInteractor(), - alertPresenter: AlertPresenter(context: self) - )) - - case .apiAccess: - return .childCoordinator(ListAccessMethodCoordinator( - navigationController: navigationController, - accessMethodRepository: accessMethodRepository, - proxyConfigurationTester: proxyConfigurationTester - )) - - case .faq: - // Handled separately and presented as a modal. - return .failed - - case .multihop: - let viewModel = MultihopTunnelSettingsViewModel(tunnelManager: interactorFactory.tunnelManager) - let view = SettingsMultihopView(tunnelViewModel: viewModel) - - let host = UIHostingController(rootView: view) - host.title = NSLocalizedString( - "NAVIGATION_TITLE_MULTIHOP", - tableName: "Settings", - value: "Multihop", - comment: "" - ) - - return .viewController(host) - - case .daita: - let viewModel = DAITATunnelSettingsViewModel(tunnelManager: interactorFactory.tunnelManager) - let view = SettingsDAITAView(tunnelViewModel: viewModel) - - let host = UIHostingController(rootView: view) - host.title = NSLocalizedString( - "NAVIGATION_TITLE_DAITA", - tableName: "Settings", - value: "DAITA", - comment: "" - ) - - return .viewController(host) + } else { + return viewControllerFactory?.makeRoute(for: route) ?? .failed } } diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift new file mode 100644 index 000000000000..cf14003b6499 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift @@ -0,0 +1,179 @@ +// +// SettingsViewControllerFactory.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-26. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import Routing +import SwiftUI +import UIKit + +struct SettingsViewControllerFactory { + /// The result of creating a child representing a route. + enum MakeChildResult { + /// View controller that should be pushed into navigation stack. + case viewController(UIViewController) + + /// Child coordinator that should be added to the children hierarchy. + /// The child is responsile for presenting itself. + case childCoordinator(SettingsChildCoordinator) + + /// Failure to produce a child. + case failed + } + + private let interactorFactory: SettingsInteractorFactory + private let accessMethodRepository: AccessMethodRepositoryProtocol + private let proxyConfigurationTester: ProxyConfigurationTesterProtocol + private let ipOverrideRepository: IPOverrideRepository + private let navigationController: UINavigationController + private let alertPresenter: AlertPresenter + + init( + interactorFactory: SettingsInteractorFactory, + accessMethodRepository: AccessMethodRepositoryProtocol, + proxyConfigurationTester: ProxyConfigurationTesterProtocol, + ipOverrideRepository: IPOverrideRepository, + navigationController: UINavigationController, + alertPresenter: AlertPresenter + ) { + self.interactorFactory = interactorFactory + self.accessMethodRepository = accessMethodRepository + self.proxyConfigurationTester = proxyConfigurationTester + self.ipOverrideRepository = ipOverrideRepository + self.navigationController = navigationController + self.alertPresenter = alertPresenter + } + + func makeRoute(for route: SettingsNavigationRoute) -> MakeChildResult { + switch route { + case .root: + // Handled in SettingsCoordinator. + .failed + case .vpnSettings: + makeVPNSettingsViewController() + case .problemReport: + makeProblemReportViewController() + case .apiAccess: + makeAPIAccessViewController() + case .faq: + // Handled separately and presented as a modal. + .failed + case .multihop: + makeMultihopViewController() + case .daita: + makeDAITAViewController() + } + } + + private func makeVPNSettingsViewController() -> MakeChildResult { + return .childCoordinator(VPNSettingsCoordinator( + navigationController: navigationController, + interactorFactory: interactorFactory, + ipOverrideRepository: ipOverrideRepository + )) + } + + private func makeProblemReportViewController() -> MakeChildResult { + return .viewController(ProblemReportViewController( + interactor: interactorFactory.makeProblemReportInteractor(), + alertPresenter: alertPresenter + )) + } + + private func makeAPIAccessViewController() -> MakeChildResult { + return .childCoordinator(ListAccessMethodCoordinator( + navigationController: navigationController, + accessMethodRepository: accessMethodRepository, + proxyConfigurationTester: proxyConfigurationTester + )) + } + + private func makeMultihopViewController() -> MakeChildResult { + let viewModel = MultihopTunnelSettingsViewModel(tunnelManager: interactorFactory.tunnelManager) + let view = SettingsMultihopView(tunnelViewModel: viewModel) + + let host = UIHostingController(rootView: view) + host.title = NSLocalizedString( + "NAVIGATION_TITLE_MULTIHOP", + tableName: "Settings", + value: "Multihop", + comment: "" + ) + host.view.accessibilityIdentifier = AccessibilityIdentifier.multihopView.rawValue + + return .viewController(host) + } + + private func makeDAITAViewController() -> MakeChildResult { + let viewModel = DAITATunnelSettingsViewModel(tunnelManager: interactorFactory.tunnelManager) + let view = SettingsDAITAView(tunnelViewModel: viewModel) + + viewModel.didFailDAITAValidation = { result in + showPrompt( + for: result.item, + onSave: { + viewModel.value = result.setting + }, + onDiscard: {} + ) + } + + let host = UIHostingController(rootView: view) + host.title = NSLocalizedString( + "NAVIGATION_TITLE_DAITA", + tableName: "Settings", + value: "DAITA", + comment: "" + ) + host.view.accessibilityIdentifier = AccessibilityIdentifier.daitaView.rawValue + + return .viewController(host) + } + + private func showPrompt( + for item: DAITASettingsPromptItem, + onSave: @escaping () -> Void, + onDiscard: @escaping () -> Void + ) { + let presentation = AlertPresentation( + id: "settings-daita-prompt", + accessibilityIdentifier: .daitaPromptAlert, + icon: .info, + message: NSLocalizedString( + "SETTINGS_DAITA_ENABLE_TEXT", + tableName: "DAITA", + value: item.description, + comment: "" + ), + buttons: [ + AlertAction( + title: String(format: NSLocalizedString( + "SETTINGS_DAITA_ENABLE_OK_ACTION", + tableName: "DAITA", + value: "Enable %@", + comment: "" + ), item.title), + style: .default, + accessibilityId: .daitaConfirmAlertEnableButton, + handler: { onSave() } + ), + AlertAction( + title: NSLocalizedString( + "SETTINGS_DAITA_ENABLE_CANCEL_ACTION", + tableName: "DAITA", + value: "Back", + comment: "" + ), + style: .default, + handler: { onDiscard() } + ), + ] + ) + + alertPresenter.showAlert(presentation: presentation, animated: true) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/TunnelSettingsObservable.swift b/ios/MullvadVPN/Coordinators/Settings/TunnelSettingsObservable.swift new file mode 100644 index 000000000000..67235abfefdd --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/TunnelSettingsObservable.swift @@ -0,0 +1,35 @@ +// +// TunnelSettingsObservable.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-21. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings + +protocol TunnelSettingsObservable: ObservableObject { + associatedtype TunnelSetting + + var value: TunnelSetting { get set } + func evaluate(setting: TunnelSetting) +} + +class MockTunnelSettingsViewModel: TunnelSettingsObservable { + @Published var value: TunnelSetting + + init(setting: TunnelSetting) { + value = setting + } + + func evaluate(setting: TunnelSetting) {} +} + +protocol TunnelSettingsObserver: TunnelSettingsObservable { + associatedtype TunnelSetting + + var tunnelManager: TunnelManager { get } + var tunnelObserver: TunnelObserver? { get set } + + init(tunnelManager: TunnelManager) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Views/GroupedRowView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/GroupedRowView.swift index dc3cd0b38005..5975e2f73ce5 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Views/GroupedRowView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Views/GroupedRowView.swift @@ -27,8 +27,8 @@ struct GroupedRowView: View { #Preview("GroupedRowView") { StatefulPreviewWrapper((enabled: true, directOnly: false)) { values in GroupedRowView { - SwitchRowView(enabled: values.enabled, text: "Enable") - SwitchRowView(enabled: values.directOnly, text: "Direct only") + SwitchRowView(isOn: values.enabled, text: "Enable") + SwitchRowView(isOn: values.directOnly, text: "Direct only") } } } diff --git a/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift index a9d7fdc69d9b..62f4eeb3c380 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift @@ -85,7 +85,7 @@ struct SettingsInfoView: View { harder to trace. This results in increased latency but increases anonymity online. """, image: .multihopIllustration - ) + ), ] )) } @@ -104,12 +104,11 @@ struct SettingsInfoView: View { body: """ Multihop routes your traffic into one WireGuard server and out another, making it \ harder to trace. This results in increased latency but increases anonymity online. - Multihop routes your traffic into one WireGuard server and out another, making it \ harder to trace. This results in increased latency but increases anonymity online. """, image: .multihopIllustration - ) + ), ] )) } diff --git a/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift index a86dacfbb180..77888d9040bd 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift @@ -9,32 +9,37 @@ import SwiftUI struct SwitchRowView: View { - @Binding var enabled: Bool + @Binding var isOn: Bool + + var disabled = false let text: String + var accessibilityId: AccessibilityIdentifier? var didTapInfoButton: (() -> Void)? var body: some View { - Toggle(isOn: $enabled, label: { + Toggle(isOn: $isOn, label: { Text(text) - }).onChange(of: enabled, perform: { enabled in - $enabled.wrappedValue = enabled }) - .toggleStyle(CustomToggleStyle(infoButtonAction: didTapInfoButton)) + .toggleStyle(CustomToggleStyle( + disabled: disabled, + accessibilityId: accessibilityId, + infoButtonAction: didTapInfoButton + )) + .disabled(disabled) .font(.headline) .frame(height: UIMetrics.SettingsRowView.height) .padding(UIMetrics.SettingsRowView.layoutMargins) .background(Color(.primaryColor)) .foregroundColor(Color(.primaryTextColor)) .cornerRadius(UIMetrics.SettingsRowView.cornerRadius) - .accessibilityIdentifier(AccessibilityIdentifier.multihopSwitch.rawValue) } } #Preview("SwitchRowView") { StatefulPreviewWrapper(true) { SwitchRowView( - enabled: $0, + isOn: $0, text: "Enable", didTapInfoButton: { print("Tapped") diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 0ff8ba3e3828..e68d8b0c3207 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -16,15 +16,13 @@ enum UIMetrics { 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 DeviceRowView { - /// Similar to `SettingsCell.layoutMargins` however maintains equal horizontal spacing. - static let layoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24) - } - enum CustomAlert { /// Layout margins for container (main view) in `CustomAlertViewController` static let containerMargins = NSDirectionalEdgeInsets( diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift index 55e9852d7e45..d3da30a16e1c 100644 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift @@ -72,7 +72,7 @@ class DeviceRowView: UIView { accessibilityIdentifier = .deviceCell backgroundColor = .primaryColor - directionalLayoutMargins = UIMetrics.DeviceRowView.layoutMargins + directionalLayoutMargins = UIMetrics.TableView.rowViewLayoutMargins for subview in [textLabel, removeButton, activityIndicator, creationDateLabel] { addSubview(subview) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift index f958925346a1..763c3d20ad59 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift @@ -11,8 +11,6 @@ import UIKit protocol SettingsCellEventHandler { func showInfo(for button: SettingsInfoButtonItem) - func switchDaitaState(_ settings: DAITASettings) - func switchDaitaDirectOnlyState(_ settings: DAITASettings) } final class SettingsCellFactory: CellFactoryProtocol { @@ -105,57 +103,17 @@ final class SettingsCellFactory: CellFactoryProtocol { cell.disclosureType = .chevron case .daita: - guard let cell = cell as? SettingsSwitchCell else { return } + guard let cell = cell as? SettingsCell else { return } cell.titleLabel.text = NSLocalizedString( - "DAITA_LABEL", + "DAITA_CELL_LABEL", tableName: "Settings", value: "DAITA", comment: "" ) + cell.detailTitleLabel.text = nil cell.accessibilityIdentifier = item.accessibilityIdentifier - cell.setOn(viewModel.daitaSettings.daitaState.isEnabled, animated: false) - - cell.infoButtonHandler = { [weak self] in - self?.delegate?.showInfo(for: .daita) - } - - cell.action = { [weak self] isEnabled in - guard let self else { return } - - let state: DAITAState = isEnabled ? .on : .off - delegate?.switchDaitaState(DAITASettings( - daitaState: state, - directOnlyState: viewModel.daitaSettings.directOnlyState - )) - } - - case .daitaDirectOnly: - guard let cell = cell as? SettingsSwitchCell else { return } - - cell.titleLabel.text = NSLocalizedString( - "DAITA_DIRECT_ONLY_LABEL", - tableName: "Settings", - value: "Direct only", - comment: "" - ) - cell.accessibilityIdentifier = item.accessibilityIdentifier - cell.setOn(viewModel.daitaSettings.directOnlyState.isEnabled, animated: false) - cell.setSwitchEnabled(viewModel.daitaSettings.daitaState.isEnabled) - - cell.infoButtonHandler = { [weak self] in - self?.delegate?.showInfo(for: .daitaDirectOnly) - } - - cell.action = { [weak self] isEnabled in - guard let self else { return } - - let state: DirectOnlyState = isEnabled ? .on : .off - delegate?.switchDaitaDirectOnlyState(DAITASettings( - daitaState: viewModel.daitaSettings.daitaState, - directOnlyState: state - )) - } + cell.disclosureType = .chevron case .multihop: guard let cell = cell as? SettingsCell else { return } @@ -169,19 +127,6 @@ final class SettingsCellFactory: CellFactoryProtocol { cell.detailTitleLabel.text = nil cell.accessibilityIdentifier = item.accessibilityIdentifier cell.disclosureType = .chevron - - case .daita2: - guard let cell = cell as? SettingsCell else { return } - - cell.titleLabel.text = NSLocalizedString( - "DAITA_CELL_LABEL", - tableName: "Settings", - value: "DAITA", - comment: "" - ) - cell.detailTitleLabel.text = nil - cell.accessibilityIdentifier = item.accessibilityIdentifier - cell.disclosureType = .chevron } } } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift index 6d2d406bbbb0..795c4732b045 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift @@ -13,15 +13,9 @@ final class SettingsDataSource: UITableViewDiffableDataSource Bool { switch itemIdentifier(for: indexPath) { - case .vpnSettings, .problemReport, .faq, .apiAccess, .multihop, .daita2: + case .vpnSettings, .problemReport, .faq, .apiAccess, .daita, .multihop: true - case .version, .daita, .daitaDirectOnly, .none: + case .version, .none: false } } @@ -168,8 +151,6 @@ final class SettingsDataSource: UITableViewDiffableDataSource Void)? = nil, - onDiscard: @escaping () -> Void - ) { - let updateSettings = { [weak self] in - self?.settingsCellFactory.viewModel.setDAITASettings(settings) - self?.interactor.updateDAITASettings(settings) - - onSave?() - } - - var promptItemSetting: DAITASettingsPromptItem.Setting? - switch item { - case .daita: - promptItemSetting = .daita - case .daitaDirectOnly: - promptItemSetting = .directOnly - default: - break - } - - if let promptItemSetting, let error = interactor.evaluateDaitaSettingsCompatibility(settings) { - switch error { - case .singlehop: - delegate?.showPrompt( - for: .daitaSettingIncompatibleWithSinglehop(promptItemSetting), - onSave: { updateSettings() }, - onDiscard: onDiscard - ) - case .multihop: - delegate?.showPrompt( - for: .daitaSettingIncompatibleWithMultihop(promptItemSetting), - onSave: { updateSettings() }, - onDiscard: onDiscard - ) - } - } else { - updateSettings() - } - } } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift index 4800271dc8d5..44e119599e32 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift @@ -12,9 +12,4 @@ import UIKit protocol SettingsDataSourceDelegate: AnyObject { func didSelectItem(item: SettingsDataSource.Item) func showInfo(for: SettingsInfoButtonItem) - func showPrompt( - for: DAITASettingsPromptItem, - onSave: @escaping () -> Void, - onDiscard: @escaping () -> Void - ) } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift index 11fa0add56bc..ffe9344d163d 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift @@ -98,49 +98,6 @@ extension SettingsViewController: SettingsDataSourceDelegate { alertPresenter.showAlert(presentation: presentation, animated: true) } - - func showPrompt( - for item: DAITASettingsPromptItem, - onSave: @escaping () -> Void, - onDiscard: @escaping () -> Void - ) { - let presentation = AlertPresentation( - id: "settings-daita-prompt", - accessibilityIdentifier: .daitaPromptAlert, - icon: .info, - message: NSLocalizedString( - "SETTINGS_DAITA_ENABLE_TEXT", - tableName: "DAITA", - value: item.description, - comment: "" - ), - buttons: [ - AlertAction( - title: String(format: NSLocalizedString( - "SETTINGS_DAITA_ENABLE_OK_ACTION", - tableName: "DAITA", - value: "Enable %@", - comment: "" - ), item.title), - style: .default, - accessibilityId: .daitaConfirmAlertEnableButton, - handler: { onSave() } - ), - AlertAction( - title: NSLocalizedString( - "SETTINGS_DAITA_ENABLE_CANCEL_ACTION", - tableName: "DAITA", - value: "Back", - comment: "" - ), - style: .default, - handler: { onDiscard() } - ), - ] - ) - - alertPresenter.showAlert(presentation: presentation, animated: true) - } } extension SettingsDataSource.Item { @@ -148,7 +105,7 @@ extension SettingsDataSource.Item { switch self { case .vpnSettings: return .vpnSettings - case .version, .daita, .daitaDirectOnly: + case .version: return nil case .problemReport: return .problemReport @@ -156,10 +113,10 @@ extension SettingsDataSource.Item { return .faq case .apiAccess: return .apiAccess + case .daita: + return .daita case .multihop: return .multihop - case .daita2: - return .daita } } } diff --git a/ios/MullvadVPN/Views/CustomToggleStyle.swift b/ios/MullvadVPN/Views/CustomToggleStyle.swift index f5940391605b..e114d17adc7c 100644 --- a/ios/MullvadVPN/Views/CustomToggleStyle.swift +++ b/ios/MullvadVPN/Views/CustomToggleStyle.swift @@ -14,11 +14,14 @@ struct CustomToggleStyle: ToggleStyle { private let height: CGFloat = 30 private let circleRadius: CGFloat = 23 + var disabled = false + let accessibilityId: AccessibilityIdentifier? var infoButtonAction: (() -> Void)? func makeBody(configuration: Configuration) -> some View { HStack { configuration.label + .opacity(disabled ? 0.2 : 1) if let infoButtonAction { Button(action: infoButtonAction) { @@ -41,6 +44,7 @@ struct CustomToggleStyle: ToggleStyle { lineWidth: 2 ) ) + .opacity(disabled ? 0.2 : 1) Circle() .frame(width: circleRadius, height: circleRadius) @@ -50,7 +54,9 @@ struct CustomToggleStyle: ToggleStyle { ? Color(uiColor: UIColor.Switch.onThumbColor) : Color(uiColor: UIColor.Switch.offThumbColor) ) + .opacity(disabled ? 0.4 : 1) } + .accessibilityIdentifier(accessibilityId?.rawValue ?? "") .onTapGesture { toggle(configuration) } @@ -60,7 +66,7 @@ struct CustomToggleStyle: ToggleStyle { private func toggle(_ configuration: Configuration) { withAnimation(.easeInOut(duration: 0.25)) { - configuration.$isOn.wrappedValue.toggle() + configuration.isOn.toggle() } } } diff --git a/ios/MullvadVPN/Views/RowSeparator.swift b/ios/MullvadVPN/Views/RowSeparator.swift index 4784c5780d88..b3c2b0b1642a 100644 --- a/ios/MullvadVPN/Views/RowSeparator.swift +++ b/ios/MullvadVPN/Views/RowSeparator.swift @@ -9,7 +9,7 @@ import SwiftUI struct RowSeparator: View { - var color: Color = Color(.secondaryColor) + var color = Color(.secondaryColor) var body: some View { color diff --git a/ios/MullvadVPNUITests/Pages/DAITAPage.swift b/ios/MullvadVPNUITests/Pages/DAITAPage.swift new file mode 100644 index 000000000000..e603709252b8 --- /dev/null +++ b/ios/MullvadVPNUITests/Pages/DAITAPage.swift @@ -0,0 +1,79 @@ +// +// DAITAPage.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-25. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class DAITAPage: Page { + @discardableResult override init(_ app: XCUIApplication) { + super.init(app) + + self.pageElement = app.otherElements[.daitaView] + waitForPageToBeShown() + } + + @discardableResult func tapBackButton() -> Self { + // Workaround for setting accessibility identifier on navigation bar button being non-trivial + app.buttons.matching(identifier: "Settings").allElementsBoundByIndex.last?.tap() + return self + } + + @discardableResult func verifyTwoPages() -> Self { + XCTAssertEqual(app.pageIndicators.firstMatch.value as? String, "page 1 of 2") + return self + } + + @discardableResult func tapEnableSwitch() -> Self { + app.switches[AccessibilityIdentifier.daitaSwitch].tap() + return self + } + + @discardableResult func tapEnableSwitchIfOn() -> Self { + let switchElement = app.switches[AccessibilityIdentifier.daitaSwitch] + + if switchElement.value as? String == "1" { + tapEnableSwitch() + } + return self + } + + @discardableResult func verifyDirectOnlySwitchIsEnabled() -> Self { + XCTAssertTrue(app.switches[AccessibilityIdentifier.daitaDirectOnlySwitch].isEnabled) + return self + } + + @discardableResult func verifyDirectOnlySwitchIsDisabled() -> Self { + XCTAssertFalse(app.switches[AccessibilityIdentifier.daitaDirectOnlySwitch].isEnabled) + return self + } + + @discardableResult func tapDirectOnlySwitch() -> Self { + app.switches[AccessibilityIdentifier.daitaDirectOnlySwitch].tap() + return self + } + + @discardableResult func tapDirectOnlySwitchIfOn() -> Self { + let switchElement = app.switches[AccessibilityIdentifier.daitaDirectOnlySwitch] + + if switchElement.value as? String == "1" { + tapEnableSwitch() + } + return self + } + + @discardableResult func verifyDirectOnlySwitchOn() -> Self { + let switchElement = app.switches[AccessibilityIdentifier.daitaDirectOnlySwitch] + + guard let switchValue = switchElement.value as? String else { + XCTFail("Failed to read switch state") + return self + } + + XCTAssertEqual(switchValue, "1") + return self + } +} diff --git a/ios/MullvadVPNUITests/Pages/MultihopPage.swift b/ios/MullvadVPNUITests/Pages/MultihopPage.swift new file mode 100644 index 000000000000..4a8bc58f52ff --- /dev/null +++ b/ios/MullvadVPNUITests/Pages/MultihopPage.swift @@ -0,0 +1,43 @@ +// +// MultihopPage.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-25. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class MultihopPage: Page { + @discardableResult override init(_ app: XCUIApplication) { + super.init(app) + + self.pageElement = app.otherElements[.multihopView] + waitForPageToBeShown() + } + + @discardableResult func tapBackButton() -> Self { + // Workaround for setting accessibility identifier on navigation bar button being non-trivial + app.buttons.matching(identifier: "Settings").allElementsBoundByIndex.last?.tap() + return self + } + + @discardableResult func verifyOnePage() -> Self { + XCTAssertEqual(app.pageIndicators.firstMatch.value as? String, "page 1 of 1") + return self + } + + @discardableResult func tapEnableSwitch() -> Self { + app.switches[AccessibilityIdentifier.multihopSwitch].tap() + return self + } + + @discardableResult func tapEnableSwitchIfOn() -> Self { + let switchElement = app.switches[AccessibilityIdentifier.multihopSwitch] + + if switchElement.value as? String == "1" { + tapEnableSwitch() + } + return self + } +} diff --git a/ios/MullvadVPNUITests/Pages/SettingsPage.swift b/ios/MullvadVPNUITests/Pages/SettingsPage.swift index 8d40154abf7d..bf24d767be73 100644 --- a/ios/MullvadVPNUITests/Pages/SettingsPage.swift +++ b/ios/MullvadVPNUITests/Pages/SettingsPage.swift @@ -32,6 +32,22 @@ class SettingsPage: Page { return self } + @discardableResult func tapDAITACell() -> Self { + app.tables[AccessibilityIdentifier.settingsTableView] + .cells[AccessibilityIdentifier.daitaCell] + .tap() + + return self + } + + @discardableResult func tapMultihopCell() -> Self { + app.tables[AccessibilityIdentifier.settingsTableView] + .cells[AccessibilityIdentifier.multihopCell] + .tap() + + return self + } + @discardableResult func tapVPNSettingsCell() -> Self { app.tables[AccessibilityIdentifier.settingsTableView] .cells[AccessibilityIdentifier.vpnSettingsCell] diff --git a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift index fc8f6c044aef..6929ee1f5ac5 100644 --- a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift +++ b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift @@ -199,6 +199,13 @@ class TunnelControlPage: Page { return self } + /// Verify that the app attempts to connect using DAITA. + @discardableResult func verifyConnectingUsingDAITA() -> Self { + let relayName = getCurrentRelayName().lowercased() + XCTAssertTrue(relayName.contains("using daita")) + return self + } + func getInIPAddressFromConnectionStatus() -> String { let inAddressRow = app.otherElements[AccessibilityIdentifier.connectionPanelInAddressRow] diff --git a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift index 11402ee6841e..a6bdb6aeb25d 100644 --- a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift +++ b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift @@ -126,13 +126,6 @@ class VPNSettingsPage: Page { return self } - @discardableResult func tapMultihopSwitch() -> Self { - app.cells[AccessibilityIdentifier.multihopSwitch] - .switches[AccessibilityIdentifier.customSwitch] - .tap() - return self - } - @discardableResult func verifyCustomWireGuardPortSelected(portNumber: String) -> Self { let cell = app.cells[AccessibilityIdentifier.wireGuardCustomPort] XCTAssertTrue(cell.isSelected) @@ -170,26 +163,4 @@ class VPNSettingsPage: Page { XCTAssertTrue(cell.isSelected) return self } - - @discardableResult func verifyMultihopSwitchOn() -> Self { - let switchElement = app.cells[.multihopSwitch] - .switches[AccessibilityIdentifier.customSwitch] - - guard let switchValue = switchElement.value as? String else { - XCTFail("Failed to read switch state") - return self - } - - XCTAssertEqual(switchValue, "1") - return self - } - - @discardableResult func tapMultihopSwitchIfOn() -> Self { - let switchElement = app.cells[.multihopSwitch].switches[AccessibilityIdentifier.customSwitch] - - if switchElement.value as? String == "1" { - tapMultihopSwitch() - } - return self - } } diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index 319a1b0a2370..1bb8aee30b69 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -286,6 +286,46 @@ class RelayTests: LoggedInWithTimeUITestCase { .tapDisconnectButton() } + func testDAITASettings() throws { + // Undo enabling DAITA in teardown + addTeardownBlock { + HeaderBar(self.app) + .tapSettingsButton() + + SettingsPage(self.app) + .tapDAITACell() + + DAITAPage(self.app) + .tapEnableSwitchIfOn() + } + + HeaderBar(app) + .tapSettingsButton() + + SettingsPage(app) + .tapDAITACell() + + DAITAPage(app) + .verifyTwoPages() + .verifyDirectOnlySwitchIsDisabled() + .tapEnableSwitch() + .verifyDirectOnlySwitchIsEnabled() + .tapBackButton() + + SettingsPage(app) + .tapDoneButton() + + TunnelControlPage(app) + .tapSecureConnectionButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForSecureConnectionLabel() + .verifyConnectingUsingDAITA() + .tapDisconnectButton() + } + func testMultihopSettings() throws { // Undo enabling Multihop in teardown addTeardownBlock { @@ -293,20 +333,21 @@ class RelayTests: LoggedInWithTimeUITestCase { .tapSettingsButton() SettingsPage(self.app) - .tapVPNSettingsCell() + .tapMultihopCell() - VPNSettingsPage(self.app) - .tapMultihopSwitchIfOn() + MultihopPage(self.app) + .tapEnableSwitchIfOn() } HeaderBar(app) .tapSettingsButton() SettingsPage(app) - .tapVPNSettingsCell() + .tapMultihopCell() - VPNSettingsPage(app) - .tapMultihopSwitch() + MultihopPage(app) + .verifyOnePage() + .tapEnableSwitch() .tapBackButton() SettingsPage(app) diff --git a/ios/MullvadVPNUITests/SettingsMigrationTests.swift b/ios/MullvadVPNUITests/SettingsMigrationTests.swift index dd6e55ce6dd7..581bba946dd0 100644 --- a/ios/MullvadVPNUITests/SettingsMigrationTests.swift +++ b/ios/MullvadVPNUITests/SettingsMigrationTests.swift @@ -138,7 +138,6 @@ class SettingsMigrationTests: BaseUITestCase { .tapUDPOverTCPPort80Cell() .tapQuantumResistantTunnelExpandButton() .tapQuantumResistantTunnelOnCell() - .tapMultihopSwitch() } func testVerifySettingsStillChanged() { @@ -170,6 +169,5 @@ class SettingsMigrationTests: BaseUITestCase { .verifyUDPOverTCPPort80Selected() .tapQuantumResistantTunnelExpandButton() .verifyQuantumResistantTunnelOnSelected() - .verifyMultihopSwitchOn() } }