summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@mullvad.net>2025-03-28 15:10:23 +0100
committerJon Petersson <jon.petersson@mullvad.net>2025-03-31 10:28:54 +0200
commit224a064617dd19e45d7a51aa9b3f277631e96a35 (patch)
tree71dd52fe05e43386fa21bf879e6fb82a3bcc6ec0
parent5b84342bba0dfe78acbcef3ccea3f4f14c14ae43 (diff)
downloadmullvadvpn-search-anything.tar.xz
mullvadvpn-search-anything.zip
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj4
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift1
-rw-r--r--ios/MullvadVPN/Containers/Root/HeaderBarView.swift17
-rw-r--r--ios/MullvadVPN/Containers/Root/RootContainerViewController.swift12
-rw-r--r--ios/MullvadVPN/Containers/Root/SearchAnythingViewController.swift163
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift3
-rw-r--r--ios/MullvadVPN/Coordinators/TunnelCoordinator.swift49
-rw-r--r--ios/MullvadVPN/Extensions/UIImage+Assets.swift4
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift36
9 files changed, 284 insertions, 5 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 1e623f8bc0..2b225ea306 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -500,6 +500,7 @@
7A28826A2BA8336600FD9F20 /* VPNSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2882692BA8336600FD9F20 /* VPNSettingsCoordinator.swift */; };
7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */; };
7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */; };
+ 7A2C0E8E2D969C45003D8048 /* SearchAnythingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2C0E8D2D969C3E003D8048 /* SearchAnythingViewController.swift */; };
7A2E7B702D6C9FCF009EF2C3 /* APITransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E7B672D6C9D7A009EF2C3 /* APITransport.swift */; };
7A2E7B712D6C9FE0009EF2C3 /* APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E7B6E2D6C9ED9009EF2C3 /* APIError.swift */; };
7A2E7B722D6C9FE5009EF2C3 /* APIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E7B6C2D6C9E53009EF2C3 /* APIRequest.swift */; };
@@ -2034,6 +2035,7 @@
7A2882692BA8336600FD9F20 /* VPNSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsCoordinator.swift; sourceTree = "<group>"; };
7A2960F52A963F7500389B82 /* AlertCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCoordinator.swift; sourceTree = "<group>"; };
7A2960FC2A964BB700389B82 /* AlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresentation.swift; sourceTree = "<group>"; };
+ 7A2C0E8D2D969C3E003D8048 /* SearchAnythingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAnythingViewController.swift; sourceTree = "<group>"; };
7A2E7B672D6C9D7A009EF2C3 /* APITransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITransport.swift; sourceTree = "<group>"; };
7A2E7B6C2D6C9E53009EF2C3 /* APIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRequest.swift; sourceTree = "<group>"; };
7A2E7B6E2D6C9ED9009EF2C3 /* APIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIError.swift; sourceTree = "<group>"; };
@@ -3963,6 +3965,7 @@
58D1560C29C0B27600749324 /* Root */ = {
isa = PBXGroup;
children = (
+ 7A2C0E8D2D969C3E003D8048 /* SearchAnythingViewController.swift */,
58F3C0A3249CB069003E76BE /* HeaderBarView.swift */,
7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */,
587425C02299833500CA2045 /* RootContainerViewController.swift */,
@@ -6199,6 +6202,7 @@
5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */,
7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */,
F062000C2CB7EB5D002E6DB9 /* UIImage+Helpers.swift in Sources */,
+ 7A2C0E8E2D969C45003D8048 /* SearchAnythingViewController.swift in Sources */,
F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */,
7A6389EB2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift in Sources */,
440870822D7A00B70038972F /* UIImage+Assets.swift in Sources */,
diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
index c94c188e34..9201f16001 100644
--- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
+++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
@@ -46,6 +46,7 @@ public enum AccessibilityIdentifier: Equatable {
case selectLocationButton
case closeSelectLocationButton
case settingsButton
+ case searchAnythingButton
case startUsingTheAppButton
case problemReportAppLogsButton
case problemReportSendButton
diff --git a/ios/MullvadVPN/Containers/Root/HeaderBarView.swift b/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
index 3ea6fbd041..5ec9785aa2 100644
--- a/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
+++ b/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
@@ -46,7 +46,7 @@ class HeaderBarView: UIView {
}()
private lazy var buttonContainer: UIStackView = {
- let stackView = UIStackView(arrangedSubviews: [accountButton, settingsButton])
+ let stackView = UIStackView(arrangedSubviews: [accountButton, settingsButton, searchButton])
stackView.spacing = 12
return stackView
}()
@@ -85,6 +85,20 @@ class HeaderBarView: UIView {
return button
}()
+ let searchButton: UIButton = {
+ let button = makeHeaderBarButton(with: UIImage.Buttons.search)
+ button.setAccessibilityIdentifier(.searchAnythingButton)
+ button.accessibilityLabel = NSLocalizedString(
+ "HEADER_BAR_SEARCH_BUTTON_ACCESSIBILITY_LABEL",
+ tableName: "HeaderBar",
+ value: "Search anything",
+ comment: ""
+ )
+ button.heightAnchor.constraint(equalToConstant: UIMetrics.Button.barButtonSize).isActive = true
+ button.widthAnchor.constraint(equalTo: button.heightAnchor, multiplier: 1).isActive = true
+ return button
+ }()
+
class func makeHeaderBarButton(with image: UIImage?) -> IncreasedHitButton {
let buttonImage = image?.withTintColor(UIColor.HeaderBar.buttonColor, renderingMode: .alwaysOriginal)
let barButton = IncreasedHitButton(type: .system)
@@ -113,6 +127,7 @@ class HeaderBarView: UIView {
private var isAccountButtonHidden = false {
didSet {
accountButton.isHidden = isAccountButtonHidden
+ searchButton.isHidden = isAccountButtonHidden
}
}
diff --git a/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
index 4abc1dc7da..eb107b4074 100644
--- a/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
+++ b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
@@ -371,6 +371,12 @@ class RootContainerViewController: UIViewController {
for: .touchUpInside
)
+ headerBarView.searchButton.addTarget(
+ self,
+ action: #selector(handleSearchButtonTap),
+ for: .touchUpInside
+ )
+
view.addSubview(headerBarView)
NSLayoutConstraint.activate(constraints)
@@ -424,6 +430,12 @@ class RootContainerViewController: UIViewController {
showSettings(animated: true)
}
+ @objc private func handleSearchButtonTap() {
+ if let controller = (viewControllers.first { $0 is TunnelViewController }) as? TunnelViewController {
+ controller.toggleSearchController()
+ }
+ }
+
// swiftlint:disable:next function_body_length
private func setViewControllersInternal(
_ newViewControllers: [UIViewController],
diff --git a/ios/MullvadVPN/Containers/Root/SearchAnythingViewController.swift b/ios/MullvadVPN/Containers/Root/SearchAnythingViewController.swift
new file mode 100644
index 0000000000..5e44879b64
--- /dev/null
+++ b/ios/MullvadVPN/Containers/Root/SearchAnythingViewController.swift
@@ -0,0 +1,163 @@
+//
+// SearchAnythingViewController.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2025-03-28.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+import MullvadREST
+import MullvadTypes
+
+class SearchAnythingViewController: UIViewController {
+ struct Item {
+ let title: String
+ let destination: Destination?
+ let location: RelayLocation?
+ let cell: Cell
+ }
+
+ enum Destination {
+ case account, selectLocation, changelog, daita, multihop, settings, vpnSettings, problemReport, faq, apiAccess, copyAccountNumber
+ }
+
+ enum Cell: String, CaseIterable, CellIdentifierProtocol {
+ case setting, location
+
+ var cellClass: AnyClass {
+ switch self {
+ case .setting, .location: SettingsCell.self
+ }
+ }
+
+ var type: String {
+ switch self {
+ case .setting: "Setting"
+ case .location: "Location"
+ }
+ }
+ }
+
+ let searchBar = UISearchBar()
+ let tableView = UITableView()
+
+ var items = [Item]()
+ var searchItems = [Item]()
+
+ var didSearch: ((String) -> Void)?
+ var didSelect: ((Item) -> Void)?
+
+ init(relays: [RelayWithLocation<REST.ServerRelay>]) {
+ var added = [String]()
+
+ items.append(Item(title: "Select location", destination: .selectLocation, location: nil, cell: .setting))
+ items.append(Item(title: "Settings", destination: .settings, location: nil, cell: .setting))
+ items.append(Item(title: "VPN settings", destination: .vpnSettings, location: nil, cell: .setting))
+ items.append(Item(title: "API access", destination: .apiAccess, location: nil, cell: .setting))
+ items.append(Item(title: "DAITA", destination: .daita, location: nil, cell: .setting))
+ items.append(Item(title: "Multihop", destination: .multihop, location: nil, cell: .setting))
+ items.append(Item(title: "Problem report", destination: .problemReport, location: nil, cell: .setting))
+ items.append(Item(title: "Changelog", destination: .changelog, location: nil, cell: .setting))
+ items.append(Item(title: "FAQ", destination: .faq, location: nil, cell: .setting))
+ items.append(Item(title: "Copy account number", destination: .copyAccountNumber, location: nil, cell: .setting))
+
+ let cities: [Item] = relays.compactMap {
+ if added.contains($0.serverLocation.city) { return nil }
+ added.append($0.serverLocation.city)
+ return Item(title: $0.serverLocation.city, destination: nil, location: RelayLocation(dashSeparatedString: "\($0.serverLocation.countryCode)-\($0.serverLocation.cityCode)"), cell: .location)
+ }.sorted { $0.title < $1.title }
+
+ let countries: [Item] = relays.compactMap {
+ if added.contains($0.serverLocation.country) { return nil }
+ added.append($0.serverLocation.country)
+ return Item(title: $0.serverLocation.country, destination: nil, location: RelayLocation(dashSeparatedString: "\($0.serverLocation.countryCode)"), cell: .location)
+ }.sorted { $0.title < $1.title }
+
+ items.append(contentsOf: countries)
+ items.append(contentsOf: cities)
+
+ searchItems = items
+
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .secondaryColor
+ tableView.backgroundColor = .clear
+
+ tableView.dataSource = self
+ tableView.delegate = self
+ tableView.keyboardDismissMode = .onDrag
+ tableView.registerReusableViews(from: Cell.self)
+
+ setUpSearchBar()
+
+ view.addConstrainedSubviews([searchBar, tableView]) {
+ searchBar.pinEdgesToSuperview(.all().excluding(.bottom))
+ tableView.pinEdgesToSuperview(.all().excluding(.top))
+ tableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor)
+ }
+
+ searchBar.becomeFirstResponder()
+ }
+
+ private func setUpSearchBar() {
+ searchBar.delegate = self
+ searchBar.searchBarStyle = .minimal
+ searchBar.layer.cornerRadius = 8
+ searchBar.clipsToBounds = true
+ searchBar.placeholder = NSLocalizedString(
+ "SEARCHBAR_PLACEHOLDER",
+ tableName: "SearchAnything",
+ value: "Search for...",
+ comment: ""
+ )
+
+ UITextField.SearchTextFieldAppearance.inactive.apply(to: searchBar)
+ }
+}
+
+extension SearchAnythingViewController: UITableViewDataSource {
+ func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ searchItems.count
+ }
+
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ let item = searchItems[indexPath.row]
+
+ let cell = (tableView.dequeueReusableCell(withIdentifier: item.cell.rawValue, for: indexPath) as? SettingsCell) ?? SettingsCell()
+ cell.titleLabel.text = item.title
+ cell.detailTitleLabel.text = item.cell.type
+
+ return cell
+ }
+}
+
+extension SearchAnythingViewController: UITableViewDelegate {
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ let item = searchItems[indexPath.row]
+ didSelect?(item)
+ }
+}
+
+extension SearchAnythingViewController: UISearchBarDelegate {
+ func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
+ searchItems = searchText.isEmpty ? items : items.filter { $0.title.fuzzyMatch(searchText) }
+ tableView.reloadData()
+ }
+
+ func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
+ UITextField.SearchTextFieldAppearance.active.apply(to: searchBar)
+ }
+
+ func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
+ UITextField.SearchTextFieldAppearance.inactive.apply(to: searchBar)
+ }
+}
diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
index 58dac8181d..d1d610c175 100644
--- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
@@ -495,7 +495,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
let tunnelCoordinator = TunnelCoordinator(
tunnelManager: tunnelManager,
outgoingConnectionService: outgoingConnectionService,
- ipOverrideRepository: ipOverrideRepository
+ ipOverrideRepository: ipOverrideRepository,
+ relaySelectorWrapper: relaySelectorWrapper
)
tunnelCoordinator.showSelectLocationPicker = { [weak self] in
diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift
index 0ff34e9062..f6e8fc4b72 100644
--- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift
@@ -7,6 +7,7 @@
//
import MullvadSettings
+import MullvadREST
import Routing
import UIKit
@@ -28,7 +29,8 @@ class TunnelCoordinator: Coordinator, Presenting {
init(
tunnelManager: TunnelManager,
outgoingConnectionService: OutgoingConnectionServiceHandling,
- ipOverrideRepository: IPOverrideRepositoryProtocol
+ ipOverrideRepository: IPOverrideRepositoryProtocol,
+ relaySelectorWrapper: RelaySelectorWrapper
) {
self.tunnelManager = tunnelManager
@@ -38,7 +40,10 @@ class TunnelCoordinator: Coordinator, Presenting {
ipOverrideRepository: ipOverrideRepository
)
- controller = TunnelViewController(interactor: interactor)
+ let relays = try! relaySelectorWrapper.relayCache.read()
+ let relayLocations = RelayWithLocation.locateRelays(relays: relays.relays.wireguard.relays, locations: relays.relays.locations)
+
+ controller = TunnelViewController(interactor: interactor, relays: relayLocations)
super.init()
@@ -49,6 +54,46 @@ class TunnelCoordinator: Coordinator, Presenting {
controller.shouldShowCancelTunnelAlert = { [weak self] in
self?.showCancelTunnelAlert()
}
+
+ controller.didSelect = { [weak self] item in
+ switch item.cell {
+ case .location:
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ relayConstraints.exitLocations = .only(.init(locations: [item.location!]))
+
+ tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { [weak self] in
+ self?.tunnelManager.startTunnel()
+ }
+ case .setting:
+ switch item.destination {
+ case .daita:
+ self?.applicationRouter?.present(.daita)
+ case .account:
+ self?.applicationRouter?.present(.account)
+ case .selectLocation:
+ self?.applicationRouter?.present(.selectLocation)
+ case .changelog:
+ self?.applicationRouter?.present(.changelog)
+ case .multihop:
+ self?.applicationRouter?.present(.settings(.multihop))
+ case .settings:
+ self?.applicationRouter?.present(.settings(nil))
+ case .vpnSettings:
+ self?.applicationRouter?.present(.settings(.vpnSettings))
+ case .problemReport:
+ self?.applicationRouter?.present(.settings(.problemReport))
+ case .faq:
+ self?.applicationRouter?.present(.settings(.problemReport))
+ case .apiAccess:
+ self?.applicationRouter?.present(.settings(.apiAccess))
+ case .copyAccountNumber:
+ guard let accountData = tunnelManager.deviceState.accountData else { return }
+ UIPasteboard.general.string = accountData.number
+ case .none:
+ break
+ }
+ }
+ }
}
func start() {
diff --git a/ios/MullvadVPN/Extensions/UIImage+Assets.swift b/ios/MullvadVPN/Extensions/UIImage+Assets.swift
index 7e543be140..7f960896b2 100644
--- a/ios/MullvadVPN/Extensions/UIImage+Assets.swift
+++ b/ios/MullvadVPN/Extensions/UIImage+Assets.swift
@@ -26,6 +26,10 @@ extension UIImage {
UIImage(named: "IconSettings")!
}
+ static var search: UIImage {
+ UIImage(systemName: "magnifyingglass")!
+ }
+
static var back: UIImage {
UIImage(named: "IconBack")!
}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
index bb6cebdc3c..e397507924 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
@@ -22,9 +22,12 @@ class TunnelViewController: UIViewController, RootContainment {
private var indicatorsViewViewModel: FeatureIndicatorsViewModel
private var connectionView: ConnectionView
private var connectionController: UIHostingController<ConnectionView>?
+ private var searchController: SearchAnythingViewController?
+ private let relays: [RelayWithLocation<REST.ServerRelay>]
var shouldShowSelectLocationPicker: (() -> Void)?
var shouldShowCancelTunnelAlert: (() -> Void)?
+ var didSelect: ((SearchAnythingViewController.Item) -> Void)?
let activityIndicator: SpinnerActivityIndicatorView = {
let activityIndicator = SpinnerActivityIndicatorView(style: .large)
@@ -61,8 +64,9 @@ class TunnelViewController: UIViewController, RootContainment {
false
}
- init(interactor: TunnelViewControllerInteractor) {
+ init(interactor: TunnelViewControllerInteractor, relays: [RelayWithLocation<REST.ServerRelay>]) {
self.interactor = interactor
+ self.relays = relays
tunnelState = interactor.tunnelStatus.state
connectionViewViewModel = ConnectionViewViewModel(
@@ -157,6 +161,29 @@ class TunnelViewController: UIViewController, RootContainment {
}
}
+ func toggleSearchController() {
+ guard searchController == nil else {
+ hideSearchController()
+ return
+ }
+
+ let controller = SearchAnythingViewController(relays: relays)
+ searchController = controller
+
+ addChild(controller)
+ controller.didMove(toParent: self)
+
+ controller.didSelect = { [weak self] item in
+ self?.toggleSearchController()
+ self?.didSelect?(item)
+ }
+
+ view.addConstrainedSubviews([controller.view]) {
+ controller.view.pinEdgesToSuperview(.all().excluding(.top))
+ controller.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 148)
+ }
+ }
+
// MARK: - Private
private func setTunnelState(_ tunnelState: TunnelState, animated: Bool) {
@@ -248,4 +275,11 @@ class TunnelViewController: UIViewController, RootContainment {
connectionViewProxy.pinEdgesToSuperview(.all())
}
}
+
+ private func hideSearchController() {
+ searchController?.view.removeFromSuperview()
+ searchController?.removeFromParent()
+
+ searchController = nil
+ }
}