diff options
| author | Mojgan <mojgan.jelodar@mullvad.net> | 2026-03-31 10:53:08 +0200 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2026-03-31 11:40:35 +0200 |
| commit | b422a9aa4b15f57094e73885fc667ac4fad7835b (patch) | |
| tree | b6701ebc138fcfea98d2b1f3980b391a164ba689 | |
| parent | 879212ebcc28d3ca4700cc29c5865d8ec0d22304 (diff) | |
| download | mullvadvpn-b422a9aa4b15f57094e73885fc667ac4fad7835b.tar.xz mullvadvpn-b422a9aa4b15f57094e73885fc667ac4fad7835b.zip | |
Change the search behaviour
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) } } } |
