summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2024-11-28 09:56:30 +0100
committerBug Magnet <marco.nikic@mullvad.net>2024-11-28 09:56:30 +0100
commitc0667d4735235e3222ca754762dd2c126cd185dd (patch)
treeaca7b1913ec541e6a90ec29d8031991b75cbb022
parentb1b697f93681329688d159798451d0b420ae5f3a (diff)
parent85b91d0e466f305e7c8be5a48e16618ea1c3b361 (diff)
downloadmullvadvpn-c0667d4735235e3222ca754762dd2c126cd185dd.tar.xz
mullvadvpn-c0667d4735235e3222ca754762dd2c126cd185dd.zip
Merge branch 'IOS-881-shadowsocks-obfuscation-settings'
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj24
-rw-r--r--ios/MullvadVPN/UI appearance/UIColor+Palette.swift9
-rw-r--r--ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift72
-rw-r--r--ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift40
-rw-r--r--ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift22
-rw-r--r--ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift (renamed from ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift)12
-rw-r--r--ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsViewModel.swift (renamed from ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift)17
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift336
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift2
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift15
10 files changed, 492 insertions, 57 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index d4b7a3e1d0..8e60b70042 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -40,11 +40,13 @@
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 */; };
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 */; };
@@ -1392,11 +1394,13 @@
06FAE67A28F83CA50033DD93 /* RESTDevicesProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTDevicesProxy.swift; sourceTree = "<group>"; };
06FAE67B28F83CA50033DD93 /* REST.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = REST.swift; sourceTree = "<group>"; };
06FAE67D28F83CA50033DD93 /* RESTTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTTransport.swift; sourceTree = "<group>"; };
- 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsViewModel.swift; sourceTree = "<group>"; };
+ 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsViewModel.swift; sourceTree = "<group>"; };
440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPreviewWrapper.swift; sourceTree = "<group>"; };
440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = "<group>"; };
- 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsView.swift; sourceTree = "<group>"; };
+ 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsView.swift; sourceTree = "<group>"; };
4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceList.swift; sourceTree = "<group>"; };
+ 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsViewModel.swift; sourceTree = "<group>"; };
+ 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsView.swift; sourceTree = "<group>"; };
449275412C3570CA000526DE /* ICMP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICMP.swift; sourceTree = "<group>"; };
449275432C3C3029000526DE /* TunnelPinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelPinger.swift; sourceTree = "<group>"; };
449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = "<group>"; };
@@ -2602,9 +2606,11 @@
4422C06F2CCFF6520001A385 /* Obfuscation */ = {
isa = PBXGroup;
children = (
+ 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */,
+ 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */,
440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */,
- 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */,
- 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */,
+ 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */,
+ 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */,
);
path = Obfuscation;
sourceTree = "<group>";
@@ -5662,9 +5668,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 */,
@@ -5747,6 +5753,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 */,
@@ -5889,6 +5896,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 9d5cfe84bc..df7c675a41 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 0000000000..3c402a8605
--- /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<VM>: 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 {
+ let 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 0000000000..4d917496a6
--- /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 58e8144ad3..c83ff13028 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<T: Equatable>: Observabl
let keyPath: WritableKeyPath<WireGuardObfuscationSettings, T>
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<WireGuardObfuscationSettings, T>, _ initialValue: T) {
+ init(tunnelManager: TunnelManager, keyPath: WritableKeyPath<WireGuardObfuscationSettings, T>) {
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<T: Equatable>: 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/UDPOverTCPObfuscationSettingsView.swift
index 70769d71ee..c14cd3e709 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<VM>: View where VM: UDPTCPObfuscationSettingsViewModel {
+struct UDPOverTCPObfuscationSettingsView<VM>: View where VM: UDPOverTCPObfuscationSettingsViewModel {
@StateObject var viewModel: VM
var body: some View {
@@ -29,11 +29,13 @@ struct UDPTCPObfuscationSettingsView<VM>: View where VM: UDPTCPObfuscationSettin
value: "\(item)",
comment: ""
) }
- )
+ ).onDisappear {
+ viewModel.commit()
+ }
}
}
#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
index f712f0e644..1d2c7ac917 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,29 +9,32 @@
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) {
self.value = udpTcpPort
}
+
+ func commit() {}
}
/** 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,
- 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 6077539b5d..182e2c9885 100644
--- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift
@@ -9,30 +9,176 @@
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<Item>: 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
+ )
+
+ ```
+ */
+
+// swiftlint:disable function_parameter_count
+
+struct SingleChoiceList<Value>: View where Value: Equatable {
let title: String
- let options: [Item]
- var value: Binding<Item>
- let itemDescription: (Item) -> String
+ private let options: [OptionSpec]
+ var value: Binding<Value>
+ @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?
+ )
+ }
- init(title: String, options: [Item], value: Binding<Item>, itemDescription: ((Item) -> String)? = nil) {
+ let id: Int
+ let value: OptValue
+ }
+
+ // an internal constructor, building the element from basics
+ fileprivate init(
+ title: String,
+ optionSpecs: [OptionSpec.OptValue],
+ value: Binding<Value>,
+ 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<Value>, itemDescription: ((Value) -> String)? = nil) {
+ self.init(
+ title: title,
+ optionSpecs: options.map { .literal($0) },
+ value: value,
+ itemDescription: itemDescription
+ )
+ }
+
+ /// 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<Value>,
+ 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
+ )
}
- func row(_ item: Item) -> some View {
- let isSelected = value.wrappedValue == item
- return HStack {
+ // Construct a row with arbitrary content and the correct style
+ private func row<V: View>(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(
@@ -41,9 +187,123 @@ struct SingleChoiceList<Item>: 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
+ // swiftlint:disable function_body_length
+ 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
+ )
+ .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) { _ 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
+ }
+ }
+ }
+
+ // swiftlint:enable function_body_length
+
+ 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 {
@@ -54,17 +314,59 @@ struct SingleChoiceList<Item>: 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 {
+// swiftlint:enable function_parameter_count
+
+#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 f74717f1bc..6e33890235 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 f6628feaa9..23d64b8487 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",
@@ -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?) {