diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 31cab3122857..829cdc21f653 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -48,6 +48,8 @@ 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 */; }; + 4495ECD12D0B170700A7358B /* UDPOverTCPObfuscationSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4495ECD02D0B16F700A7358B /* UDPOverTCPObfuscationSettingsPage.swift */; }; + 4495ECD52D131A4800A7358B /* ShadowsocksObfuscationSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */; }; 449872E12B7BBC5400094DDC /* TunnelSettingsUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */; }; 449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */; }; 449EBA262B975B9700DFA4EB /* EphemeralPeerReceiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449EBA252B975B9700DFA4EB /* EphemeralPeerReceiving.swift */; }; @@ -1436,6 +1438,8 @@ 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 = ""; }; + 4495ECD02D0B16F700A7358B /* UDPOverTCPObfuscationSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsPage.swift; sourceTree = ""; }; + 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsPage.swift; sourceTree = ""; }; 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = ""; }; 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdateTests.swift; sourceTree = ""; }; 449EB9FC2B95F8AD00DFA4EB /* DeviceMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceMock.swift; sourceTree = ""; }; @@ -4193,8 +4197,10 @@ 8542F7522BCFBD050035C042 /* SelectLocationFilterPage.swift */, 850201DC2B503D8C00EF8C96 /* SelectLocationPage.swift */, 850201E22B51A93C00EF8C96 /* SettingsPage.swift */, + 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */, 852969392B4F0238007EAD4C /* TermsOfServicePage.swift */, 850201DE2B5040A500EF8C96 /* TunnelControlPage.swift */, + 4495ECD02D0B16F700A7358B /* UDPOverTCPObfuscationSettingsPage.swift */, 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */, 85FB5A0B2B6903990015DCED /* WelcomePage.swift */, ); @@ -6395,11 +6401,13 @@ 850201DD2B503D8C00EF8C96 /* SelectLocationPage.swift in Sources */, 85D039982BA4711800940E7F /* SettingsMigrationTests.swift in Sources */, 85021CAE2BDBC4290098B400 /* AppLogsPage.swift in Sources */, + 4495ECD12D0B170700A7358B /* UDPOverTCPObfuscationSettingsPage.swift in Sources */, 850201DB2B503D7700EF8C96 /* RelayTests.swift in Sources */, 7A45CFC62C05FF6A00D80B21 /* ScreenshotTests.swift in Sources */, 852D054D2BC3DE3A008578D2 /* APIAccessPage.swift in Sources */, 85139B2D2B84B4A700734217 /* OutOfTimePage.swift in Sources */, 852969362B4E9724007EAD4C /* AccessbilityIdentifier.swift in Sources */, + 4495ECD52D131A4800A7358B /* ShadowsocksObfuscationSettingsPage.swift in Sources */, 85E3BDE52B70E18C00FA71FD /* Networking.swift in Sources */, 856952E22BD6B04C008C1F84 /* XCUIElement+Extensions.swift in Sources */, 85C7A2E92B89024B00035D5A /* SettingsTests.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 5aa1e0c86823..86fc7df767dc 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -210,6 +210,10 @@ public enum AccessibilityIdentifier: Equatable { // Multihop case multihopSwitch + // WireGuard obfuscation settings + case wireGuardObfuscationUdpOverTcpTable + case wireGuardObfuscationShadowsocksTable + // Error case unknown } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift index e5434b06e701..4c4ddbad6c3d 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift @@ -24,6 +24,7 @@ struct ShadowsocksObfuscationSettingsView: View where VM: ShadowsocksObfusca title: portString, options: [WireGuardObfuscationShadowsocksPort.automatic], value: $viewModel.value, + tableAccessibilityIdentifier: AccessibilityIdentifier.wireGuardObfuscationShadowsocksTable.asString, itemDescription: { item in NSLocalizedString( "SHADOWSOCKS_PORT_VALUE_\(item)", tableName: "Shadowsocks", diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift index c14cd3e7090a..3d074a0d1dc3 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPOverTCPObfuscationSettingsView.swift @@ -23,6 +23,7 @@ struct UDPOverTCPObfuscationSettingsView: View where VM: UDPOverTCPObfuscati title: portString, options: [WireGuardObfuscationUdpOverTcpPort.automatic, .port80, .port5001], value: $viewModel.value, + tableAccessibilityIdentifier: AccessibilityIdentifier.wireGuardObfuscationUdpOverTcpTable.asString, itemDescription: { item in NSLocalizedString( "UDP_TCP_PORT_VALUE_\(item)", tableName: "UdpToTcp", diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift index 4f9ce77f263d..bdfba41480e5 100644 --- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift +++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift @@ -56,8 +56,8 @@ struct SingleChoiceList: View where Value: Equatable { private let options: [OptionSpec] var value: Binding @State var initialValue: Value? + let tableAccessibilityIdentifier: String let itemDescription: (Value) -> String - let itemAccessibilityIdentifier: (Value) -> String let customFieldMode: CustomFieldMode /// The configuration for the field for a custom value row @@ -84,7 +84,6 @@ struct SingleChoiceList: View where Value: Equatable { // 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, - accessibilityIdentifier: String, prompt: String, legend: String?, minInputWidth: CGFloat?, @@ -103,15 +102,15 @@ struct SingleChoiceList: View where Value: Equatable { title: String, optionSpecs: [OptionSpec.OptValue], value: Binding, + tableAccessibilityIdentifier: String?, itemDescription: ((Value) -> String)? = nil, - itemAccessibilityIdentifier: ((Value) -> String)? = nil, customFieldMode: CustomFieldMode = .freeText ) { self.title = title self.options = optionSpecs.enumerated().map { OptionSpec(id: $0.offset, value: $0.element) } self.value = value self.itemDescription = itemDescription ?? { "\($0)" } - self.itemAccessibilityIdentifier = itemAccessibilityIdentifier ?? { "\($0)" } + self.tableAccessibilityIdentifier = tableAccessibilityIdentifier ?? "SingleChoiceList" self.customFieldMode = customFieldMode self.initialValue = value.wrappedValue } @@ -122,11 +121,11 @@ struct SingleChoiceList: View where Value: Equatable { /// - 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. - /// - itemAccessibilityIdentifier: An optional function that, when given a `Value`, returns the accessibility identifier for the value's list item. If not provided, this will be generated naïvely using string interpolation. init( title: String, options: [Value], value: Binding, + tableAccessibilityIdentifier: String? = nil, itemDescription: ((Value) -> String)? = nil, itemAccessibilityIdentifier: ((Value) -> String)? = nil ) { @@ -134,8 +133,8 @@ struct SingleChoiceList: View where Value: Equatable { title: title, optionSpecs: options.map { .literal($0) }, value: value, - itemDescription: itemDescription, - itemAccessibilityIdentifier: itemAccessibilityIdentifier + tableAccessibilityIdentifier: tableAccessibilityIdentifier, + itemDescription: itemDescription ) } @@ -144,12 +143,11 @@ struct SingleChoiceList: View where Value: Equatable { /// - 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. + /// - tableAccessibilityIdentifier: an optional string value for the accessibility identifier of the table element enclosing the list. If not present, it will be "SingleChoiceList" /// - 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. - /// - itemAccessibilityIdentifier: An optional function that, when given a `Value`, returns the accessibility identifier for the value's list item. If not provided, this will be generated naïvely using string interpolation. /// - 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. - /// - customAccessibilityIdentifier: The accessibility identifier to use for the custom row. If not provided, "customValue" will be used. The accessibility identifier for the text field will be this value with ".input" appended. /// - 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 @@ -159,12 +157,11 @@ struct SingleChoiceList: View where Value: Equatable { title: String, options: [Value], value: Binding, + tableAccessibilityIdentifier: String? = nil, itemDescription: ((Value) -> String)? = nil, - itemAccessibilityIdentifier: ((Value) -> String)? = nil, parseCustomValue: @escaping ((String) -> Value?), formatCustomValue: @escaping ((Value) -> String?), customLabel: String, - customAccessibilityIdentifier: String = "customValue", customPrompt: String, customLegend: String? = nil, customInputMinWidth: CGFloat? = nil, @@ -175,7 +172,6 @@ struct SingleChoiceList: View where Value: Equatable { title: title, optionSpecs: options.map { .literal($0) } + [.custom( label: customLabel, - accessibilityIdentifier: customAccessibilityIdentifier, prompt: customPrompt, legend: customLegend, minInputWidth: customInputMinWidth, @@ -184,8 +180,8 @@ struct SingleChoiceList: View where Value: Equatable { fromValue: formatCustomValue )], value: value, + tableAccessibilityIdentifier: tableAccessibilityIdentifier, itemDescription: itemDescription, - itemAccessibilityIdentifier: itemAccessibilityIdentifier, customFieldMode: customFieldMode ) } @@ -220,14 +216,12 @@ struct SingleChoiceList: View where Value: Equatable { customValueIsFocused = false customValueInput = "" } - .accessibilityIdentifier(itemAccessibilityIdentifier(item)) } // Construct the one row with a custom input field for a custom value // swiftlint:disable function_body_length private func customRow( label: String, - accessibilityIdentifier: String, prompt: String, inputWidth: CGFloat?, maxInputLength: Int?, @@ -308,7 +302,6 @@ struct SingleChoiceList: View where Value: Equatable { customValueInput = valueText } } - .accessibilityIdentifier(accessibilityIdentifier + ".input") } .onTapGesture { if let v = toValue(customValueInput) { @@ -317,7 +310,6 @@ struct SingleChoiceList: View where Value: Equatable { customValueIsFocused = true } } - .accessibilityIdentifier(accessibilityIdentifier) } // swiftlint:enable function_body_length @@ -331,6 +323,10 @@ struct SingleChoiceList: View where Value: Equatable { } .padding(.horizontal, UIMetrics.SettingsCell.layoutMargins.leading) .padding(.vertical, 4) + .background( + Color(.secondaryColor) + ) + .foregroundColor(Color(UIColor.Cell.titleTextColor)) } var body: some View { @@ -341,34 +337,41 @@ struct SingleChoiceList: View where Value: Equatable { } .padding(EdgeInsets(UIMetrics.SettingsCell.layoutMargins)) .background(Color(UIColor.Cell.Background.normal)) - ForEach(options) { opt in - switch opt.value { - case let .literal(v): - literalRow(v) - case let .custom( - label, - accessibilityIdentifier, - prompt, - legend, - inputWidth, - maxInputLength, - toValue, - fromValue - ): - customRow( - label: label, - accessibilityIdentifier: accessibilityIdentifier, - prompt: prompt, - inputWidth: inputWidth, - maxInputLength: maxInputLength, - toValue: toValue, - fromValue: fromValue - ) - if let legend { - subtitleRow(legend) + List { + Section { + 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) + } + } } } + .listRowInsets(.init()) // remove insets } + .accessibilityIdentifier(tableAccessibilityIdentifier) + .listStyle(.plain) + .listRowSpacing(UIMetrics.TableView.separatorHeight) + .environment(\.defaultMinListRowHeight, 0) Spacer() } .padding(EdgeInsets(top: 24, leading: 0, bottom: 0, trailing: 0)) diff --git a/ios/MullvadVPNUITests/Pages/ShadowsocksObfuscationSettingsPage.swift b/ios/MullvadVPNUITests/Pages/ShadowsocksObfuscationSettingsPage.swift new file mode 100644 index 000000000000..0334c9e49f39 --- /dev/null +++ b/ios/MullvadVPNUITests/Pages/ShadowsocksObfuscationSettingsPage.swift @@ -0,0 +1,54 @@ +// +// ShadowsocksObfuscationSettingsPage.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2024-12-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +class ShadowsocksObfuscationSettingsPage: Page { + @discardableResult override init(_ app: XCUIApplication) { + super.init(app) + } + + private var table: XCUIElement { + app.collectionViews[AccessibilityIdentifier.wireGuardObfuscationShadowsocksTable] + } + + private func portCell(_ index: Int) -> XCUIElement { + table.cells.element(boundBy: index) + } + + private var customCell: XCUIElement { + // assumption: the last cell is the legend + table.cells.allElementsBoundByIndex.dropLast().last! + } + + private var customTextField: XCUIElement { + customCell.textFields.firstMatch + } + + @discardableResult func tapPortCell(_ index: Int) -> Self { + portCell(index).tap() + return self + } + + @discardableResult func tapCustomCell() -> Self { + customCell.tap() + return self + } + + @discardableResult func typeTextIntoCustomField(_ text: String) -> Self { + customTextField.typeText(text) + return self + } + + @discardableResult func tapBackButton() -> Self { + // Workaround for setting accessibility identifier on navigation bar button being non-trivial + app.navigationBars.buttons.element(boundBy: 0).tap() + return self + } +} diff --git a/ios/MullvadVPNUITests/Pages/UDPOverTCPObfuscationSettingsPage.swift b/ios/MullvadVPNUITests/Pages/UDPOverTCPObfuscationSettingsPage.swift new file mode 100644 index 000000000000..c133ce776ac2 --- /dev/null +++ b/ios/MullvadVPNUITests/Pages/UDPOverTCPObfuscationSettingsPage.swift @@ -0,0 +1,35 @@ +// +// UDPOverTCPObfuscationSettingsPage.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2024-12-12. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +class UDPOverTCPObfuscationSettingsPage: Page { + @discardableResult override init(_ app: XCUIApplication) { + super.init(app) + } + + private var table: XCUIElement { + app.collectionViews[AccessibilityIdentifier.wireGuardObfuscationUdpOverTcpTable] + } + + private func portCell(_ index: Int) -> XCUIElement { + table.cells.element(boundBy: index) + } + + @discardableResult func tapPortCell(_ index: Int) -> Self { + portCell(index).tap() + return self + } + + @discardableResult func tapBackButton() -> Self { + // Workaround for setting accessibility identifier on navigation bar button being non-trivial + app.navigationBars.buttons.element(boundBy: 0).tap() + return self + } +} diff --git a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift index 33a8023ca6c4..446960716852 100644 --- a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift +++ b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift @@ -19,7 +19,7 @@ class VPNSettingsPage: Page { _ subButtonAccessibilityIdentifier: AccessibilityIdentifier ) -> XCUIElement { let tableView = app.tables[AccessibilityIdentifier.vpnSettingsTableView] - let matchingCells = tableView.otherElements[cellAccessiblityIdentifier.asString] + let matchingCells = tableView.cells[cellAccessiblityIdentifier] let expandButton = matchingCells.buttons[subButtonAccessibilityIdentifier] let lastCell = tableView.cells.allElementsBoundByIndex.last! tableView.scrollDownToElement(element: lastCell) @@ -27,7 +27,12 @@ class VPNSettingsPage: Page { } private func cellExpandButton(_ cellAccessiblityIdentifier: AccessibilityIdentifier) -> XCUIElement { - return cellSubButton(cellAccessiblityIdentifier, .expandButton) + let tableView = app.tables[AccessibilityIdentifier.vpnSettingsTableView] + let matchingCells = tableView.otherElements[cellAccessiblityIdentifier] + let expandButton = matchingCells.buttons[.expandButton] + let lastCell = tableView.cells.allElementsBoundByIndex.last! + tableView.scrollDownToElement(element: lastCell) + return expandButton } private func cellPortSelectorButton(_ cellAccessiblityIdentifier: AccessibilityIdentifier) -> XCUIElement { diff --git a/ios/MullvadVPNUITests/SettingsMigrationTests.swift b/ios/MullvadVPNUITests/SettingsMigrationTests.swift index 581bba946dd0..0d4281b6824b 100644 --- a/ios/MullvadVPNUITests/SettingsMigrationTests.swift +++ b/ios/MullvadVPNUITests/SettingsMigrationTests.swift @@ -132,12 +132,22 @@ class SettingsMigrationTests: BaseUITestCase { .tapCustomWireGuardPortTextField() .enterText(wireGuardPort) .dismissKeyboard() - .tapWireGuardObfuscationExpandButton() - .tapWireGuardObfuscationUdpOverTcpCell() - .tapUDPOverTCPPortExpandButton() - .tapUDPOverTCPPort80Cell() .tapQuantumResistantTunnelExpandButton() .tapQuantumResistantTunnelOnCell() + .tapWireGuardObfuscationExpandButton() + .tapUDPOverTCPPortSelectorButton() + + UDPOverTCPObfuscationSettingsPage(app) + .tapPortCell(2) + .tapPortCell(1) + .tapBackButton() + VPNSettingsPage(app) + .tapShadowsocksPortSelectorButton() + ShadowsocksObfuscationSettingsPage(app) + .tapPortCell(0) + .tapCustomCell() + .enterText("1234") + .tapBackButton() } func testVerifySettingsStillChanged() {