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.