From 07fb6319632349c4ff1ccb1bef23b126505b099a Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Tue, 5 Nov 2024 13:01:03 +0100 Subject: [PATCH 01/10] Implement SwiftUI UI for UDP TCP Obfuscation port selector view --- .../VPNSettings/VPNSettingsCellFactory.swift | 13 +++++++++++++ .../VPNSettings/VPNSettingsDataSource.swift | 12 ++++++++++++ .../VPNSettings/VPNSettingsDataSourceDelegate.swift | 1 + 3 files changed, 26 insertions(+) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index 526fb1c16c8c..eee4103311bb 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -66,6 +66,19 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { cell.disclosureType = .chevron cell.accessibilityIdentifier = item.accessibilityIdentifier + case .udpTcpObfuscationSettings: + guard let cell = cell as? SettingsCell else { return } + + cell.titleLabel.text = NSLocalizedString( + "UDP_TCP_OBFUSCATION_CELL_LABEL", + tableName: "VPNSettings", + value: "UDP/TCP Obfuscation", + comment: "" + ) + + cell.disclosureType = .chevron + cell.accessibilityIdentifier = item.accessibilityIdentifier + case let .wireGuardPort(port): guard let cell = cell as? SelectableSettingsCell else { return } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index f74717f1bcae..d5f83c0ac081 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -22,6 +22,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardCustomPort case wireGuardObfuscation case wireGuardObfuscationOption + case udpTcpObfuscationSettings case wireGuardObfuscationPort case quantumResistance case multihop @@ -40,6 +41,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return SelectableSettingsDetailsCell.self case .wireGuardObfuscation: return SelectableSettingsCell.self + case .udpTcpObfuscationSettings: + return SettingsCell.self case .wireGuardObfuscationPort: return SelectableSettingsCell.self case .quantumResistance: @@ -74,6 +77,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case ipOverrides case wireGuardPort(_ port: UInt16?) case wireGuardCustomPort + case udpTcpObfuscationSettings case wireGuardObfuscationAutomatic case wireGuardObfuscationUdpOverTcp case wireGuardObfuscationShadowsocks @@ -127,6 +131,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .wireGuardPort case .wireGuardCustomPort: return .wireGuardCustomPort + case .udpTcpObfuscationSettings: + return .udpTcpObfuscationSettings case .wireGuardObfuscationAutomatic: return .wireGuardObfuscationAutomatic case .wireGuardObfuscationUdpOverTcp: @@ -158,6 +164,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .wireGuardPort case .wireGuardCustomPort: return .wireGuardCustomPort + case .udpTcpObfuscationSettings: + return .udpTcpObfuscationSettings case .wireGuardObfuscationAutomatic, .wireGuardObfuscationOff: return .wireGuardObfuscation case .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationShadowsocks: @@ -311,6 +319,10 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .wireGuardCustomPort: getCustomPortCell()?.textField.becomeFirstResponder() + case .udpTcpObfuscationSettings: + tableView.deselectRow(at: indexPath, animated: false) + delegate?.showUDPTCPObfuscationSettings() + case .wireGuardObfuscationAutomatic: selectObfuscationState(.automatic) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift index 98695d1f7fed..f86dbbeebb11 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift @@ -20,6 +20,7 @@ protocol VPNSettingsDataSourceDelegate: AnyObject { func showDetails(for: VPNSettingsDetailsButtonItem) func showDNSSettings() func showIPOverrides() + func showUDPTCPObfuscationSettings() func didSelectWireGuardPort(_ port: UInt16?) func humanReadablePortRepresentation() -> String } From b5704768612e3f8ef21e4309fb4ae8396debdcb0 Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Mon, 11 Nov 2024 15:28:49 +0100 Subject: [PATCH 02/10] Replace udpTcp with udpOverTcp --- .../VPNSettings/VPNSettingsCellFactory.swift | 2 +- .../VPNSettings/VPNSettingsDataSource.swift | 18 +++++++++--------- .../VPNSettingsDataSourceDelegate.swift | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index eee4103311bb..fe32ddd70f82 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -66,7 +66,7 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { cell.disclosureType = .chevron cell.accessibilityIdentifier = item.accessibilityIdentifier - case .udpTcpObfuscationSettings: + case .udpOverTcpObfuscationSettings: guard let cell = cell as? SettingsCell else { return } cell.titleLabel.text = NSLocalizedString( diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index d5f83c0ac081..852ef3f8d426 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -22,7 +22,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardCustomPort case wireGuardObfuscation case wireGuardObfuscationOption - case udpTcpObfuscationSettings + case udpOverTcpObfuscationSettings case wireGuardObfuscationPort case quantumResistance case multihop @@ -41,7 +41,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return SelectableSettingsDetailsCell.self case .wireGuardObfuscation: return SelectableSettingsCell.self - case .udpTcpObfuscationSettings: + case .udpOverTcpObfuscationSettings: return SettingsCell.self case .wireGuardObfuscationPort: return SelectableSettingsCell.self @@ -77,7 +77,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case ipOverrides case wireGuardPort(_ port: UInt16?) case wireGuardCustomPort - case udpTcpObfuscationSettings + case udpOverTcpObfuscationSettings case wireGuardObfuscationAutomatic case wireGuardObfuscationUdpOverTcp case wireGuardObfuscationShadowsocks @@ -131,8 +131,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .wireGuardPort case .wireGuardCustomPort: return .wireGuardCustomPort - case .udpTcpObfuscationSettings: - return .udpTcpObfuscationSettings + case .udpOverTcpObfuscationSettings: + return .udpOverTcpObfuscationSettings case .wireGuardObfuscationAutomatic: return .wireGuardObfuscationAutomatic case .wireGuardObfuscationUdpOverTcp: @@ -164,8 +164,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .wireGuardPort case .wireGuardCustomPort: return .wireGuardCustomPort - case .udpTcpObfuscationSettings: - return .udpTcpObfuscationSettings + case .udpOverTcpObfuscationSettings: + return .udpOverTcpObfuscationSettings case .wireGuardObfuscationAutomatic, .wireGuardObfuscationOff: return .wireGuardObfuscation case .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationShadowsocks: @@ -319,9 +319,9 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .wireGuardCustomPort: getCustomPortCell()?.textField.becomeFirstResponder() - case .udpTcpObfuscationSettings: + case .udpOverTcpObfuscationSettings: tableView.deselectRow(at: indexPath, animated: false) - delegate?.showUDPTCPObfuscationSettings() + delegate?.showUDPOverTCPObfuscationSettings() case .wireGuardObfuscationAutomatic: selectObfuscationState(.automatic) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift index f86dbbeebb11..3256e37fbdff 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift @@ -20,7 +20,7 @@ protocol VPNSettingsDataSourceDelegate: AnyObject { func showDetails(for: VPNSettingsDetailsButtonItem) func showDNSSettings() func showIPOverrides() - func showUDPTCPObfuscationSettings() + func showUDPOverTCPObfuscationSettings() func didSelectWireGuardPort(_ port: UInt16?) func humanReadablePortRepresentation() -> String } From 0bfa0aa3049a0ebcad96e09d542491fcd64d99ac Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Wed, 13 Nov 2024 00:07:20 +0100 Subject: [PATCH 03/10] Add changes from PR --- .../View controllers/VPNSettings/VPNSettingsCellFactory.swift | 2 +- .../View controllers/VPNSettings/VPNSettingsDataSource.swift | 2 +- .../VPNSettings/VPNSettingsDataSourceDelegate.swift | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index fe32ddd70f82..1fe60481d83d 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -72,7 +72,7 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { cell.titleLabel.text = NSLocalizedString( "UDP_TCP_OBFUSCATION_CELL_LABEL", tableName: "VPNSettings", - value: "UDP/TCP Obfuscation", + value: "UDP-over-TCP", comment: "" ) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index 852ef3f8d426..b5590112a727 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -321,7 +321,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .udpOverTcpObfuscationSettings: tableView.deselectRow(at: indexPath, animated: false) - delegate?.showUDPOverTCPObfuscationSettings() + delegate?.showDetails(for: .udpOverTcp) case .wireGuardObfuscationAutomatic: selectObfuscationState(.automatic) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift index 3256e37fbdff..98695d1f7fed 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSourceDelegate.swift @@ -20,7 +20,6 @@ protocol VPNSettingsDataSourceDelegate: AnyObject { func showDetails(for: VPNSettingsDetailsButtonItem) func showDNSSettings() func showIPOverrides() - func showUDPOverTCPObfuscationSettings() func didSelectWireGuardPort(_ port: UInt16?) func humanReadablePortRepresentation() -> String } From 14b0b6ad8e2e7cadf25c8e94397857b1104cecc5 Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Wed, 13 Nov 2024 13:08:57 +0100 Subject: [PATCH 04/10] Adjust spacing for UDP-over-TCP section --- .../View controllers/VPNSettings/VPNSettingsDataSource.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index b5590112a727..ad56443f3055 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -401,7 +401,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< let sectionIdentifier = snapshot().sectionIdentifiers[section] switch sectionIdentifier { - case .dnsSettings, .ipOverrides, .privacyAndSecurity: + case .dnsSettings, .ipOverrides, .privacyAndSecurity, .udpOverTcpObfuscationSettings: return .leastNonzeroMagnitude default: return tableView.estimatedRowHeight From c6f1d48bcb275e94767d2e9272a1fe4ac3aff5bc Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Thu, 14 Nov 2024 15:25:47 +0100 Subject: [PATCH 05/10] Move localisation out of SingleChoiceList to the client --- .../Settings/SwiftUI components/SingleChoiceList.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift index 6077539b5d28..adb0f7bd28f4 100644 --- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift +++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift @@ -19,6 +19,7 @@ struct SingleChoiceList: View where Item: Hashable { var value: Binding let itemDescription: (Item) -> String + init(title: String, options: [Item], value: Binding, itemDescription: ((Item) -> String)? = nil) { self.title = title self.options = options From 4f83de5a1446288b473d4a0099a12a23d806a76c Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Thu, 14 Nov 2024 16:14:21 +0100 Subject: [PATCH 06/10] Remove settings table view cell for UDP port selection menu --- .../VPNSettings/VPNSettingsCellFactory.swift | 13 ------------- .../VPNSettings/VPNSettingsDataSource.swift | 14 +------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift index 1fe60481d83d..526fb1c16c8c 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift @@ -66,19 +66,6 @@ final class VPNSettingsCellFactory: CellFactoryProtocol { cell.disclosureType = .chevron cell.accessibilityIdentifier = item.accessibilityIdentifier - case .udpOverTcpObfuscationSettings: - guard let cell = cell as? SettingsCell else { return } - - cell.titleLabel.text = NSLocalizedString( - "UDP_TCP_OBFUSCATION_CELL_LABEL", - tableName: "VPNSettings", - value: "UDP-over-TCP", - comment: "" - ) - - cell.disclosureType = .chevron - cell.accessibilityIdentifier = item.accessibilityIdentifier - case let .wireGuardPort(port): guard let cell = cell as? SelectableSettingsCell else { return } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index ad56443f3055..f74717f1bcae 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -22,7 +22,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case wireGuardCustomPort case wireGuardObfuscation case wireGuardObfuscationOption - case udpOverTcpObfuscationSettings case wireGuardObfuscationPort case quantumResistance case multihop @@ -41,8 +40,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return SelectableSettingsDetailsCell.self case .wireGuardObfuscation: return SelectableSettingsCell.self - case .udpOverTcpObfuscationSettings: - return SettingsCell.self case .wireGuardObfuscationPort: return SelectableSettingsCell.self case .quantumResistance: @@ -77,7 +74,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case ipOverrides case wireGuardPort(_ port: UInt16?) case wireGuardCustomPort - case udpOverTcpObfuscationSettings case wireGuardObfuscationAutomatic case wireGuardObfuscationUdpOverTcp case wireGuardObfuscationShadowsocks @@ -131,8 +127,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .wireGuardPort case .wireGuardCustomPort: return .wireGuardCustomPort - case .udpOverTcpObfuscationSettings: - return .udpOverTcpObfuscationSettings case .wireGuardObfuscationAutomatic: return .wireGuardObfuscationAutomatic case .wireGuardObfuscationUdpOverTcp: @@ -164,8 +158,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< return .wireGuardPort case .wireGuardCustomPort: return .wireGuardCustomPort - case .udpOverTcpObfuscationSettings: - return .udpOverTcpObfuscationSettings case .wireGuardObfuscationAutomatic, .wireGuardObfuscationOff: return .wireGuardObfuscation case .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationShadowsocks: @@ -319,10 +311,6 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .wireGuardCustomPort: getCustomPortCell()?.textField.becomeFirstResponder() - case .udpOverTcpObfuscationSettings: - tableView.deselectRow(at: indexPath, animated: false) - delegate?.showDetails(for: .udpOverTcp) - case .wireGuardObfuscationAutomatic: selectObfuscationState(.automatic) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) @@ -401,7 +389,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< let sectionIdentifier = snapshot().sectionIdentifiers[section] switch sectionIdentifier { - case .dnsSettings, .ipOverrides, .privacyAndSecurity, .udpOverTcpObfuscationSettings: + case .dnsSettings, .ipOverrides, .privacyAndSecurity: return .leastNonzeroMagnitude default: return tableView.estimatedRowHeight From cc3f74dc82ed13579d2249e42d42c1e670dc00b4 Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Mon, 11 Nov 2024 14:47:16 +0100 Subject: [PATCH 07/10] Implement SwiftUI Shadowsocks settings view with custom field editing --- ios/MullvadVPN.xcodeproj/project.pbxproj | 8 + .../UI appearance/UIColor+Palette.swift | 9 +- .../ShadowsocksObfuscationSettingsView.swift | 72 ++++ ...dowsocksObfuscationSettingsViewModel.swift | 40 +++ ...tionSettingsWatchingObservableObject.swift | 22 +- .../UDPTCPObfuscationSettingsView.swift | 4 +- .../UDPTCPObfuscationSettingsViewModel.swift | 7 +- .../SwiftUI components/SingleChoiceList.swift | 328 +++++++++++++++++- .../VPNSettings/VPNSettingsDataSource.swift | 2 - .../VPNSettingsViewController.swift | 11 +- 10 files changed, 465 insertions(+), 38 deletions(-) create mode 100644 ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift create mode 100644 ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 6ce2fca25e6f..cd24ad305aac 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -45,6 +45,8 @@ 440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */; }; 4422C0712CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */; }; 4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */; }; + 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */; }; + 447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */; }; 449275422C3570CA000526DE /* ICMP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449275412C3570CA000526DE /* ICMP.swift */; }; 449872E12B7BBC5400094DDC /* TunnelSettingsUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */; }; 449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */; }; @@ -1401,6 +1403,8 @@ 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = ""; }; 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsView.swift; sourceTree = ""; }; 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceList.swift; sourceTree = ""; }; + 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsViewModel.swift; sourceTree = ""; }; + 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsView.swift; sourceTree = ""; }; 449275412C3570CA000526DE /* ICMP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICMP.swift; sourceTree = ""; }; 449275432C3C3029000526DE /* TunnelPinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelPinger.swift; sourceTree = ""; }; 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = ""; }; @@ -2610,6 +2614,8 @@ 4422C06F2CCFF6520001A385 /* Obfuscation */ = { isa = PBXGroup; children = ( + 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */, + 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */, 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */, 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */, 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */, @@ -5763,6 +5769,7 @@ 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */, 7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */, + 447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */, 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */, 7A0B311E2B303A0D004B12E0 /* AccessbilityIdentifier.swift in Sources */, E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */, @@ -5905,6 +5912,7 @@ F02F41A22B9723AF00625A4F /* AddLocationsCoordinator.swift in Sources */, 7A27E3CD2CB814EF0088BCFF /* DAITAInfoView.swift in Sources */, F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */, + 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */, 7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */, 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */, 58CAFA002983FF0200BE19F7 /* LoginInteractor.swift in Sources */, diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift index 9d5cfe84bcb5..df7c675a415a 100644 --- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift +++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift @@ -31,18 +31,21 @@ extension UIColor { enum TextField { static let placeholderTextColor = UIColor(red: 0.16, green: 0.30, blue: 0.45, alpha: 0.40) + static let inactivePlaceholderTextColor = UIColor(white: 1.0, alpha: 0.4) static let textColor = UIColor(red: 0.16, green: 0.30, blue: 0.45, alpha: 1.0) + static let inactiveTextColor = UIColor.white static let backgroundColor = UIColor.white + static let inactiveBackgroundColor = UIColor(white: 1.0, alpha: 0.1) static let invalidInputTextColor = UIColor.dangerColor } enum SearchTextField { static let placeholderTextColor = TextField.placeholderTextColor - static let inactivePlaceholderTextColor = UIColor(white: 1.0, alpha: 0.4) + static let inactivePlaceholderTextColor = TextField.inactivePlaceholderTextColor static let textColor = TextField.textColor - static let inactiveTextColor = UIColor.white + static let inactiveTextColor = TextField.inactiveTextColor static let backgroundColor = TextField.backgroundColor - static let inactiveBackgroundColor = UIColor(white: 1.0, alpha: 0.1) + static let inactiveBackgroundColor = TextField.inactiveBackgroundColor static let leftViewTintColor = UIColor.primaryColor static let inactiveLeftViewTintColor = UIColor.white } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift new file mode 100644 index 000000000000..0c5184ecf712 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift @@ -0,0 +1,72 @@ +// +// ShadowsocksObfuscationSettingsView.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2024-11-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import SwiftUI + +struct ShadowsocksObfuscationSettingsView: View where VM: ShadowsocksObfuscationSettingsViewModel { + @StateObject var viewModel: VM + + var body: some View { + let portString = NSLocalizedString( + "SHADOWSOCKS_PORT_LABEL", + tableName: "Shadowsocks", + value: "Port", + comment: "" + ) + + SingleChoiceList( + title: portString, + options: [WireGuardObfuscationShadowsockPort.automatic], + value: $viewModel.value, + itemDescription: { item in NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_\(item)", + tableName: "Shadowsocks", + value: "\(item)", + comment: "" + ) }, + parseCustomValue: { UInt16($0).flatMap { $0 > 0 ? WireGuardObfuscationShadowsockPort.custom($0) : nil } + }, + formatCustomValue: { + if case let .custom(port) = $0 { + "\(port)" + } else { + nil + } + }, + customLabel: NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_CUSTOM", + tableName: "Shadowsocks", + value: "Custom", + comment: "" + ), + customPrompt: NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_PORT_PROMPT", + tableName: "Shadowsocks", + value: "Port", + comment: "" + ), + customLegend: NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_PORT_LEGEND", + tableName: "Shadowsocks", + value: "Valid range: 1 - 65535", + comment: "" + ), + customInputMinWidth: 100, + customInputMaxLength: 5, + customFieldMode: .numericText + ).onDisappear { + viewModel.commit() + } + } +} + +#Preview { + var model = MockShadowsocksObfuscationSettingsViewModel(shadowsocksPort: .automatic) + return ShadowsocksObfuscationSettingsView(viewModel: model) +} diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift new file mode 100644 index 000000000000..4d917496a6b0 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift @@ -0,0 +1,40 @@ +// +// ShadowsocksObfuscationSettingsViewModel.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2024-11-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +protocol ShadowsocksObfuscationSettingsViewModel: ObservableObject { + var value: WireGuardObfuscationShadowsockPort { get set } + + func commit() +} + +/** A simple mock view model for use in Previews and similar */ +class MockShadowsocksObfuscationSettingsViewModel: ShadowsocksObfuscationSettingsViewModel { + @Published var value: WireGuardObfuscationShadowsockPort + + init(shadowsocksPort: WireGuardObfuscationShadowsockPort = .automatic) { + self.value = shadowsocksPort + } + + func commit() {} +} + +/// ** The live view model which interfaces with the TunnelManager */ +class TunnelShadowsocksObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchingObservableObject< + WireGuardObfuscationShadowsockPort +>, + ShadowsocksObfuscationSettingsViewModel { + init(tunnelManager: TunnelManager) { + super.init( + tunnelManager: tunnelManager, + keyPath: \.shadowsocksPort + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift index 58e8144ad363..c83ff130284d 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift @@ -17,21 +17,12 @@ class TunnelObfuscationSettingsWatchingObservableObject: Observabl let keyPath: WritableKeyPath private var tunnelObserver: TunnelObserver? - // this is essentially @Published from scratch - var value: T { - willSet(newValue) { - guard newValue != self.value else { return } - objectWillChange.send() - var obfuscationSettings = tunnelManager.settings.wireGuardObfuscation - obfuscationSettings[keyPath: keyPath] = newValue - tunnelManager.updateSettings([.obfuscation(obfuscationSettings)]) - } - } + @Published var value: T - init(tunnelManager: TunnelManager, keyPath: WritableKeyPath, _ initialValue: T) { + init(tunnelManager: TunnelManager, keyPath: WritableKeyPath) { self.tunnelManager = tunnelManager self.keyPath = keyPath - self.value = initialValue + self.value = tunnelManager.settings.wireGuardObfuscation[keyPath: keyPath] tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in guard let self else { return } @@ -45,4 +36,11 @@ class TunnelObfuscationSettingsWatchingObservableObject: Observabl value = newValue } } + + // Commit the temporarily stored value upstream + func commit() { + var obfuscationSettings = tunnelManager.settings.wireGuardObfuscation + obfuscationSettings[keyPath: keyPath] = value + tunnelManager.updateSettings([.obfuscation(obfuscationSettings)]) + } } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift index 70769d71eef7..ac8abdb261eb 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift @@ -29,7 +29,9 @@ struct UDPTCPObfuscationSettingsView: View where VM: UDPTCPObfuscationSettin value: "\(item)", comment: "" ) } - ) + ).onDisappear { + viewModel.commit() + } } } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift index f712f0e644e8..5a4d595ae3da 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift @@ -11,6 +11,8 @@ import MullvadSettings protocol UDPTCPObfuscationSettingsViewModel: ObservableObject { var value: WireGuardObfuscationUdpOverTcpPort { get set } + + func commit() } /** A simple mock view model for use in Previews and similar */ @@ -20,6 +22,8 @@ class MockUDPTCPObfuscationSettingsViewModel: UDPTCPObfuscationSettingsViewModel init(udpTcpPort: WireGuardObfuscationUdpOverTcpPort = .automatic) { self.value = udpTcpPort } + + func commit() {} } /** The live view model which interfaces with the TunnelManager */ @@ -30,8 +34,7 @@ class TunnelUDPTCPObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchin init(tunnelManager: TunnelManager) { super.init( tunnelManager: tunnelManager, - keyPath: \.udpOverTcpPort, - tunnelManager.settings.wireGuardObfuscation.udpOverTcpPort + keyPath: \.udpOverTcpPort ) } } diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift index adb0f7bd28f4..f2265a6e068a 100644 --- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift +++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift @@ -9,31 +9,174 @@ import SwiftUI /** - A component presenting a vertical list in the Mullvad style for selecting a single item from a list. - The items can be any Hashable type. - */ + A component presenting a vertical list in the Mullvad style for selecting a single item from a list. + This is parametrised over a value type known as `Value`, which can be any Equatable type. One would typically use an `enum` for this. As the name suggests, this allows one value to be chosen, which it sets a provided binding to. -struct SingleChoiceList: View where Item: Hashable { + The simplest use case for `SingleChoiceList` is to present a list of options, each of which being a simple value without additional data; i.e., + + ```swift + SingleChoiceList( + title: "Colour", + options: [.red, .green, .blue], + value: $colour, + itemDescription: { NSLocalizedString("colour_\($0)") } + ) + ``` + + `SingleChoiceList` also provides support for having a value that takes a user-defined value, and presents a UI for filling this. In this case, the caller needs to provide not only the UI elements but functions for parsing the entered text to a value and unparsing the value to the text field, like so: + + ```swift + enum TipAmount { + case none + case fivePercent + case tenPercent + case custom(Int) + } + + SingleChoiceList( + title: "Tip", + options: [.none, .fivePercent, .tenPercent], + value: $tipAmount, + parseCustomValue: { Int($0).map { TipAmount.custom($0) }, + formatCustomValue: { + if case let .custom(t) = $0 { "\(t)" } else { nil } + }, + customLabel: "Custom", + customPrompt: "% ", + customFieldMode: .numericText + ) + + ``` + */ + +struct SingleChoiceList: View where Value: Equatable { let title: String - let options: [Item] - var value: Binding - let itemDescription: (Item) -> String + private let options: [OptionSpec] + var value: Binding + @State var initialValue: Value? + let itemDescription: (Value) -> String + let customFieldMode: CustomFieldMode + + /// The configuration for the field for a custom value row + enum CustomFieldMode { + /// The field is a text field into which any text may be typed + case freeText + /// The field is a text field configured for numeric input; i.e., the user will see a numeric keyboard + case numericText + } + + // Assumption: there will be only one custom value input per list. + // This makes sense if it's something like a port; if we ever need to + // use this with a type with more than one form of custom value, we will + // need to add some mitigations + @State var customValueInput = "" + @FocusState var customValueIsFocused: Bool + @State var customValueInputIsInvalid = false + + // an individual option being presented in a row + fileprivate struct OptionSpec: Identifiable { + enum OptValue { + // this row consists of a constant item with a fixed Value. It may only be selected as is + case literal(Value) + // this row consists of a text field into which the user can enter a custom value, which may yield a valid Value. This has accompanying text, and functions to translate between text field contents and the Value. (The fromValue method only needs to give a non-nil value if its input is a custom value that could have come from this row.) + case custom( + label: String, + prompt: String, + legend: String?, + minInputWidth: CGFloat?, + maxInputLength: Int?, + toValue: (String) -> Value?, + fromValue: (Value) -> String? + ) + } + let id: Int + let value: OptValue + } - init(title: String, options: [Item], value: Binding, itemDescription: ((Item) -> String)? = nil) { + // an internal constructor, building the element from basics + fileprivate init( + title: String, + optionSpecs: [OptionSpec.OptValue], + value: Binding, + itemDescription: ((Value) -> String)? = nil, + customFieldMode: CustomFieldMode = .freeText + ) { self.title = title - self.options = options + self.options = optionSpecs.enumerated().map { OptionSpec(id: $0.offset, value: $0.element) } self.value = value self.itemDescription = itemDescription ?? { "\($0)" } + self.customFieldMode = customFieldMode + self.initialValue = value.wrappedValue + } + + /// Create a `SingleChoiceList` presenting a choice of several fixed values. + /// + /// - Parameters: + /// - title: The title of the list, which is typically the name of the item being chosen. + /// - options: A list of `Value`s to be presented. + /// - itemDescription: An optional function that, when given a `Value`, returns the string representation to present in the list. If not provided, this will be generated naïvely using string interpolation. + init(title: String, options: [Value], value: Binding, itemDescription: ((Value) -> String)? = nil) { + self.init( + title: title, + optionSpecs: options.map { .literal($0) }, + value: value, + itemDescription: itemDescription + ) } - func row(_ item: Item) -> some View { - let isSelected = value.wrappedValue == item - return HStack { + /// Create a `SingleChoiceList` presenting a choice of several fixed values, plus a row where the user may enter an argument for a custom value. + /// + /// - Parameters: + /// - title: The title of the list, which is typically the name of the item being chosen. + /// - options: A list of fixed `Value`s to be presented. + /// - itemDescription: An optional function that, when given a `Value`, returns the string representation to present in the list. If not provided, this will be generated naïvely using string interpolation. This is only used for the non-custom values. + /// - parseCustomValue: A function that attempts to parse the text entered into the text field and produce a `Value` (typically the tagged custom value with an argument applied to it). If the text is not valid for a value, it should return `nil` + /// - formatCustomValue: A function that, when passed a `Value` containing user-entered custom data, formats that data into a string, which should match what the user would have entered. This function can expect to only be called for the custom value, and should return `nil` in the event of its argument not being a valid custom value. + /// - customLabel: The caption to display in the custom row, next to the text field. + /// - customPrompt: The text to display, greyed, in the text field when it is empty. This also serves to set the width of the field, and should be right-padded with spaces as appropriate. + /// - customLegend: Optional text to display below the custom field, i.e., to explain sensible values + /// - customInputWidth: An optional minimum width (in pseudo-pixels) for the custom input field + /// - customInputMaxLength: An optional maximum length to which input is truncated + /// - customFieldMode: An enumeration that sets the mode of the custom value entry text field. If this is `.numericText`, the data is expected to be a decimal number, and the device will present a numeric keyboard when the field is focussed. If it is `.freeText`, a standard alphanumeric keyboard will be presented. If not specified, this defaults to `.freeText`. + init( + title: String, + options: [Value], + value: Binding, + itemDescription: ((Value) -> String)? = nil, + parseCustomValue: @escaping ((String) -> Value?), + formatCustomValue: @escaping ((Value) -> String?), + customLabel: String, + customPrompt: String, + customLegend: String? = nil, + customInputMinWidth: CGFloat? = nil, + customInputMaxLength: Int? = nil, + customFieldMode: CustomFieldMode = .freeText + ) { + self.init( + title: title, + optionSpecs: options.map { .literal($0) } + [.custom( + label: customLabel, + prompt: customPrompt, + legend: customLegend, + minInputWidth: customInputMinWidth, + maxInputLength: customInputMaxLength, + toValue: parseCustomValue, + fromValue: formatCustomValue + )], + value: value, + itemDescription: itemDescription, + customFieldMode: customFieldMode + ) + } + + // Construct a row with arbitrary content and the correct style + private func row(isSelected: Bool, @ViewBuilder items: () -> V) -> some View { + HStack { Image(uiImage: UIImage(resource: .iconTick)).opacity(isSelected ? 1.0 : 0.0) Spacer().frame(width: UIMetrics.SettingsCell.selectableSettingsCellLeftViewSpacing) - Text(verbatim: itemDescription(item)) - Spacer() + + items() } .padding(EdgeInsets(UIMetrics.SettingsCell.layoutMargins)) .background( @@ -42,9 +185,120 @@ struct SingleChoiceList: View where Item: Hashable { : Color(UIColor.Cell.Background.indentationLevelOne) ) .foregroundColor(Color(UIColor.Cell.titleTextColor)) + } + + // Construct a literal row for a specific literal value + private func literalRow(_ item: Value) -> some View { + row( + isSelected: value.wrappedValue == item && !customValueIsFocused + ) { + Text(verbatim: itemDescription(item)) + Spacer() + } .onTapGesture { value.wrappedValue = item + customValueIsFocused = false + customValueInput = "" + } + } + + // Construct the one row with a custom input field for a custom value + private func customRow( + label: String, + prompt: String, + inputWidth: CGFloat?, + maxInputLength: Int?, + toValue: @escaping (String) -> Value?, + fromValue: @escaping (Value) -> String? + ) -> some View { + row( + isSelected: value.wrappedValue == toValue(customValueInput) || customValueIsFocused + ) { + Text(label) + Spacer() + TextField( + "value", + text: $customValueInput, + prompt: Text(prompt).foregroundColor( + customValueIsFocused + ? Color(UIColor.TextField.placeholderTextColor) + : Color(UIColor.TextField.inactivePlaceholderTextColor) + ) + ) + .keyboardType(customFieldMode == .numericText ? .numberPad : .default) + .multilineTextAlignment( + customFieldMode == .numericText + ? .trailing + : .leading/*@END_MENU_TOKEN@*/ + ) + .frame(minWidth: inputWidth, maxWidth: .infinity) + .fixedSize() + .padding(4) + .foregroundColor( + customValueIsFocused + ? customValueInputIsInvalid + ? Color(UIColor.TextField.invalidInputTextColor) + : Color(UIColor.TextField.textColor) + : Color(UIColor.TextField.inactiveTextColor) + ) + .background( + customValueIsFocused + ? Color(UIColor.TextField.backgroundColor) + : Color(UIColor.TextField.inactiveBackgroundColor) + ) + .cornerRadius(4.0) + // .border doesn't honour .cornerRadius, so overlaying a RoundedRectangle is necessary + .overlay( + RoundedRectangle(cornerRadius: 4.0) + .stroke( + customValueInputIsInvalid ? Color(UIColor.TextField.invalidInputTextColor) : .clear, + lineWidth: 1 + ) + ) + .focused($customValueIsFocused) + .onChange(of: customValueInput) { newValue in + if let maxInputLength { + if customValueInput.count > maxInputLength { + customValueInput = String(customValueInput.prefix(maxInputLength)) + } + } + if let parsedValue = toValue(customValueInput) { + value.wrappedValue = parsedValue + customValueInputIsInvalid = false + } else { + // this is not a valid value, so we fall back to the + // initial value, showing the invalid-value state if + // the field is not empty + if let initialValue { + value.wrappedValue = initialValue + } + customValueInputIsInvalid = !customValueInput.isEmpty + } + } + .onAppear { + if let valueText = fromValue(value.wrappedValue) { + customValueInput = valueText + } + } + } + .onTapGesture { + if let v = toValue(customValueInput) { + value.wrappedValue = v + } else { + customValueIsFocused = true + } + } + } + + private func subtitleRow(_ text: String) -> some View { + HStack { + Text(text) + .font(.callout) + .opacity(0.6) + Spacer() } + .padding(.horizontal, UIMetrics.SettingsCell.layoutMargins.leading) + .padding(.vertical, 4) } var body: some View { @@ -55,17 +309,57 @@ struct SingleChoiceList: View where Item: Hashable { } .padding(EdgeInsets(UIMetrics.SettingsCell.layoutMargins)) .background(Color(UIColor.Cell.Background.normal)) - ForEach(options, id: \.self) { opt in - row(opt) + ForEach(options) { opt in + switch opt.value { + case let .literal(v): + literalRow(v) + case let .custom(label, prompt, legend, inputWidth, maxInputLength, toValue, fromValue): + customRow( + label: label, + prompt: prompt, + inputWidth: inputWidth, + maxInputLength: maxInputLength, + toValue: toValue, + fromValue: fromValue + ) + if let legend { + subtitleRow(legend) + } + } } Spacer() } .padding(EdgeInsets(top: 24, leading: 0, bottom: 0, trailing: 0)) .background(Color(.secondaryColor)) .foregroundColor(Color(.primaryTextColor)) + .onAppear { + initialValue = value.wrappedValue + } } } -#Preview { +#Preview("Static values") { StatefulPreviewWrapper(1) { SingleChoiceList(title: "Test", options: [1, 2, 3], value: $0) } } + +#Preview("Optional value") { + enum ExampleValue: Equatable { + case two + case three + case someNumber(Int) + } + return StatefulPreviewWrapper(ExampleValue.two) { SingleChoiceList( + title: "Test", + options: [.two, .three], + value: $0, + parseCustomValue: { Int($0).flatMap { $0 > 3 ? ExampleValue.someNumber($0) : nil } }, + formatCustomValue: { if case let .someNumber(n) = $0 { "\(n)" } else { nil } }, + customLabel: "Custom", + customPrompt: "Number", + customLegend: "The legend goes here", + customInputMinWidth: 120, + customInputMaxLength: 6, + customFieldMode: .numericText + ) + } +} diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index f74717f1bcae..6e33890235db 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -317,11 +317,9 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .wireGuardObfuscationUdpOverTcp: selectObfuscationState(.udpOverTcp) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) - // TODO: When ready, add implementation for selected obfuscation (navigate to new view etc). case .wireGuardObfuscationShadowsocks: selectObfuscationState(.shadowsocks) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) - // TODO: When ready, add implementation for selected obfuscation (navigate to new view etc). case .wireGuardObfuscationOff: selectObfuscationState(.off) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift index f6628feaa9e3..916956c76d5f 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift @@ -144,7 +144,16 @@ extension VPNSettingsViewController: VPNSettingsDataSourceDelegate { } private func showShadowsocksObfuscationSettings() { - // TODO: + let viewModel = TunnelShadowsocksObfuscationSettingsViewModel(tunnelManager: interactor.tunnelManager) + let view = ShadowsocksObfuscationSettingsView(viewModel: viewModel) + let vc = UIHostingController(rootView: view) + vc.title = NSLocalizedString( + "SHADOWSOCKS_TITLE", + tableName: "VPNSettings", + value: "Shadowsocks", + comment: "" + ) + navigationController?.pushViewController(vc, animated: true) } func didSelectWireGuardPort(_ port: UInt16?) { From c0f46a4c99fac5e76d5e1c937b8805e428ce01d2 Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Wed, 27 Nov 2024 11:48:56 +0100 Subject: [PATCH 08/10] Make minor fixes --- .../Obfuscation/ShadowsocksObfuscationSettingsView.swift | 2 +- .../Settings/SwiftUI components/SingleChoiceList.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift index 0c5184ecf712..3c402a860519 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift @@ -67,6 +67,6 @@ struct ShadowsocksObfuscationSettingsView: View where VM: ShadowsocksObfusca } #Preview { - var model = MockShadowsocksObfuscationSettingsViewModel(shadowsocksPort: .automatic) + let model = MockShadowsocksObfuscationSettingsViewModel(shadowsocksPort: .automatic) return ShadowsocksObfuscationSettingsView(viewModel: model) } diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift index f2265a6e068a..7f6cab3da3bd 100644 --- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift +++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift @@ -229,7 +229,7 @@ struct SingleChoiceList: View where Value: Equatable { .multilineTextAlignment( customFieldMode == .numericText ? .trailing - : .leading/*@END_MENU_TOKEN@*/ + : .leading ) .frame(minWidth: inputWidth, maxWidth: .infinity) .fixedSize() From a84f1dc2f654e18581aa9e6148bc309f5cfad241 Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Wed, 27 Nov 2024 11:52:30 +0100 Subject: [PATCH 09/10] Rename instances of UDPTCP* to UDPOverTCP* --- ios/MullvadVPN.xcodeproj/project.pbxproj | 16 ++++++++-------- ...t => UDPOverTCPObfuscationSettingsView.swift} | 8 ++++---- ...UDPOverTCPObfuscationSettingsViewModel.swift} | 10 +++++----- .../VPNSettings/VPNSettingsViewController.swift | 4 ++-- 4 files changed, 19 insertions(+), 19 deletions(-) rename ios/MullvadVPN/View controllers/Settings/Obfuscation/{UDPTCPObfuscationSettingsView.swift => UDPOverTCPObfuscationSettingsView.swift} (75%) rename ios/MullvadVPN/View controllers/Settings/Obfuscation/{UDPTCPObfuscationSettingsViewModel.swift => UDPOverTCPObfuscationSettingsViewModel.swift} (68%) diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index cd24ad305aac..bd5e718b0fe8 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -40,10 +40,10 @@ 06799AFC28F98EE300ACD94E /* AddressCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114128F8413A0037AF9A /* AddressCache.swift */; }; 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0697D6E628F01513007A9E99 /* TransportMonitor.swift */; }; 06AC116228F94C450037AF9A /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; }; - 44075DFB2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */; }; + 44075DFB2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */; }; 440E5AB02CDBD67D00B09614 /* StatefulPreviewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */; }; 440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */; }; - 4422C0712CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */; }; + 4422C0712CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */; }; 4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */; }; 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */; }; 447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */; }; @@ -1398,10 +1398,10 @@ 06FAE67A28F83CA50033DD93 /* RESTDevicesProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTDevicesProxy.swift; sourceTree = ""; }; 06FAE67B28F83CA50033DD93 /* REST.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = REST.swift; sourceTree = ""; }; 06FAE67D28F83CA50033DD93 /* RESTTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTTransport.swift; sourceTree = ""; }; - 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsViewModel.swift; sourceTree = ""; }; + 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsViewModel.swift; sourceTree = ""; }; 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPreviewWrapper.swift; sourceTree = ""; }; 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = ""; }; - 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsView.swift; sourceTree = ""; }; + 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsView.swift; sourceTree = ""; }; 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceList.swift; sourceTree = ""; }; 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsViewModel.swift; sourceTree = ""; }; 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsView.swift; sourceTree = ""; }; @@ -2617,8 +2617,8 @@ 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */, 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */, 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */, - 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */, - 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */, + 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */, + 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */, ); path = Obfuscation; sourceTree = ""; @@ -5684,9 +5684,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 44075DFB2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift in Sources */, + 44075DFB2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift in Sources */, 7A6389DC2B7E3BD6008E77E1 /* CustomListViewModel.swift in Sources */, - 4422C0712CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift in Sources */, + 4422C0712CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift in Sources */, 7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */, 5827B0A42B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift in Sources */, 586C0D852B03D31E00E7CDD7 /* SocksSectionHandler.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift similarity index 75% rename from ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift rename to ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift index ac8abdb261eb..c14cd3e7090a 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift @@ -1,5 +1,5 @@ // -// UDPTCPObfuscationSettingsView.swift +// UDPOverTCPObfuscationSettingsView.swift // MullvadVPN // // Created by Andrew Bulhak on 2024-10-28. @@ -9,7 +9,7 @@ import MullvadSettings import SwiftUI -struct UDPTCPObfuscationSettingsView: View where VM: UDPTCPObfuscationSettingsViewModel { +struct UDPOverTCPObfuscationSettingsView: View where VM: UDPOverTCPObfuscationSettingsViewModel { @StateObject var viewModel: VM var body: some View { @@ -36,6 +36,6 @@ struct UDPTCPObfuscationSettingsView: View where VM: UDPTCPObfuscationSettin } #Preview { - let model = MockUDPTCPObfuscationSettingsViewModel(udpTcpPort: .port5001) - return UDPTCPObfuscationSettingsView(viewModel: model) + let model = MockUDPOverTCPObfuscationSettingsViewModel(udpTcpPort: .port5001) + return UDPOverTCPObfuscationSettingsView(viewModel: model) } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsViewModel.swift similarity index 68% rename from ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift rename to ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsViewModel.swift index 5a4d595ae3da..1d2c7ac9171e 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsViewModel.swift @@ -1,5 +1,5 @@ // -// UDPTCPObfuscationSettingsViewModel.swift +// UDPOverTCPObfuscationSettingsViewModel.swift // MullvadVPN // // Created by Andrew Bulhak on 2024-11-05. @@ -9,14 +9,14 @@ import Foundation import MullvadSettings -protocol UDPTCPObfuscationSettingsViewModel: ObservableObject { +protocol UDPOverTCPObfuscationSettingsViewModel: ObservableObject { var value: WireGuardObfuscationUdpOverTcpPort { get set } func commit() } /** A simple mock view model for use in Previews and similar */ -class MockUDPTCPObfuscationSettingsViewModel: UDPTCPObfuscationSettingsViewModel { +class MockUDPOverTCPObfuscationSettingsViewModel: UDPOverTCPObfuscationSettingsViewModel { @Published var value: WireGuardObfuscationUdpOverTcpPort init(udpTcpPort: WireGuardObfuscationUdpOverTcpPort = .automatic) { @@ -27,10 +27,10 @@ class MockUDPTCPObfuscationSettingsViewModel: UDPTCPObfuscationSettingsViewModel } /** The live view model which interfaces with the TunnelManager */ -class TunnelUDPTCPObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchingObservableObject< +class TunnelUDPOverTCPObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchingObservableObject< WireGuardObfuscationUdpOverTcpPort >, - UDPTCPObfuscationSettingsViewModel { + UDPOverTCPObfuscationSettingsViewModel { init(tunnelManager: TunnelManager) { super.init( tunnelManager: tunnelManager, diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift index 916956c76d5f..23d64b848785 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift @@ -131,8 +131,8 @@ extension VPNSettingsViewController: VPNSettingsDataSourceDelegate { } private func showUDPOverTCPObfuscationSettings() { - let viewModel = TunnelUDPTCPObfuscationSettingsViewModel(tunnelManager: interactor.tunnelManager) - let view = UDPTCPObfuscationSettingsView(viewModel: viewModel) + let viewModel = TunnelUDPOverTCPObfuscationSettingsViewModel(tunnelManager: interactor.tunnelManager) + let view = UDPOverTCPObfuscationSettingsView(viewModel: viewModel) let vc = UIHostingController(rootView: view) vc.title = NSLocalizedString( "UDP_OVER_TCP_TITLE", From 861749d61e687a8494dd39cfd3574af4816bc1dc Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Wed, 27 Nov 2024 12:09:25 +0100 Subject: [PATCH 10/10] Make SwiftLint happy --- .../Settings/SwiftUI components/SingleChoiceList.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift index 7f6cab3da3bd..1302921d188a 100644 --- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift +++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift @@ -49,6 +49,8 @@ import SwiftUI ``` */ +// swiftlint:disable function_parameter_count + struct SingleChoiceList: View where Value: Equatable { let title: String private let options: [OptionSpec] @@ -203,6 +205,7 @@ struct SingleChoiceList: View where Value: Equatable { } // Construct the one row with a custom input field for a custom value + // swiftlint:disable function_body_length private func customRow( label: String, prompt: String, @@ -256,7 +259,7 @@ struct SingleChoiceList: View where Value: Equatable { ) ) .focused($customValueIsFocused) - .onChange(of: customValueInput) { newValue in + .onChange(of: customValueInput) { _ in if let maxInputLength { if customValueInput.count > maxInputLength { customValueInput = String(customValueInput.prefix(maxInputLength))