diff --git a/ios/MullvadSettings/MultihopSettings.swift b/ios/MullvadSettings/MultihopSettings.swift index 1d3dc2306bda..a9a17f2b4358 100644 --- a/ios/MullvadSettings/MultihopSettings.swift +++ b/ios/MullvadSettings/MultihopSettings.swift @@ -9,7 +9,7 @@ import Foundation import MullvadTypes -/// Whether Multihop is enabled +/// Whether multihop is enabled. public enum MultihopState: Codable { case on case off diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 3b436d763ddb..09531c4b28f4 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -565,11 +565,10 @@ 7A8A190E2CEB77C1000BCB5B /* SettingsRowViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A190D2CEB77B7000BCB5B /* SettingsRowViewFooter.swift */; }; 7A8A19102CEE391B000BCB5B /* RowSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A190F2CEE3918000BCB5B /* RowSeparator.swift */; }; 7A8A19122CEF1E68000BCB5B /* SettingsInfoContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19112CEF1E58000BCB5B /* SettingsInfoContainerView.swift */; }; - 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsObserver.swift */; }; - 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsObserver.swift */; }; - 7A8A19182CEF27AB000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19172CEF279C000BCB5B /* DAITATunnelSettingsViewModel.swift */; }; + 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */; }; + 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */; }; 7A8A191A2CEF41AF000BCB5B /* GroupedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A19192CEF41AC000BCB5B /* GroupedRowView.swift */; }; - 7A8A191C2CEF55E3000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A191B2CEF55DA000BCB5B /* MultihopTunnelSettingsViewModel.swift */; }; + 7A8A191E2CEF5CF2000BCB5B /* TunnelSettingsObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A191D2CEF5CDF000BCB5B /* TunnelSettingsObservable.swift */; }; 7A9BE5A22B8F88C500E2A7D0 /* LocationNodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */; }; 7A9BE5A32B8F89B900E2A7D0 /* LocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389F72B864CDF008E77E1 /* LocationNode.swift */; }; 7A9BE5A52B90760C00E2A7D0 /* CustomListsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */; }; @@ -1921,11 +1920,10 @@ 7A8A190D2CEB77B7000BCB5B /* SettingsRowViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRowViewFooter.swift; sourceTree = ""; }; 7A8A190F2CEE3918000BCB5B /* RowSeparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowSeparator.swift; sourceTree = ""; }; 7A8A19112CEF1E58000BCB5B /* SettingsInfoContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInfoContainerView.swift; sourceTree = ""; }; - 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITATunnelSettingsObserver.swift; sourceTree = ""; }; - 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopTunnelSettingsObserver.swift; sourceTree = ""; }; - 7A8A19172CEF279C000BCB5B /* DAITATunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITATunnelSettingsViewModel.swift; sourceTree = ""; }; + 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITATunnelSettingsViewModel.swift; sourceTree = ""; }; + 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopTunnelSettingsViewModel.swift; sourceTree = ""; }; 7A8A19192CEF41AC000BCB5B /* GroupedRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedRowView.swift; sourceTree = ""; }; - 7A8A191B2CEF55DA000BCB5B /* MultihopTunnelSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopTunnelSettingsViewModel.swift; sourceTree = ""; }; + 7A8A191D2CEF5CDF000BCB5B /* TunnelSettingsObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsObservable.swift; sourceTree = ""; }; 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 = ""; }; @@ -2887,7 +2885,6 @@ 4424CDD12CDBD457009D8C9F /* SwiftUI components */, 4422C06F2CCFF6520001A385 /* Obfuscation */, 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */, - F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */, 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */, 7A27E3CE2CBD4A830088BCFF /* SelectableSettingsDetailsCell.swift */, 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */, @@ -3825,6 +3822,7 @@ 7A9CCCAD2A96302800DD6A34 /* SettingsCoordinator.swift */, 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */, 7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */, + 7A8A191D2CEF5CDF000BCB5B /* TunnelSettingsObservable.swift */, ); path = Settings; sourceTree = ""; @@ -3978,8 +3976,7 @@ 7A8A18F72CE34E8F000BCB5B /* Multihop */ = { isa = PBXGroup; children = ( - 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsObserver.swift */, - 7A8A191B2CEF55DA000BCB5B /* MultihopTunnelSettingsViewModel.swift */, + 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */, 7A8A18F82CE34E9F000BCB5B /* SettingsMultihopView.swift */, ); path = Multihop; @@ -4000,8 +3997,8 @@ 7A8A19082CE5FFD7000BCB5B /* DAITA */ = { isa = PBXGroup; children = ( - 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsObserver.swift */, - 7A8A19172CEF279C000BCB5B /* DAITATunnelSettingsViewModel.swift */, + F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */, + 7A8A19132CEF2527000BCB5B /* DAITATunnelSettingsViewModel.swift */, 7A8A19092CE5FFDF000BCB5B /* SettingsDAITAView.swift */, ); path = DAITA; @@ -5781,7 +5778,7 @@ 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */, 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */, 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */, - 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsObserver.swift in Sources */, + 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */, 7A8A18F92CE34EA8000BCB5B /* SettingsMultihopView.swift in Sources */, 44BB5F972BE527F4002520EB /* TunnelState+UI.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, @@ -5932,7 +5929,6 @@ F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */, F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */, 7A6000F62B60092F001CF0D9 /* AccessMethodViewModelEditing.swift in Sources */, - 7A8A191C2CEF55E3000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */, 5878A27729093A4F0096FC88 /* StorePaymentBlockObserver.swift in Sources */, 58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */, 5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */, @@ -5960,6 +5956,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 */, @@ -5968,7 +5965,6 @@ 58CEB2F32AFD0BA100E6E088 /* TextCellContentView.swift in Sources */, 7A6389E72B7E42BE008E77E1 /* CustomListViewController.swift in Sources */, 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */, - 7A8A19182CEF27AB000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */, 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */, @@ -6002,7 +5998,7 @@ 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, 58FF9FF02B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift in Sources */, - 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsObserver.swift in Sources */, + 7A8A19162CEF269E000BCB5B /* MultihopTunnelSettingsViewModel.swift in Sources */, 58CEB2FD2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift in Sources */, F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/Settings/DAITASettingsPromptItem.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITASettingsPromptItem.swift similarity index 100% rename from ios/MullvadVPN/View controllers/Settings/DAITASettingsPromptItem.swift rename to ios/MullvadVPN/Coordinators/Settings/DAITA/DAITASettingsPromptItem.swift diff --git a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsObserver.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsObserver.swift deleted file mode 100644 index 028f60fb3b64..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsObserver.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// DAITATunnelSettingsObserver.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-11-21. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadSettings - -class DAITATunnelSettingsObserver: ObservableObject { - private let tunnelManager: TunnelManager - private var tunnelObserver: TunnelObserver? - - var value: DAITASettings { - willSet(newValue) { - guard newValue != self.value else { return } - - objectWillChange.send() - tunnelManager.updateSettings([.daita(newValue)]) - } - } - - init(tunnelManager: TunnelManager) { - self.tunnelManager = tunnelManager - value = tunnelManager.settings.daita - - tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in - self?.value = newSettings.daita - }) - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift index bd0d13029cd8..d1f3e7945108 100644 --- a/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift +++ b/ios/MullvadVPN/Coordinators/Settings/DAITA/DAITATunnelSettingsViewModel.swift @@ -1,24 +1,107 @@ // -// DAITATunnelSettingsObservable.swift +// DAITATunnelSettingsViewModel.swift // MullvadVPN // // Created by Jon Petersson on 2024-11-21. // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Foundation +import MullvadREST import MullvadSettings -protocol DAITATunnelSettingsObservable: ObservableObject { - var value: DAITASettings { get set } +class DAITATunnelSettingsViewModel: TunnelSettingsObserver, ObservableObject { + typealias TunnelSetting = DAITASettings + + let tunnelManager: TunnelManager + var tunnelObserver: TunnelObserver? + + var didFailDAITAValidation: (((item: DAITASettingsPromptItem, setting: DAITASettings)) -> Void)? + + var value: DAITASettings { + willSet { + guard newValue != value else { return } + + objectWillChange.send() + tunnelManager.updateSettings([.daita(newValue)]) + } + } + + required init(tunnelManager: TunnelManager) { + self.tunnelManager = tunnelManager + value = tunnelManager.settings.daita + + tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in + self?.value = newSettings.daita + }) + } + + func evaluate(setting: DAITASettings) { + if let error = evaluateDaitaSettingsCompatibility(setting) { + let promptItem = promptItem(from: error, setting: setting) + + didFailDAITAValidation?((item: promptItem, setting: setting)) + return + } + + value = setting + } +} + +extension DAITATunnelSettingsViewModel { + private func promptItem( + from error: DAITASettingsCompatibilityError, + setting: DAITASettings + ) -> DAITASettingsPromptItem { + let promptItemSetting: DAITASettingsPromptItem.Setting = if setting.daitaState != value.daitaState { + .daita + } else { + .directOnly + } + + var promptItem: DAITASettingsPromptItem + + switch error { + case .singlehop: + promptItem = .daitaSettingIncompatibleWithSinglehop(promptItemSetting) + case .multihop: + promptItem = .daitaSettingIncompatibleWithMultihop(promptItemSetting) + } + + return promptItem + } + + private func evaluateDaitaSettingsCompatibility(_ settings: DAITASettings) -> DAITASettingsCompatibilityError? { + guard settings.daitaState.isEnabled else { return nil } + + var tunnelSettings = tunnelManager.settings + tunnelSettings.daita = settings + + var compatibilityError: DAITASettingsCompatibilityError? + + do { + _ = try tunnelManager.selectRelays(tunnelSettings: tunnelSettings) + } catch let error as NoRelaysSatisfyingConstraintsError where error.reason == .noDaitaRelaysFound { + // Return error if no relays could be selected due to DAITA constraints. + compatibilityError = tunnelSettings.tunnelMultihopState.isEnabled ? .multihop : .singlehop + } catch _ as NoRelaysSatisfyingConstraintsError { + // Even if the constraints error is not DAITA specific, if both DAITA and Direct only are enabled, + // we should return a DAITA related error since the current settings would have resulted in the + // relay selector not being able to select a DAITA relay anyway. + if settings.isDirectOnly { + compatibilityError = tunnelSettings.tunnelMultihopState.isEnabled ? .multihop : .singlehop + } + } catch {} + + return compatibilityError + } } -class MockDAITATunnelSettingsViewModel: DAITATunnelSettingsObservable { +class MockDAITATunnelSettingsViewModel: TunnelSettingsObservable { @Published var value: DAITASettings init(daitaSettings: DAITASettings = DAITASettings()) { value = daitaSettings } -} -class DAITATunnelSettingsViewModel: DAITATunnelSettingsObserver, DAITATunnelSettingsObservable {} + func evaluate(setting: MullvadSettings.DAITASettings) {} +} diff --git a/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift b/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift index 44265b80155d..51f1d9a1424d 100644 --- a/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/DAITA/SettingsDAITAView.swift @@ -6,52 +6,37 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import MullvadSettings import SwiftUI -struct SettingsDAITAView: View where VM: DAITATunnelSettingsObservable { - @StateObject var tunnelViewModel: VM +struct SettingsDAITAView: View where ViewModel: TunnelSettingsObservable { + @StateObject var tunnelViewModel: ViewModel - private let dataViewModel = SettingsInfoViewModel( - pages: [ - SettingsInfoViewModelPage( - body: NSLocalizedString( - "SETTINGS_INFO_DAITA_PAGE_1", - tableName: "Settings", - value: """ - DAITA (Defense against AI-guided Traffic Analysis) hides patterns in \ - your encrypted VPN traffic. + var daitaState: Binding { + Binding( + get: { + tunnelViewModel.value.daitaState.isEnabled + }, set: { + var settings = tunnelViewModel.value + settings.daitaState.isEnabled = $0 - 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. + tunnelViewModel.evaluate(setting: settings) + } + ) + } - Not all our servers are DAITA-enabled. Therefore, we use multihop \ - automatically to enable DAITA with any server. + var directOnlyState: Binding { + Binding( + get: { + tunnelViewModel.value.directOnlyState.isEnabled + }, set: { + var settings = tunnelViewModel.value + settings.directOnlyState.isEnabled = $0 - Attention: Be cautious if you have a limited data plan as this feature \ - will increase your network traffic. - """, - comment: "" - ), - image: .daitaOnIllustration - ) - ] - ) + tunnelViewModel.evaluate(setting: settings) + } + ) + } var body: some View { SettingsInfoContainerView { @@ -61,7 +46,7 @@ struct SettingsDAITAView: View where VM: DAITATunnelSettingsObservable { VStack { GroupedRowView { SwitchRowView( - enabled: $tunnelViewModel.value.daitaState.isEnabled, + enabled: daitaState, text: NSLocalizedString( "SETTINGS_SWITCH_DAITA_ENABLE", tableName: "Settings", @@ -71,7 +56,7 @@ struct SettingsDAITAView: View where VM: DAITATunnelSettingsObservable { ) RowSeparator() SwitchRowView( - enabled: $tunnelViewModel.value.directOnlyState.isEnabled, + enabled: directOnlyState, text: NSLocalizedString( "SETTINGS_SWITCH_DAITA_DIRECT_ONLY", tableName: "Settings", @@ -104,3 +89,49 @@ struct SettingsDAITAView: View where VM: DAITATunnelSettingsObservable { #Preview { SettingsDAITAView(tunnelViewModel: MockDAITATunnelSettingsViewModel()) } + +extension SettingsDAITAView { + private var dataViewModel: SettingsInfoViewModel { + SettingsInfoViewModel( + pages: [ + SettingsInfoViewModelPage( + body: NSLocalizedString( + "SETTINGS_INFO_DAITA_PAGE_1", + tableName: "Settings", + value: """ + DAITA (Defense against AI-guided Traffic Analysis) hides patterns in \ + your encrypted VPN traffic. + + By using sophisticated AI it’s possible to analyze the traffic of data \ + packets going in and out of your device (even if the traffic is encrypted). + + If an observer monitors these data packets, DAITA makes it significantly \ + harder for them to identify which websites you are visiting or with whom \ + you are communicating. + """, + comment: "" + ), + image: .daitaOffIllustration + ), + SettingsInfoViewModelPage( + body: NSLocalizedString( + "SETTINGS_INFO_DAITA_PAGE_2", + tableName: "Settings", + value: """ + DAITA does this by carefully adding network noise and making all network \ + packets the same size. + + Not all our servers are DAITA-enabled. Therefore, we use multihop \ + automatically to enable DAITA with any server. + + Attention: Be cautious if you have a limited data plan as this feature \ + will increase your network traffic. + """, + comment: "" + ), + image: .daitaOnIllustration + ), + ] + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsObserver.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsObserver.swift deleted file mode 100644 index 4f55ff145399..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsObserver.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// MultihopTunnelSettingsObserver.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-11-21. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadSettings - -class MultihopTunnelSettingsObserver: ObservableObject { - private let tunnelManager: TunnelManager - private var tunnelObserver: TunnelObserver? - - var value: MultihopState { - willSet(newValue) { - guard newValue != value else { return } - - objectWillChange.send() - tunnelManager.updateSettings([.multihop(newValue)]) - } - } - - init(tunnelManager: TunnelManager) { - self.tunnelManager = tunnelManager - value = tunnelManager.settings.tunnelMultihopState - - tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in - self?.value = newSettings.tunnelMultihopState - }) - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift index e4e84e7ede0c..45ae1b9a12a4 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopTunnelSettingsViewModel.swift @@ -6,19 +6,43 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Foundation import MullvadSettings -protocol MultihopTunnelSettingsObservable: ObservableObject { - var value: MultihopState { get set } +class MultihopTunnelSettingsViewModel: TunnelSettingsObserver, ObservableObject { + typealias TunnelSetting = MultihopState + + let tunnelManager: TunnelManager + var tunnelObserver: TunnelObserver? + + var value: MultihopState { + willSet(newValue) { + guard newValue != value else { return } + + objectWillChange.send() + tunnelManager.updateSettings([.multihop(newValue)]) + } + } + + required init(tunnelManager: TunnelManager) { + self.tunnelManager = tunnelManager + value = tunnelManager.settings.tunnelMultihopState + + tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in + self?.value = newSettings.tunnelMultihopState + }) + } + + func evaluate(setting: MultihopState) { + // No op. + } } -class MockMultihopTunnelSettingsViewModel: MultihopTunnelSettingsObservable { +class MockMultihopTunnelSettingsViewModel: TunnelSettingsObservable { @Published var value: MultihopState init(multihopState: MultihopState = .off) { value = multihopState } -} -class MultihopTunnelSettingsViewModel: MultihopTunnelSettingsObserver, MultihopTunnelSettingsObservable {} + func evaluate(setting: MullvadSettings.MultihopState) {} +} diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift index d7717f16495d..5971f9f6fa09 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Multihop/SettingsMultihopView.swift @@ -6,33 +6,16 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Combine +import MullvadSettings import SwiftUI -struct SettingsMultihopView: View where VM: MultihopTunnelSettingsObservable { - @StateObject var tunnelViewModel: VM - - private let viewModel = SettingsInfoViewModel( - pages: [ - SettingsInfoViewModelPage( - body: NSLocalizedString( - "SETTINGS_INFO_MULTIHOP", - tableName: "Settings", - value: """ - Multihop routes your traffic into one WireGuard server and out another, making it \ - harder to trace. This results in increased latency but increases anonymity online. - """, - comment: "" - ), - image: .multihopIllustration - ) - ] - ) +struct SettingsMultihopView: View where ViewModel: TunnelSettingsObservable { + @StateObject var tunnelViewModel: ViewModel var body: some View { SettingsInfoContainerView { VStack(alignment: .leading, spacing: 8) { - SettingsInfoView(viewModel: viewModel) + SettingsInfoView(viewModel: dataViewModel) SwitchRowView( enabled: $tunnelViewModel.value.isEnabled, @@ -53,3 +36,24 @@ struct SettingsMultihopView: View where VM: MultihopTunnelSettingsObservable #Preview { SettingsMultihopView(tunnelViewModel: MockMultihopTunnelSettingsViewModel()) } + +extension SettingsMultihopView { + private var dataViewModel: SettingsInfoViewModel { + SettingsInfoViewModel( + pages: [ + SettingsInfoViewModelPage( + body: NSLocalizedString( + "SETTINGS_INFO_MULTIHOP", + tableName: "Settings", + value: """ + Multihop routes your traffic into one WireGuard server and out another, making it \ + harder to trace. This results in increased latency but increases anonymity online. + """, + comment: "" + ), + image: .multihopIllustration + ), + ] + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift index 33075ec61f80..1e1ecd1ed601 100644 --- a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift @@ -293,6 +293,16 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV let viewModel = DAITATunnelSettingsViewModel(tunnelManager: interactorFactory.tunnelManager) let view = SettingsDAITAView(tunnelViewModel: viewModel) + viewModel.didFailDAITAValidation = { [weak self] result in + self?.showPrompt( + for: result.item, + onSave: { + viewModel.value = result.setting + }, + onDiscard: {} + ) + } + let host = UIHostingController(rootView: view) host.title = NSLocalizedString( "NAVIGATION_TITLE_DAITA", @@ -305,6 +315,49 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV } } + 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(context: self).showAlert(presentation: presentation, animated: true) + } + /// Map the view controller to the individual route. /// - Parameter viewController: an instance of a view controller within the navigation stack. /// - Returns: a route upon success, otherwise `nil`. 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/SettingsInfoView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift index a9d7fdc69d9b..8b79a67a7a34 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SettingsInfoView.swift @@ -85,7 +85,7 @@ struct SettingsInfoView: View { harder to trace. This results in increased latency but increases anonymity online. """, image: .multihopIllustration - ) + ), ] )) } @@ -109,7 +109,7 @@ struct SettingsInfoView: View { harder to trace. This results in increased latency but increases anonymity online. """, image: .multihopIllustration - ) + ), ] )) } diff --git a/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift b/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift index a86dacfbb180..9f4d0e8f9511 100644 --- a/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/Views/SwitchRowView.swift @@ -17,8 +17,6 @@ struct SwitchRowView: View { var body: some View { Toggle(isOn: $enabled, label: { Text(text) - }).onChange(of: enabled, perform: { enabled in - $enabled.wrappedValue = enabled }) .toggleStyle(CustomToggleStyle(infoButtonAction: didTapInfoButton)) .font(.headline) diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 0ff8ba3e3828..e68d8b0c3207 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -16,15 +16,13 @@ enum UIMetrics { static let separatorHeight: CGFloat = 0.33 /// Spacing used between distinct sections of views static let sectionSpacing: CGFloat = 24 + /// Common layout margins for row views presentation + /// Similar to `SettingsCell.layoutMargins` however maintains equal horizontal spacing + static let rowViewLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24) /// Common cell indentation width static let cellIndentationWidth: CGFloat = 16 } - enum DeviceRowView { - /// Similar to `SettingsCell.layoutMargins` however maintains equal horizontal spacing. - static let layoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24) - } - enum CustomAlert { /// Layout margins for container (main view) in `CustomAlertViewController` static let containerMargins = NSDirectionalEdgeInsets( diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift index 55e9852d7e45..d3da30a16e1c 100644 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift @@ -72,7 +72,7 @@ class DeviceRowView: UIView { accessibilityIdentifier = .deviceCell backgroundColor = .primaryColor - directionalLayoutMargins = UIMetrics.DeviceRowView.layoutMargins + directionalLayoutMargins = UIMetrics.TableView.rowViewLayoutMargins for subview in [textLabel, removeButton, activityIndicator, creationDateLabel] { addSubview(subview) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift index f958925346a1..763c3d20ad59 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift @@ -11,8 +11,6 @@ import UIKit protocol SettingsCellEventHandler { func showInfo(for button: SettingsInfoButtonItem) - func switchDaitaState(_ settings: DAITASettings) - func switchDaitaDirectOnlyState(_ settings: DAITASettings) } final class SettingsCellFactory: CellFactoryProtocol { @@ -105,57 +103,17 @@ final class SettingsCellFactory: CellFactoryProtocol { cell.disclosureType = .chevron case .daita: - guard let cell = cell as? SettingsSwitchCell else { return } + guard let cell = cell as? SettingsCell else { return } cell.titleLabel.text = NSLocalizedString( - "DAITA_LABEL", + "DAITA_CELL_LABEL", tableName: "Settings", value: "DAITA", comment: "" ) + cell.detailTitleLabel.text = nil cell.accessibilityIdentifier = item.accessibilityIdentifier - cell.setOn(viewModel.daitaSettings.daitaState.isEnabled, animated: false) - - cell.infoButtonHandler = { [weak self] in - self?.delegate?.showInfo(for: .daita) - } - - cell.action = { [weak self] isEnabled in - guard let self else { return } - - let state: DAITAState = isEnabled ? .on : .off - delegate?.switchDaitaState(DAITASettings( - daitaState: state, - directOnlyState: viewModel.daitaSettings.directOnlyState - )) - } - - case .daitaDirectOnly: - guard let cell = cell as? SettingsSwitchCell else { return } - - cell.titleLabel.text = NSLocalizedString( - "DAITA_DIRECT_ONLY_LABEL", - tableName: "Settings", - value: "Direct only", - comment: "" - ) - cell.accessibilityIdentifier = item.accessibilityIdentifier - cell.setOn(viewModel.daitaSettings.directOnlyState.isEnabled, animated: false) - cell.setSwitchEnabled(viewModel.daitaSettings.daitaState.isEnabled) - - cell.infoButtonHandler = { [weak self] in - self?.delegate?.showInfo(for: .daitaDirectOnly) - } - - cell.action = { [weak self] isEnabled in - guard let self else { return } - - let state: DirectOnlyState = isEnabled ? .on : .off - delegate?.switchDaitaDirectOnlyState(DAITASettings( - daitaState: viewModel.daitaSettings.daitaState, - directOnlyState: state - )) - } + cell.disclosureType = .chevron case .multihop: guard let cell = cell as? SettingsCell else { return } @@ -169,19 +127,6 @@ final class SettingsCellFactory: CellFactoryProtocol { cell.detailTitleLabel.text = nil cell.accessibilityIdentifier = item.accessibilityIdentifier cell.disclosureType = .chevron - - case .daita2: - guard let cell = cell as? SettingsCell else { return } - - cell.titleLabel.text = NSLocalizedString( - "DAITA_CELL_LABEL", - tableName: "Settings", - value: "DAITA", - comment: "" - ) - cell.detailTitleLabel.text = nil - cell.accessibilityIdentifier = item.accessibilityIdentifier - cell.disclosureType = .chevron } } } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift index 6d2d406bbbb0..795c4732b045 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift @@ -13,15 +13,9 @@ final class SettingsDataSource: UITableViewDiffableDataSource Bool { switch itemIdentifier(for: indexPath) { - case .vpnSettings, .problemReport, .faq, .apiAccess, .multihop, .daita2: + case .vpnSettings, .problemReport, .faq, .apiAccess, .daita, .multihop: true - case .version, .daita, .daitaDirectOnly, .none: + case .version, .none: false } } @@ -168,8 +151,6 @@ final class SettingsDataSource: UITableViewDiffableDataSource Void)? = nil, - onDiscard: @escaping () -> Void - ) { - let updateSettings = { [weak self] in - self?.settingsCellFactory.viewModel.setDAITASettings(settings) - self?.interactor.updateDAITASettings(settings) - - onSave?() - } - - var promptItemSetting: DAITASettingsPromptItem.Setting? - switch item { - case .daita: - promptItemSetting = .daita - case .daitaDirectOnly: - promptItemSetting = .directOnly - default: - break - } - - if let promptItemSetting, let error = interactor.evaluateDaitaSettingsCompatibility(settings) { - switch error { - case .singlehop: - delegate?.showPrompt( - for: .daitaSettingIncompatibleWithSinglehop(promptItemSetting), - onSave: { updateSettings() }, - onDiscard: onDiscard - ) - case .multihop: - delegate?.showPrompt( - for: .daitaSettingIncompatibleWithMultihop(promptItemSetting), - onSave: { updateSettings() }, - onDiscard: onDiscard - ) - } - } else { - updateSettings() - } - } } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift index 4800271dc8d5..44e119599e32 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSourceDelegate.swift @@ -12,9 +12,4 @@ import UIKit protocol SettingsDataSourceDelegate: AnyObject { func didSelectItem(item: SettingsDataSource.Item) func showInfo(for: SettingsInfoButtonItem) - func showPrompt( - for: DAITASettingsPromptItem, - onSave: @escaping () -> Void, - onDiscard: @escaping () -> Void - ) } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift index 11fa0add56bc..ffe9344d163d 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift @@ -98,49 +98,6 @@ extension SettingsViewController: SettingsDataSourceDelegate { alertPresenter.showAlert(presentation: presentation, animated: true) } - - func showPrompt( - for item: DAITASettingsPromptItem, - onSave: @escaping () -> Void, - onDiscard: @escaping () -> Void - ) { - let presentation = AlertPresentation( - id: "settings-daita-prompt", - accessibilityIdentifier: .daitaPromptAlert, - icon: .info, - message: NSLocalizedString( - "SETTINGS_DAITA_ENABLE_TEXT", - tableName: "DAITA", - value: item.description, - comment: "" - ), - buttons: [ - AlertAction( - title: String(format: NSLocalizedString( - "SETTINGS_DAITA_ENABLE_OK_ACTION", - tableName: "DAITA", - value: "Enable %@", - comment: "" - ), item.title), - style: .default, - accessibilityId: .daitaConfirmAlertEnableButton, - handler: { onSave() } - ), - AlertAction( - title: NSLocalizedString( - "SETTINGS_DAITA_ENABLE_CANCEL_ACTION", - tableName: "DAITA", - value: "Back", - comment: "" - ), - style: .default, - handler: { onDiscard() } - ), - ] - ) - - alertPresenter.showAlert(presentation: presentation, animated: true) - } } extension SettingsDataSource.Item { @@ -148,7 +105,7 @@ extension SettingsDataSource.Item { switch self { case .vpnSettings: return .vpnSettings - case .version, .daita, .daitaDirectOnly: + case .version: return nil case .problemReport: return .problemReport @@ -156,10 +113,10 @@ extension SettingsDataSource.Item { return .faq case .apiAccess: return .apiAccess + case .daita: + return .daita case .multihop: return .multihop - case .daita2: - return .daita } } } diff --git a/ios/MullvadVPN/Views/RowSeparator.swift b/ios/MullvadVPN/Views/RowSeparator.swift index 4784c5780d88..b3c2b0b1642a 100644 --- a/ios/MullvadVPN/Views/RowSeparator.swift +++ b/ios/MullvadVPN/Views/RowSeparator.swift @@ -9,7 +9,7 @@ import SwiftUI struct RowSeparator: View { - var color: Color = Color(.secondaryColor) + var color = Color(.secondaryColor) var body: some View { color