From fc659c6203fc6596bc85a9298a59e262cf762ebd Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Wed, 4 Dec 2024 10:38:55 +0100 Subject: [PATCH] Add the multihop and DAITA pages to the root of the settings page --- ios/AdditionalAssets/DaitaOffIllustration.svg | 88 +++++++++ ios/AdditionalAssets/DaitaOnIllustration.svg | 103 ++++++++++ ios/AdditionalAssets/MultihopIllustration.svg | 137 ++++++++++++++ ios/CHANGELOG.md | 1 + ios/MullvadSettings/DAITASettings.swift | 18 +- ios/MullvadSettings/MultihopSettings.swift | 9 +- ios/MullvadVPN.xcodeproj/project.pbxproj | 94 ++++++++- .../Classes/AccessbilityIdentifier.swift | 4 + .../DAITA}/DAITASettingsPromptItem.swift | 8 +- .../DAITA/DAITATunnelSettingsViewModel.swift | 107 +++++++++++ .../Settings/DAITA/SettingsDAITAView.swift | 142 ++++++++++++++ .../MultihopTunnelSettingsViewModel.swift | 48 +++++ .../Multihop/SettingsMultihopView.swift | 60 ++++++ .../Settings/SettingsCoordinator.swift | 73 +++---- .../SettingsViewControllerFactory.swift | 179 ++++++++++++++++++ .../Settings/TunnelSettingsObservable.swift | 35 ++++ .../Settings/Views/GroupedRowView.swift | 34 ++++ .../Views/SettingsInfoContainerView.swift | 38 ++++ .../Settings/Views/SettingsInfoView.swift | 114 +++++++++++ .../Views/SettingsRowViewFooter.swift | 21 ++ .../Settings/Views/SwitchRowView.swift | 49 +++++ ios/MullvadVPN/Extensions/View+Size.swift | 33 ++++ .../Extensions/View+TapAreaSize.swift | 33 ++++ .../Contents.json | 15 ++ .../DaitaOffIllustration.svg | 88 +++++++++ .../Contents.json | 15 ++ .../DaitaOnIllustration.svg | 103 ++++++++++ .../Contents.json | 15 ++ .../MultihopIllustration.svg | 137 ++++++++++++++ .../UIEdgeInsets+Extensions.swift | 7 +- ios/MullvadVPN/UI appearance/UIMetrics.swift | 16 +- .../Settings/SettingsCellFactory.swift | 49 +---- .../Settings/SettingsDataSource.swift | 105 ++-------- .../Settings/SettingsDataSourceDelegate.swift | 5 - .../Settings/SettingsViewController.swift | 49 +---- .../VPNSettings/VPNSettingsCellFactory.swift | 23 +-- .../VPNSettings/VPNSettingsDataSource.swift | 16 +- ios/MullvadVPN/Views/CustomToggleStyle.swift | 72 +++++++ ios/MullvadVPN/Views/IncreasedHitButton.swift | 1 + ios/MullvadVPN/Views/RowSeparator.swift | 23 +++ ios/MullvadVPNUITests/Pages/DAITAPage.swift | 79 ++++++++ .../Pages/MultihopPage.swift | 43 +++++ .../Pages/SettingsPage.swift | 16 ++ .../Pages/TunnelControlPage.swift | 7 + .../Pages/VPNSettingsPage.swift | 29 --- ios/MullvadVPNUITests/RelayTests.swift | 55 +++++- .../SettingsMigrationTests.swift | 2 - ios/convert-assets.rb | 2 +- 48 files changed, 2091 insertions(+), 309 deletions(-) create mode 100644 ios/AdditionalAssets/DaitaOffIllustration.svg create mode 100644 ios/AdditionalAssets/DaitaOnIllustration.svg create mode 100644 ios/AdditionalAssets/MultihopIllustration.svg rename ios/MullvadVPN/{View controllers/Settings => Coordinators/Settings/DAITA}/DAITASettingsPromptItem.swift (72%) create mode 100644 ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/TunnelSettingsObservable.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/Views/GroupedRowView.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoContainerView.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/Views/SettingsRowViewFooter.swift create mode 100644 ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift create mode 100644 ios/MullvadVPN/Extensions/View+Size.swift create mode 100644 ios/MullvadVPN/Extensions/View+TapAreaSize.swift create mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/Contents.json create mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/DaitaOffIllustration.svg create mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/Contents.json create mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/DaitaOnIllustration.svg create mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/Contents.json create mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/MultihopIllustration.svg create mode 100644 ios/MullvadVPN/Views/CustomToggleStyle.swift create mode 100644 ios/MullvadVPN/Views/RowSeparator.swift create mode 100644 ios/MullvadVPNUITests/Pages/DAITAPage.swift create mode 100644 ios/MullvadVPNUITests/Pages/MultihopPage.swift diff --git a/ios/AdditionalAssets/DaitaOffIllustration.svg b/ios/AdditionalAssets/DaitaOffIllustration.svg new file mode 100644 index 000000000000..8e0ebc4b29bb --- /dev/null +++ b/ios/AdditionalAssets/DaitaOffIllustration.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/AdditionalAssets/DaitaOnIllustration.svg b/ios/AdditionalAssets/DaitaOnIllustration.svg new file mode 100644 index 000000000000..e61f1fe23505 --- /dev/null +++ b/ios/AdditionalAssets/DaitaOnIllustration.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/AdditionalAssets/MultihopIllustration.svg b/ios/AdditionalAssets/MultihopIllustration.svg new file mode 100644 index 000000000000..58bbca050de8 --- /dev/null +++ b/ios/AdditionalAssets/MultihopIllustration.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index f0f5d12ff53b..7e8465212679 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -25,6 +25,7 @@ Line wrap the file at 100 chars. Th ### Added - Add WireGuard over Shadowsocks obfuscation. It can be enabled in "VPN settings". This will also be used automatically when connecting fails with other methods. +- Add new settings views for DAITA and multihop. ## [2024.10 - 2024-11-20] ### Fixed diff --git a/ios/MullvadSettings/DAITASettings.swift b/ios/MullvadSettings/DAITASettings.swift index eec7cc6ad7a0..7fd3170bf9f3 100644 --- a/ios/MullvadSettings/DAITASettings.swift +++ b/ios/MullvadSettings/DAITASettings.swift @@ -14,7 +14,12 @@ public enum DAITAState: Codable { case off public var isEnabled: Bool { - self == .on + get { + self == .on + } + set { + self = newValue ? .on : .off + } } } @@ -24,7 +29,12 @@ public enum DirectOnlyState: Codable { case off public var isEnabled: Bool { - self == .on + get { + self == .on + } + set { + self = newValue ? .on : .off + } } } @@ -37,8 +47,8 @@ public struct DAITASettings: Codable, Equatable { @available(*, deprecated, renamed: "daitaState") public let state: DAITAState = .off - public let daitaState: DAITAState - public let directOnlyState: DirectOnlyState + public var daitaState: DAITAState + public var directOnlyState: DirectOnlyState public var isAutomaticRouting: Bool { daitaState.isEnabled && !directOnlyState.isEnabled diff --git a/ios/MullvadSettings/MultihopSettings.swift b/ios/MullvadSettings/MultihopSettings.swift index a6abe4c7bcb4..a9a17f2b4358 100644 --- a/ios/MullvadSettings/MultihopSettings.swift +++ b/ios/MullvadSettings/MultihopSettings.swift @@ -9,12 +9,17 @@ import Foundation import MullvadTypes -/// Whether Multi-hop is enabled +/// Whether multihop is enabled. public enum MultihopState: Codable { case on case off public var isEnabled: Bool { - self == .on + get { + self == .on + } + set { + self = newValue ? .on : .off + } } } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index e1bcdb6dca32..05a800581dcf 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -557,6 +557,23 @@ 7A88DCF42A93471F00D2FF0E /* ApplicationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5893C6FB29C311E9009090D1 /* ApplicationRouter.swift */; }; 7A88DCF52A93471F00D2FF0E /* ApplicationRouterTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBCA2A8E45DC00E5CE4C /* ApplicationRouterTypes.swift */; }; 7A88DCF62A93471F00D2FF0E /* AppRouteProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBC62A8E457A00E5CE4C /* AppRouteProtocol.swift */; }; + 7A8A18F92CE34EA8000BCB5B /* SettingsMultihopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A18F82CE34E9F000BCB5B /* SettingsMultihopView.swift */; }; + 7A8A18FB2CE4B678000BCB5B /* View+TapAreaSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A18FA2CE4B66C000BCB5B /* View+TapAreaSize.swift */; }; + 7A8A18FD2CE4BE8D000BCB5B /* CustomToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A18FC2CE4BE88000BCB5B /* CustomToggleStyle.swift */; }; + 7A8A19052CE4E9A9000BCB5B /* SwitchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19042CE4E9A5000BCB5B /* SwitchRowView.swift */; }; + 7A8A19072CE4E9D3000BCB5B /* SettingsInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19062CE4E9CC000BCB5B /* SettingsInfoView.swift */; }; + 7A8A190A2CE5FFE9000BCB5B /* SettingsDAITAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19092CE5FFDF000BCB5B /* SettingsDAITAView.swift */; }; + 7A8A190C2CE618D3000BCB5B /* View+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A190B2CE618CE000BCB5B /* View+Size.swift */; }; + 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 /* 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 */; }; + 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 */; }; @@ -1900,6 +1917,23 @@ 7A88DCD02A8FABBE00D2FF0E /* Routing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Routing.h; sourceTree = ""; }; 7A88DCD72A8FABBE00D2FF0E /* RoutingTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RoutingTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7A88DCDE2A8FABBF00D2FF0E /* RoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingTests.swift; sourceTree = ""; }; + 7A8A18F82CE34E9F000BCB5B /* SettingsMultihopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMultihopView.swift; sourceTree = ""; }; + 7A8A18FA2CE4B66C000BCB5B /* View+TapAreaSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TapAreaSize.swift"; sourceTree = ""; }; + 7A8A18FC2CE4BE88000BCB5B /* CustomToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomToggleStyle.swift; sourceTree = ""; }; + 7A8A19042CE4E9A5000BCB5B /* SwitchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchRowView.swift; sourceTree = ""; }; + 7A8A19062CE4E9CC000BCB5B /* SettingsInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInfoView.swift; sourceTree = ""; }; + 7A8A19092CE5FFDF000BCB5B /* SettingsDAITAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDAITAView.swift; sourceTree = ""; }; + 7A8A190B2CE618CE000BCB5B /* View+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Size.swift"; sourceTree = ""; }; + 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 /* 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 = ""; }; + 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 = ""; }; @@ -2863,7 +2897,6 @@ 4424CDD12CDBD457009D8C9F /* SwiftUI components */, 4422C06F2CCFF6520001A385 /* Obfuscation */, 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */, - F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */, 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */, 7A27E3CE2CBD4A830088BCFF /* SelectableSettingsDetailsCell.swift */, 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */, @@ -2973,11 +3006,13 @@ 58ACF64E26567A7100ACE4B7 /* CustomSwitchContainer.swift */, 58293FB025124117005D0BB5 /* CustomTextField.swift */, 58293FB2251241B3005D0BB5 /* CustomTextView.swift */, + 7A8A18FC2CE4BE88000BCB5B /* CustomToggleStyle.swift */, 5892A45D265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift */, 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */, F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */, 7A9F29382CABFAEC005F2089 /* InfoHeaderView.swift */, 7A5869942B32E9C700640D27 /* LinkButton.swift */, + 7A8A190F2CEE3918000BCB5B /* RowSeparator.swift */, 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */, E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */, 58EF581025D69DB400AEBA94 /* StatusImageView.swift */, @@ -3056,6 +3091,8 @@ 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */, 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */, 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */, + 7A8A190B2CE618CE000BCB5B /* View+Size.swift */, + 7A8A18FA2CE4B66C000BCB5B /* View+TapAreaSize.swift */, ); path = Extensions; sourceTree = ""; @@ -3789,11 +3826,16 @@ isa = PBXGroup; children = ( 58CEB2E72AFBB9F300E6E088 /* APIAccess */, + 7A8A19082CE5FFD7000BCB5B /* DAITA */, 7A5869A92B55516700640D27 /* IPOverride */, + 7A8A18F72CE34E8F000BCB5B /* Multihop */, + 7A8A18FE2CE4C7FA000BCB5B /* Views */, 58EFC7702AFB45E500E9F4CB /* SettingsChildCoordinator.swift */, 7A9CCCAD2A96302800DD6A34 /* SettingsCoordinator.swift */, 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */, 7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */, + 7A8A19272CF603E3000BCB5B /* SettingsViewControllerFactory.swift */, + 7A8A191D2CEF5CDF000BCB5B /* TunnelSettingsObservable.swift */, ); path = Settings; sourceTree = ""; @@ -3940,6 +3982,37 @@ path = RoutingTests; sourceTree = ""; }; + 7A8A18F72CE34E8F000BCB5B /* Multihop */ = { + isa = PBXGroup; + children = ( + 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */, + 7A8A18F82CE34E9F000BCB5B /* SettingsMultihopView.swift */, + ); + path = Multihop; + sourceTree = ""; + }; + 7A8A18FE2CE4C7FA000BCB5B /* Views */ = { + isa = PBXGroup; + children = ( + 7A8A19192CEF41AC000BCB5B /* GroupedRowView.swift */, + 7A8A19112CEF1E58000BCB5B /* SettingsInfoContainerView.swift */, + 7A8A19062CE4E9CC000BCB5B /* SettingsInfoView.swift */, + 7A8A190D2CEB77B7000BCB5B /* SettingsRowViewFooter.swift */, + 7A8A19042CE4E9A5000BCB5B /* SwitchRowView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 7A8A19082CE5FFD7000BCB5B /* DAITA */ = { + isa = PBXGroup; + children = ( + F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */, + 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */, + 7A8A19092CE5FFDF000BCB5B /* SettingsDAITAView.swift */, + ); + path = DAITA; + sourceTree = ""; + }; 7A9BE5A02B8F881B00E2A7D0 /* SelectLocation */ = { isa = PBXGroup; children = ( @@ -4042,6 +4115,7 @@ 85021CAD2BDBC4290098B400 /* AppLogsPage.swift */, 8587A05C2B84D43100152938 /* ChangeLogAlert.swift */, A9BFB0002BD00B7F00F2BCA1 /* CustomListPage.swift */, + 7A8A19252CF4D373000BCB5B /* DAITAPage.swift */, F09084672C6E88ED001CD36E /* DaitaPromptAlert.swift */, 85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */, 852A26452BA9C9CB006EB9C8 /* DNSSettingsPage.swift */, @@ -4050,6 +4124,7 @@ 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */, A998DA802BD147AD001D61A2 /* ListCustomListsPage.swift */, 852969342B4E9270007EAD4C /* LoginPage.swift */, + 7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */, 85139B2C2B84B4A700734217 /* OutOfTimePage.swift */, 852969322B4E9232007EAD4C /* Page.swift */, 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */, @@ -5711,6 +5786,7 @@ 5867771429097BCD006F721F /* PaymentState.swift in Sources */, F0EF50D32A8FA47E0031E8DF /* ChangeLogInteractor.swift in Sources */, 7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */, + 7A8A191A2CEF41AF000BCB5B /* GroupedRowView.swift in Sources */, 58FF9FE22B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift in Sources */, F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */, F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */, @@ -5720,11 +5796,14 @@ 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */, 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */, 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */, + 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */, + 7A8A18F92CE34EA8000BCB5B /* SettingsMultihopView.swift in Sources */, 44BB5F972BE527F4002520EB /* TunnelState+UI.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, 5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */, 5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */, 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, + 7A8A190A2CE5FFE9000BCB5B /* SettingsDAITAView.swift in Sources */, F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */, 7A5869952B32E9C700640D27 /* LinkButton.swift in Sources */, F09A297D2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift in Sources */, @@ -5748,9 +5827,12 @@ 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 */, 5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */, + 7A8A19052CE4E9A9000BCB5B /* SwitchRowView.swift in Sources */, A91614D62B10B26B00F416EB /* TunnelControlViewModel.swift in Sources */, 7A5869972B32EA4500640D27 /* AppButton.swift in Sources */, 586C0D8F2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift in Sources */, @@ -5779,6 +5861,7 @@ 7AC8A3AE2ABC6FBB00DC4939 /* SettingsHeaderView.swift in Sources */, 588D7EDC2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift in Sources */, 588D7ED62AF3903F005DF40A /* ListAccessMethodViewController.swift in Sources */, + 7A8A190E2CEB77C1000BCB5B /* SettingsRowViewFooter.swift in Sources */, 7A6000FC2B628DF6001CF0D9 /* ListCellContentConfiguration.swift in Sources */, 582BB1B1229569620055B6EF /* UINavigationBar+Appearance.swift in Sources */, 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */, @@ -5893,6 +5976,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 */, @@ -5935,6 +6019,7 @@ 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, 58FF9FF02B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift in Sources */, + 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */, 58CEB2FD2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift in Sources */, F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, @@ -5943,6 +6028,7 @@ 5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */, 588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */, 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, + 7A8A18FD2CE4BE8D000BCB5B /* CustomToggleStyle.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, 58FF9FE82B07650A00E4C97D /* ButtonCellContentConfiguration.swift in Sources */, 7A6652B82BB44C3E0042D848 /* LocationDiffableDataSourceProtocol.swift in Sources */, @@ -5952,6 +6038,7 @@ 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, 586C0D832B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, + 7A8A190C2CE618D3000BCB5B /* View+Size.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, F0DA87492A9CBA9F006044F1 /* AccountDeviceRow.swift in Sources */, 58FF9FE42B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift in Sources */, @@ -5979,6 +6066,7 @@ 58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */, 586C0D932B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift in Sources */, 58EFC7712AFB45E500E9F4CB /* SettingsChildCoordinator.swift in Sources */, + 7A8A19102CEE391B000BCB5B /* RowSeparator.swift in Sources */, 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, F0E8CC0A2A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift in Sources */, 581DA2752A1E283E0046ED47 /* WgKeyRotation.swift in Sources */, @@ -6023,6 +6111,7 @@ F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */, 584D26C4270C855B004EA533 /* VPNSettingsDataSource.swift in Sources */, F0D8825B2B04F53600D3EF9A /* OutgoingConnectionData.swift in Sources */, + 7A8A18FB2CE4B678000BCB5B /* View+TapAreaSize.swift in Sources */, 7A6F2FAF2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift in Sources */, 7A6389DD2B7E3BD6008E77E1 /* CustomListDataSourceConfiguration.swift in Sources */, 5827B0BF2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift in Sources */, @@ -6041,6 +6130,7 @@ 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, 7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */, 585B1FF02AB09F97008AD470 /* VPNConnectionProtocol.swift in Sources */, + 7A8A19122CEF1E68000BCB5B /* SettingsInfoContainerView.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, 7A6389DB2B7E3BD6008E77E1 /* CustomListCellConfiguration.swift in Sources */, F09A297C2A9F8A9B00EA3B6F /* VoucherTextField.swift in Sources */, @@ -6245,6 +6335,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 */, @@ -6254,6 +6345,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 f916e7ae507e..a277b851bd41 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -90,6 +90,8 @@ public enum AccessibilityIdentifier: String { case customListLocationCell case daitaConfirmAlertBackButton case daitaConfirmAlertEnableButton + case multihopCell + case daitaCell // Labels case accountPageDeviceNameLabel @@ -144,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..1e71ed4b5b89 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/DAITATunnelSettingsViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift new file mode 100644 index 000000000000..d1f3e7945108 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift @@ -0,0 +1,107 @@ +// +// DAITATunnelSettingsViewModel.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-21. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST +import MullvadSettings + +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: TunnelSettingsObservable { + @Published var value: DAITASettings + + init(daitaSettings: DAITASettings = DAITASettings()) { + value = daitaSettings + } + + func evaluate(setting: MullvadSettings.DAITASettings) {} +} diff --git a/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift new file mode 100644 index 000000000000..a2fc820e39a2 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift @@ -0,0 +1,142 @@ +// +// SettingsDAITAView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import SwiftUI + +struct SettingsDAITAView: View where ViewModel: TunnelSettingsObservable { + @StateObject var tunnelViewModel: ViewModel + + var body: some View { + SettingsInfoContainerView { + VStack(alignment: .leading, spacing: 8) { + SettingsInfoView(viewModel: dataViewModel) + + VStack { + GroupedRowView { + SwitchRowView( + isOn: daitaIsEnabled, + text: NSLocalizedString( + "SETTINGS_SWITCH_DAITA_ENABLE", + tableName: "Settings", + value: "Enable", + comment: "" + ), + accessibilityId: .daitaSwitch + ) + RowSeparator() + SwitchRowView( + isOn: directOnlyIsEnabled, + disabled: !daitaIsEnabled.wrappedValue, + text: NSLocalizedString( + "SETTINGS_SWITCH_DAITA_DIRECT_ONLY", + tableName: "Settings", + value: "Direct only", + comment: "" + ), + accessibilityId: .daitaDirectOnlySwitch + ) + } + + SettingsRowViewFooter( + text: NSLocalizedString( + "SETTINGS_SWITCH_DAITA_ENABLE", + tableName: "Settings", + value: """ + By enabling "Direct only" you will have to manually select a server that \ + is DAITA-enabled. Multihop won't automatically be used to enable DAITA with \ + any server. + """, + comment: "" + ) + ) + } + .padding(.leading, UIMetrics.contentInsets.left) + .padding(.trailing, UIMetrics.contentInsets.right) + } + } + } +} + +#Preview { + SettingsDAITAView(tunnelViewModel: MockDAITATunnelSettingsViewModel()) +} + +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) + } + ) + } +} + +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/MultihopTunnelSettingsViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift new file mode 100644 index 000000000000..45ae1b9a12a4 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift @@ -0,0 +1,48 @@ +// +// MultihopTunnelSettingsViewModel.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-21. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings + +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: TunnelSettingsObservable { + @Published var value: MultihopState + + init(multihopState: MultihopState = .off) { + value = multihopState + } + + func evaluate(setting: MullvadSettings.MultihopState) {} +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift new file mode 100644 index 000000000000..2547f54e763a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift @@ -0,0 +1,60 @@ +// +// SettingsMultihopView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import SwiftUI + +struct SettingsMultihopView: View where ViewModel: TunnelSettingsObservable { + @StateObject var tunnelViewModel: ViewModel + + var body: some View { + SettingsInfoContainerView { + VStack(alignment: .leading, spacing: 8) { + SettingsInfoView(viewModel: dataViewModel) + + SwitchRowView( + 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) + } + } + } +} + +#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 2e9501f25a36..f8c501ff11f6 100644 --- a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift @@ -10,6 +10,7 @@ import MullvadLogging import MullvadSettings import Operations import Routing +import SwiftUI import UIKit /// Settings navigation route. @@ -28,6 +29,12 @@ enum SettingsNavigationRoute: Equatable { /// API access route. case apiAccess + + /// Multihop route. + case multihop + + /// DAITA route. + case daita } /// Top-level settings coordinator. @@ -35,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 @@ -71,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. @@ -193,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) @@ -218,55 +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 + } 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 new file mode 100644 index 000000000000..5975e2f73ce5 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Views/GroupedRowView.swift @@ -0,0 +1,34 @@ +// +// GroupedRowView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-21. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct GroupedRowView: View { + let content: Content + + init(@ViewBuilder _ content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(spacing: 0) { + content + } + .background(Color(UIColor.primaryColor)) + .cornerRadius(UIMetrics.SettingsRowView.cornerRadius) + } +} + +#Preview("GroupedRowView") { + StatefulPreviewWrapper((enabled: true, directOnly: false)) { values in + GroupedRowView { + SwitchRowView(isOn: values.enabled, text: "Enable") + SwitchRowView(isOn: values.directOnly, text: "Direct only") + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoContainerView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoContainerView.swift new file mode 100644 index 000000000000..1247930dfad7 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoContainerView.swift @@ -0,0 +1,38 @@ +// +// SettingsInfoContainerView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-21. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct SettingsInfoContainerView: View { + let content: Content + + init(@ViewBuilder _ content: () -> Content) { + self.content = content() + } + + var body: some View { + ZStack { + List { + content + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color(.secondaryColor)) + .padding(.top, UIMetrics.contentInsets.top) + .padding(.bottom, UIMetrics.contentInsets.bottom) + } + .listStyle(.plain) + } + .background(Color(.secondaryColor)) + } +} + +#Preview { + SettingsInfoContainerView { + SettingsMultihopView(tunnelViewModel: MockMultihopTunnelSettingsViewModel()) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift new file mode 100644 index 000000000000..62f4eeb3c380 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift @@ -0,0 +1,114 @@ +// +// SettingsInfoView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-13. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct SettingsInfoViewModel { + let pages: [SettingsInfoViewModelPage] +} + +struct SettingsInfoViewModelPage: Hashable { + let body: String + let image: ImageResource +} + +struct SettingsInfoView: View { + let viewModel: SettingsInfoViewModel + @State var height: CGFloat = 0 + + // Extra spacing to allow for some room around the page indicators. + var pageIndicatorSpacing: CGFloat { + viewModel.pages.count > 1 ? 72 : 24 + } + + var body: some View { + TabView { + ForEach(viewModel.pages, id: \.self) { page in + VStack { + contentView(for: page) + Spacer() + } + .padding(UIMetrics.SettingsInfoView.layoutMargins) + } + } + .frame( + height: height + pageIndicatorSpacing + ) + .tabViewStyle(.page) + .foregroundColor(Color(.primaryTextColor)) + .background { + Color(.secondaryColor) + preRenderViewSize() + } + } + + private func contentView(for page: SettingsInfoViewModelPage) -> some View { + VStack(alignment: .leading, spacing: 16) { + Image(page.image) + .resizable() + .aspectRatio(contentMode: .fit) + Text(page.body) + .font(.subheadline) + .opacity(0.6) + } + } + + // Renders the content of each page, determining the maximum height between them + // when laid out on screen. Since we only want this to update the real view + // this function should be called from a .background() and its contents hidden. + private func preRenderViewSize() -> some View { + ZStack { + ForEach(viewModel.pages, id: \.self) { page in + contentView(for: page) + } + } + .hidden() + .sizeOfView { size in + if size.height > height { + height = size.height + } + } + } +} + +#Preview("Single page") { + SettingsInfoView(viewModel: SettingsInfoViewModel( + pages: [ + SettingsInfoViewModelPage( + 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. + """, + image: .multihopIllustration + ), + ] + )) +} + +#Preview("Multiple pages") { + SettingsInfoView(viewModel: SettingsInfoViewModel( + pages: [ + SettingsInfoViewModelPage( + 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. + """, + image: .multihopIllustration + ), + SettingsInfoViewModelPage( + 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/SettingsRowViewFooter.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsRowViewFooter.swift new file mode 100644 index 000000000000..428138045c25 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsRowViewFooter.swift @@ -0,0 +1,21 @@ +// +// SettingsRowViewFooter.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct SettingsRowViewFooter: View { + let text: String + + var body: some View { + Text(verbatim: text) + .font(.footnote) + .opacity(0.6) + .foregroundStyle(Color(.primaryTextColor)) + .padding(UIMetrics.SettingsRowView.footerLayoutMargins) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift new file mode 100644 index 000000000000..77888d9040bd --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift @@ -0,0 +1,49 @@ +// +// SwitchRowView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-13. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct SwitchRowView: View { + @Binding var isOn: Bool + + var disabled = false + let text: String + var accessibilityId: AccessibilityIdentifier? + + var didTapInfoButton: (() -> Void)? + + var body: some View { + Toggle(isOn: $isOn, label: { + Text(text) + }) + .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) + } +} + +#Preview("SwitchRowView") { + StatefulPreviewWrapper(true) { + SwitchRowView( + isOn: $0, + text: "Enable", + didTapInfoButton: { + print("Tapped") + } + ) + } +} diff --git a/ios/MullvadVPN/Extensions/View+Size.swift b/ios/MullvadVPN/Extensions/View+Size.swift new file mode 100644 index 000000000000..34be86795b99 --- /dev/null +++ b/ios/MullvadVPN/Extensions/View+Size.swift @@ -0,0 +1,33 @@ +// +// View+Size.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-14. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension View { + /// Measures view size. + func sizeOfView(_ onSizeChange: @escaping ((CGSize) -> Void)) -> some View { + return self + .background { + GeometryReader { proxy in + Color.clear + .preference(key: ViewSizeKey.self, value: proxy.size) + .onPreferenceChange(ViewSizeKey.self) { size in + onSizeChange(size) + } + } + } + } +} + +private struct ViewSizeKey: PreferenceKey { + static var defaultValue: CGSize = .zero + + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} diff --git a/ios/MullvadVPN/Extensions/View+TapAreaSize.swift b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift new file mode 100644 index 000000000000..1e4ed64d3790 --- /dev/null +++ b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift @@ -0,0 +1,33 @@ +// +// View+TapAreaSize.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-13. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension View { + /// Adjusts tappable area to at least minimum (default) size without changing + /// actual view size. + func adjustingTapAreaSize() -> some View { + modifier(TappablePadding()) + } +} + +private struct TappablePadding: ViewModifier { + @State var actualViewSize: CGSize = .zero + let tappableViewSize = UIMetrics.Button.minimumTappableAreaSize + + func body(content: Content) -> some View { + content + .sizeOfView { actualViewSize = $0 } + .frame( + width: max(actualViewSize.width, tappableViewSize.width), + height: max(actualViewSize.height, tappableViewSize.height) + ) + .contentShape(Rectangle()) + .frame(width: actualViewSize.width, height: actualViewSize.height) + } +} diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/Contents.json new file mode 100644 index 000000000000..b08fd4512fff --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "DaitaOffIllustration.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/DaitaOffIllustration.svg b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/DaitaOffIllustration.svg new file mode 100644 index 000000000000..8e0ebc4b29bb --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOffIllustration.imageset/DaitaOffIllustration.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/Contents.json new file mode 100644 index 000000000000..2c526d5886fe --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "DaitaOnIllustration.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/DaitaOnIllustration.svg b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/DaitaOnIllustration.svg new file mode 100644 index 000000000000..e61f1fe23505 --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/DaitaOnIllustration.imageset/DaitaOnIllustration.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/Contents.json new file mode 100644 index 000000000000..c5e6c2565835 --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "MultihopIllustration.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/MultihopIllustration.svg b/ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/MultihopIllustration.svg new file mode 100644 index 000000000000..58bbca050de8 --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/MultihopIllustration.imageset/MultihopIllustration.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift b/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift index 343543b9e7a6..b5febb25d8d1 100644 --- a/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift +++ b/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift @@ -6,7 +6,7 @@ // Copyright © 2023 Mullvad VPN AB. All rights reserved. // -import Foundation +import SwiftUI import UIKit extension UIEdgeInsets { @@ -19,4 +19,9 @@ extension UIEdgeInsets { trailing: right ) } + + /// Returns edge insets. + var toEdgeInsets: EdgeInsets { + EdgeInsets(toDirectionalInsets) + } } diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index e1be91814cb3..e68d8b0c3207 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -7,6 +7,7 @@ // import MullvadTypes +import SwiftUI import UIKit enum UIMetrics { @@ -68,6 +69,7 @@ enum UIMetrics { enum Button { static let barButtonSize: CGFloat = 44 + static let minimumTappableAreaSize = CGSize(width: 44, height: 44) } enum SettingsCell { @@ -95,6 +97,18 @@ enum UIMetrics { static let apiAccessPickerListContentInsetTop: CGFloat = 16 static let verticalDividerHeight: CGFloat = 22 static let detailsButtonSize: CGFloat = 60 + static let infoButtonLeadingMargin: CGFloat = 8 + } + + enum SettingsInfoView { + static let layoutMargins = EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 24) + } + + enum SettingsRowView { + static let height: CGFloat = 44 + static let cornerRadius: CGFloat = 10 + static let layoutMargins = EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + static let footerLayoutMargins = EdgeInsets(top: 5, leading: 16, bottom: 5, trailing: 16) } enum InAppBannerNotification { @@ -167,7 +181,7 @@ extension UIMetrics { static let preferredFormSheetContentSize = CGSize(width: 480, height: 640) /// Common layout margins for content presentation - static let contentLayoutMargins = NSDirectionalEdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24) + static let contentLayoutMargins = contentInsets.toDirectionalInsets /// Common content margins for content presentation static let contentInsets = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift index d079501f1f29..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,30 @@ 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 - )) - } + cell.disclosureType = .chevron - case .daitaDirectOnly: - guard let cell = cell as? SettingsSwitchCell else { return } + case .multihop: + guard let cell = cell as? SettingsCell else { return } cell.titleLabel.text = NSLocalizedString( - "DAITA_DIRECT_ONLY_LABEL", + "MULTIHOP_CELL_LABEL", tableName: "Settings", - value: "Direct only", + value: "Multihop", comment: "" ) + cell.detailTitleLabel.text = nil 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 } } } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift index be44c8e3cb97..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: + case .vpnSettings, .problemReport, .faq, .apiAccess, .daita, .multihop: true - case .version, .daita, .daitaDirectOnly, .none: + case .version, .none: false } } @@ -159,17 +148,16 @@ final class SettingsDataSource: UITableViewDiffableDataSource() if interactor.deviceState.isLoggedIn { - snapshot.appendSections([.daita]) - snapshot.appendItems([.daita, .daitaDirectOnly], toSection: .daita) - } - - snapshot.appendSections([.main]) - - if interactor.deviceState.isLoggedIn { - snapshot.appendItems([.vpnSettings], toSection: .main) + snapshot.appendSections([.vpnSettings]) + snapshot.appendItems([ + .daita, + .multihop, + .vpnSettings, + ], toSection: .vpnSettings) } - snapshot.appendItems([.apiAccess], toSection: .main) + snapshot.appendSections([.apiAccess]) + snapshot.appendItems([.apiAccess], toSection: .apiAccess) snapshot.appendSections([.version, .problemReport]) snapshot.appendItems([.version], toSection: .version) @@ -184,66 +172,9 @@ extension SettingsDataSource: SettingsCellEventHandler { delegate?.showInfo(for: button) } - func switchDaitaState(_ settings: DAITASettings) { - testDaitaCompatibility(for: .daita, settings: settings) { [weak self] in - self?.reloadItem(.daitaDirectOnly) - } onDiscard: { [weak self] in - self?.reloadItem(.daita) - } - } - - func switchDaitaDirectOnlyState(_ settings: DAITASettings) { - testDaitaCompatibility(for: .daitaDirectOnly, settings: settings, onDiscard: { [weak self] in - self?.reloadItem(.daitaDirectOnly) - }) - } - private func reloadItem(_ item: Item) { var snapshot = snapshot() snapshot.reloadItems([item]) apply(snapshot, animatingDifferences: false) } - - private func testDaitaCompatibility( - for item: Item, - settings: DAITASettings, - onSave: (() -> 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 01b4df8ea6cb..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,6 +113,10 @@ extension SettingsDataSource.Item { return .faq case .apiAccess: return .apiAccess + case .daita: + return .daita + case .multihop: + return .multihop } } } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index 5bef5509f000..15219b50fda8 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -208,6 +208,7 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { ) cell.accessibilityIdentifier = "\(item.accessibilityIdentifier.rawValue)\(portString)" cell.applySubCellStyling() + case .quantumResistanceAutomatic: guard let cell = cell as? SelectableSettingsCell else { return } @@ -231,6 +232,7 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { ) cell.accessibilityIdentifier = item.accessibilityIdentifier cell.applySubCellStyling() + case .quantumResistanceOff: guard let cell = cell as? SelectableSettingsCell else { return } @@ -242,27 +244,6 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { ) cell.accessibilityIdentifier = item.accessibilityIdentifier cell.applySubCellStyling() - - case .multihopSwitch: - guard let cell = cell as? SettingsSwitchCell else { return } - - cell.titleLabel.text = NSLocalizedString( - "MULTIHOP_LABEL", - tableName: "VPNSettings", - value: "Multihop", - comment: "" - ) - cell.accessibilityIdentifier = item.accessibilityIdentifier - cell.setOn(viewModel.multihopState.isEnabled, animated: false) - - cell.infoButtonHandler = { [weak self] in - self?.delegate?.showInfo(for: .multihop) - } - - cell.action = { [weak self] isEnabled in - let state: MultihopState = isEnabled ? .on : .off - self?.delegate?.switchMultihop(state) - } } } } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index 2e3700039e20..afa780e4eb85 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -24,8 +24,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardObfuscationOption case wireGuardObfuscationPort case quantumResistance - case multihop - case daita + var reusableViewClass: AnyClass { switch self { case .dnsSettings: @@ -44,10 +43,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return SelectableSettingsCell.self case .quantumResistance: return SelectableSettingsCell.self - case .multihop: - return SettingsSwitchCell.self - case .daita: - return SettingsSwitchCell.self } } } @@ -82,7 +77,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case quantumResistanceAutomatic case quantumResistanceOn case quantumResistanceOff - case multihopSwitch static var wireGuardPorts: [Item] { let defaultPorts = VPNSettingsViewModel.defaultWireGuardPorts.map { @@ -138,8 +132,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .quantumResistanceOn case .quantumResistanceOff: return .quantumResistanceOff - case .multihopSwitch: - return .multihopSwitch } } @@ -161,8 +153,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .wireGuardObfuscationPort case .quantumResistanceAutomatic, .quantumResistanceOn, .quantumResistanceOff: return .quantumResistance - case .multihopSwitch: - return .multihop } } } @@ -455,7 +445,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< snapshot.appendSections(Section.allCases) snapshot.appendItems([.dnsSettings], toSection: .dnsSettings) snapshot.appendItems([.ipOverrides], toSection: .ipOverrides) - snapshot.appendItems([.multihopSwitch], toSection: .privacyAndSecurity) applySnapshot(snapshot, animated: animated, completion: completion) } @@ -602,7 +591,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< /* Since we are dequeuing headers, it's crucial to maintain the state of expansion. - Using screenshots as a single source of truth to capture the state allows us to determine whether headers are expanded or not. + Using screenshots as a single source of truth to capture the state allows us to + determine whether headers are expanded or not. */ private func isExpanded(_ section: Section) -> Bool { let snapshot = snapshot() diff --git a/ios/MullvadVPN/Views/CustomToggleStyle.swift b/ios/MullvadVPN/Views/CustomToggleStyle.swift new file mode 100644 index 000000000000..e114d17adc7c --- /dev/null +++ b/ios/MullvadVPN/Views/CustomToggleStyle.swift @@ -0,0 +1,72 @@ +// +// CustomToggle.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-13. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +/// Custom (default) toggle style used for switches. +struct CustomToggleStyle: ToggleStyle { + private let width: CGFloat = 48 + 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) { + Image(.iconInfo) + } + .adjustingTapAreaSize() + .tint(.white) + } + + Spacer() + + ZStack(alignment: configuration.isOn ? .trailing : .leading) { + Capsule(style: .circular) + .frame(width: width, height: height) + .foregroundColor(.clear) + .overlay( + RoundedRectangle(cornerRadius: circleRadius) + .stroke( + Color(.white.withAlphaComponent(0.8)), + lineWidth: 2 + ) + ) + .opacity(disabled ? 0.2 : 1) + + Circle() + .frame(width: circleRadius, height: circleRadius) + .padding(3) + .foregroundColor( + configuration.isOn + ? Color(uiColor: UIColor.Switch.onThumbColor) + : Color(uiColor: UIColor.Switch.offThumbColor) + ) + .opacity(disabled ? 0.4 : 1) + } + .accessibilityIdentifier(accessibilityId?.rawValue ?? "") + .onTapGesture { + toggle(configuration) + } + .adjustingTapAreaSize() + } + } + + private func toggle(_ configuration: Configuration) { + withAnimation(.easeInOut(duration: 0.25)) { + configuration.isOn.toggle() + } + } +} diff --git a/ios/MullvadVPN/Views/IncreasedHitButton.swift b/ios/MullvadVPN/Views/IncreasedHitButton.swift index e3a79838d637..d85cce07b557 100644 --- a/ios/MullvadVPN/Views/IncreasedHitButton.swift +++ b/ios/MullvadVPN/Views/IncreasedHitButton.swift @@ -6,6 +6,7 @@ // Copyright © 2023 Mullvad VPN AB. All rights reserved. // +import SwiftUI import UIKit final class IncreasedHitButton: UIButton { diff --git a/ios/MullvadVPN/Views/RowSeparator.swift b/ios/MullvadVPN/Views/RowSeparator.swift new file mode 100644 index 000000000000..b3c2b0b1642a --- /dev/null +++ b/ios/MullvadVPN/Views/RowSeparator.swift @@ -0,0 +1,23 @@ +// +// Separator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-11-20. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct RowSeparator: View { + var color = Color(.secondaryColor) + + var body: some View { + color + .frame(height: UIMetrics.TableView.separatorHeight) + .padding(.leading, 16) + } +} + +#Preview { + RowSeparator(color: Color(.primaryColor)) +} 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..b5951eb2f558 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) @@ -387,4 +428,4 @@ class RelayTests: LoggedInWithTimeUITestCase { try Networking.verifyDNSServerProvider(dnsServerProviderName, isMullvad: false) } -} +} // swiftlint:disable:this file_length 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() } } diff --git a/ios/convert-assets.rb b/ios/convert-assets.rb index 62412a166eed..5419e6282d9e 100755 --- a/ios/convert-assets.rb +++ b/ios/convert-assets.rb @@ -45,7 +45,7 @@ "icon-close-sml.svg", "icon-copy.svg", "icon-obscure.svg", - "icon-unobscure.svg", + "icon-unobscure.svg" ] # graphical assets to resize.