summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMojgan <mojgan.jelodar@mullvad.net>2026-03-31 10:53:08 +0200
committerJon Petersson <jon.petersson@mullvad.net>2026-03-31 11:40:35 +0200
commitb422a9aa4b15f57094e73885fc667ac4fad7835b (patch)
treeb6701ebc138fcfea98d2b1f3980b391a164ba689
parent879212ebcc28d3ca4700cc29c5865d8ec0d22304 (diff)
downloadmullvadvpn-b422a9aa4b15f57094e73885fc667ac4fad7835b.tar.xz
mullvadvpn-b422a9aa4b15f57094e73885fc667ac4fad7835b.zip
Change the search behaviour
-rw-r--r--ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift78
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj12
-rw-r--r--ios/MullvadVPN/Extensions/String+FuzzyMatch.swift36
-rw-r--r--ios/MullvadVPN/Extensions/String+Search.swift72
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift146
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift2
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift9
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/RecentListDataSource.swift1
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift9
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift49
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift24
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift1
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift1
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/RecentLocationsListView.swift1
-rw-r--r--ios/MullvadVPN/Views/MullvadSecondaryTextField.swift2
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift78
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift20
17 files changed, 360 insertions, 181 deletions
diff --git a/ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift b/ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift
index 0f1ada0bdd..a19809b0b2 100644
--- a/ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift
+++ b/ios/MullvadMockData/MullvadREST/ServerRelaysResponse+Stubs.swift
@@ -99,6 +99,24 @@ public enum ServerRelaysResponseStubs {
latitude: 40.6963302,
longitude: -74.6034843
),
+ "hr-zag": REST.ServerLocation(
+ country: "Croatia",
+ city: "Zagreb",
+ latitude: 45.821,
+ longitude: 15.973
+ ),
+ "bg-sof": REST.ServerLocation(
+ country: "Bulgaria",
+ city: "Sofia",
+ latitude: 42.683333,
+ longitude: 23.316667
+ ),
+ "gr-ath": REST.ServerLocation(
+ country: "Greece",
+ city: "Athens",
+ latitude: 37.98381,
+ longitude: 23.727539
+ ),
],
wireguard: REST.ServerWireguardTunnels(
ipv4Gateway: .loopback,
@@ -289,6 +307,66 @@ public enum ServerRelaysResponseStubs {
shadowsocksExtraAddrIn: nil,
features: .init(daita: .init(), quic: nil, lwo: nil)
),
+ REST.ServerRelay(
+ hostname: "hr-zag-wg-001",
+ active: true,
+ owned: false,
+ location: "hr-zag",
+ provider: "DataPacket",
+ weight: 100,
+ ipv4AddrIn: .loopback,
+ ipv6AddrIn: .loopback,
+ publicKey: PrivateKey().publicKey.rawValue,
+ includeInCountry: true,
+ daita: false,
+ shadowsocksExtraAddrIn: nil,
+ features: .init(daita: .init(), quic: nil, lwo: nil)
+ ),
+ REST.ServerRelay(
+ hostname: "bg-sof-wg-001",
+ active: true,
+ owned: false,
+ location: "bg-sof",
+ provider: "M247",
+ weight: 100,
+ ipv4AddrIn: .loopback,
+ ipv6AddrIn: .loopback,
+ publicKey: PrivateKey().publicKey.rawValue,
+ includeInCountry: true,
+ daita: false,
+ shadowsocksExtraAddrIn: nil,
+ features: .init(daita: .init(), quic: nil, lwo: nil)
+ ),
+ REST.ServerRelay(
+ hostname: "gr-ath-wg-101",
+ active: true,
+ owned: false,
+ location: "gr-ath",
+ provider: "DataPacket",
+ weight: 100,
+ ipv4AddrIn: .loopback,
+ ipv6AddrIn: .loopback,
+ publicKey: PrivateKey().publicKey.rawValue,
+ includeInCountry: true,
+ daita: false,
+ shadowsocksExtraAddrIn: nil,
+ features: .init(daita: .init(), quic: nil, lwo: nil)
+ ),
+ REST.ServerRelay(
+ hostname: "us-nyc-wg-101",
+ active: true,
+ owned: false,
+ location: "us-nyc",
+ provider: "DataPacket",
+ weight: 100,
+ ipv4AddrIn: .loopback,
+ ipv6AddrIn: .loopback,
+ publicKey: PrivateKey().publicKey.rawValue,
+ includeInCountry: true,
+ daita: true,
+ shadowsocksExtraAddrIn: nil,
+ features: .init(daita: .init(), quic: nil, lwo: nil)
+ ),
],
shadowsocksPortRanges: shadowsocksPortRanges
),
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index c569876c76..af37d9ab48 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -463,7 +463,7 @@
58FF9FEA2B07653800E4C97D /* ButtonCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FE92B07653800E4C97D /* ButtonCellContentView.swift */; };
58FF9FF02B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FEF2B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift */; };
58FF9FF42B07C61B00E4C97D /* AccessMethodValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FF32B07C61B00E4C97D /* AccessMethodValidationError.swift */; };
- 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */; };
+ 7A09C98129D99215000C2CAC /* String+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+Search.swift */; };
7A0B311E2B303A0D004B12E0 /* AccessbilityIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0B311D2B303A0D004B12E0 /* AccessbilityIdentifier.swift */; };
7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */; };
7A0EAE9A2D01B41500D3EB8B /* MainButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0EAE992D01B41500D3EB8B /* MainButtonStyle.swift */; };
@@ -830,7 +830,7 @@
A9A5F9ED2ACB05160083449F /* NSRegularExpression+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */; };
A9A5F9EE2ACB05160083449F /* StorePaymentOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FAE67828F83CA50033DD93 /* StorePaymentOutcome.swift */; };
A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; };
- A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */; };
+ A9A5F9F02ACB05160083449F /* String+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+Search.swift */; };
A9A5F9F12ACB05160083449F /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */; };
A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C8191729FAA2C400DEB1B4 /* NotificationConfiguration.swift */; };
A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B75402668FD7700DEF7E9 /* AccountExpirySystemNotificationProvider.swift */; };
@@ -2143,7 +2143,7 @@
58FF9FEF2B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentAccessMethod+ViewModel.swift"; sourceTree = "<group>"; };
58FF9FF32B07C61B00E4C97D /* AccessMethodValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodValidationError.swift; sourceTree = "<group>"; };
7A02D4EA2A9CEC7A00C19E31 /* MullvadVPNScreenshots.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNScreenshots.xctestplan; sourceTree = "<group>"; };
- 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FuzzyMatch.swift"; sourceTree = "<group>"; };
+ 7A09C98029D99215000C2CAC /* String+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Search.swift"; sourceTree = "<group>"; };
7A0B311D2B303A0D004B12E0 /* AccessbilityIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessbilityIdentifier.swift; sourceTree = "<group>"; };
7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Coordinator+Router.swift"; sourceTree = "<group>"; };
7A0EAE992D01B41500D3EB8B /* MainButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainButtonStyle.swift; sourceTree = "<group>"; };
@@ -3583,7 +3583,7 @@
58B9EB142489139B00095626 /* RESTError+Display.swift */,
E158B35F285381C60002F069 /* String+AccountFormatting.swift */,
7AA564472F2B680D001D1FB9 /* String+Assets.swift */,
- 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */,
+ 7A09C98029D99215000C2CAC /* String+Search.swift */,
5807E2BF2432038B00F5FF30 /* String+Helpers.swift */,
58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */,
5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */,
@@ -6164,7 +6164,7 @@
7A9BE5A72B907EEC00E2A7D0 /* AllLocationDataSource.swift in Sources */,
A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */,
F9EDB26C2EC4C0480015DE36 /* CustomListInteractorTests.swift in Sources */,
- A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */,
+ A9A5F9F02ACB05160083449F /* String+Search.swift in Sources */,
F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */,
7A460FA02F35D05A005A265D /* NewAppVersionInAppNotificationProvider.swift in Sources */,
F0FA16152D7F3E16007E2546 /* Collection+Sorting.swift in Sources */,
@@ -6886,7 +6886,7 @@
7A5869B72B56B41500640D27 /* IPOverrideTextViewController.swift in Sources */,
58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */,
F96D04E92EC317B9004A4D48 /* ExitLocationView.swift in Sources */,
- 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */,
+ 7A09C98129D99215000C2CAC /* String+Search.swift in Sources */,
F02F41A12B9723AF00625A4F /* AddLocationsDataSource.swift in Sources */,
58EFC76E2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift in Sources */,
5827B0B92B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift in Sources */,
diff --git a/ios/MullvadVPN/Extensions/String+FuzzyMatch.swift b/ios/MullvadVPN/Extensions/String+FuzzyMatch.swift
deleted file mode 100644
index 2cc6531768..0000000000
--- a/ios/MullvadVPN/Extensions/String+FuzzyMatch.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-//
-// String+FuzzyMatch.swift
-// MullvadVPN
-//
-// Created by Jon Petersson on 2023-04-02.
-// Copyright © 2026 Mullvad VPN AB. All rights reserved.
-//
-
-import Foundation
-
-extension String {
- func fuzzyMatch(_ needle: String) -> Bool {
- guard !needle.isEmpty else { return false }
-
- let haystack = lowercased()
- let needle = needle.lowercased()
-
- var indices: [Index] = []
- var remainder = needle[...].utf8
-
- for index in haystack.utf8.indices {
- let character = haystack.utf8[index]
-
- if character == remainder[remainder.startIndex] {
- indices.append(index)
- remainder.removeFirst()
-
- if remainder.isEmpty {
- return !indices.isEmpty
- }
- }
- }
-
- return false
- }
-}
diff --git a/ios/MullvadVPN/Extensions/String+Search.swift b/ios/MullvadVPN/Extensions/String+Search.swift
new file mode 100644
index 0000000000..94ac7b98b4
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/String+Search.swift
@@ -0,0 +1,72 @@
+//
+// String+Search.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2023-04-02.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+enum SearchScore: Comparable {
+ case none
+ case score(Int)
+
+ static func < (lhs: SearchScore, rhs: SearchScore) -> Bool {
+ switch (lhs, rhs) {
+ case (.none, .none): return false
+ case (.none, _): return true
+ case (_, .none): return false
+ case (.score(let l), .score(let r)): return l < r
+ }
+ }
+}
+
+extension String {
+ func search(_ query: String) -> SearchScore {
+ guard !query.isEmpty else { return .none }
+
+ let text = self.localizedLowercase
+ let query = query.trimmingCharacters(in: .whitespacesAndNewlines).localizedLowercase
+
+ // Substring match
+ if let range = text.range(of: query) {
+ let index = text.distance(from: text.startIndex, to: range.lowerBound)
+
+ // Starts with
+ if index == 0 {
+ return .score(1000)
+ }
+
+ var currentIndex = 0
+ let words = text.split(separator: " ")
+ for word in words {
+ if word.hasPrefix(query) {
+ // earlier word = higher score
+ return .score(1000 - currentIndex)
+ }
+ currentIndex += word.count + 1
+ }
+
+ // Contains match
+ return .score(500 - index)
+ } else if fuzzyMatch(text, query: query) {
+ return .score(300)
+ }
+ return .none
+ }
+
+ private func fuzzyMatch(_ text: String, query: String) -> Bool {
+ var tIndex = text.startIndex
+
+ for char in query {
+ if let found = text[tIndex...].firstIndex(of: char) {
+ tIndex = text.index(after: found)
+ } else {
+ return false
+ }
+ }
+
+ return true
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift
index 30d4266f0a..d954adf0f7 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift
@@ -14,41 +14,95 @@ protocol SearchableLocationDataSource: LocationDataSourceProtocol {}
protocol LocationDataSourceProtocol {
var nodes: [LocationNode] { get }
+ var selectedNode: LocationNode? { get }
func node(by selectedRelays: UserSelectedRelays) -> LocationNode?
}
+extension LocationDataSourceProtocol {
+ var selectedNode: LocationNode? {
+ nodes
+ .flatMap { $0.flattened + [$0] }
+ .first { $0.isSelected }
+ }
+}
extension SearchableLocationDataSource {
- func search(by text: String) {
- nodes.forEachNode { node in
- node.isHiddenFromSearch = false
- node.showsChildren = false
- }
+ func search(by text: String) -> [LocationNode] {
guard !text.isEmpty else {
- return
+ return nodes
}
- nodes.forEach { node in
- _ = hideInSearch(
- node: node,
- searchText: text
- )
+ let results =
+ nodes
+ .compactMap { searchTree($0, searchText: text) }
+ .flatMap(flattenResults)
+ .sorted {
+ if $0.score != $1.score {
+ return $0.score > $1.score
+ }
+
+ if $0.bestMatchIsSelf != $1.bestMatchIsSelf {
+ return $0.bestMatchIsSelf
+ }
+
+ return $0.node.name < $1.node.name
+ }
+ .map { $0.node }
+ return results
+ }
+
+ private func searchTree(
+ _ node: LocationNode,
+ searchText: String
+ ) -> NodeResult? {
+ let isCustomListNode = node.asCustomListNode != nil
+ let name = isCustomListNode ? node.name : node.name.split(separator: "-").prefix(2).joined(separator: "-")
+ let selfScore = name.search(searchText)
+ let selfMatches = selfScore != .none
+
+ let childResults: [NodeResult] = node.children.compactMap({
+ searchTree($0, searchText: searchText)
+ })
+
+ if !selfMatches && childResults.isEmpty {
+ return nil
}
+
+ let bestChildScore = childResults.map(\.score).max() ?? .none
+ let bestScore = max(selfScore, bestChildScore)
+ let bestMatchIsSelf = selfScore >= bestChildScore
+
+ return NodeResult(
+ node: node,
+ score: bestScore,
+ matchedSelf: selfMatches,
+ bestMatchIsSelf: bestMatchIsSelf,
+ matchedChildren: childResults
+ )
}
- private func hideInSearch(node: LocationNode, searchText: String) -> Bool {
- let matchesSelf = node.name.fuzzyMatch(searchText)
- var childMatches = false
- for child in node.children where !hideInSearch(node: child, searchText: searchText) {
- childMatches = true
+ private func flattenResults(_ result: NodeResult) -> [NodeResult] {
+ let children = result.matchedChildren
+ let totalChildren = result.node.children.count
+
+ // Show the parent only if it matches AND it is the best match in its subtree
+ if result.matchedSelf, result.bestMatchIsSelf {
+ return [result]
}
- if matchesSelf && !childMatches {
- node.forEachDescendant { child in
- child.isHiddenFromSearch = false
- child.showsChildren = false
+
+ if !result.bestMatchIsSelf {
+ let matchedChildren = children.filter({ $0.score == result.score })
+ // Collapse if ALL children matched
+ if matchedChildren.count > 1 && matchedChildren.count == totalChildren {
+ return [result]
}
+ return matchedChildren.flatMap(flattenResults)
}
- node.isHiddenFromSearch = !matchesSelf && !childMatches
- node.showsChildren = childMatches
- return node.isHiddenFromSearch
+
+ // Repopulate node
+ if !children.isEmpty {
+ return children.flatMap(flattenResults)
+ }
+
+ return []
}
}
@@ -104,39 +158,6 @@ extension LocationDataSourceProtocol {
}
}
- func search(by text: String) {
- nodes.forEachNode { node in
- node.isHiddenFromSearch = false
- node.showsChildren = false
- }
- guard !text.isEmpty else {
- return
- }
- nodes.forEach { node in
- _ = hideInSearch(
- node: node,
- searchText: text
- )
- }
- }
-
- private func hideInSearch(node: LocationNode, searchText: String) -> Bool {
- let matchesSelf = node.name.fuzzyMatch(searchText)
- var childMatches = false
- for child in node.children where !hideInSearch(node: child, searchText: searchText) {
- childMatches = true
- }
- if matchesSelf && !childMatches {
- node.forEachDescendant { child in
- child.isHiddenFromSearch = false
- child.showsChildren = false
- }
- }
- node.isHiddenFromSearch = !matchesSelf && !childMatches
- node.showsChildren = childMatches
- return node.isHiddenFromSearch
- }
-
func descendantNode(
in rootNode: LocationNode,
for location: RelayLocation,
@@ -172,3 +193,16 @@ extension LocationDataSourceProtocol {
return codes
}
}
+private struct NodeResult {
+ let node: LocationNode
+ let score: SearchScore
+ let matchedSelf: Bool
+ let bestMatchIsSelf: Bool
+ let matchedChildren: [NodeResult]
+}
+
+extension Array where Element == LocationDataSourceProtocol {
+ var firstSelectedNode: LocationNode? {
+ return compactMap(\.selectedNode).first
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift
index 1be93d67e3..94ed8bfc25 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift
@@ -147,7 +147,7 @@ extension LocationDiffableDataSourceProtocol {
) -> [LocationCellViewModel] {
var viewModels = [LocationCellViewModel]()
- for childNode in node.children where !childNode.isHiddenFromSearch {
+ for childNode in node.children {
viewModels.append(
LocationCellViewModel(
section: section,
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift
index d23bca3261..7967f103d8 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift
@@ -18,7 +18,6 @@ class LocationNode: @unchecked Sendable {
weak var parent: LocationNode?
var children: [LocationNode]
var showsChildren: Bool
- var isHiddenFromSearch: Bool
var isConnected: Bool
var isSelected: Bool
var isExcluded: Bool
@@ -33,7 +32,6 @@ class LocationNode: @unchecked Sendable {
parent: LocationNode? = nil,
children: [LocationNode] = [],
showsChildren: Bool = false,
- isHiddenFromSearch: Bool = false,
isConnected: Bool = false,
isSelected: Bool = false,
isExcluded: Bool = false,
@@ -46,7 +44,6 @@ class LocationNode: @unchecked Sendable {
self.parent = parent
self.children = children
self.showsChildren = showsChildren
- self.isHiddenFromSearch = isHiddenFromSearch
self.isConnected = isConnected
self.isSelected = isSelected
self.isExcluded = isExcluded
@@ -64,7 +61,6 @@ class LocationNode: @unchecked Sendable {
parent: parent,
children: [],
showsChildren: showsChildren,
- isHiddenFromSearch: isHiddenFromSearch,
isConnected: isConnected,
isSelected: false, // explicity set to false since it's a different node
isExcluded: isExcluded,
@@ -212,7 +208,6 @@ class CustomListLocationNode: LocationNode, @unchecked Sendable {
parent: LocationNode? = nil,
children: [LocationNode] = [],
showsChildren: Bool = false,
- isHiddenFromSearch: Bool = false,
customList: CustomList
) {
self.customList = customList
@@ -225,7 +220,6 @@ class CustomListLocationNode: LocationNode, @unchecked Sendable {
parent: parent,
children: children,
showsChildren: showsChildren,
- isHiddenFromSearch: isHiddenFromSearch,
isOverridden: false
)
}
@@ -241,7 +235,6 @@ class CustomListLocationNode: LocationNode, @unchecked Sendable {
parent: parent,
children: [],
showsChildren: showsChildren,
- isHiddenFromSearch: isHiddenFromSearch,
customList: customList,
)
@@ -263,7 +256,6 @@ class RecentLocationNode: LocationNode, @unchecked Sendable {
parent: LocationNode? = nil,
children: [LocationNode] = [],
showsChildren: Bool = false,
- isHiddenFromSearch: Bool = false,
locationInfo: [String]?,
isIPOverriden: Bool = false
) {
@@ -277,7 +269,6 @@ class RecentLocationNode: LocationNode, @unchecked Sendable {
parent: parent,
children: children,
showsChildren: showsChildren,
- isHiddenFromSearch: isHiddenFromSearch,
isOverridden: isIPOverriden
)
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/RecentListDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/RecentListDataSource.swift
index 7040535e9f..285e4afd25 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/RecentListDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/RecentListDataSource.swift
@@ -42,7 +42,6 @@ class RecentListDataSource: LocationDataSourceProtocol {
parent: copiedNode.parent,
children: copiedNode.children,
showsChildren: false, // Recents shouldn't be expandable
- isHiddenFromSearch: true, // Recents shouldn't be searchable
locationInfo: allLocationNode?.pathToRoot()) // Store relay location info (country, city)
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift
index 45e4476f54..7ea377a0a2 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift
@@ -1,4 +1,5 @@
struct LocationContext {
+ var customListAvailableLocations: [LocationNode]
var recents: [LocationNode]
var locations: [LocationNode]
var customLists: [LocationNode]
@@ -6,6 +7,7 @@ struct LocationContext {
let selectLocation: (LocationNode) -> Void
var totalRelayCount: Int
var availableRelayCount: Int
+ var selectedLocation: LocationNode?
init(
recents: [LocationNode] = [],
@@ -25,12 +27,7 @@ struct LocationContext {
self.totalRelayCount = totalRelayCount
self.availableRelayCount = availableRelayCount
self.selectLocation = selectLocation
- }
-
- var selectedLocation: LocationNode? {
- (recents + customLists + locations)
- .flatMap { $0.flattened + [$0] }
- .first { $0.isSelected }
+ self.customListAvailableLocations = locations
}
var relaysAreFiltered: Bool { availableRelayCount < totalRelayCount }
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift
index db7e527d47..8cd3aff97e 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift
@@ -254,10 +254,10 @@ class SelectLocationViewModelImpl: SelectLocationViewModel {
switch multihopContext {
case .entry:
delegate
- .showEditCustomListView(entryContext.locations, customList)
+ .showEditCustomListView(entryContext.customListAvailableLocations, customList)
case .exit:
delegate
- .showEditCustomListView(exitContext.locations, customList)
+ .showEditCustomListView(exitContext.customListAvailableLocations, customList)
}
}
@@ -330,6 +330,7 @@ class SelectLocationViewModelImpl: SelectLocationViewModel {
exitLocationsDataSource
.reload(relaysCandidates.exitRelays.toLocationRelays())
exitContext.locations = exitLocationsDataSource.nodes
+ exitContext.customListAvailableLocations = exitLocationsDataSource.nodes
exitContext.availableRelayCount = relaysCandidates.exitRelays.count
if let entryRelays = relaysCandidates.entryRelays {
@@ -337,6 +338,7 @@ class SelectLocationViewModelImpl: SelectLocationViewModel {
.reload(entryRelays.toLocationRelays())
entryContext.locations =
entryLocationsDataSource.nodes
+ entryContext.customListAvailableLocations = entryLocationsDataSource.nodes
entryContext.availableRelayCount = entryRelays.count
}
} else {
@@ -354,10 +356,10 @@ class SelectLocationViewModelImpl: SelectLocationViewModel {
}
private func search(searchText: String) {
- exitLocationsDataSource.search(by: searchText)
- exitCustomListsDataSource.search(by: searchText)
- entryLocationsDataSource.search(by: searchText)
- entryCustomListsDataSource.search(by: searchText)
+ exitContext.locations = exitLocationsDataSource.search(by: searchText)
+ exitContext.customLists = exitCustomListsDataSource.search(by: searchText)
+ entryContext.locations = entryLocationsDataSource.search(by: searchText)
+ entryContext.customLists = entryCustomListsDataSource.search(by: searchText)
}
private func updateSelections() {
@@ -393,6 +395,11 @@ class SelectLocationViewModelImpl: SelectLocationViewModel {
updateRecentsDataSources(entryRecentsDataSource, selectedEntryRelays)
updateRecentsDataSources(exitRecentsDataSource, selectedExitRelays)
+
+ exitContext.selectedLocation =
+ [exitRecentsDataSource, exitCustomListsDataSource, exitLocationsDataSource].firstSelectedNode
+ entryContext.selectedLocation =
+ [entryRecentsDataSource, entryCustomListsDataSource, entryLocationsDataSource].firstSelectedNode
}
func didFinish() {
@@ -427,6 +434,36 @@ class SelectLocationViewModelImpl: SelectLocationViewModel {
updateConnectedLocations(tunnelManager.tunnelStatus)
}
}
+
+ private func updateRecents(
+ dataSource: LocationDataSourceProtocol,
+ selected: UserSelectedRelays
+ ) {
+ dataSource.setSelectedNode(selectedRelays: selected)
+ }
+
+ private func updateLocationDataSources(
+ dataSources: [LocationDataSourceProtocol],
+ selected: UserSelectedRelays,
+ excluded: UserSelectedRelays
+ ) {
+ if let dataSource = dataSources.first(where: { $0.node(by: selected) != nil }) {
+ dataSource.setSelectedNode(selectedRelays: selected)
+ dataSource.expandSelection()
+ }
+
+ guard isMultihopEnabled else { return }
+
+ dataSources.forEach {
+ $0.setExcludedNode(excludedSelection: excluded)
+ }
+ }
+
+ private func selectedNode(
+ from dataSources: [LocationDataSourceProtocol]
+ ) -> LocationNode? {
+ dataSources.firstSelectedNode
+ }
}
private extension LatestTunnelSettings {
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift
index 642daef724..c1ff7472b8 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift
@@ -5,19 +5,17 @@ struct ExitLocationView<ViewModel: SelectLocationViewModel>: View {
@Binding var context: LocationContext
@State var newCustomListAlert: MullvadInputAlert?
@State var alert: MullvadAlert?
+ private let topAnchor = "topAnchor"
let onScrollVisibilityChange: (Bool) -> Void
var isShowingCustomListsSection: Bool {
viewModel.searchText.isEmpty
|| (!viewModel.searchText.isEmpty
- && !context.customLists
- .filter {
- !$0.isHiddenFromSearch
- }.isEmpty)
+ && !context.customLists.isEmpty)
}
var isShowingAllLocationsSection: Bool {
- !context.locations.filter({ !$0.isHiddenFromSearch }).isEmpty
+ !context.locations.isEmpty
}
var isShowingRecentsSection: Bool {
@@ -28,8 +26,13 @@ struct ExitLocationView<ViewModel: SelectLocationViewModel>: View {
ScrollViewReader { scrollProxy in
// All items in the list are arranged in a flat hierarchy
List {
+ EmptyView()
+ .frame(height: 0)
+ .id(topAnchor)
+
Group {
- Color.clear.frame(height: 0)
+ Color.clear
+ .frame(height: 0)
.onAppear {
onScrollVisibilityChange(true)
}
@@ -81,6 +84,10 @@ struct ExitLocationView<ViewModel: SelectLocationViewModel>: View {
.onChange(of: viewModel.isRecentsEnabled) {
scrollToCurrentSelection(scrollProxy)
}
+ .onChange(of: viewModel.searchText) { oldValue, newValue in
+ guard oldValue.isEmpty && !newValue.isEmpty else { return }
+ scrollProxy.scrollTo(topAnchor, anchor: .center)
+ }
}
.mullvadInputAlert(item: $newCustomListAlert)
.mullvadAlert(item: $alert)
@@ -140,8 +147,7 @@ struct ExitLocationView<ViewModel: SelectLocationViewModel>: View {
MullvadListSectionHeader(title: "Custom lists")
Button {
viewModel.showAddCustomListView(
- locations: context
- .locations)
+ locations: context.customListAvailableLocations)
} label: {
Image.mullvadIconAdd
.padding(.horizontal, 10)
@@ -151,7 +157,7 @@ struct ExitLocationView<ViewModel: SelectLocationViewModel>: View {
if !context.customLists.isEmpty {
Button {
viewModel.showEditCustomListView(
- locations: context.locations
+ locations: context.customListAvailableLocations
)
} label: {
Image.mullvadIconEdit
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
index 41193d4bfb..e0bed32f72 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
@@ -12,7 +12,6 @@ struct LocationListItem<ContextMenu>: View where ContextMenu: View {
var filteredChildrenIndices: [Int] {
location.children
.enumerated()
- .filter { !$0.element.isHiddenFromSearch }
.map { $0.offset }
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift
index aff5af2093..916b173c93 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift
@@ -10,7 +10,6 @@ struct LocationsListView<ContextMenu>: View where ContextMenu: View {
var filteredLocationIndices: [Int] {
locations
.enumerated()
- .filter { !$0.element.isHiddenFromSearch }
.map { $0.offset }
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/RecentLocationsListView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/RecentLocationsListView.swift
index 75d0c1d3b0..82ebbd6acc 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/Views/RecentLocationsListView.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/RecentLocationsListView.swift
@@ -17,7 +17,6 @@ struct RecentLocationsListView<ContextMenu>: View where ContextMenu: View {
var filteredLocationIndices: [Int] {
locations
.enumerated()
- .filter { $0.element.isHiddenFromSearch }
.map { $0.offset }
}
diff --git a/ios/MullvadVPN/Views/MullvadSecondaryTextField.swift b/ios/MullvadVPN/Views/MullvadSecondaryTextField.swift
index 58e2a590f7..e2080b3252 100644
--- a/ios/MullvadVPN/Views/MullvadSecondaryTextField.swift
+++ b/ios/MullvadVPN/Views/MullvadSecondaryTextField.swift
@@ -20,6 +20,8 @@ struct MullvadSecondaryTextField: View {
isEnabled ? .MullvadTextField.inputPlaceholder : .MullvadTextField.textDisabled
)
)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
.focused($isFocused)
if !text.isEmpty && isEnabled {
Button {
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
index f06e253265..a27f4289b1 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
@@ -31,55 +31,61 @@ class AllLocationsDataSourceTests: XCTestCase {
}
func testSearchCity() throws {
- dataSource.search(by: "got")
- let rootNode = RootLocationNode(children: dataSource.nodes)
-
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "got"])?.isHiddenFromSearch == false)
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "sto"])?.isHiddenFromSearch == true)
+ let result = dataSource.search(by: "got")
+ let rootNode = RootLocationNode(children: result)
+ XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["se", "got"]))
+ XCTAssertNil(rootNode.descendantNodeFor(codes: ["se", "sto"]))
}
func testSearchShowsParentsAndChildrenIfBothMatch() throws {
- dataSource.search(by: "se")
- let rootNode = RootLocationNode(children: dataSource.nodes)
+ let result = dataSource.search(by: "se")
+ let rootNode = RootLocationNode(children: result)
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se"])?.isHiddenFromSearch == false)
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "got"])?.isHiddenFromSearch == false)
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se10-wireguard"])?.isHiddenFromSearch == false)
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "sto"])?.isHiddenFromSearch == false)
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se2-wireguard"])?.isHiddenFromSearch == false)
+ XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["se"]))
+ XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["se", "got"]))
+ XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["se10-wireguard"]))
+ XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["se", "sto"]))
+ XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["se2-wireguard"]))
}
- func testSearchCityExpandsParents() throws {
- dataSource.search(by: "Sweden")
- let rootNode = RootLocationNode(children: dataSource.nodes)
+ func testShowsParentIfChildrenMatchSearch() throws {
+ let result = dataSource.search(by: "se")
+ let rootNode = RootLocationNode(children: result)
let node = rootNode.descendantNodeFor(codes: ["se"])!
-
- node.forEachAncestor { location in
- XCTAssertFalse(location.isHiddenFromSearch)
- XCTAssertTrue(location.showsChildren)
- }
- XCTAssertFalse(node.isHiddenFromSearch)
+ let subNode = rootNode.descendantNodeFor(codes: ["se-"])
+ XCTAssertNil(subNode)
XCTAssertFalse(node.showsChildren)
}
- func testSearchCityIncludesChildren() throws {
- dataSource.search(by: "Sweden")
- let rootNode = RootLocationNode(children: dataSource.nodes)
- let node = rootNode.descendantNodeFor(codes: ["se"])!
+ func testRankSearchResultsCorrectly() throws {
+ let query = "gr"
+ let greece = "Greece"
+ let bulgaria = "Bulgaria"
+ let result = dataSource.search(by: query)
+ let greeceIndex = try XCTUnwrap(result.firstIndex(where: { $0.name == greece }))
+ let bulgariaIndex = try XCTUnwrap(result.firstIndex(where: { $0.name == bulgaria }))
+ XCTAssertLessThan(greeceIndex, bulgariaIndex)
+ }
- node.forEachDescendant { child in
- XCTAssertFalse(child.isHiddenFromSearch)
- XCTAssertFalse(child.showsChildren)
- }
- XCTAssertFalse(node.isHiddenFromSearch)
- XCTAssertFalse(node.showsChildren)
+ func testFilterCountryWhenCityMatches() throws {
+ let query = "za"
+ let zagreb = "Zagreb"
+ let result = dataSource.search(by: query)
+ let zagrebIndex = try XCTUnwrap(result.firstIndex(where: { $0.name == zagreb }))
+ XCTAssertNotNil(zagrebIndex)
}
- func testSearchWithEmptyText() throws {
- dataSource.search(by: "")
- dataSource.nodes.forEachNode {
- XCTAssertFalse($0.isHiddenFromSearch)
- }
+ func testAbbreviationsMatches() throws {
+ let query = "ny"
+ let newYork = "New York"
+ let result = dataSource.search(by: query)
+ let newYorkIndex = try XCTUnwrap(result.firstIndex(where: { $0.name.contains(newYork) }))
+ XCTAssertNotNil(newYorkIndex)
+ }
+
+ func testSearchWithEmptyText() {
+ let result = dataSource.search(by: "")
+ XCTAssertEqual(dataSource.nodes.count, result.count)
}
func testNodeByLocation() throws {
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
index e2a5b192ab..6720de8776 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
@@ -47,25 +47,21 @@ class CustomListsDataSourceTests: XCTestCase {
}
func testSearch() throws {
- dataSource.search(by: "got")
- let rootNode = RootLocationNode(children: dataSource.nodes)
-
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["Netflix", "se", "got"])?.isHiddenFromSearch == false)
- XCTAssertTrue(rootNode.descendantNodeFor(codes: ["Netflix", "se", "sto"])?.isHiddenFromSearch == true)
+ let result = dataSource.search(by: "got")
+ let rootNode = RootLocationNode(children: result)
+ XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["Netflix", "se", "got"]))
}
func testSearchWithEmptyText() throws {
- dataSource.search(by: "")
- dataSource.nodes.forEachNode {
- XCTAssertFalse($0.isHiddenFromSearch)
- }
+ let result = dataSource.search(by: "")
+ XCTAssertEqual(result.count, dataSource.nodes.count)
}
func testSearchYieldsNoListNodes() throws {
- dataSource.search(by: "net")
- dataSource.nodes.forEachNode {
+ let result = dataSource.search(by: "net")
+ result.forEachNode {
if $0.name == "Netflix" {
- XCTAssertFalse($0.isHiddenFromSearch)
+ XCTAssertFalse($0.showsChildren)
}
}
}