diff options
| author | Jon Petersson <jon.petersson@mullvad.net> | 2025-03-28 15:10:23 +0100 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2025-03-31 10:28:54 +0200 |
| commit | 224a064617dd19e45d7a51aa9b3f277631e96a35 (patch) | |
| tree | 71dd52fe05e43386fa21bf879e6fb82a3bcc6ec0 | |
| parent | 5b84342bba0dfe78acbcef3ccea3f4f14c14ae43 (diff) | |
| download | mullvadvpn-search-anything.tar.xz mullvadvpn-search-anything.zip | |
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 + } } |
